Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-01 20:29:13 +08:00
11 changed files with 285 additions and 145 deletions

View File

@@ -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": {

View File

@@ -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. 首版任务拆解

View File

@@ -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 调试设计

View File

@@ -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`

View File

@@ -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/<version>/spacetimedb-cli``$HOME/.local/share/spacetime/bin/<version>/spacetimedb-cli` 同步到 `.spacetimedb/bin/current/spacetimedb-cli`,当前线上 `spacetime` 入口为 `/usr/local/bin/spacetime`;启动参数为 `spacetime --root-dir ./.spacetimedb start --edition standalone --listen-addr <host>:<port>`,探活必须确认 `server ping` 输出包含 `Server is online:`;普通启动先无清库发布,若 publish 输出可判定为 schema 冲突,则自动导出旧库、清库发布新 wasm、导入回灌如果以 `--clear-database` 启动,则内部 `spacetime publish` 会追加 `-c=on-conflict`,代表人工确认清库,不触发自动回灌。
9. 默认执行 `scp -r -i ~\.ssh\dsk.pem build/<timestamp> ubuntu@82.157.175.59:/home/ubuntu/genarrative/` 上传发布包。
@@ -158,7 +158,9 @@ build/<timestamp>/
├─ .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/<timestamp>
./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/<timestamp>
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 的关系

View File

@@ -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",

View File

@@ -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 <typecheck|build> [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);
}

View File

@@ -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)));
}

View File

@@ -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/<database>/\`,随后清库发布新 wasm并用 \`--replace-existing\` 导入回灌。
## 入口
- 主站:\`http://<web-host>:<web-port>/\`
- 后台:\`http://<web-host>:<web-port>/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/<database>/\`。
- 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}"

View File

@@ -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 <path> 必填,待部署的发布目录,例如 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

View File

@@ -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<T>(
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 400no 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!([