修复图集拆分运行时身份传递

在 procedure 外层捕获调用 identity 并显式传入事务
补齐素材归组持久表的迁移导入导出清单
修正 DDD 门禁注释解析并补齐编辑器 Agent 事件落位
同步后端架构约束与项目排障记忆
This commit is contained in:
2026-07-17 14:41:57 +08:00
parent 8febb799f3
commit 93b27a2307
8 changed files with 38 additions and 10 deletions
@@ -3149,3 +3149,11 @@
- 处理:latest snapshot 必须是未锁定且存在同 offset `.snapshot_bsatn` 的完整目录,空目录或同名 `.lock` 存在时忽略。每个 replica 独立保留 `max(segment_start <= latest_snapshot)` 及全部后缀,只处理更早 segment 对;旧 snapshot 只保留最新一个。`--storage-format files` 必须先发布完整 full cataloghistory 对每个候选文件 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`
@@ -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<String>` 做跨层 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`
+4 -1
View File
@@ -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)),
);
@@ -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 = [
@@ -0,0 +1 @@
//! 编辑器 Agent 领域事件预留落位,当前不导出独立事件类型。
@@ -2,6 +2,7 @@ mod application;
mod commands;
mod domain;
mod errors;
mod events;
pub use application::*;
pub use commands::*;
@@ -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<Option<EditorAssetSnapshot>, 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<EditorAssetSnapshot, 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 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<EditorAssetSnapshot, String> {
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(),
@@ -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,