diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 4dc46c268..69ea2448c 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3149,3 +3149,11 @@ - 处理:latest snapshot 必须是未锁定且存在同 offset `.snapshot_bsatn` 的完整目录,空目录或同名 `.lock` 存在时忽略。每个 replica 独立保留 `max(segment_start <= latest_snapshot)` 及全部后缀,只处理更早 segment 对;旧 snapshot 只保留最新一个。`--storage-format files` 必须先发布完整 full catalog;history 对每个候选文件 CAS 对象、history catalog 和 full catalog 执行 HEAD 长度/SHA 验真,再复算边界与 stat fingerprint,最后发布并验真固定 `latest.json`;pointer 失败时不得推进 state 或删除源文件。不要把在线逐文件 full 扫描当成跨文件一致备份,基线必须来自停库目录或已验证冻结副本。SSH 或工具超时后先检查 work-dir PID lock 与原进程,不要直接并发重跑;不要在 history 模式传 `--stop-service`。定时任务通过 Server-Provision 的显式 profile 和仓库 drop-in 管理,启用前 dry-run 验证 baseline,切回 archive 时同时移除托管与现场遗留 drop-in;不要在 `/etc/systemd/system` 长期保留手写覆盖,release 必须建立和验证自己的 full baseline 与 work-dir,不能直接复用 dev 的本地 state。 - 验证:dry-run 输出 replica 的 `latestSnapshot`、`boundarySegment` 和候选清单;从另一台机器仅凭 OSS `latest.json` 自动定位 full catalog,创建目录、下载文件并逐项校验长度/SHA,启动隔离 data-dir 验证 `/v1/ping`、snapshot restore、commitlog replay、module launch、代表性 SQL 与 reducer。 - 关联:`scripts/database-backup-to-oss.mjs`、`scripts/check-database-backup-to-oss.mjs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## Procedure 事务鉴权要兼容 SpacetimeDB 2.6.0 的匿名 TxContext + +- 现象:api-server 的 SpacetimeDB token identity 与 `editor_generation_pricing_config.writer_identity` 完全一致,服务启动门禁也通过,但手动拆分图集调用 `find_editor_asset_group_source_and_return` 仍返回“当前 identity 无权调用模型生成运行时服务”。 +- 原因:SpacetimeDB 2.6.0 的 `ProcedureContext::try_with_tx` 使用内部匿名事务上下文,闭包参数 `TxContext` 解引用得到的 `ReducerContext::sender()` 固定为 `Identity::ZERO`,不是 procedure 外层调用者。把 runtime writer 鉴权写成 `require_*(tx, tx.sender())` 会稳定误拒绝合法服务 identity。SpacetimeDB 2.6.1 已修复为向事务透传 procedure caller,但尚未升级的 dev / release 模块仍受 2.6.0 行为影响。 +- 处理:在调用 `try_with_tx` 前通过 `let caller = ctx.sender()` 捕获真实调用者,再把 `caller` 显式传入事务函数;迁移、后台账号、runtime profile、外部生成等现有 procedure 已采用这一模式。`npm run check:spacetime-runtime-access` 禁止编辑器 runtime writer 鉴权重新直接读取事务 `ctx.sender()`。运行环境升级到 2.6.1 后继续保留显式 caller,避免鉴权依赖 SDK 版本细节。 +- 验证:运行 `npm run check:spacetime-runtime-access`、`cargo test -p spacetime-module --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`;发布使用 SpacetimeDB 2.6.1 构建的新模块后,以 runtime writer identity 重试 `POST /api/editor/icon-spritesheets/slices`,确认来源查询、分片素材写入与 cohort 完成不再返回 identity 403。 +- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`scripts/check-spacetime-runtime-access.mjs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 67b409635..6c1dc704f 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -175,7 +175,8 @@ npm run check:server-rs-ddd 7. 面向公开列表的只读投影优先做成 public view / public 读模型表,并由 `api-server` 的 `spacetime-client` 长期订阅后读本地 cache。跨玩法公开作品统一主读模型是 `public_work_gallery_entry` 和 `public_work_detail_entry`;公开作品资产读取授权投影是 `public_work_asset_read_grant`;各玩法既有 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 保留为 source view 和兼容路径。短期不把作品列表整体交给浏览器前端直接订阅;不要让 HTTP 列表接口每次请求都调用 procedure 重新组装全量列表。需要请求时间窗口的轻量统计可订阅 `public_work_play_daily_stat` 后在 `api-server` 本地聚合,需要写入副作用的详情、点赞、游玩记录仍走玩法 procedure / reducer。前端不得直接订阅 `puzzle_work_profile`、`custom_world_profile` 等领域源表,也不得自己做 join、聚合或权限逻辑。首屏、排序、字段归一、权限降级和 HTTP fallback 由 `api-server` BFF 维持。 8. 多列索引按 SpacetimeDB 绑定生成的元组参数直接传入,例如 `.filter((source_type, profile_id, played_day))`;前缀查询只传前缀元组,例如 `.filter((scope_kind, scope_id.as_str()))`。不要为了绕过类型问题退回整表遍历。 9. procedure result 必须返回 typed snapshot / typed value。`spacetime-client` mapper 不得再通过 `row_json/session_json/work_json/items_json/run_json/event_json/feedback_json: Option` 做跨层 JSON 字符串传输,也不得在 mapper 里反序列化旧 `*JsonRecord` 兼容结构。业务内部持久化字段如 `profile_payload_json`、`levels_json` 等不属于 procedure result 载荷例外,仍按各自表契约处理。 -10. 修改后运行: +10. procedure 需要按调用者 identity 鉴权时,必须先从外层 `ProcedureContext::sender()` 捕获 caller,再把 caller 显式传入 `try_with_tx` 闭包内的事务函数。SpacetimeDB 2.6.0 的 `TxContext` 使用内部匿名事务身份,`tx.sender()` 固定为 `Identity::ZERO`;2.6.1 已向事务透传 procedure caller,但项目仍保持显式 caller 参数,兼容尚未升级的运行环境并让鉴权边界不依赖 SDK 隐式语义。 +11. 修改后运行: ```bash npm run spacetime:generate @@ -560,14 +561,14 @@ npm run check:server-rs-ddd - Rust 结构体:`EditorAssetGroupSourceProvenance` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:手动图集拆分的私有可信来源索引。仅当账号素材由后端生成链路写入且具有正式 `source_resource_id` 时,按 owner + source resource、asset object 和 Object Key 记录真实来源 `task_id`;普通素材 / 资源创建请求不能直接写该表。跨项目复用素材后可通过稳定媒体引用找回原任务,历史行首次扫描命中后补写索引。 +- 说明:手动图集拆分的私有可信来源索引。仅当账号素材由后端生成链路写入且具有正式 `source_resource_id` 时,按 owner + source resource、asset object 和 Object Key 记录真实来源 `task_id`;普通素材 / 资源创建请求不能直接写该表。跨项目复用素材后可通过稳定媒体引用找回原任务,历史行首次扫描命中后补写索引。该持久表纳入 `migration_tables!` 导入导出清单,数据库迁移与恢复不得遗漏来源归组事实。 - 主键:`lookup_key`。 ### `editor_asset_group_cohort` - Rust 结构体:`EditorAssetGroupCohort` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:手动拆分批次的私有完成事实。api-server 只有在预期的全部 asset ID 已成功落库并由 runtime service identity 逐行校验 owner、真实 task、归组 task 和预期数量一致后才能插入;事实写入后不随用户删除单片素材而撤销。没有完成事实的现代批次不能并入来源根任务。 +- 说明:手动拆分批次的私有完成事实。api-server 只有在预期的全部 asset ID 已成功落库并由 runtime service identity 逐行校验 owner、真实 task、归组 task 和预期数量一致后才能插入;事实写入后不随用户删除单片素材而撤销。没有完成事实的现代批次不能并入来源根任务。该持久表纳入 `migration_tables!` 导入导出清单,数据库迁移与恢复必须保留不可逆批次完成事实。 - 主键:`cohort_id`。 ### `editor_showcase_asset` diff --git a/scripts/check-server-rs-ddd-boundaries.mjs b/scripts/check-server-rs-ddd-boundaries.mjs index 54bf0536f..607ccd0e7 100644 --- a/scripts/check-server-rs-ddd-boundaries.mjs +++ b/scripts/check-server-rs-ddd-boundaries.mjs @@ -146,8 +146,11 @@ function collectMigrationTables() { return new Set(); } + const migrationTableBody = macroMatch[1] + .replace(/\/\*[\s\S]*?\*\//gu, ' ') + .replace(/\/\/[^\r\n]*/gu, ' '); return new Set( - [...macroMatch[1].matchAll(/\b([a-z][a-z0-9_]*)\b/gu)] + [...migrationTableBody.matchAll(/\b([a-z][a-z0-9_]*)\b/gu)] .map((match) => match[1]) .filter((name) => !['arg'].includes(name)), ); diff --git a/scripts/check-spacetime-runtime-access.mjs b/scripts/check-spacetime-runtime-access.mjs index 0931ef21a..271a68337 100644 --- a/scripts/check-spacetime-runtime-access.mjs +++ b/scripts/check-spacetime-runtime-access.mjs @@ -108,6 +108,12 @@ const forbiddenSnippets = [ snippet: '.custom_world_profile()\n .iter()\n .filter(|profile| {', reason: 'custom_world_profile Published 同步已有 by_custom_world_profile_publication_status 索引', }, + { + file: 'server-rs/crates/spacetime-module/src/editor_project_storage.rs', + snippet: 'require_editor_generation_runtime_service_identity(ctx, ctx.sender())', + reason: + 'procedure runtime writer 鉴权必须显式传 caller,兼容 TxContext sender 为 Identity::ZERO 的 SpacetimeDB 2.6.0', + }, ]; const procedureResultFiles = [ diff --git a/server-rs/crates/module-editor-agent/src/events.rs b/server-rs/crates/module-editor-agent/src/events.rs new file mode 100644 index 000000000..d434b0985 --- /dev/null +++ b/server-rs/crates/module-editor-agent/src/events.rs @@ -0,0 +1 @@ +//! 编辑器 Agent 领域事件预留落位,当前不导出独立事件类型。 diff --git a/server-rs/crates/module-editor-agent/src/lib.rs b/server-rs/crates/module-editor-agent/src/lib.rs index b68fa5247..b700e48b0 100644 --- a/server-rs/crates/module-editor-agent/src/lib.rs +++ b/server-rs/crates/module-editor-agent/src/lib.rs @@ -2,6 +2,7 @@ mod application; mod commands; mod domain; mod errors; +mod events; pub use application::*; pub use commands::*; diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 70cfc7cea..19f80f00c 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -1095,7 +1095,8 @@ pub fn find_editor_asset_group_source_and_return( ctx: &mut ProcedureContext, input: EditorAssetGroupSourceLookupInput, ) -> EditorAssetProcedureResult { - match ctx.try_with_tx(|tx| find_editor_asset_group_source(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| find_editor_asset_group_source(tx, caller, input.clone())) { Ok(asset) => editor_asset_ok(asset), Err(message) => editor_asset_error(message), } @@ -1139,7 +1140,8 @@ pub fn create_editor_asset_and_return( ctx: &mut ProcedureContext, input: EditorAssetCreateInput, ) -> EditorAssetProcedureResult { - match ctx.try_with_tx(|tx| create_editor_asset(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| create_editor_asset(tx, caller, input.clone())) { Ok(asset) => editor_asset_ok(Some(asset)), Err(message) => editor_asset_error(message), } @@ -1150,7 +1152,8 @@ pub fn complete_editor_asset_group_cohort_and_return( ctx: &mut ProcedureContext, input: EditorAssetGroupCohortCompleteInput, ) -> EditorAssetProcedureResult { - match ctx.try_with_tx(|tx| complete_editor_asset_group_cohort(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| complete_editor_asset_group_cohort(tx, caller, input.clone())) { Ok(asset) => editor_asset_ok(Some(asset)), Err(message) => editor_asset_error(message), } @@ -1855,9 +1858,10 @@ fn admin_list_editor_assets( fn find_editor_asset_group_source( ctx: &ReducerContext, + caller: Identity, input: EditorAssetGroupSourceLookupInput, ) -> Result, String> { - require_editor_generation_runtime_service_identity(ctx, ctx.sender())?; + require_editor_generation_runtime_service_identity(ctx, caller)?; let owner_user_id = normalize_required(&input.owner_user_id, "owner_user_id")?; let source_resource_ids = input .source_resource_ids @@ -1924,9 +1928,10 @@ fn find_editor_asset_group_source( fn complete_editor_asset_group_cohort( ctx: &ReducerContext, + caller: Identity, input: EditorAssetGroupCohortCompleteInput, ) -> Result { - require_editor_generation_runtime_service_identity(ctx, ctx.sender())?; + require_editor_generation_runtime_service_identity(ctx, caller)?; let owner_user_id = normalize_required(&input.owner_user_id, "owner_user_id")?; let task_id = normalize_required(&input.task_id, "task_id")?; let group_task_id = normalize_required(&input.group_task_id, "group_task_id")?; @@ -2101,6 +2106,7 @@ fn delete_editor_asset_folder( fn create_editor_asset( ctx: &ReducerContext, + caller: Identity, input: EditorAssetCreateInput, ) -> Result { let asset_id = normalize_required(&input.asset_id, "editor_asset.asset_id")?; @@ -2131,7 +2137,7 @@ fn create_editor_asset( return Err("素材任务预期产物数量必须与归组 Task ID 一起提供".to_string()); } if group_task_id.is_some() { - require_editor_generation_runtime_service_identity(ctx, ctx.sender())?; + require_editor_generation_runtime_service_identity(ctx, caller)?; } ctx.db.editor_asset().insert(EditorAsset { asset_id: asset_id.clone(), diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 4d99dbb55..f5be3b9c1 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -254,6 +254,8 @@ macro_rules! migration_tables { editor_project_resource, editor_asset_folder, editor_asset, + editor_asset_group_source_provenance, + editor_asset_group_cohort, editor_showcase_asset, editor_showcase_asset_like, editor_showcase_campaign_config,