Compare commits

..

2 Commits

Author SHA1 Message Date
kdletters b93d15e46e 合并 master:DirectProject 旧消息重解析优化
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m35s
Project CI / Backend tests (pull_request) Failing after 17s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
- 合并 origin/master(a46302846:优化 DirectProject 旧消息重解析导致的会话区卡顿)。
- 自动合并无冲突;Phaser 发布前构建与发行网关根路径改动保持接线。
- 同步目的:满足仓库「PR head 必须包含最新 base」的门禁,随后由 Project CI 复验。
2026-09-22 20:42:32 +08:00
kdletters 57a72f652c Phaser 项目一键发布:发布前自动构建与打包
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
- AGC:export_local_project_package 改为发布前构建——已有可玩入口直接打包;否则解析 game/(Phaser 4 + Vite 脚手架)或项目根的 npm build 脚本,缺 game/node_modules 时先跑 project.bootstrap(npm install),再走 project.verify 的受控 npm 运行器执行 npm run build,最后校验入口并打包;安装/构建失败返回带日志尾部的可操作错误。
- 新增 resolve_publish_build_plan 纯函数与用例:game 子工程优先并要求先装依赖、根 npm 工程回退、没有可构建工程时失败关闭。
- 后端:发行网关根路径(含尾斜杠)等价于 index.html,路由级用例覆盖 Cookie 拒绝门与根路径;生产仍由每游戏 origin 把根路径映射到该游戏入口。
- 脚本:check:game-distribution-media-e2e 支持 E2E_PACKAGE_ZIP 直接发布真实构建产物,断言从包内派生入口/资源,本地审核入口改为发行网关路径。
- 文档:玩法链路与实施计划记录「作者不构建、不打 ZIP」的 Phaser 发布口径与验证证据。
- 验证:真实 Phaser 4.2.1 + Vite 7 构建产物(相对引用)经发布链路后网关 index.html 200 / assets 1,388,719 B 200,播放页在 allow-scripts 沙箱 iframe 内渲染 PHASER-PUBLISH-OK 且点击交互生效。
2026-09-22 20:38:00 +08:00
411 changed files with 29229 additions and 1688 deletions
@@ -2641,6 +2641,48 @@ const databaseTableLabelMap: Record<string, string> = {
profile_recharge_order: '充值订单',
profile_feedback_submission: '反馈提交',
profile_save_archive: '存档记录',
story_session: '剧情会话',
story_event: '剧情事件',
npc_state: 'NPC 状态',
inventory_slot: '背包槽位',
battle_state: '战斗状态',
treasure_record: '宝藏记录',
quest_record: '任务记录',
quest_log: '任务日志',
player_progression: '玩家进度',
chapter_progression: '章节进度',
custom_world_profile: '自定义世界档案',
custom_world_session: '自定义世界会话',
custom_world_agent_session: '自定义世界 Agent 会话',
custom_world_agent_message: '自定义世界 Agent 消息',
custom_world_agent_operation: '自定义世界 Agent 操作',
custom_world_draft_card: '自定义世界草稿卡片',
custom_world_gallery_entry: '自定义世界画廊条目',
puzzle_agent_session: '拼图 Agent 会话',
puzzle_agent_message: '拼图 Agent 消息',
puzzle_work_profile: '拼图作品档案',
puzzle_event: '拼图事件',
puzzle_runtime_run: '拼图运行记录',
puzzle_leaderboard_entry: '拼图排行榜条目',
match3d_agent_session: '抓大鹅 Agent 会话',
match3d_agent_message: '抓大鹅 Agent 消息',
match3d_work_profile: '抓大鹅作品档案',
match3d_runtime_run: '抓大鹅运行记录',
square_hole_agent_session: '方洞挑战 Agent 会话',
square_hole_agent_message: '方洞挑战 Agent 消息',
square_hole_work_profile: '方洞挑战作品档案',
square_hole_runtime_run: '方洞挑战运行记录',
visual_novel_agent_session: '视觉小说 Agent 会话',
visual_novel_agent_message: '视觉小说 Agent 消息',
visual_novel_work_profile: '视觉小说作品档案',
visual_novel_runtime_run: '视觉小说运行记录',
visual_novel_runtime_history_entry: '视觉小说历史条目',
visual_novel_runtime_event: '视觉小说运行事件',
big_fish_creation_session: '大鱼吃小鱼创建会话',
big_fish_agent_message: '大鱼吃小鱼 Agent 消息',
big_fish_asset_slot: '大鱼吃小鱼资产槽位',
big_fish_event: '大鱼吃小鱼事件',
big_fish_runtime_run: '大鱼吃小鱼运行记录',
asset_object: '资产对象',
asset_entity_binding: '资产实体绑定',
asset_event: '资产事件',
@@ -2682,6 +2724,48 @@ const databaseTableDescriptionMap: Record<string, string> = {
profile_recharge_order: '充值订单表',
profile_feedback_submission: '反馈提交记录表',
profile_save_archive: '用户存档记录表',
story_session: '剧情会话表',
story_event: '剧情事件表',
npc_state: 'NPC 状态表',
inventory_slot: '背包槽位表',
battle_state: '战斗状态表',
treasure_record: '宝藏记录表',
quest_record: '任务记录表',
quest_log: '任务日志表',
player_progression: '玩家进度表',
chapter_progression: '章节进度表',
custom_world_profile: '自定义世界档案表',
custom_world_session: '自定义世界会话表',
custom_world_agent_session: '自定义世界 Agent 会话表',
custom_world_agent_message: '自定义世界 Agent 消息表',
custom_world_agent_operation: '自定义世界 Agent 操作表',
custom_world_draft_card: '自定义世界草稿卡片表',
custom_world_gallery_entry: '自定义世界画廊条目表',
puzzle_agent_session: '拼图 Agent 会话表',
puzzle_agent_message: '拼图 Agent 消息表',
puzzle_work_profile: '拼图作品档案表',
puzzle_event: '拼图事件表',
puzzle_runtime_run: '拼图运行记录表',
puzzle_leaderboard_entry: '拼图排行榜条目表',
match3d_agent_session: '抓大鹅 Agent 会话表',
match3d_agent_message: '抓大鹅 Agent 消息表',
match3d_work_profile: '抓大鹅作品档案表',
match3d_runtime_run: '抓大鹅运行记录表',
square_hole_agent_session: '方洞挑战 Agent 会话表',
square_hole_agent_message: '方洞挑战 Agent 消息表',
square_hole_work_profile: '方洞挑战作品档案表',
square_hole_runtime_run: '方洞挑战运行记录表',
visual_novel_agent_session: '视觉小说 Agent 会话表',
visual_novel_agent_message: '视觉小说 Agent 消息表',
visual_novel_work_profile: '视觉小说作品档案表',
visual_novel_runtime_run: '视觉小说运行记录表',
visual_novel_runtime_history_entry: '视觉小说历史条目表',
visual_novel_runtime_event: '视觉小说运行事件表',
big_fish_creation_session: '大鱼吃小鱼创建会话表',
big_fish_agent_message: '大鱼吃小鱼 Agent 消息表',
big_fish_asset_slot: '大鱼吃小鱼资产槽位表',
big_fish_event: '大鱼吃小鱼事件表',
big_fish_runtime_run: '大鱼吃小鱼运行记录表',
asset_object: '资产对象表',
asset_entity_binding: '资产实体绑定表',
asset_event: '资产事件表',
@@ -5974,15 +5974,19 @@ pub(crate) fn create_local_project_checkpoint(
create_local_project_checkpoint_at(root)
}
/// 为发布导出试玩包:项目还没有可玩入口时先跑项目自己的 `npm run build`。
///
/// 作者只点一次「发布」:已有 `game/index.html` 或 `dist/index.html` 直接打包;只有源码时
/// 走 `project.verify` 的受控 npm 运行器构建后再打包,失败信息带构建日志尾部。
#[tauri::command]
pub(crate) fn export_local_project_package(
pub(crate) async fn export_local_project_package(
project_path: String,
) -> Result<LocalProjectExportPackageResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.export_package")?;
let _lock = acquire_project_write_lock(root, "project.export_package")?;
advance_agent_runtime_project_revision_locked(root)?;
export_local_project_package_at(root)
export_local_project_package_for_publish_at(root).await
}
#[tauri::command]
@@ -136,6 +136,159 @@ pub(crate) fn export_local_project_package_at(
///
/// The caller receives the package bytes and a deterministic file manifest, but
/// never receives a filesystem path that it could accidentally send to the API.
/// 发布前构建的超时上限:与 `project.verify` 的上限保持一致(构建属于常规步骤,
/// 给足时间但必须有界),避免发布路径越过校验器允许的区间。
pub(crate) const PUBLISH_BUILD_TIMEOUT_SECONDS: u64 = 300;
/// 找到声明了 `scripts.build` 的 npm 工作目录(项目根或 `game/` 子工程)。
///
/// 只读 `package.json`,不执行任何东西;真正的执行交给 `project.verify` 的受控
/// npm 运行器(脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义、沙箱与超时都在那里)。
pub(crate) fn resolve_publish_build_cwd(root: &Path) -> Result<Option<&'static str>, String> {
for cwd in [".", "game"] {
let package_root = if cwd == "." {
root.to_path_buf()
} else {
resolve_local_project_path(root, cwd)?
};
let package_path = package_root.join("package.json");
let metadata = match fs::symlink_metadata(&package_path) {
Ok(metadata) => metadata,
Err(_) => continue,
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
continue;
}
let Ok(content) = fs::read_to_string(&package_path) else {
continue;
};
let Ok(package) = serde_json::from_str::<serde_json::Value>(&content) else {
continue;
};
let declared = package
.get("scripts")
.and_then(|scripts| scripts.get("build"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
if declared.is_some() {
return Ok(Some(cwd));
}
}
Ok(None)
}
/// 读取声明的 build 脚本原文:`project.verify` 用它做 expectedCommand 反漂移校验。
pub(crate) fn read_publish_build_command(
root: &Path,
cwd_relative: &str,
) -> Result<String, String> {
let package_root = if cwd_relative == "." {
root.to_path_buf()
} else {
resolve_local_project_path(root, cwd_relative)?
};
let package_path = package_root.join("package.json");
let content = fs::read_to_string(&package_path).map_err(|error| {
format!(
"读取 package.json 失败:{}: {error}",
package_path.display()
)
})?;
let package: serde_json::Value = serde_json::from_str(&content)
.map_err(|error| format!("解析 package.json 失败:{error}"))?;
package
.get("scripts")
.and_then(|scripts| scripts.get("build"))
.and_then(serde_json::Value::as_str)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| "package.json 未定义 build 脚本".to_string())
}
/// 构建失败的日志尾部:命令输出有界,直接回传最后一段给作者判断。
fn publish_build_failure_tail(output: &str) -> String {
const MAX_CHARS: usize = 2_000;
let trimmed = output.trim();
let chars = trimmed.chars().count();
if chars <= MAX_CHARS {
return trimmed.to_string();
}
let tail = trimmed.chars().skip(chars - MAX_CHARS).collect::<String>();
format!("{tail}")
}
/// 发布前构建计划:在哪个目录构建、构建脚本原文、以及是否需要先装依赖。
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct PublishBuildPlan {
pub(crate) cwd_relative: &'static str,
pub(crate) command: String,
/// `game/` 子工程缺 `node_modules` 时为 true:构建前必须先跑 `project.bootstrap`。
pub(crate) needs_dependency_install: bool,
}
/// 解析发布前构建计划;项目没有任何可构建的 npm 工程时返回可操作错误。
pub(crate) fn resolve_publish_build_plan(root: &Path) -> Result<PublishBuildPlan, String> {
let Some(cwd_relative) = resolve_publish_build_cwd(root)? else {
return Err(
"项目还没有可玩入口,且项目根 / game 目录的 package.json 都没有 build 脚本:请让 Agent 生成可玩产物,或补上 build 脚本后重试"
.to_string(),
);
};
let command = read_publish_build_command(root, cwd_relative)?;
let needs_dependency_install =
cwd_relative == "game" && !root.join("game").join("node_modules").is_dir();
Ok(PublishBuildPlan {
cwd_relative,
command,
needs_dependency_install,
})
}
/// 为发布导出试玩包:项目还没有可玩入口时,先跑项目自己的 `npm run build`。
///
/// 作者只需要点一次「发布」:已有可玩产物(`game/index.html` 或 `dist/index.html`)直接打包;
/// 只有源码时用 `project.verify` 的受控 npm 运行器执行 build,再校验入口并打包。构建失败
/// 返回带日志尾部的可操作错误,不回传本地路径。
pub(crate) async fn export_local_project_package_for_publish_at(
root: &Path,
) -> Result<LocalProjectExportPackageResult, String> {
if validate_project_game_entry(root).is_ok() {
return export_local_project_package_at(root);
}
let plan = resolve_publish_build_plan(root)?;
// `game/` 子工程构建前必须先有依赖:缺 node_modules 时由发布流程自己补一次安装,
// 否则作者要点两次(先 bootstrap 再发布)。
if plan.needs_dependency_install {
let bootstrap =
crate::project::run_project_bootstrap_at(root, PUBLISH_BUILD_TIMEOUT_SECONDS).await?;
if bootstrap.status != "completed" {
return Err(format!(
"安装 game 依赖失败(npm install 未通过):\n{}",
publish_build_failure_tail(&bootstrap.output)
));
}
}
let built = crate::project::verification::run_project_verification_with_commit_at(
root,
"build",
&plan.command,
PUBLISH_BUILD_TIMEOUT_SECONDS,
plan.cwd_relative,
|| Ok(()),
)
.await?;
if built.status != "completed" {
return Err(format!(
"构建可玩版本失败(npm run build 未通过):\n{}",
publish_build_failure_tail(&built.output)
));
}
validate_project_game_entry(root)
.map_err(|error| format!("构建完成但项目仍没有可玩入口:{error}"))?;
export_local_project_package_at(root)
}
pub(crate) fn read_local_project_export_package_at(
root: &Path,
package_relative_path: &str,
@@ -3935,6 +3935,165 @@ fn local_project_export_package_uses_runtime_whitelist_and_records() {
fs::remove_dir_all(root).ok();
}
#[test]
fn publish_build_plan_prefers_game_subproject_and_requires_dependencies() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-plan-game", "Phaser 工程").expect("project init");
// 脚手架是 game/ + vite buildPhaser 4 工程):缺依赖时必须先 install。
let plan = resolve_publish_build_plan(&root).expect("解析构建计划");
assert_eq!(plan.cwd_relative, "game");
assert_eq!(plan.command, "vite build");
assert!(
plan.needs_dependency_install,
"缺少 game/node_modules 时应先装依赖"
);
fs::create_dir_all(root.join("game/node_modules")).expect("create node_modules");
let installed = resolve_publish_build_plan(&root).expect("解析构建计划");
assert!(
!installed.needs_dependency_install,
"已有依赖时不应重复 install"
);
fs::remove_dir_all(root).ok();
}
#[test]
fn publish_build_plan_falls_back_to_root_npm_build() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-plan-root", "根工程构建").expect("project init");
// 去掉 game 子工程的 build,改用项目根 npm 工程构建。
fs::write(
root.join("game/package.json"),
serde_json::to_string_pretty(&serde_json::json!({
"name": "plan-root-game",
"private": true,
"scripts": { "check": "node -e \"process.exit(0)\"" }
}))
.expect("serialize game package json"),
)
.expect("write game package json");
fs::write(
root.join("package.json"),
serde_json::to_string_pretty(&serde_json::json!({
"name": "plan-root-fixture",
"private": true,
"scripts": { "build": "node build-root.mjs" }
}))
.expect("serialize root package json"),
)
.expect("write root package json");
let plan = resolve_publish_build_plan(&root).expect("解析构建计划");
assert_eq!(plan.cwd_relative, ".");
assert_eq!(plan.command, "node build-root.mjs");
assert!(!plan.needs_dependency_install);
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn publish_export_runs_project_build_before_packaging() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-auto-build", "自动构建发布项目")
.expect("project init");
// 只有源码:package.json 声明 build,构建脚本产出 dist/ 可玩产物。
fs::write(
root.join("package.json"),
serde_json::to_string_pretty(&serde_json::json!({
"name": "publish-auto-build-fixture",
"private": true,
"scripts": { "build": "node build-publish.mjs" }
}))
.expect("serialize package json"),
)
.expect("write package json");
fs::write(
root.join("build-publish.mjs"),
r#"import { mkdirSync, writeFileSync } from 'node:fs';
mkdirSync('dist/assets', { recursive: true });
writeFileSync('dist/index.html', '<!doctype html><html><head><meta charset="utf-8"><title>Auto Build</title><script src="assets/app.js"></script></head><body><h1>AUTO-BUILD</h1></body></html>');
writeFileSync('dist/assets/app.js', 'document.documentElement.dataset.autoBuild = "1";');
"#,
)
.expect("write build script");
write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme");
let result = export_local_project_package_for_publish_at(&root)
.await
.expect("发布前构建并导出");
assert!(root.join("dist/index.html").is_file());
assert!(root.join("dist/assets/app.js").is_file());
assert!(result
.package_relative_path
.starts_with("exports/playtest-package-"));
let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default();
assert!(
log.contains("project.verify build"),
"发布前应记录一次 project.verify build{log}"
);
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn publish_export_skips_build_when_playable_entry_exists() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-publish-skip", "已构建发布项目")
.expect("project init");
write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html)
.expect("write playable html");
write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme");
let result = export_local_project_package_for_publish_at(&root)
.await
.expect("已有可玩入口时直接导出");
assert!(result.package_relative_path.ends_with(".zip"));
let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default();
assert!(
!log.contains("project.verify build"),
"已有可玩入口时不应触发构建:{log}"
);
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn publish_export_reports_actionable_error_without_entry_or_build_script() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-no-entry", "缺少可玩入口项目")
.expect("project init");
// 脚手架默认带 build 脚本;这里改成只有 check 脚本,模拟“没有可玩产物且没有构建脚本”。
fs::write(
root.join("game/package.json"),
serde_json::to_string_pretty(&serde_json::json!({
"name": "publish-no-entry-fixture",
"private": true,
"scripts": { "check": "node -e \"process.exit(0)\"" }
}))
.expect("serialize package json"),
)
.expect("write package json");
let error = export_local_project_package_for_publish_at(&root)
.await
.expect_err("缺少入口且没有 build 脚本时必须失败关闭");
assert!(
error.contains("还没有可玩入口"),
"错误应说明缺少可玩入口:{error}"
);
assert!(
error.contains("build 脚本"),
"错误应指向 build 脚本:{error}"
);
fs::remove_dir_all(root).ok();
}
#[test]
fn local_project_export_package_publish_payload_contains_bytes_and_file_digests() {
let root = unique_project_path();
@@ -116,6 +116,9 @@
- 发布入口灰度下发:`GET /api/runtime/frontend-config` 新增 `gameDistributionPublishEnabled`,复用既有 `is_game_distribution_publish_enabled_for_user`(未配置 `game-distribution:publish``enabled=false` 时对已登录作者默认开放,显式收紧后只放行白名单/灰度命中,匿名恒为 false),避免前端入口与写入口出现两套判据。网页端 `PlatformEntryActiveFlowShell` 据此隐藏「发布游戏 / 发布新版本」入口,`/games/publish` 直接访问时渲染「发布功能正在灰度中」并提供重新检查;AGC 端 `readGamePublishAvailability` 同样读该字段,只有命中才把发布回调交给 DirectProject 聊天头。
- 灰度验证:`cargo test -p api-server frontend_runtime_config`6 passed,含新增的 `frontend_runtime_config_game_distribution_publish_is_scoped_to_authenticated_gate`:无 gate 行 → 登录作者 true/匿名 false`enabled=true` 无白名单 → false;白名单命中 → true;`deny_user_ids` → false`enabled=false` → true`rolloutPercent=100` → true)、网页发布页 15 用例(含灰度未命中隐藏表单与「重新检查」放行)、平台壳 18 用例(含广场入口按灰度隐藏/显示)、AGC 发布服务 6 用例(含字段缺失与读取失败按不开放处理)。
- Phaser 一键发布闭环(作者不构建、不打 ZIP):`export_local_project_package` 改为发布前构建——已有可玩入口直接打包,否则解析 `game/` 或项目根的 npm `build` 脚本(`resolve_publish_build_plan`),缺 `game/node_modules` 时先跑 `project.bootstrap`,再走 `project.verify` 的受控 npm 运行器执行 build,最后校验入口并打包;构建或安装失败返回带日志尾部的可操作错误。真实 Phaser 4.2.1 + Vite 7 工程验证:构建产物使用相对引用(`./assets/...`),ZIP 370,969 B 经真实素材直传 + 创建游戏/版本/上传/送审/审核通过后,发行网关 `index.html` 200323 B)与 `assets/index-DZGg_tPs.js` 2001,388,719 B),网页播放页在 `allow-scripts` 沙箱 iframe 内渲染出 `PHASER-PUBLISH-OK` 与可点击按钮。
- 发行网关根路径:`GET /api/game-distribution/releases/{gameId}` 与带尾斜杠的同一路径等价于 `index.html`(生产由每游戏 origin 映射根路径,本地直连网关或入口直接填网关地址时同样可玩);路由级用例覆盖 Cookie 拒绝门与根路径。
## 尚未完成
- 真实独立发行域名、通配 TLS 与 CDN 仍属部署侧:边缘模板与门禁已就绪,本地已用真实 nginx 验证按主机映射、Cookie 403 与命名空间隔离,但仍需在真实域名/证书下跑一次“审核通过 → 游玩 → 换版 → 下架”并确认 CDN TTL 不超过 60 秒窗口。
@@ -797,15 +797,6 @@ Godot 编辑器操控复用既有 AGC 插件宿主、EditorAdapter、Runner 和
- 验证方式:运行评论弹层恢复竞态回归、完整 `appSurface.test.ts`,并执行类型、编码和 diff 检查。
- 关联文档:`docs/technical/【技术方案】立项策划AgentFast GDD-2026-08-10.md``apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx`
## 2026-09-22 旧创作模板历史表从 schema 与生成绑定中删除
- 背景:旧创作模板的 63 张历史玩法表已经完成业务代码退役;release 在 2026-09-22 apply 前仍有 26 张表、5922 行历史数据。维护窗口内先完成可恢复备份,再执行固定清单清空,继续保留 table 定义、迁移白名单、旧行兼容分支和生成绑定会让当前 schema、客户端类型和迁移合同长期停留在退役状态。
- 决策:从 `spacetime-module` 可达源码删除旧 gameplay、自定义世界、Puzzle / Puzzle Clear、Bark Battle、Match3D、Jump Hop、Wooden Fish、Square Hole、Visual Novel 与 Big Fish 的 63 张表定义及专属类型;同步删除只服务这些表的 `clear_retired_database_tables` procedure、固定清单、migration 导入导出项和旧行归一化分支,并重新生成 `spacetime-client` bindings。`runtime_setting``runtime_snapshot``user_browse_history``creation_entry_config` 等现役表和通用迁移能力保持不变。
- 门禁处理:SpacetimeDB schema guard 只对本轮 63 个已确认 accessor 的从基线删除放行,其他表删除/改名以及字段级破坏性变更仍失败;该一次性白名单在删除结果进入后续主线基线后移除。生产发布仍必须单独完成冷备份、客户端兼容性和运行态确认,禁止用 SQL `DROP TABLE``--delete-data=always` 或系统表写入代替受控发布。
- 影响范围:`server-rs/crates/spacetime-module/src/{active.rs,migration.rs}`、旧表专属 module/legacy schema 文件、`server-rs/crates/spacetime-client/src/module_bindings*`、schema guard 及后端数据契约。
- 生产数据与发布闭环:`genarrative-prod` 在维护态、API/controller/worker 已停止时,以已授权 migration operator 执行 `clear_retired_database_tables`apply 返回 63 张表且每张 `cleared_row_count == row_count_before`,共清空 5922 行。apply 前后两次 `dry_run=true` 均为 63/63 全零,第二次在 10 分钟观察窗口结束后执行。清空前完成 files-minimal OSS 备份、latest/catalog 验真和 restore dry-run;随后将 release 从 `2.7.0` 直接切换到本地锁定并有 commit 校验的 `2.8.3 / 8e410d…` 二进制,再用 source commit `05a5ea4d8534b3f96d4d462c6cfda9b0775073df` 的 wasm 发布。发布后 schema 为 84 张表、旧表为 0、`clear_retired_database_tables` 为 0API/controller/worker 已恢复,维护退出,`genarrative.world``www.genarrative.world` 返回 200。
- 验证方式:`npm run spacetime:generate` 生成 766 个 binding 文件;`cargo check -p spacetime-client -p api-server``cargo test -p spacetime-module migration`21 passed)、`npm run check:server-rs-ddd``npm run check:spacetime-schema`84 tables)、`npm run check:spacetime-runtime-access`、schema guard 单测(9 passed)、`npm run check:encoding``npm run check:doc-index``git diff --check` 通过。
## 2026-09-02 旧玩法表采用两阶段退役清理
- 背景:旧创作模板的业务代码已退出现役编译链,但 SpacetimeDB 中的历史表仍需先完成数据清理;直接删除表定义会扩大 schema 迁移和客户端兼容风险。
@@ -49,8 +49,6 @@
按上游 [#5555](https://github.com/clockworklabs/SpacetimeDB/pull/5555) 的 retention 语义,只有最近 `retain-snapshots`(默认 2)份 snapshot 与覆盖它之后的 commitlog 段是重启所需,其余历史可丢;因此备份改为 `files + full + --minimal --retain-snapshots 2`release 实测 40G → 3.6G,热备不停服),不再做增量差异计算,也不需要 44.7G 冷备空间。
2026-09-22 的 release 清表窗口再次验证:41.2GiB 数据目录执行 `archive` 会因根盘不足失败;尝试非 minimal 的 `files + full + --stop-service` 虽通过空间预检,但在扫描 43GiB 后于 catalog 序列化阶段报 `RangeError: Invalid string length`。生产中等数据目录继续使用可验真的 `files + full + --minimal` 备份,并在 apply 前执行 `restore-files-state --dry-run`;非 minimal files 大库备份需要先修复 catalog 序列化上限,不能把失败备份当作通过。
## copyArtifacts 报「Unable to find project for artifact copy」的用户触发构建差异
Copy Artifact 插件在**非 SYSTEM 认证**下按「认证用户」判权:只有当被复制 Job 的 `CopyArtifactPermissionProperty`(仓库里由 Declarative 的 `copyArtifactPermission(...)` 维护)显式列出当前消费者,或者该 Job 对认证用户开放 Item.Read 时才放行;`ACL.SYSTEM2` 的定时构建会短路通过。因此会出现「定时调度一路成功、手动发布必挂」的现象(2026-09-21 手动发布 #6/#7 与同期的用户触发探测全部命中,定时调度 #104+ 正常)。`Genarrative-Agc-Global-Version-Issue` 生产权限模式的授权名单必须同时包含 `Genarrative-Scheduled-Revision-Trigger``Genarrative-Manual-Build-And-Deploy`;改完 `copyArtifactPermission` 后要先跑一次发号 Job 把 Job property 写回 Jenkins,只改仓库文件不生效。
File diff suppressed because it is too large Load Diff
@@ -1141,7 +1141,20 @@ SELECT * FROM profile_recharge_product_config ORDER BY sort_order ASC;
- 微信新用户的内部 `user_id` 改为不可复用的 `user_` 前缀 UUID 风格,避免清库后旧作品被后来的顺序号账号顶替。
- 当作品作者的 `owner_user_id` 找不到真实账号时,作品统一显示为占位作者:`失效作者`,公开陶泥号固定为 `SY-00000000`,占位账号 ID 为 `wx-openid-placeholder`
- 该占位账号只用于作品作者域,不扩展到全站其它身份域。
- 旧作品表退役后,迁移 JSON 回填工具 `scripts/rebind-orphan-work-owners.mjs` 已删除,不再提供回填脚本用法或当前操作入口;占位作者的显示与身份域语义继续保持上述规则
- 如需把历史孤儿作品批量回填到占位作者,使用 `scripts/rebind-orphan-work-owners.mjs` 先基于当前 auth 快照识别有效用户,再把缺失作者对应的作品表写回为占位 ID;脚本输入输出都基于 SpacetimeDB 迁移 JSON
### 回填脚本用法
```bash
node scripts/rebind-orphan-work-owners.mjs --in <exported-migration.json> --out <rebound-migration.json>
node scripts/rebind-orphan-work-owners.mjs --in <exported-migration.json> --dry-run
node scripts/rebind-orphan-work-owners.mjs --in <exported-migration.json> --out <rebound-migration.json> --placeholder-user-id wx-openid-placeholder
```
- `--in`SpacetimeDB 导出的迁移 JSON。
- `--out`:写回后的迁移 JSON 输出路径。
- `--dry-run`:只统计回填行数,不写文件。
- `--placeholder-user-id`:需要时可覆盖默认占位账号 ID。
## 维护页目标文件安全边界(2026-08-05)
@@ -86,10 +86,10 @@
2. 所有运行依赖都必须在发行包内。资源 URL 使用与发行版本目录兼容的相对地址;前导 `/assets`、本地文件 URL、外部脚本/样式/媒体/字体地址均不属于可接受发行合同。客户端给出可操作错误,服务器仍独立校验;静态校验不能代替运行时 CSP 阻断。
3. 建议首版限额:压缩包 100 MiB、展开总量 250 MiB、单文件 64 MiB、最多 10,000 个文件、展开/压缩比不超过 100。服务端拒绝加密 ZIP、重复或大小写冲突路径、绝对路径、`..`、符号链接/重解析点、设备文件和嵌套压缩包;拒绝 `.agent`、版本控制目录、`node_modules`、凭据文件与源码映射文件。超限返回明确错误,不截断后继续发布。
4. 提交声明 ZIP 的 SHA-256 与字节数,服务端对收到的真实 ZIP 重新计算,再对展开文件建立相对路径、字节数和 SHA-256 清单。摘要不一致、缺文件或入口损坏时停止;只有 metadata 而没有已确认完整对象的提交必须失败。
5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。发布入口按灰度下发:后端灰度配置键固定为 `game-distribution:publish`(后台「灰度发布配置」可改,支持 `enabled` / `rolloutPercent` / `allowUserIds` / `allowUserTags`)。未配置该键、或 `enabled=false` 时对已登录作者默认开放;显式 `enabled=true` 后只有白名单或灰度命中的作者拿到开放状态,匿名恒为不开放。发布入口的开放状态随 `/api/runtime/frontend-config``gameDistributionPublishEnabled` 下发,网页广场/我的游戏入口与 AGC 聊天头「发布到游戏广场」按钮据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 503 与可读文案,读接口、目录、详情、发行网关与安全下架不受影响。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。
5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者不需要自己构建或打 ZIP:AGC 发布时对 `game/` 子工程按需执行 `npm install`(复用 `project.bootstrap`)与 `npm run build`(复用 `project.verify` 的受控 npm 运行器,脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义),再把 `game/dist` 归一化成根 `index.html` 的发行包上传;已有可玩入口(`game/index.html``dist/index.html`)时跳过构建。Phaser 4 + Vite 已按此口径端到端验证(构建产物、发行网关与网页沙箱播放)。发布入口按灰度下发:后端灰度配置键固定为 `game-distribution:publish`(后台「灰度发布配置」可改,支持 `enabled` / `rolloutPercent` / `allowUserIds` / `allowUserTags`)。未配置该键、或 `enabled=false` 时对已登录作者默认开放;显式 `enabled=true` 后只有白名单或灰度命中的作者拿到开放状态,匿名恒为不开放。发布入口的开放状态随 `/api/runtime/frontend-config``gameDistributionPublishEnabled` 下发,网页广场/我的游戏入口与 AGC 聊天头「发布到游戏广场」按钮据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 503 与可读文案,读接口、目录、详情、发行网关与安全下架不受影响。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。
6. `supportedDevices` 至少包含 `desktop``mobile``inputModes` 来自 `keyboard``mouse``touch`;声明移动端必须包含 `touch``orientation``landscape``portrait``responsive`。这些是待人工复核的作者声明,目录只显示已经随版本审核通过的值。
7. 原始 ZIP、未审核展开目录、审核资料均为私有对象;公开版本不暴露源码镜像键、本地路径、访问凭据或私有账号元数据。运行文件只能由发行网关按游戏、版本和文件白名单读取,不能绕过网关访问公开 OSS bucket。
8. 现役发行网关由 `api-server` 提供:`GET /api/game-distribution/releases/{gameId}/{assetPath}` 只服务当前已公开版本包内的文件,私有 ZIP 与未公开版本不因知道 ID 而可读。响应按扩展名白名单设定内容类型,未知扩展名返回 404;全部响应带 `X-Content-Type-Options: nosniff``Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`(发行文档运行在 `allow-scripts` 的 opaque origin 沙箱里,`same-origin` 会让游戏自己的脚本被浏览器拦下),HTML 追加最小权限 CSP。带平台 `Cookie` 的请求一律 `403`,避免发行文件被主站同源读取;发行网关必须部署在独立来源。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。
8. 现役发行网关由 `api-server` 提供:`GET /api/game-distribution/releases/{gameId}`(含尾斜杠)等价于该游戏的 `index.html``GET /api/game-distribution/releases/{gameId}/{assetPath}` 只服务当前已公开版本包内的文件,私有 ZIP 与未公开版本不因知道 ID 而可读。响应按扩展名白名单设定内容类型,未知扩展名返回 404;全部响应带 `X-Content-Type-Options: nosniff``Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`(发行文档运行在 `allow-scripts` 的 opaque origin 沙箱里,`same-origin` 会让游戏自己的脚本被浏览器拦下),HTML 追加最小权限 CSP。带平台 `Cookie` 的请求一律 `403`,避免发行文件被主站同源读取;发行网关必须部署在独立来源。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。
9. 审核通过时必须提交绝对 HTTPS `entryUrl`,且不接受凭据、query 和 fragment;服务端不根据请求 Host 或本地路径拼默认发行地址,避免把内网地址或主站来源写进公开投影。 非生产环境额外允许 http 回环地址(`127.0.0.1` / `localhost` / `[::1]`),口径与前端 `normalizeGameEntryUrl` 一致,便于本地在没有 TLS 的情况下验证内嵌游玩;生产环境只接受 HTTPS。
### 身份、状态、审核与更新
@@ -119,7 +119,7 @@
| --- | --- | --- |
| `GET /games` | 游客 | **已实现**:关键词与分类筛选,最多 48 项;仅公开可玩版本 |
| `GET /games/{gameId}` | 游客 | **已实现**:当前公开资料与 `currentVersion.entryUrl`;不可见时 404 |
| `GET /game-distribution/releases/{gameId}/{assetPath}` | 游客 | **已实现**:发行网关只服务当前已公开版本包内文件,按扩展名白名单设内容类型,未知扩展名 404,带 Cookie 的请求 403;游玩页的入口来自详情投影的 `currentVersion.entryUrl` |
| `GET /game-distribution/releases/{gameId}[/{assetPath}]` | 游客 | **已实现**根路径等价于 `index.html`发行网关只服务当前已公开版本包内文件,按扩展名白名单设内容类型,未知扩展名 404,带 Cookie 的请求 403;游玩页的入口来自详情投影的 `currentVersion.entryUrl` |
| `GET /my/games` | 登录作者 | **已实现**:当前账号游戏、最近版本状态与驳回理由;owner 只从认证主体派生 |
| `POST /games` | 登录作者 | **已实现**:幂等创建游戏身份,尚不公开;带 `localProjectId` 时同一作者复用既有 `gameId` |
| `POST /games/{gameId}/versions` | owner | **已实现**:创建不可变待上传版本,冻结包摘要/字节数/文件数与资料 |
+59 -10
View File
@@ -4,10 +4,15 @@
// E2E_ADMIN_USER=<管理员用户名> E2E_ADMIN_PASSWORD=<管理员密码> \
// npm run check:game-distribution-media-e2e
// E2E_API_BASE 可覆盖 api-server 地址(默认 http://127.0.0.1:12401)。
// E2E_PACKAGE_ZIP 指向一个已经构建好的发行包(根目录含 index.html),例如真实
// Phaser/Vite 工程 `game/dist/**` 打成的 ZIP;不传时使用脚本内置的最小 fixture。
// E2E_GAME_TITLE 可覆盖游戏标题,便于在广场里认出这次验证。
//
// 覆盖:真实素材直传 OSS → 创建游戏(素材归属校验)→ 创建版本(资料冻结)→ 送审 →
// 作者回读 frozenMetadata → 待审期间匿名不可见/不可读 → 管理员审核通过 → 公开投影
// 暴露对象键且不泄露素材 ID → 匿名换签读封面与截图 → 发行网关可直接游玩。
import { readFile } from 'node:fs/promises';
import JSZip from 'jszip';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:12401';
@@ -139,7 +144,39 @@ function gameMetadata(overrides = {}) {
};
}
const externalPackageZip = (process.env.E2E_PACKAGE_ZIP ?? '').trim();
const gameTitleOverride = (process.env.E2E_GAME_TITLE ?? '').trim();
/** 返回待发布的发行包字节与条目数:优先使用调用方真实构建产物,否则用内置 fixture。 */
async function buildZip() {
if (externalPackageZip) {
const bytes = await readFile(externalPackageZip);
const archive = new JSZip();
const parsed = await archive.loadAsync(bytes);
const entryNames = Object.keys(parsed.files).filter(
(name) => !parsed.files[name].dir,
);
if (!entryNames.includes('index.html')) {
throw new Error(
`E2E_PACKAGE_ZIP 根目录缺少 index.html${externalPackageZip}`,
);
}
// 真实构建产物(Phaser/Vite 等)资源名带哈希:从包内派生一个资源路径做网关断言。
const assetPath =
entryNames.find((name) => /^assets\/.+\.js$/u.test(name)) ??
entryNames.find((name) => name.endsWith('.js'));
if (!assetPath) {
throw new Error(
`E2E_PACKAGE_ZIP 内没有可断言的 JS 资源:${externalPackageZip}`,
);
}
return {
bytes: Buffer.from(bytes),
fileCount: entryNames.length,
assetPath,
entryMarker: null,
};
}
const zip = new JSZip();
zip.file(
'index.html',
@@ -147,7 +184,12 @@ async function buildZip() {
);
zip.file('assets/app.js', 'document.documentElement.dataset.e2e="media";');
const bytes = await zip.generateAsync({ type: 'uint8array' });
return Buffer.from(bytes);
return {
bytes: Buffer.from(bytes),
fileCount: 2,
assetPath: 'assets/app.js',
entryMarker: 'E2E-MEDIA-OK',
};
}
async function main() {
@@ -230,7 +272,7 @@ async function main() {
// 4. 创建游戏 + 版本(冻结资料)
const metadata = gameMetadata({
title: `分发媒体验证 ${id.slice(-6)}`,
title: gameTitleOverride || `分发媒体验证 ${id.slice(-6)}`,
coverAssetId: cover.assetObjectId,
screenshots: [shot1.assetObjectId, shot2.assetObjectId],
});
@@ -284,7 +326,8 @@ async function main() {
`status=${ghostVersion.status}`,
);
const zipBytes = await buildZip();
const built = await buildZip();
const zipBytes = built.bytes;
const crypto = await import('node:crypto');
const sha256 = crypto.createHash('sha256').update(zipBytes).digest('hex');
const version = await api(`/api/game-distribution/games/${gameId}/versions`, {
@@ -294,7 +337,7 @@ async function main() {
body: {
packageSha256: sha256,
packageBytes: zipBytes.length,
packageFileCount: 2,
packageFileCount: built.fileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
},
@@ -423,7 +466,8 @@ async function main() {
body: {
decision: 'approve',
expectedPublicationRevision: readback.data.version.publicationRevision,
entryUrl: `${API}`,
// 本地用发行网关路径当入口,让「审核通过 → 游玩」在本地也走真实网关。
entryUrl: `${API}/api/game-distribution/releases/${gameId}/`,
},
},
);
@@ -480,22 +524,27 @@ async function main() {
`${API}/api/game-distribution/releases/${gameId}/index.html`,
);
const releaseBody = await release.text();
const entryOk =
release.status === 200 &&
/<html|<!doctype html/iu.test(releaseBody) &&
(built.entryMarker === null || releaseBody.includes(built.entryMarker));
check(
'发行网关返回游戏入口',
release.status === 200 && releaseBody.includes('E2E-MEDIA-OK'),
`status=${release.status}`,
entryOk,
`status=${release.status} bytes=${releaseBody.length}`,
);
check(
'发行入口带 nosniff',
release.headers.get('x-content-type-options') === 'nosniff',
);
const releaseAsset = await fetch(
`${API}/api/game-distribution/releases/${gameId}/assets/app.js`,
`${API}/api/game-distribution/releases/${gameId}/${built.assetPath}`,
);
const assetBody = await releaseAsset.arrayBuffer();
check(
'发行网关返回包内资源',
releaseAsset.status === 200,
`status=${releaseAsset.status}`,
releaseAsset.status === 200 && assetBody.byteLength > 0,
`status=${releaseAsset.status} path=${built.assetPath} bytes=${assetBody.byteLength}`,
);
console.log(`\n结果:${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
+1 -78
View File
@@ -12,80 +12,6 @@ const tableCatalogPath =
'docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md';
const bindingsRoot = 'server-rs/crates/spacetime-client/src/module_bindings/';
const allowBreaking = process.env.SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING === '1';
// 本次历史表退役的一次性迁移白名单。该清单只允许这些已确认表从基线消失;
// 待删除结果进入后续主线基线后必须删除本清单,其他表仍按既有破坏性变更路径失败。
export const APPROVED_RETIRED_TABLE_DELETIONS = Object.freeze([
'player_progression',
'chapter_progression',
'npc_state',
'story_session',
'story_event',
'inventory_slot',
'battle_state',
'treasure_record',
'quest_record',
'quest_log',
'custom_world_profile',
'custom_world_session',
'custom_world_agent_session',
'custom_world_agent_message',
'custom_world_agent_operation',
'custom_world_draft_card',
'custom_world_gallery_entry',
'puzzle_agent_session',
'puzzle_background_compile_task',
'puzzle_agent_message',
'puzzle_work_profile',
'puzzle_event',
'puzzle_runtime_run',
'puzzle_leaderboard_entry',
'puzzle_clear_agent_session',
'puzzle_clear_work_profile',
'puzzle_clear_runtime_run',
'puzzle_clear_event',
'bark_battle_draft_config',
'bark_battle_published_config',
'bark_battle_runtime_run',
'bark_battle_score_record',
'bark_battle_leaderboard_entry',
'bark_battle_work_stats_projection',
'bark_battle_personal_best_projection',
'match3d_agent_session',
'match3d_agent_message',
'match_3_d_work_profile',
'match3d_runtime_run',
'jump_hop_agent_session',
'jump_hop_work_profile',
'jump_hop_runtime_run',
'jump_hop_event',
'jump_hop_leaderboard_entry',
'wooden_fish_agent_session',
'wooden_fish_work_profile',
'wooden_fish_runtime_run',
'wooden_fish_event',
'square_hole_agent_session',
'square_hole_agent_message',
'square_hole_work_profile',
'square_hole_runtime_run',
'visual_novel_agent_session',
'visual_novel_agent_message',
'visual_novel_work_profile',
'visual_novel_runtime_run',
'visual_novel_runtime_history_entry',
'visual_novel_runtime_event',
'big_fish_creation_session',
'big_fish_agent_message',
'big_fish_asset_slot',
'big_fish_runtime_run',
'big_fish_event',
]);
const approvedRetiredTableDeletions = new Set(APPROVED_RETIRED_TABLE_DELETIONS);
export function isApprovedRetiredTableDeletion(accessor) {
return approvedRetiredTableDeletions.has(accessor);
}
function normalizePath(path) {
return path.replace(/\\/gu, '/');
}
@@ -713,7 +639,7 @@ function fieldDescription(field) {
return `${field.name}: ${field.type}`;
}
export function compareTables(baseTables, currentTables) {
function compareTables(baseTables, currentTables) {
const failures = [];
let schemaChanged = false;
let breakingChanged = false;
@@ -722,9 +648,6 @@ export function compareTables(baseTables, currentTables) {
const currentTable = currentTables.get(accessor);
if (!currentTable) {
schemaChanged = true;
if (isApprovedRetiredTableDeletion(accessor)) {
continue;
}
breakingChanged = true;
failures.push(
`${baseTable.path}:${baseTable.line}: SpacetimeDB 表 ${accessor} 被删除或改名。表删除/改名必须先询问用户并确认迁移计划。`,
@@ -1,9 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
APPROVED_RETIRED_TABLE_DELETIONS,
collectTablesFromSources,
compareTables,
isFormalGateEnvironment,
listReachableRustFiles,
resolveBaseRef,
@@ -38,15 +36,6 @@ function createGitResolver(entries: Record<string, string | null>) {
return (args: string[]) => entries[args.join(' ')] ?? null;
}
function collectTables(accessors: string[]) {
return collectTablesFromSources(
accessors.map((accessor, index) => ({
path: `${sourceRoot}/schema.rs`,
text: tableSource(`Table${index}`, accessor),
})),
).tables;
}
describe('SpacetimeDB schema guard base resolution', () => {
it('prefers an explicit Jenkins-provided base ref', () => {
expect(
@@ -136,61 +125,3 @@ describe('SpacetimeDB schema guard module reachability', () => {
expect(result.failures[0]).toMatch(/table accessor example /u);
});
});
describe('SpacetimeDB schema guard retired table deletion allowlist', () => {
it('allows exactly the 63 approved historical tables to disappear', () => {
expect(APPROVED_RETIRED_TABLE_DELETIONS).toHaveLength(63);
expect(new Set(APPROVED_RETIRED_TABLE_DELETIONS).size).toBe(63);
const result = compareTables(
collectTables([...APPROVED_RETIRED_TABLE_DELETIONS]),
new Map(),
);
expect(result.failures).toEqual([]);
expect(result.schemaChanged).toBe(true);
expect(result.breakingChanged).toBe(false);
});
it('still rejects deletion of a table outside the approved list', () => {
const result = compareTables(
collectTables([APPROVED_RETIRED_TABLE_DELETIONS[0], 'active_table']),
new Map(),
);
expect(result.failures).toHaveLength(1);
expect(result.failures[0]).toMatch(/active_table.*/u);
expect(result.schemaChanged).toBe(true);
expect(result.breakingChanged).toBe(true);
});
it('does not suppress retained-table field deletion or rename checks', () => {
const baseTables = collectTablesFromSources([
{
path: `${sourceRoot}/ranks.rs`,
text: `#[spacetimedb::table(accessor = retained_deleted_field)]\npub struct RetainedDeletedField {\n pub id: u64,\n pub title: String,\n}\n`,
},
{
path: `${sourceRoot}/titles.rs`,
text: `#[spacetimedb::table(accessor = retained_renamed_field)]\npub struct RetainedRenamedField {\n pub id: u64,\n pub title: String,\n}\n`,
},
]).tables;
const currentTables = collectTablesFromSources([
{
path: `${sourceRoot}/ranks.rs`,
text: `#[spacetimedb::table(accessor = retained_deleted_field)]\npub struct RetainedDeletedField {\n pub id: u64,\n}\n`,
},
{
path: `${sourceRoot}/titles.rs`,
text: `#[spacetimedb::table(accessor = retained_renamed_field)]\npub struct RetainedRenamedField {\n pub id: u64,\n pub name: String,\n}\n`,
},
]).tables;
const result = compareTables(baseTables, currentTables);
expect(result.failures).toHaveLength(2);
expect(result.failures.join('\n')).toMatch(//u);
expect(result.failures.join('\n')).toMatch(//u);
expect(result.breakingChanged).toBe(true);
});
});
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env node
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
export const DEFAULT_ORPHAN_WORK_OWNER_USER_ID = 'wx-openid-placeholder';
export const WORK_OWNER_TABLES = [
'custom_world_profile',
'custom_world_gallery_entry',
'custom_world_session',
'custom_world_agent_session',
'custom_world_draft_card',
'puzzle_agent_session',
'puzzle_work_profile',
'bark_battle_draft_config',
'bark_battle_published_config',
'match3d_agent_session',
'match3d_work_profile',
'jump_hop_agent_session',
'jump_hop_work_profile',
'wooden_fish_agent_session',
'wooden_fish_work_profile',
'square_hole_agent_session',
'square_hole_work_profile',
'visual_novel_agent_session',
'visual_novel_work_profile',
'big_fish_creation_session',
];
const ROW_KEY_FIELDS = [
'profile_id',
'work_id',
'session_id',
'draft_id',
'gallery_entry_id',
'id',
];
if (isCliEntry()) {
runCli(process.argv.slice(2)).catch((error) => {
console.error(
`[rebind-orphan-work-owners] ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(1);
});
}
export function rebindOrphanWorkOwnersInMigration(
migration,
{
placeholderUserId = DEFAULT_ORPHAN_WORK_OWNER_USER_ID,
validUserIds = [],
} = {},
) {
if (!migration || !Array.isArray(migration.tables)) {
throw new Error('迁移 JSON 必须包含 tables 数组。');
}
const normalizedPlaceholderUserId = placeholderUserId.trim();
const validUserIdSet = new Set(
(Array.isArray(validUserIds) ? validUserIds : [])
.map((value) => String(value).trim())
.filter(Boolean),
);
validUserIdSet.add(normalizedPlaceholderUserId);
const reboundRows = [];
for (const table of migration.tables) {
if (
!table ||
!WORK_OWNER_TABLES.includes(table.name) ||
!Array.isArray(table.rows)
) {
continue;
}
for (const row of table.rows) {
if (!row || typeof row !== 'object') {
continue;
}
const currentOwner =
typeof row.owner_user_id === 'string' ? row.owner_user_id.trim() : '';
if (
currentOwner === normalizedPlaceholderUserId ||
validUserIdSet.has(currentOwner)
) {
continue;
}
const originalOwner =
typeof row.owner_user_id === 'string' ? row.owner_user_id : '';
row.owner_user_id = normalizedPlaceholderUserId;
reboundRows.push({
table: table.name,
rowKey: resolveRowKey(row),
from: originalOwner,
to: normalizedPlaceholderUserId,
});
}
}
return { reboundRows, validUserCount: validUserIdSet.size };
}
function resolveRowKey(row) {
for (const field of ROW_KEY_FIELDS) {
const value = row[field];
if (typeof value === 'string' && value.trim()) {
return value;
}
}
return '<unknown>';
}
async function runCli(argv) {
const options = parseCliArgs(argv);
const inputPath = path.resolve(options.in);
const outputPath = path.resolve(options.out);
const migration = JSON.parse(await readFile(inputPath, 'utf8'));
const result = rebindOrphanWorkOwnersInMigration(migration, {
placeholderUserId: options.placeholderUserId,
validUserIds: collectValidUserIds(migration),
});
if (!options.dryRun) {
await writeFile(
outputPath,
`${JSON.stringify(migration, null, 2)}\n`,
'utf8',
);
}
console.log(
`[rebind-orphan-work-owners] ${options.dryRun ? 'dry-run' : `已写入 ${outputPath}`},回填 ${result.reboundRows.length}`,
);
}
function parseCliArgs(argv) {
const options = {
in: '',
out: '',
placeholderUserId: DEFAULT_ORPHAN_WORK_OWNER_USER_ID,
dryRun: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const readValue = (name) => {
const value = argv[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`${name} 缺少参数值。`);
}
index += 1;
return value;
};
if (arg === '--in') {
options.in = readValue(arg);
} else if (arg === '--out') {
options.out = readValue(arg);
} else if (arg === '--placeholder-user-id') {
options.placeholderUserId = readValue(arg);
} else if (arg === '--dry-run') {
options.dryRun = true;
} else {
throw new Error(`未知参数: ${arg}`);
}
}
if (!options.in) {
throw new Error('必须传入 --in。');
}
if (!options.out && !options.dryRun) {
throw new Error('非 dry-run 必须传入 --out。');
}
return options;
}
function collectValidUserIds(migration) {
const result = new Set();
for (const table of migration.tables ?? []) {
if (!table || !Array.isArray(table.rows)) {
continue;
}
if (table.name === 'user_account') {
for (const row of table.rows) {
if (typeof row?.user_id === 'string' && row.user_id.trim()) {
result.add(row.user_id.trim());
}
}
}
}
return result;
}
function isCliEntry() {
const entry = process.argv[1];
return entry
? import.meta.url === `file://${entry.replace(/\\/gu, '/')}`
: false;
}
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import { rebindOrphanWorkOwnersInMigration } from './rebind-orphan-work-owners.mjs';
const placeholderUserId = 'wx-openid-placeholder';
function table(name, rows) {
return { name, rows };
}
describe('rebindOrphanWorkOwnersInMigration', () => {
it('把作品表里认证表不存在的 owner_user_id 回填到占位用户', () => {
const migration = {
schema_version: 1,
exported_at_micros: 1,
tables: [
table('user_account', [
{ user_id: 'user_alive' },
{ user_id: placeholderUserId },
]),
table('puzzle_work_profile', [
{ profile_id: 'p1', owner_user_id: 'user_missing' },
{ profile_id: 'p2', owner_user_id: 'user_alive' },
{ profile_id: 'p3', owner_user_id: placeholderUserId },
]),
table('puzzle_agent_session', [
{ session_id: 'draft-1', owner_user_id: '' },
]),
table('tracking_event', [
{ event_id: 't1', owner_user_id: 'user_missing' },
]),
],
};
const result = rebindOrphanWorkOwnersInMigration(migration, {
placeholderUserId,
validUserIds: ['user_alive'],
});
expect(result.reboundRows).toEqual([
{
table: 'puzzle_work_profile',
rowKey: 'p1',
from: 'user_missing',
to: placeholderUserId,
},
{
table: 'puzzle_agent_session',
rowKey: 'draft-1',
from: '',
to: placeholderUserId,
},
]);
expect(migration.tables[1].rows[0].owner_user_id).toBe(placeholderUserId);
expect(migration.tables[1].rows[1].owner_user_id).toBe('user_alive');
expect(migration.tables[1].rows[2].owner_user_id).toBe(placeholderUserId);
expect(migration.tables[2].rows[0].owner_user_id).toBe(placeholderUserId);
expect(migration.tables[3].rows[0].owner_user_id).toBe('user_missing');
});
});
@@ -224,10 +224,29 @@ pub fn router(state: AppState) -> Router<AppState> {
"/api/game-distribution/releases/{game_id}/{*asset_path}",
get(serve_release_asset),
)
// 根路径等价于入口页:生产由发行来源(每游戏 origin)把 `/` 映射到 index.html
// 本地直连网关或入口直接填网关地址时也必须能打开游戏。
.route(
"/api/game-distribution/releases/{game_id}",
get(serve_release_entry),
)
.route(
"/api/game-distribution/releases/{game_id}/",
get(serve_release_entry),
)
.merge(protected)
.merge(admin)
}
/// 发行网关根路径:等价于请求该游戏的 `index.html`。
async fn serve_release_entry(
state: State<AppState>,
headers: HeaderMap,
Path(game_id): Path<String>,
) -> Result<Response, AppError> {
serve_release_asset(state, headers, Path((game_id, "index.html".to_string()))).await
}
/// 公开发行网关。
///
/// 只服务当前已公开版本的游戏文件,路径必须在白名单内容类型内;私有 ZIP 对象和
@@ -1902,6 +1921,7 @@ mod tests {
// 未在白名单内的扩展名直接 404,不进入 SpacetimeDB 与对象存储。
let unknown_extension = app
.clone()
.oneshot(
Request::builder()
.uri("/api/game-distribution/releases/game_1/payload.bin")
@@ -1911,6 +1931,29 @@ mod tests {
.await
.expect("路由响应");
assert_eq!(unknown_extension.status(), StatusCode::NOT_FOUND);
// 根路径(含尾斜杠)等价于入口页:生产由发行来源映射,直接连网关时也必须能开。
for uri in [
"/api/game-distribution/releases/game_1",
"/api/game-distribution/releases/game_1/",
] {
let with_cookie = app
.clone()
.oneshot(
Request::builder()
.uri(uri)
.header("cookie", "genarrative.refresh-token=1")
.body(Body::empty())
.expect("请求"),
)
.await
.expect("路由响应");
assert_eq!(
with_cookie.status(),
StatusCode::FORBIDDEN,
"{uri} 必须先过 Cookie 拒绝门,而不是 404"
);
}
}
#[tokio::test]
File diff suppressed because it is too large Load Diff
@@ -34,10 +34,10 @@ pub trait acknowledge_external_generation_job_summaries_and_return {
input: ExternalGenerationJobAcknowledgeInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<ExternalGenerationJobSummaryProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<ExternalGenerationJobSummaryProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
@@ -47,10 +47,10 @@ impl acknowledge_external_generation_job_summaries_and_return for super::RemoteP
input: ExternalGenerationJobAcknowledgeInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<ExternalGenerationJobSummaryProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<ExternalGenerationJobSummaryProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, ExternalGenerationJobSummaryProcedureResult>(
@@ -34,10 +34,10 @@ pub trait acknowledge_external_generation_jobs_and_return {
input: ExternalGenerationJobAcknowledgeInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
@@ -47,10 +47,10 @@ impl acknowledge_external_generation_jobs_and_return for super::RemoteProcedures
input: ExternalGenerationJobAcknowledgeInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>(
@@ -31,10 +31,10 @@ pub trait activate_editor_canvas_layout_and_return {
input: EditorCanvasLayoutMigrationInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorCanvasLayoutMigrationProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<EditorCanvasLayoutMigrationProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
@@ -44,10 +44,10 @@ impl activate_editor_canvas_layout_and_return for super::RemoteProcedures {
input: EditorCanvasLayoutMigrationInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorCanvasLayoutMigrationProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<EditorCanvasLayoutMigrationProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, EditorCanvasLayoutMigrationProcedureResult>(
@@ -31,10 +31,10 @@ pub trait admin_disable_profile_redeem_code {
input: RuntimeProfileRedeemCodeAdminDisableInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
@@ -44,10 +44,10 @@ impl admin_disable_profile_redeem_code for super::RemoteProcedures {
input: RuntimeProfileRedeemCodeAdminDisableInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>(
@@ -31,10 +31,10 @@ pub trait admin_disable_profile_task_config {
input: RuntimeProfileTaskConfigAdminDisableInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
@@ -44,10 +44,10 @@ impl admin_disable_profile_task_config for super::RemoteProcedures {
input: RuntimeProfileTaskConfigAdminDisableInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileTaskConfigAdminProcedureResult>(
@@ -31,10 +31,10 @@ pub trait admin_get_profile_wallet_and_return {
input: RuntimeProfileAdminWalletGetInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileAdminWalletProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<RuntimeProfileAdminWalletProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
@@ -44,10 +44,10 @@ impl admin_get_profile_wallet_and_return for super::RemoteProcedures {
input: RuntimeProfileAdminWalletGetInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileAdminWalletProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<RuntimeProfileAdminWalletProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileAdminWalletProcedureResult>(
@@ -31,10 +31,10 @@ pub trait admin_get_profile_wallet_config {
input: RuntimeProfileWalletConfigAdminGetInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileWalletConfigAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<RuntimeProfileWalletConfigAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
@@ -44,10 +44,10 @@ impl admin_get_profile_wallet_config for super::RemoteProcedures {
input: RuntimeProfileWalletConfigAdminGetInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileWalletConfigAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<RuntimeProfileWalletConfigAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileWalletConfigAdminProcedureResult>(
@@ -31,10 +31,10 @@ pub trait admin_get_profile_wallet_detail_and_return {
input: RuntimeProfileAdminWalletGetInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileAdminWalletDetailProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<RuntimeProfileAdminWalletDetailProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
@@ -44,10 +44,10 @@ impl admin_get_profile_wallet_detail_and_return for super::RemoteProcedures {
input: RuntimeProfileAdminWalletGetInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileAdminWalletDetailProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<RuntimeProfileAdminWalletDetailProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileAdminWalletDetailProcedureResult>(

Some files were not shown because too many files have changed in this diff Show More