diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json index 7704e562..883c0688 100644 --- a/apps/admin-web/package.json +++ b/apps/admin-web/package.json @@ -5,8 +5,8 @@ "type": "module", "scripts": { "dev": "vite --host 127.0.0.1", - "build": "tsc --noEmit && vite build", - "typecheck": "tsc --noEmit", + "build": "node ../../scripts/admin-web-build.mjs build", + "typecheck": "node ../../scripts/admin-web-build.mjs typecheck", "preview": "vite preview --host 127.0.0.1" }, "dependencies": { diff --git a/docs/prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md b/docs/prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md index 7e162b20..51abaedc 100644 --- a/docs/prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md +++ b/docs/prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md @@ -12,6 +12,7 @@ 2. 管理数据、业务规则、权限校验和写操作继续统一走 `server-rs/crates/api-server`。 3. v1 只接管已有管理能力:管理员登录、当前管理员信息、服务/数据库概览、受控 API 调试、兑换码管理、注册邀请码管理。 4. 保持管理端清爽、可扫读、移动端可用,不在界面堆大段规则说明。 +5. 发布包内由 Web 网关把独立后台前端挂到同域 `/admin/`;Rust `api-server` 自身仍不恢复旧的 `GET /admin` 内嵌页面。 ## 2. 用户与使用场景 @@ -66,7 +67,7 @@ 3. 不新增 SpacetimeDB 表结构。 4. 不实现完整用户管理、作品审核、资产审核、充值订单后台。 5. 不实现多角色权限体系、管理员 refresh session、多端会话管理。 -6. 不保留 `GET /admin` 同源内嵌页面作为正式后台入口。 +6. 不保留 Rust `api-server` 的 `GET /admin` 同源内嵌页面作为正式后台入口;部署态 `/admin/` 只允许由独立后台前端静态产物承接。 ## 4. 页面与交互要求 @@ -144,7 +145,7 @@ API 调试页是受控接口调试台,不是通用代理工具: 5. API 调试页可通过后端调试接口访问 `/healthz`。 6. 兑换码管理页可创建/更新、停用兑换码,并展示返回记录。 7. 邀请码管理页可创建/更新注册邀请码,并展示返回记录。 -8. `GET /admin` 保持 404,不恢复旧内嵌页面。 +8. 直连 Rust `api-server` 时 `GET /admin` 保持 404,不恢复旧内嵌页面;通过发布包 Web 网关访问 `/admin/` 时返回独立后台前端。 9. `npm run check:encoding` 通过。 ## 7. 首版任务拆解 diff --git a/docs/technical/ADMIN_CONSOLE_SERVICE_DESIGN_2026-04-23.md b/docs/technical/ADMIN_CONSOLE_SERVICE_DESIGN_2026-04-23.md index 789eb88a..184b3737 100644 --- a/docs/technical/ADMIN_CONSOLE_SERVICE_DESIGN_2026-04-23.md +++ b/docs/technical/ADMIN_CONSOLE_SERVICE_DESIGN_2026-04-23.md @@ -154,47 +154,11 @@ claims 设计: 2. 当前 `SpacetimeDB server/database` 配置。 3. `SpacetimeDB` 数据库基础信息。 4. 当前 schema 表清单。 -5. 首批关键表的行数统计。 +5. schema 表清单对应的逐表行数统计。 -首批关键表固定覆盖: +表统计必须以 SpacetimeDB schema 返回的表名为唯一来源,`schemaTableNames` 的数量必须与 `tableStats` 的行数一致。后台服务只对 schema 中符合安全 SQL 标识符格式的表名发起 `SELECT COUNT(*)`,不提供任意 SQL 输入能力。 -1. `runtime_setting` -2. `runtime_snapshot` -3. `user_browse_history` -4. `profile_dashboard_state` -5. `profile_wallet_ledger` -6. `profile_played_world` -7. `profile_save_archive` -8. `story_session` -9. `story_event` -10. `battle_state` -11. `inventory_slot` -12. `quest_record` -13. `quest_log` -14. `treasure_record` -15. `npc_state` -16. `custom_world_profile` -17. `custom_world_gallery_entry` -18. `custom_world_agent_session` -19. `custom_world_agent_message` -20. `custom_world_agent_operation` -21. `custom_world_draft_card` -22. `big_fish_creation_session` -23. `big_fish_agent_message` -24. `big_fish_asset_slot` -25. `big_fish_runtime_run` -26. `puzzle_work_profile` -27. `puzzle_agent_session` -28. `puzzle_agent_message` -29. `puzzle_runtime_run` -30. `ai_task` -31. `ai_task_stage` -32. `ai_text_chunk` -33. `ai_result_reference` -34. `asset_object` -35. `asset_entity_binding` - -返回中的计数失败项必须带错误信息,不能静默吞掉。 +返回中的计数失败项必须带错误信息,不能静默吞掉。SpacetimeDB private 表或当前身份不可见的表可能在 `/sql` 下返回 `no such table` / `marked private`,这类项统一展示为“不可统计(private 或当前身份不可见)”,不作为整页读取失败处理。 ## 8. API 调试设计 diff --git a/docs/technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md b/docs/technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md index 9bc76210..a0a0ac71 100644 --- a/docs/technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md +++ b/docs/technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md @@ -4,13 +4,13 @@ 对应 PRD:[后台管理独立前端工程 PRD](../prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md) -落地状态:`2026-04-30` 已创建 `apps/admin-web` 独立前端工程,包含登录、总览、API 调试、兑换码管理和注册邀请码管理首版页面;根工程已补 `admin-web:*` 转发脚本。 +落地状态:`2026-04-30` 已创建 `apps/admin-web` 独立前端工程,包含登录、总览、API 调试、兑换码管理和注册邀请码管理首版页面;根工程已补 `admin-web:*` 转发脚本。`2026-05-01` 起,根构建与 Ubuntu 发布包会同步构建后台前端,并在发布包 Web 网关中以同域 `/admin/` 暴露。 ## 1. 结论 后台管理端采用独立前端工程,路径固定为 `apps/admin-web`。它只负责 UI 表现、输入采集、请求发起和结果渲染;所有鉴权、聚合、写操作、SpacetimeDB 访问和业务校验继续收口在 `server-rs/crates/api-server`。 -本方案接管旧 `api-server` 内嵌 HTML/CSS/JS 页面,旧 `GET /admin` 不再挂载。后续后台入口由独立前端工程部署产物承接。 +本方案接管旧 `api-server` 内嵌 HTML/CSS/JS 页面,Rust `api-server` 直连时旧 `GET /admin` 不再挂载。部署态后台入口由发布包内 `web-server.mjs` 承接:`/admin/` 返回独立前端静态产物,`/admin/api/*` 继续反代到 `api-server`。 ## 2. 工程结构 @@ -253,9 +253,13 @@ export interface ProfileInviteCodeAdminResponse { 后端读取 SpacetimeDB schema 时必须请求 `/v1/database/{database}/schema?version=9`。SpacetimeDB 2.x schema HTTP API 缺少 `version` query 会返回 `400 missing field version`,后台页面只能展示读取异常,不能拿到真实表名。 +`schemaTableNames` 与 `tableStats` 必须采用同一份 schema 表清单生成,不能再用硬编码关键表白名单补齐统计项。后台右上角显示的表数量必须等于统计表格实际行数;schema 读取失败时两者均为空,并通过 `fetchErrors` 暴露读取失败原因。 + 后端读取表行数时必须按 SpacetimeDB 2.x `/sql` 响应解析:接口返回 statement result 数组,单条结果内的 `schema.elements` 描述列名,`rows` 是按列顺序排列的数组行,例如 `rows: [[0]]`。后台服务不能再假设响应是 `{ rows: [{ row_count: 0 }] }` 的对象行形状;为了兼容小版本差异,可保留对象行兜底解析。 -`tableStats` 中单表失败必须展示 `errorMessage`,不能让整页变成空白。 +`tableStats` 中单表失败必须展示 `errorMessage`,不能让整页变成空白。SpacetimeDB private 表或当前身份不可见的表在 `/sql` 下可能返回 `no such table` / `marked private`,后台服务必须将这类错误归一为“不可统计(private 或当前身份不可见)”,避免把预期的访问边界展示成原始 HTTP 400 故障。 + +线上如果大量表都显示“不可统计(private 或当前身份不可见)”,优先检查 `api-server` 启动环境中的 `GENARRATIVE_SPACETIME_TOKEN` / `GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN` 是否存在且属于目标库 owner。Jenkins 覆盖发布包时必须保留部署目录已有运行 token;只带迁移 token 不能让后台概览读取 private 表。 ### 4.6 API 调试 contract @@ -384,12 +388,14 @@ export interface ProfileInviteCodeAdminResponse { ### 7.2 构建部署 -首版构建产物由独立后台工程输出到 `apps/admin-web/dist`。部署可以选择: +当前发布形态固定为同域 `/admin/`: -1. 独立静态站点域名,例如 `https://admin.example.com`。 -2. 与主站同域不同路径,由网关把后台静态资源和 `/admin/api/*` 分别路由到正确目标。 +1. 本地单独执行 `npm run admin-web:build` 时,后台构建产物默认输出到 `apps/admin-web/dist`。 +2. 根工程执行 `npm run build` 时,会先构建主前端,再构建后台前端;任一构建失败或输出 warning 都会让构建门禁失败。 +3. Ubuntu 发布包执行 `npm run deploy:rust:remote` 时,后台前端以 Vite `--base /admin/` 构建到发布包 `web/admin/`。 +4. 发布包 `web-server.mjs` 对 `/admin` 返回 301 到 `/admin/`,对 `/admin/` 与 `/admin/*` 提供后台 SPA fallback,对 `/admin/api/*` 优先反代到 `api-server`。 -无论哪种方式,`server-rs` 仍然是唯一管理 API 后端。 +该形态不新增后台静态端口和后台专用后端。`server-rs` 仍然是唯一管理 API 后端,后台前端不直连 SpacetimeDB。 ### 7.3 后台工程脚本 @@ -399,8 +405,8 @@ export interface ProfileInviteCodeAdminResponse { { "scripts": { "dev": "vite --host 127.0.0.1", - "build": "tsc --noEmit && vite build", - "typecheck": "tsc --noEmit", + "build": "node ../../scripts/admin-web-build.mjs build", + "typecheck": "node ../../scripts/admin-web-build.mjs typecheck", "preview": "vite preview --host 127.0.0.1" } } @@ -408,6 +414,8 @@ export interface ProfileInviteCodeAdminResponse { 如果后续接入根 npm workspace,再在根 `package.json` 增加转发脚本;本轮不要为了后台工程强行重排现有前端脚本。 +当前工程没有启用 npm workspace,因此后台构建脚本必须从仓库根目录调用 root toolchain。`scripts/admin-web-build.mjs` 统一执行 `tsc --noEmit -p apps/admin-web/tsconfig.json` 与 Vite 构建,避免 `npm --prefix apps/admin-web` 在子目录找不到 `tsc`。 + 当前根工程同步提供以下转发脚本: 1. `npm run admin-web:dev` diff --git a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md index 9a539e74..2dc49f6b 100644 --- a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md +++ b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md @@ -138,11 +138,11 @@ npm run deploy:rust:remote 1. 在仓库根目录创建 `build/`。 2. 在 `build/` 下创建当前时间命名的目标目录,例如 `build/20260422-153000/`。 -3. 使用 Vite 构建前端 release 到目标目录的 `web/`。 +3. 使用 Vite 构建主前端 release 到目标目录的 `web/`,并构建后台管理前端 release 到 `web/admin/`;后台构建固定使用 `/admin/` 作为 Vite base。 4. 执行 `cargo build -p api-server --release --target x86_64-unknown-linux-gnu --manifest-path server-rs/Cargo.toml`,并把 `api-server` 复制到目标目录。 5. 执行 `cargo build -p spacetime-module --release --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml`,并把 `spacetime_module.wasm` 复制到目标目录。 6. 把仓库根目录的 `.env` 与 `.env.local` 分别复制到目标目录根部和目标目录的 `web/` 下;复制后统一移除 UTF-8 BOM 与 CRLF,并把 `GENARRATIVE_SPACETIME_DATABASE` 覆盖为本次 `--database` 参数,避免 Jenkins 工作区里残留的旧 `.env.local` 覆盖发布包目标库。 -7. 在目标目录写入 `web-server.mjs`,用于托管 `web/` 并把 `/api/*`、`/generated-*`、`/healthz` 反代到本包内的 `api-server`。 +7. 在目标目录写入 `web-server.mjs`,用于托管 `web/` 与 `web/admin/`;其中 `/admin` 跳转到 `/admin/`,`/admin/` 提供后台 SPA,`/admin/api/*`、`/api/*`、`/generated-*`、`/healthz` 反代到本包内的 `api-server`。 8. 在目标目录写入 `start.sh` 与 `stop.sh`;`start.sh` 会先按 `KEY=value` 子集加载发布目录根部的 `.env`、`.env.local`,兼容 UTF-8 BOM 与 CRLF,再回退到构建时通过 `--database`、`--api-port`、`--web-host`、`--web-port`、`--spacetime-host`、`--spacetime-port` 写入的默认值,其中 Web 默认只监听 `127.0.0.1`;并默认导出 `NO_COLOR=1` 与 `CARGO_TERM_COLOR=never`,避免 ANSI 控制码写入日志文件;同时按 Ubuntu 发布环境使用发布目录内 `.spacetimedb/` 作为 root-dir,不再额外设置 `--data-dir`,启动前先执行 `sync_ubuntu_spacetime_install`,优先从 `/usr/.local/share/spacetime/bin//spacetimedb-cli` 或 `$HOME/.local/share/spacetime/bin//spacetimedb-cli` 同步到 `.spacetimedb/bin/current/spacetimedb-cli`,当前线上 `spacetime` 入口为 `/usr/local/bin/spacetime`;启动参数为 `spacetime --root-dir ./.spacetimedb start --edition standalone --listen-addr :`,探活必须确认 `server ping` 输出包含 `Server is online:`;普通启动先无清库发布,若 publish 输出可判定为 schema 冲突,则自动导出旧库、清库发布新 wasm、导入回灌;如果以 `--clear-database` 启动,则内部 `spacetime publish` 会追加 `-c=on-conflict`,代表人工确认清库,不触发自动回灌。 9. 默认执行 `scp -r -i ~\.ssh\dsk.pem build/ ubuntu@82.157.175.59:/home/ubuntu/genarrative/` 上传发布包。 @@ -158,7 +158,9 @@ build// ├─ .env.local ├─ web/ │ ├─ .env -│ └─ .env.local +│ ├─ .env.local +│ └─ admin/ +│ └─ index.html ├─ api-server ├─ spacetime_module.wasm ├─ migration-bootstrap-secret.txt @@ -179,6 +181,8 @@ npm run build:rust:ubuntu -- --database genarrative-dev --web-host 127.0.0.1 --w npm run build:rust:ubuntu -- --skip-upload ``` +`--skip-web-build` 会同时跳过主前端和后台管理前端构建,仅用于调试已有发布包内容;正式发布不应使用该参数。 + 目标服务器启动: ```bash @@ -187,7 +191,7 @@ cd build/ ./stop.sh ``` -如果后续通过 Jenkins 的部署脚本把发布包覆盖到固定部署目录,部署阶段默认只替换 `web/`、`api-server`、`spacetime_module.wasm`、`migration-bootstrap-secret.txt`、`scripts/`、`.env*`、`start.sh`、`stop.sh`、`web-server.mjs`、`README.md` 等发布产物;文件产物使用普通复制,`web/`、`scripts/` 等目录产物递归复制,不会删除部署目录中的 `.spacetimedb/`、`logs/`、`run/`、`deploy-state/`、`database-migrations/` 这类运行态目录。 +如果后续通过 Jenkins 的部署脚本把发布包覆盖到固定部署目录,部署阶段默认只替换 `web/`、`api-server`、`spacetime_module.wasm`、`migration-bootstrap-secret.txt`、`scripts/`、`.env*`、`start.sh`、`stop.sh`、`web-server.mjs`、`README.md` 等发布产物;后台管理前端位于 `web/admin/`,随 `web/` 一并覆盖。文件产物使用普通复制,`web/`、`scripts/` 等目录产物递归复制,不会删除部署目录中的 `.spacetimedb/`、`logs/`、`run/`、`deploy-state/`、`database-migrations/` 这类运行态目录。Jenkins 覆盖 `.env.local` 时会保留目标部署目录已有的 `GENARRATIVE_SPACETIME_TOKEN` / `GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN`,避免后台表统计在部署后失去读取 private 表所需的 owner 身份。 安全边界: @@ -208,7 +212,7 @@ cd build/ 1. Ubuntu x86_64。 2. 已安装 `node`,用于运行发布包内的 `web-server.mjs`。 3. 已安装 `spacetime` CLI,`start.sh` 会启动本地 SpacetimeDB 并发布 wasm。 -4. 业务密钥通过目标服务器环境变量或发布包同目录 `.env.local` 提供。 +4. 业务密钥通过目标服务器环境变量或发布包同目录 `.env.local` 提供;后台概览如果需要统计 private 表,`GENARRATIVE_SPACETIME_TOKEN` 必须是目标库 owner 或具备等效读取权限的 token。 ## 4. 与 M7 的关系 diff --git a/package.json b/package.json index 4aa815fe..6cbe731e 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "dev:rust:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh", "dev:web": "node scripts/dev-web-rust.mjs", "admin-web:dev": "npm --prefix apps/admin-web run dev --", - "admin-web:build": "npm --prefix apps/admin-web run build --", - "admin-web:typecheck": "npm --prefix apps/admin-web run typecheck --", + "admin-web:build": "node scripts/admin-web-build.mjs build", + "admin-web:typecheck": "node scripts/admin-web-build.mjs typecheck", "admin-web:preview": "npm --prefix apps/admin-web run preview --", "spacetime:publish:maincloud": "node scripts/run-bash-script.mjs scripts/spacetime-publish-maincloud.sh", "spacetime:generate": "node scripts/generate-spacetime-bindings.mjs", diff --git a/scripts/admin-web-build.mjs b/scripts/admin-web-build.mjs new file mode 100644 index 00000000..2b1620f5 --- /dev/null +++ b/scripts/admin-web-build.mjs @@ -0,0 +1,73 @@ +import {spawnSync} from 'node:child_process'; +import {dirname, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(scriptDir, '..'); +const adminWebDir = resolve(repoRoot, 'apps/admin-web'); +const adminTsconfigPath = resolve(adminWebDir, 'tsconfig.json'); +const adminViteConfigPath = resolve(adminWebDir, 'vite.config.ts'); +const tscBinPath = resolve(repoRoot, 'node_modules/typescript/bin/tsc'); +const viteCliPath = resolve(scriptDir, 'vite-cli.mjs'); + +const command = process.argv[2] ?? 'build'; +const extraArgs = process.argv.slice(3); + +function usage() { + console.error('用法: node scripts/admin-web-build.mjs [vite-build-args...]'); +} + +function runNodeScript(label, args) { + console.log(`[admin-web] ${label}`); + const result = spawnSync(process.execPath, args, { + cwd: repoRoot, + encoding: 'utf8', + }); + + if (result.stdout) { + process.stdout.write(result.stdout); + } + + if (result.stderr) { + process.stderr.write(result.stderr); + } + + if (result.error) { + console.error(`[admin-web] ${label} failed to start: ${result.error.message}`); + process.exit(1); + } + + if (result.signal) { + console.error(`[admin-web] ${label} was terminated by signal ${result.signal}`); + process.exit(1); + } + + if ((result.status ?? 0) !== 0) { + process.exit(result.status ?? 1); + } +} + +function runTypecheck() { + runNodeScript('typecheck', [ + tscBinPath, + '--noEmit', + '-p', + adminTsconfigPath, + ]); +} + +if (command === 'typecheck') { + runTypecheck(); +} else if (command === 'build') { + runTypecheck(); + runNodeScript('vite build', [ + viteCliPath, + 'build', + '--config', + adminViteConfigPath, + ...extraArgs, + ]); +} else { + usage(); + process.exit(1); +} diff --git a/scripts/build-gate.mjs b/scripts/build-gate.mjs index d5e7244d..70142ee4 100644 --- a/scripts/build-gate.mjs +++ b/scripts/build-gate.mjs @@ -2,39 +2,60 @@ import {spawnSync} from 'node:child_process'; import {fileURLToPath} from 'node:url'; const viteCliPath = fileURLToPath(new URL('./vite-cli.mjs', import.meta.url)); -const args = [viteCliPath, 'build', ...process.argv.slice(2)]; +const adminWebBuildPath = fileURLToPath(new URL('./admin-web-build.mjs', import.meta.url)); +const forwardedArgs = process.argv.slice(2); -const result = spawnSync(process.execPath, args, { - cwd: process.cwd(), - encoding: 'utf8', -}); - -if (result.stdout) { - process.stdout.write(result.stdout); -} - -if (result.stderr) { - process.stderr.write(result.stderr); -} - -if ((result.status ?? 0) !== 0) { - process.exit(result.status ?? 1); -} - -const warningPattern = /\bwarn(?:ing)?\b/i; -const ignoredWarningPatterns = [ - /ExperimentalWarning/u, +const results = [ + runBuildStep('web', [viteCliPath, 'build', ...forwardedArgs]), + runBuildStep('admin-web', [adminWebBuildPath, 'build']), ]; -const warningLines = `${result.stdout ?? ''}\n${result.stderr ?? ''}` - .split(/\r?\n/u) - .map(line => line.trim()) - .filter(line => line.length > 0) - .filter(line => warningPattern.test(line)) - .filter(line => !ignoredWarningPatterns.some(pattern => pattern.test(line))); +const failedResult = results.find(result => result.error || result.signal || (result.status ?? 0) !== 0); +if (failedResult) { + if (failedResult.error) { + console.error(`Build gate failed to start a build step: ${failedResult.error.message}`); + } else if (failedResult.signal) { + console.error(`Build gate step was terminated by signal ${failedResult.signal}`); + } + process.exit(failedResult.status ?? 1); +} + +const warningLines = results.flatMap((result) => collectWarningLines(result)); if (warningLines.length > 0) { console.error('Build gate failed because warnings were emitted:'); [...new Set(warningLines)].forEach(line => console.error(`- ${line}`)); process.exit(1); } + +function runBuildStep(label, args) { + console.log(`[build-gate] ${label}`); + const result = spawnSync(process.execPath, args, { + cwd: process.cwd(), + encoding: 'utf8', + }); + + if (result.stdout) { + process.stdout.write(result.stdout); + } + + if (result.stderr) { + process.stderr.write(result.stderr); + } + + return result; +} + +function collectWarningLines(result) { + const warningPattern = /\bwarn(?:ing)?\b/i; + const ignoredWarningPatterns = [ + /ExperimentalWarning/u, + ]; + + return `${result.stdout ?? ''}\n${result.stderr ?? ''}` + .split(/\r?\n/u) + .map(line => line.trim()) + .filter(line => line.length > 0) + .filter(line => warningPattern.test(line)) + .filter(line => !ignoredWarningPatterns.some(pattern => pattern.test(line))); +} diff --git a/scripts/deploy-rust-remote.sh b/scripts/deploy-rust-remote.sh index 22bd13d8..5776afce 100644 --- a/scripts/deploy-rust-remote.sh +++ b/scripts/deploy-rust-remote.sh @@ -310,6 +310,7 @@ fi TARGET_DIR="${BUILD_ROOT}/${BUILD_NAME}" WEB_DIR="${TARGET_DIR}/web" +ADMIN_WEB_DIR="${WEB_DIR}/admin" API_BINARY_SOURCE="${SERVER_RS_DIR}/target/x86_64-unknown-linux-gnu/release/api-server" WASM_SOURCE="${SERVER_RS_DIR}/target/wasm32-unknown-unknown/release/spacetime_module.wasm" @@ -364,6 +365,12 @@ if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then cd "${REPO_ROOT}" node scripts/vite-cli.mjs build --outDir "${WEB_DIR}" --emptyOutDir ) + + echo "[deploy:rust] 构建后台 Vite release -> ${ADMIN_WEB_DIR}" + ( + cd "${REPO_ROOT}" + MSYS2_ARG_CONV_EXCL="--base=" node scripts/admin-web-build.mjs build --base=/admin/ --outDir "${ADMIN_WEB_DIR}" --emptyOutDir + ) fi if [[ "${SKIP_API_BUILD}" -ne 1 ]]; then @@ -421,11 +428,14 @@ import {fileURLToPath} from 'node:url'; const releaseDir = path.dirname(fileURLToPath(import.meta.url)); const webRoot = path.join(releaseDir, 'web'); +const adminWebRoot = path.join(webRoot, 'admin'); const webHost = process.env.GENARRATIVE_WEB_HOST || '127.0.0.1'; const webPort = Number(process.env.GENARRATIVE_WEB_PORT || '3000'); const apiTarget = new URL(process.env.GENARRATIVE_API_TARGET || 'http://127.0.0.1:8082'); const indexPath = path.join(webRoot, 'index.html'); +const adminIndexPath = path.join(adminWebRoot, 'index.html'); const proxyPrefixes = [ + '/admin/api', '/api/', '/api', '/generated-character-drafts', @@ -466,11 +476,11 @@ function sendFile(response, filePath) { .pipe(response); } -function serveStatic(request, response, pathname) { +function serveStaticFromRoot(response, pathname, rootDir, fallbackIndexPath) { const decodedPath = decodeURIComponent(pathname); const relativePath = decodedPath === '/' ? '/index.html' : decodedPath; - const filePath = path.normalize(path.join(webRoot, relativePath)); - const safeRelativePath = path.relative(webRoot, filePath); + const filePath = path.normalize(path.join(rootDir, relativePath)); + const safeRelativePath = path.relative(rootDir, filePath); if (safeRelativePath.startsWith('..') || path.isAbsolute(safeRelativePath)) { response.writeHead(403, {'content-type': 'text/plain; charset=utf-8'}); @@ -480,12 +490,21 @@ function serveStatic(request, response, pathname) { const resolvedFilePath = fs.existsSync(filePath) && fs.statSync(filePath).isFile() ? filePath - : indexPath; + : fallbackIndexPath; response.writeHead(200, {'content-type': contentTypeFor(resolvedFilePath)}); sendFile(response, resolvedFilePath); } +function serveStatic(request, response, pathname) { + serveStaticFromRoot(response, pathname, webRoot, indexPath); +} + +function serveAdminStatic(response, pathname) { + const adminPath = pathname === '/admin/' ? '/' : pathname.replace(/^\/admin/u, ''); + serveStaticFromRoot(response, adminPath, adminWebRoot, adminIndexPath); +} + function proxyToApi(request, response) { const targetUrl = new URL(request.url || '/', apiTarget); const proxyRequest = http.request( @@ -522,6 +541,17 @@ const server = http.createServer((request, response) => { return; } + if (url.pathname === '/admin') { + response.writeHead(301, {location: '/admin/'}); + response.end(); + return; + } + + if (url.pathname === '/admin/' || url.pathname.startsWith('/admin/')) { + serveAdminStatic(response, url.pathname); + return; + } + serveStatic(request, response, url.pathname); }); @@ -1189,7 +1219,7 @@ cat >"${TARGET_DIR}/README.md" <<'EOF' ## 内容 - \`.env\` / \`.env.local\`:从仓库根目录复制的环境文件,同时各保留一份到 \`web/\` -- \`web/\`:Vite release 静态资源 +- \`web/\`:主前端 Vite release 静态资源,\`web/admin/\` 为后台管理前端静态资源 - \`api-server\`:x86_64-unknown-linux-gnu release 可执行文件 - \`spacetime_module.wasm\`:wasm32-unknown-unknown release 模块 - \`migration-bootstrap-secret.txt\`:本发布包 wasm 编译时注入的迁移引导密钥;服务器 \`start.sh\` 发布时会显示,迁移授权完成后可删除 @@ -1211,6 +1241,11 @@ cat >"${TARGET_DIR}/README.md" <<'EOF' 默认启动会先尝试无清库发布;如果 SpacetimeDB 返回 schema 冲突,\`start.sh\` 会把旧库导出到 \`database-migrations//\`,随后清库发布新 wasm,并用 \`--replace-existing\` 导入回灌。 +## 入口 + +- 主站:\`http://:/\` +- 后台:\`http://:/admin/\` + ## 环境变量 - 启动时会先加载发布目录根部的 \`.env\` 与 \`.env.local\`,再回退到脚本内默认值。 @@ -1220,12 +1255,12 @@ cat >"${TARGET_DIR}/README.md" <<'EOF' - \`GENARRATIVE_WEB_HOST\` / \`GENARRATIVE_WEB_PORT\` - \`GENARRATIVE_API_HOST\` / \`GENARRATIVE_API_PORT\` / \`GENARRATIVE_API_LOG\` - \`GENARRATIVE_SPACETIME_HOST\` / \`GENARRATIVE_SPACETIME_PORT\` -- \`GENARRATIVE_SPACETIME_SERVER_URL\` / \`GENARRATIVE_SPACETIME_DATABASE\` +- \`GENARRATIVE_SPACETIME_SERVER_URL\` / \`GENARRATIVE_SPACETIME_DATABASE\` / \`GENARRATIVE_SPACETIME_TOKEN\` - \`GENARRATIVE_SPACETIME_ROOT_DIR\`:默认使用发布目录下的 \`.spacetimedb/\`,同时承载本地 SpacetimeDB 运行数据与 CLI 身份。 - \`GENARRATIVE_SPACETIME_TIMEOUT_SECONDS\`:等待 SpacetimeDB 就绪的秒数,默认 \`60\`。 - \`GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT\`:默认 \`true\`,普通发布遇到 schema 冲突时自动导出、清库发布、导入回灌;设为 \`false\` 时保留原始发布失败。 - \`GENARRATIVE_SPACETIME_MIGRATION_DIR\`:自动迁移 JSON 输出目录,默认 \`database-migrations//\`。 -- OSS、LLM、短信、微信等业务密钥仍通过目标服务器环境变量或同目录 \`.env.local\` 管理。 +- OSS、LLM、短信、微信、SpacetimeDB owner token 等业务密钥仍通过目标服务器环境变量或同目录 \`.env.local\` 管理;后台表统计读取 private 表时需要 \`GENARRATIVE_SPACETIME_TOKEN\` 对目标库有 owner 权限。 - 迁移引导密钥由构建发布包时随机生成,构建日志和服务器 \`start.sh\` 发布日志都会显示同一份密钥。 EOF replace_placeholder_in_file "${TARGET_DIR}/README.md" "__GENARRATIVE_BUILD_NAME__" "${BUILD_NAME}" diff --git a/scripts/jenkins-deploy-release.sh b/scripts/jenkins-deploy-release.sh index 3e3155c4..1187e051 100644 --- a/scripts/jenkins-deploy-release.sh +++ b/scripts/jenkins-deploy-release.sh @@ -10,10 +10,11 @@ usage() { 说明: 1. 如果部署目录已有旧版本且存在 stop.sh,则先执行旧版本 stop.sh。 2. 仅删除并替换发布产物文件或目录,保留部署目录中的运行数据目录。 - 3. 把指定发布目录中的白名单产物复制覆盖到部署目录。 + 3. 把指定发布目录中的白名单产物复制覆盖到部署目录,后台前端随 web/admin/ 一并覆盖。 4. 如指定 --clear-database,则以清库模式执行新版本 start.sh。 5. 默认允许新版本 start.sh 在 schema 冲突时自动导出、清库发布、导入回灌。 - 6. 最后执行新版本 start.sh。 + 6. 覆盖 .env.local 时保留目标机已有 SpacetimeDB 运行 token,供 api-server 后台概览读取 private 表统计。 + 7. 最后执行新版本 start.sh。 参数: --source-dir 必填,待部署的发布目录,例如 build/123 @@ -179,6 +180,8 @@ MIGRATION_EXPORT_TOKEN="" MIGRATION_IMPORT_TOKEN="" PRESERVED_MIGRATION_EXPORT_TOKEN="" PRESERVED_MIGRATION_IMPORT_TOKEN="" +PRESERVED_SPACETIME_TOKEN="" +PRESERVED_SPACETIME_MAINCLOUD_TOKEN="" DEPLOY_ITEMS=( ".env" ".env.local" @@ -364,6 +367,8 @@ fi normalize_release_env_files "${SOURCE_DIR}" PRESERVED_MIGRATION_EXPORT_TOKEN="$(read_env_value "GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" PRESERVED_MIGRATION_IMPORT_TOKEN="$(read_env_value "GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" +PRESERVED_SPACETIME_TOKEN="$(read_env_value "GENARRATIVE_SPACETIME_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" +PRESERVED_SPACETIME_MAINCLOUD_TOKEN="$(read_env_value "GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" if [[ -x "${DEPLOY_DIR}/stop.sh" ]]; then echo "[jenkins-deploy] 先停止旧版本: ${DEPLOY_DIR}" @@ -424,6 +429,16 @@ elif [[ -n "${PRESERVED_MIGRATION_IMPORT_TOKEN}" ]] \ && [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]]; then write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN" "${PRESERVED_MIGRATION_IMPORT_TOKEN}" fi +if [[ -n "${PRESERVED_SPACETIME_TOKEN}" ]] \ + && [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]] \ + && [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]]; then + write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_TOKEN" "${PRESERVED_SPACETIME_TOKEN}" +fi +if [[ -n "${PRESERVED_SPACETIME_MAINCLOUD_TOKEN}" ]] \ + && [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]] \ + && [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]]; then + write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN" "${PRESERVED_SPACETIME_MAINCLOUD_TOKEN}" +fi DEPLOY_DATABASE="$(read_env_value "GENARRATIVE_SPACETIME_DATABASE" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" if [[ -z "${DEPLOY_DATABASE}" ]]; then diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index c54b4867..00153464 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -39,43 +39,6 @@ const BLOCKED_DEBUG_HEADERS: &[&str] = &[ "transfer-encoding", "expect", ]; -// 数据库概览首版只统计受控白名单表,禁止后台页面直接输入任意 SQL。 -const DATABASE_OVERVIEW_TABLES: &[&str] = &[ - "runtime_setting", - "runtime_snapshot", - "user_browse_history", - "profile_dashboard_state", - "profile_wallet_ledger", - "profile_played_world", - "profile_save_archive", - "story_session", - "story_event", - "battle_state", - "inventory_slot", - "quest_record", - "quest_log", - "treasure_record", - "npc_state", - "custom_world_profile", - "custom_world_gallery_entry", - "custom_world_agent_session", - "custom_world_agent_message", - "custom_world_agent_operation", - "custom_world_draft_card", - "big_fish_creation_session", - "big_fish_agent_message", - "big_fish_asset_slot", - "puzzle_work_profile", - "puzzle_agent_session", - "puzzle_agent_message", - "puzzle_runtime_run", - "ai_task", - "ai_task_stage", - "ai_text_chunk", - "ai_result_reference", - "asset_object", - "asset_entity_binding", -]; // SpacetimeDB 2.x 的 schema HTTP API 要求显式传入 BSATN JSON 版本。 // 后台总览只读取表名,固定使用当前 CLI 2.1.0 兼容的版本参数即可。 const SPACETIME_SCHEMA_VERSION_QUERY: &str = "version=9"; @@ -283,7 +246,7 @@ async fn fetch_database_overview(state: &AppState) -> AdminDatabaseOverviewPaylo .ok() .flatten(); - let mut schema_table_names = schema + let schema_table_names = schema .as_ref() .and_then(|value| value.tables.as_ref()) .map(|tables| { @@ -300,31 +263,33 @@ async fn fetch_database_overview(state: &AppState) -> AdminDatabaseOverviewPaylo .unwrap_or_default(); let mut table_stats = Vec::new(); - for table_name in DATABASE_OVERVIEW_TABLES { + for table_name in &schema_table_names { + if !is_safe_spacetime_table_name(table_name) { + table_stats.push(AdminDatabaseTableStatPayload { + table_name: table_name.clone(), + row_count: None, + error_message: Some("表名不适合 SQL 统计".to_string()), + }); + continue; + } + let sql = format!("SELECT COUNT(*) AS row_count FROM {table_name}"); match fetch_spacetime_sql_count(&client, server_root, database, token, &sql).await { Ok(row_count) => table_stats.push(AdminDatabaseTableStatPayload { - table_name: (*table_name).to_string(), + table_name: table_name.clone(), row_count: Some(row_count), error_message: None, }), Err(error) => { table_stats.push(AdminDatabaseTableStatPayload { - table_name: (*table_name).to_string(), + table_name: table_name.clone(), row_count: None, - error_message: Some(error), + error_message: Some(normalize_table_count_error(&error)), }); } } } - for table_name in DATABASE_OVERVIEW_TABLES { - if !schema_table_names.iter().any(|name| name == table_name) { - schema_table_names.push((*table_name).to_string()); - } - } - schema_table_names.sort(); - AdminDatabaseOverviewPayload { database_identity: database_info .as_ref() @@ -345,6 +310,27 @@ fn build_spacetime_schema_url(server_root: &str, database: &str) -> String { format!("{server_root}/v1/database/{database}/schema?{SPACETIME_SCHEMA_VERSION_QUERY}") } +// 表名来自 schema,但进入 SQL 前仍做最小标识符校验,避免未来 schema 来源变化时扩大风险面。 +fn is_safe_spacetime_table_name(table_name: &str) -> bool { + let mut chars = table_name.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !(first == '_' || first.is_ascii_alphabetic()) { + return false; + } + chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) +} + +// private 表在 SpacetimeDB SQL 下会表现为不可见,后台只展示可理解状态,不暴露整段 HTTP 噪音。 +fn normalize_table_count_error(error: &str) -> String { + let normalized = error.to_ascii_lowercase(); + if normalized.contains("marked private") || normalized.contains("no such table") { + return "不可统计(private 或当前身份不可见)".to_string(); + } + error.to_string() +} + async fn fetch_spacetime_json( client: &Client, url: &str, @@ -662,7 +648,8 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess #[cfg(test)] mod tests { use super::{ - build_body_preview, build_debug_base_url, build_spacetime_schema_url, normalize_debug_path, + build_body_preview, build_debug_base_url, build_spacetime_schema_url, + is_safe_spacetime_table_name, normalize_debug_path, normalize_table_count_error, parse_spacetime_sql_count_response, trim_preview, }; use axum::{http::StatusCode, response::IntoResponse}; @@ -722,6 +709,38 @@ mod tests { ); } + #[test] + fn is_safe_spacetime_table_name_accepts_schema_identifiers() { + assert!(is_safe_spacetime_table_name("runtime_setting")); + assert!(is_safe_spacetime_table_name("_private_table")); + assert!(is_safe_spacetime_table_name("AiTaskStage2")); + } + + #[test] + fn is_safe_spacetime_table_name_rejects_sql_fragments() { + assert!(!is_safe_spacetime_table_name("")); + assert!(!is_safe_spacetime_table_name("bad-name")); + assert!(!is_safe_spacetime_table_name("1bad")); + assert!(!is_safe_spacetime_table_name("runtime_setting;DROP")); + } + + #[test] + fn normalize_table_count_error_hides_private_table_http_noise() { + let error = "HTTP 400:no such table: `runtime_setting`. If the table exists, it may be marked private."; + + assert_eq!( + normalize_table_count_error(error), + "不可统计(private 或当前身份不可见)" + ); + } + + #[test] + fn normalize_table_count_error_keeps_other_errors() { + let error = "SQL 请求失败:connection refused"; + + assert_eq!(normalize_table_count_error(error), error); + } + #[test] fn parse_spacetime_sql_count_response_accepts_statement_array_rows() { let payload = json!([