修复公开作品私有资产读取

新增公开可见作品的精确资产授权视图并覆盖十类玩法

改用受限 procedure 权威点查资产对象并在失败时关闭访问

移除资产对象全表订阅并统一 ACL 与编辑器引用查询

生成 SpacetimeDB 绑定并补齐回归测试、架构文档和项目记忆
This commit is contained in:
2026-07-13 18:34:53 +08:00
parent 5536fba95e
commit 2ac57463c0
21 changed files with 1615 additions and 103 deletions
@@ -4044,3 +4044,14 @@
- 决策:后台壳层固定为 `height: 100dvh` 并隐藏壳层 overflow;桌面侧边栏使用独立 `overflow-y: auto``.admin-main` 通过 `min-height: 0``overflow: hidden` 约束网格,`.admin-content` 使用 `min-height: 0; overflow: auto` 独立滚动。
- 响应式边界:小于等于 `980px` 时仍隐藏桌面侧边栏,主内容在视口高度内滚动,底部导航继续固定。
- 验证方式:`apps/admin-web/src/styles/admin.test.ts` 锁定壳层滚动契约;桌面浏览器滚动后应保持 `window.scrollY = 0`、侧边栏 `top = 0`,只改变 `.admin-content.scrollTop`;移动视口继续由 `.admin-content` 滚动。
## 2026-07-13 公开作品资产使用派生精确读授权
- 背景:资产 ACL 严格执行后,已登记为 `private` 的作品封面和正式资产不能再依赖 generated 前缀匿名读取;但公开作品仍需要允许访客读取它实际展示和运行的资产。
- 决策:已登记 `asset_object` 继续保持 `private`,新增匿名派生 view `public_work_asset_read_grant`。view 只从 `Published + visible` 作品(`custom-world` 另要求未删除)正式发布快照中收集实际使用的资产,历史作品随 view 计算自动补齐;`spacetime-client` 将该 view 纳入每个池连接的长期订阅。
- 授权边界:grant 携带作品 owner,API 只有在它与 `asset_object.owner_user_id` 一致,且 `asset_object_id` 或精确 `object_key` 命中时才允许匿名读取。隐藏、删除或取消发布会使 grant 自动消失;参考图、未选中候选图和 `generationInputs` 明确排除。Custom World 只遍历角色、地标、营地、章节和 opening CG 等已知正式根,不能递归 legacy payload 的未知预览 / 编辑字段。
- 禁止项:不得通过放开 `generated-*` 前缀或批量把历史对象改为 `PublicRead` 修复公开作品,两种方式都会让作品可见性生命周期与资产授权脱节,并重新引入跨账号读取。
- 影响范围:`module-assets` 公开资产授权判定、`spacetime-module` 跨玩法公开资产 view、`spacetime-client` 订阅 cache 与 `api-server` 资产读取 ACL。
- 权威查询:`asset_object` 不进入 client 长期订阅。API 通过仅 runtime service identity 可调用的 procedure,按主键或 `(bucket, object_key)` 服务端索引读取事务内 metadata;只有位置查询明确返回不存在时才允许进入 legacy curated 前缀兼容,procedure 失败、超时或重复位置一律失败关闭。
- 一致性:grant 通过长期订阅进入 API 本地 cache;隐藏、删除或取消发布提交后,在连接收到 view 更新时撤销。已签发 URL 仍受其短 TTL 约束,因此该能力不是对既有签名的瞬时吊销机制。
- 验证方式:公开可见作品的正式资产可匿名读取;未选候选图、参考图、跨 owner 伪造 key 仍返回不存在;隐藏、删除或取消发布后 grant 随 view 更新撤销,再恢复公开可见时随 view 恢复。
@@ -3006,3 +3006,13 @@
- 处理:共享弹窗统一记录 `pointerdown``pointerup` 的目标,只有按下和松开都发生在遮罩自身时才允许关闭。新增弹窗优先复用 `UnifiedModal`,不要继续复制只判断最终 `click` 目标的手写遮罩逻辑。
- 验证:回归测试同时覆盖“弹窗内按下、遮罩松开不关闭”和“遮罩按下、遮罩松开正常关闭”。
- 关联:`src/components/common/UnifiedModal.tsx``src/components/common/UnifiedModal.test.tsx``src/components/auth/PlatformAuthModalShell.test.tsx`
## 公开作品资产不能用 generated 前缀或 PublicRead 批量放行
- 现象:资产 ACL 收紧后,公开页面读取其他作者作品资产集中返回 `404`;对象在 OSS 中真实存在,但已登记 `asset_object.access_policy = private`
- 原因:“作品公开”不等于“作者账号下所有 generated 对象永久公开”。只按 profile / session 关联也会误公开同会话的未选候选图、参考图或生成输入;批量改 `PublicRead` 则无法随作品隐藏、删除或取消发布自动撤销。
- 处理:已登记对象继续保持 `private`,通过 `public_work_asset_read_grant` 只派生 `Published + visible``custom-world` 还必须未删除)正式发布快照实际使用资产的匿名读授权。API 必须同时校验 grant owner 与资产 owner 一致,以及 `asset_object_id` 或精确 `object_key` 命中;明确排除参考图、未选候选图和 `generationInputs`。Custom World 只能扫描角色、地标、营地、章节和 opening CG 等正式根,不能遍历 legacy payload 的未知根。历史作品交给 view 现算补齐,不做永久 ACL 数据补丁。
- 负查询边界:不能从 `asset_object` 订阅 cache miss 推断 metadata 不存在;订阅失败或增量尚未到达会让已登记 private 对象误走 legacy 放行。资产定位必须通过受 runtime service identity 限制的 procedure 按 bucket / key 权威点查,失败时拒绝读取;同时不要在每个池连接订阅复制全量 private 资产表。
- Remix 边界:拼图、Custom World 和大鱼现有 Remix 会把源资产引用复制到新 owner,但没有持久化不可伪造的资产来源。不得因此放宽跨 owner grant;源作品隐藏后仍公开的 Remix 资产,需要后续通过 Remix 时复制资产或持久化 provenance 解决。
- 验证:资产 owner 本人仍可读;公开可见作品的正式资产可匿名读;跨 owner、只命中前缀、参考图、未选候选图和 `generationInputs` 仍返回不存在;作品隐藏、删除或取消发布后 grant 消失。
- 关联:`server-rs/crates/spacetime-module/src/public_asset_access.rs``server-rs/crates/spacetime-client/src/assets.rs``server-rs/crates/api-server/src/assets.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
@@ -58,7 +58,7 @@ npm run check:server-rs-ddd
- 认证与账号:`/api/auth/*``/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。
- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。
- 平台基础能力:`/api/llm/*``/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。
- 资产基础能力:`/api/assets/direct-upload-tickets``/api/assets/sts-upload-credentials``/api/assets/objects/*``/api/assets/read-url``/api/assets/read-bytes`,负责直传、确认、绑定和读取。两个读取入口共用同一授权函数,并按配置 bucket 与精确 key 查询 `asset_object`一旦存在 metadata,即使 key 命中 legacy 前缀,也必须按 `PublicRead`当前登录 owner 授权;只有同 bucket / key 未登记 metadata 的历史对象,才允许显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` curated 白名单后匿名兼容。任意未登记 `objectKey`、跨 owner 和匿名私有读取统一返回不存在,`read-bytes` 不得成为绕过 `read-url` 授权的同源代理。
- 资产基础能力:`/api/assets/direct-upload-tickets``/api/assets/sts-upload-credentials``/api/assets/objects/*``/api/assets/read-url``/api/assets/read-bytes`,负责直传、确认、绑定和读取。两个读取入口共用同一授权函数,并通过受 runtime service identity 限制的 procedure 按配置 bucket 与精确 key 权威查询 `asset_object`;不得把订阅 cache miss 解释为对象未登记。一旦存在 metadata,即使 key 命中 legacy 前缀,也必须按 `PublicRead`当前登录 owner,或同 owner 的 `public_work_asset_read_grant` 精确授权读取;只有同 bucket / key 的权威查询确认未登记时,才允许显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` curated 白名单后匿名兼容。已登记资产继续保持 `private`,公开作品只获得与正式发布快照生命周期一致的派生读授权,不得批量改为 `PublicRead` 或放开 `generated-*` 前缀。任意未登记 `objectKey`、跨 owner 和未获授权的匿名私有读取统一返回不存在,`read-bytes` 不得成为绕过 `read-url` 授权的同源代理。
- 外部 OpenAPI`/api/external/v1/openapi.json``/api/external/v1/assets/direct-upload-tickets``/api/external/v1/assets/objects/confirm``/api/external/v1/assets/read-url``/api/external/v1/editor/*`,使用 Bearer API Key 鉴权;API Key 管理仍在登录态 `/api/profile/api-keys`,不进入外部 OpenAPI JSON。主站和 External 的 asset object confirm 都必须从已认证主体派生 owner,不能信任请求体 owner;同 bucket / key 已登记后不得改变 owner。
- 创作 / 游玩支撑能力:`/api/creation-entry/config``/api/ai/tasks*``/api/runtime/frontend-config``/api/runtime/chat/*``/api/runtime/settings``/api/runtime/save/snapshot``/api/profile/browse-history``/api/profile/save-archives*``/api/profile/play-stats``/api/assets/history``/api/assets/character-visual/*``/api/assets/character-animation/*``/api/assets/character-workflow-cache*``/api/assets/hyper3d/*``/api/runtime/custom-world/asset-studio/*``/api/editor/projects*``/api/editor/projects/{projectId}/agent-conversations``/api/editor/agent-conversations/{conversationId}*``/api/runtime/frontend-config``api-server` 从运行时环境变量下发非敏感 UI 开关;画板右侧 Agent 入口由 `GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR` 控制,默认关闭,前端不再读取 `VITE_*` 构建期变量决定生产显示。`/api/runtime/custom-world/asset-studio/*` 解析默认角色形象 / 动作提示词时可以在 OSS 缓存不可用或未配置时按无缓存返回默认提示;保存 workflow 缓存和真实素材读写仍必须要求 OSS 正常可用。
- 后台入口配置:`/admin/api/creation-entry/config``/admin/api/creation-entry/config/banners``/admin/api/creation-entry/config/interactions`
@@ -172,7 +172,7 @@ npm run check:server-rs-ddd
4. 删除字段、改名、重排字段、改类型或修改字段属性前,必须先询问用户并确认迁移计划。
5. Vec 字段不要直接写无法 const 求值的 default;需要默认空集合时优先使用 `Option<Vec<T>>``#[default(None::<Vec<T>>)]`,业务层归一为空数组。
6. 运行态读表必须按已声明索引访问。只要 table 上存在覆盖查询前缀的 `#[index(...)]` 或主键 / unique accessor,列表、详情、快照组装和计数都先用对应 accessor `.filter(...)` / `.find(...)`,再在内存中处理索引无法覆盖的残余条件;不得用 `.iter().filter(...)` 扫整表替代现成索引。
7. 面向公开列表的只读投影优先做成 public view / public 读模型表,并由 `api-server``spacetime-client` 长期订阅后读本地 cache。跨玩法公开作品统一主读模型是 `public_work_gallery_entry``public_work_detail_entry`;各玩法既有 `*_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 维持。
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. 修改后运行:
@@ -309,7 +309,14 @@ npm run check:server-rs-ddd
- Rust 结构体:`AssetObject`
- 源码:`server-rs/crates/spacetime-module/src/asset_metadata/objects.rs`
- 说明:对象 metadata 以 bucket / key 标识正式对象及其 owner、访问策略。确认接口的 owner 必须来自登录会话或 External API Key 绑定的认证主体,不接受请求体指定 owner;同 bucket / key 首次登记后,重复 confirm 不得改变 owner。资产读取必须先查询同 bucket / key metadata,存在时严格执行 `PublicRead` / owner ACL;只有 metadata 不存在的历史对象才能进入 curated legacy 白名单兼容。
- 说明:对象 metadata 以 bucket / key 标识正式对象及其 owner、访问策略。确认接口的 owner 必须来自登录会话或 External API Key 绑定的认证主体,不接受请求体指定 owner;同 bucket / key 首次登记后,重复 confirm 不得改变 owner。已登记对象默认并继续保持 `private`。API 通过 `get_asset_object_by_location_and_return` / `get_asset_object_by_id_and_return` 在服务端索引上权威查询 private tableprocedure 只允许 runtime service identity 调用;`spacetime-client` 不订阅全量 `asset_object`。位置查询发现重复 bucket / key 时失败关闭,不能任选一条继续授权。只有权威位置查询返回不存在的历史对象才能进入 curated legacy 白名单兼容。
### SpacetimeDB view`public_work_asset_read_grant`
- Rust view`public_work_asset_read_grant`
- 返回类型:`Vec<PublicWorkAssetReadGrant>`
- 源码:`server-rs/crates/spacetime-module/src/public_asset_access.rs`
- 说明:匿名公开派生授权投影,仅从 `Published + visible` 作品(`custom-world` 还必须满足未删除)的正式发布快照收集实际使用的已登记资产。每条 grant 都携带作品 owner,读取时必须与 `asset_object.owner_user_id` 一致,并且只能按 `asset_object_id` 或精确 `object_key` 命中;跨 owner、同前缀或相似 key 不构成授权。历史公开作品由 view 现算自动补齐;作品隐藏、删除或取消发布后 grant 随 client 收到 view 更新而消失,已签发 URL 继续受短 TTL 约束。投影明确排除参考图、未选中候选图和 `generationInputs`Custom World 只扫描角色、地标、营地、章节和 opening CG 等正式根,不递归 legacy payload 未知字段,避免把预览、编辑输入或同会话其他私有资产扩大为公开资产。
### `auth_identity`
@@ -923,6 +930,7 @@ npm run check:server-rs-ddd
- `SELECT * FROM public_work_gallery_entry`
- `SELECT * FROM public_work_detail_entry`
- `SELECT * FROM public_work_asset_read_grant`
- `SELECT * FROM bark_battle_gallery_view`
- `SELECT * FROM puzzle_gallery_card_view`
- `SELECT * FROM puzzle_clear_gallery_card_view`
@@ -945,7 +953,8 @@ npm run check:server-rs-ddd
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle-clear'`
- `SELECT * FROM creation_entry_config`
- `SELECT * FROM creation_entry_type_config`
- `SELECT * FROM asset_object`
private `asset_object` 不进入长期订阅;安全判断通过受限 procedure 在服务端按主键或 `(bucket, object_key)` 索引读取事务内真相,避免全表复制、订阅失败和增量同步延迟把已登记私有对象误判为 legacy 未登记对象。
跨玩法公开作品列表 / 详情主读模型是 `public_work_gallery_entry``public_work_detail_entry`。拼图、自定义世界等旧玩法公开列表 HTTP 路由保留原响应 shape,由 BFF mapper 从统一 public cache 映射回当前 DTO;旧 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 继续作为 source view 和兼容缓存。各玩法的个人作品列表、详情、发布、点赞、游玩记录、Remix 和其它需要鉴权或写入副作用的路径继续走 procedure / reducer;不要为了公开列表性能把这些 owner-specific 或 mutation 语义混进 public view。
+84 -27
View File
@@ -664,15 +664,31 @@ async fn authorize_asset_read_target(
let asset_object = state
.spacetime_client()
.get_asset_object_by_object_key(target.object_key.clone())
.get_asset_object_by_location(module_assets::AssetObjectLocationInput {
bucket: configured_bucket.to_string(),
object_key: target.object_key.clone(),
})
.await
.map_err(map_asset_read_authorization_error)?;
if let Some(asset_object) = asset_object.as_ref() {
let public_work_granted = if asset_object.access_policy == AssetObjectAccessPolicy::Private
&& !asset_object_owner_matches(asset_object, authorization)
&& asset_object_storage_matches(asset_object, configured_bucket, &target.object_key)
{
state
.spacetime_client()
.is_asset_object_referenced_by_public_work(asset_object.clone())
.await
.map_err(map_asset_read_authorization_error)?
} else {
false
};
return require_asset_object_read_access(
asset_object,
configured_bucket,
target.object_key.as_str(),
authorization,
public_work_granted,
);
}
@@ -689,16 +705,35 @@ fn require_asset_object_read_access(
configured_bucket: &str,
object_key: &str,
authorization: &AssetReadAuthorization,
public_work_granted: bool,
) -> Result<(), AppError> {
let bucket_matches = asset_object.bucket.trim() == configured_bucket.trim();
let key_matches = asset_object.object_key.trim().trim_start_matches('/') == object_key;
if !bucket_matches || !key_matches {
if !asset_object_storage_matches(asset_object, configured_bucket, object_key) {
return Err(asset_read_not_found());
}
if asset_object.access_policy == AssetObjectAccessPolicy::PublicRead {
if asset_object.access_policy == AssetObjectAccessPolicy::PublicRead
|| public_work_granted
|| asset_object_owner_matches(asset_object, authorization)
{
return Ok(());
}
let owner_matches = match authorization {
Err(asset_read_not_found())
}
fn asset_object_storage_matches(
asset_object: &module_assets::AssetObjectRecord,
configured_bucket: &str,
object_key: &str,
) -> bool {
asset_object.bucket.trim() == configured_bucket.trim()
&& asset_object.object_key.trim().trim_start_matches('/') == object_key
}
fn asset_object_owner_matches(
asset_object: &module_assets::AssetObjectRecord,
authorization: &AssetReadAuthorization,
) -> bool {
match authorization {
AssetReadAuthorization::Owner(owner_user_id) => asset_object
.owner_user_id
.as_deref()
@@ -706,11 +741,6 @@ fn require_asset_object_read_access(
.is_some_and(|value| value == owner_user_id.trim()),
AssetReadAuthorization::Admin => true,
AssetReadAuthorization::Anonymous => false,
};
if owner_matches {
Ok(())
} else {
Err(asset_read_not_found())
}
}
@@ -993,6 +1023,7 @@ mod tests {
"genarrative-assets",
record.object_key.as_str(),
&super::AssetReadAuthorization::Owner("user-owner".to_string()),
false,
)
.is_ok()
);
@@ -1005,6 +1036,7 @@ mod tests {
"genarrative-assets",
record.object_key.as_str(),
&authorization,
false,
)
.expect_err("private asset should reject non-owner");
assert_eq!(error.status_code(), StatusCode::NOT_FOUND);
@@ -1021,6 +1053,7 @@ mod tests {
"genarrative-assets",
record.object_key.as_str(),
&super::AssetReadAuthorization::Anonymous,
false,
)
.is_ok()
);
@@ -1030,6 +1063,7 @@ mod tests {
"another-bucket",
record.object_key.as_str(),
&super::AssetReadAuthorization::Anonymous,
false,
)
.expect_err("bucket mismatch should be hidden")
.status_code(),
@@ -1037,6 +1071,37 @@ mod tests {
);
}
#[test]
fn published_visible_work_reference_grants_private_asset_read() {
let record = asset_object_record(
module_assets::AssetObjectAccessPolicy::Private,
Some("user-owner"),
);
assert!(
super::require_asset_object_read_access(
&record,
"genarrative-assets",
record.object_key.as_str(),
&super::AssetReadAuthorization::Anonymous,
true,
)
.is_ok()
);
assert_eq!(
super::require_asset_object_read_access(
&record,
"another-bucket",
record.object_key.as_str(),
&super::AssetReadAuthorization::Anonymous,
true,
)
.expect_err("public work grant must not bypass storage identity")
.status_code(),
StatusCode::NOT_FOUND
);
}
#[test]
fn legacy_public_fallback_only_accepts_curated_prefixes() {
assert!(super::is_supported_legacy_public_object_key(
@@ -1229,7 +1294,7 @@ mod tests {
}
#[tokio::test]
async fn read_url_returns_signed_legacy_public_object_url_when_oss_configured() {
async fn read_url_fails_closed_when_asset_metadata_authority_is_unavailable() {
let config = AppConfig {
oss_bucket: Some("genarrative-assets".to_string()),
oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()),
@@ -1252,7 +1317,7 @@ mod tests {
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response
.into_body()
@@ -1263,20 +1328,14 @@ mod tests {
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["ok"], Value::Bool(true));
assert_eq!(
payload["data"]["read"]["objectKey"],
Value::String("generated-characters/hero_001/visual/asset_01/master.png".to_string())
);
assert!(
payload["data"]["read"]["signedUrl"]
.as_str()
.is_some_and(|value| value.contains("x-oss-signature-version=OSS4-HMAC-SHA256"))
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string())
);
}
#[tokio::test]
async fn read_url_accepts_legacy_public_path_for_transition() {
async fn legacy_transition_does_not_bypass_asset_metadata_authority() {
let config = AppConfig {
oss_bucket: Some("genarrative-assets".to_string()),
oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()),
@@ -1298,7 +1357,7 @@ mod tests {
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response
.into_body()
@@ -1310,10 +1369,8 @@ mod tests {
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["read"]["objectKey"],
Value::String(
"generated-custom-world-scenes/profile_01/landmark_01/scene.png".to_string()
)
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string())
);
}
@@ -5818,9 +5818,15 @@ async fn ensure_editor_reference_object_key_owned(
}) {
return Ok(());
}
let Some(oss_client) = state.oss_client() else {
return Err(editor_reference_image_forbidden(object_key));
};
if let Some(asset_object) = state
.spacetime_client()
.get_asset_object_by_object_key(object_key.to_string())
.get_asset_object_by_location(module_assets::AssetObjectLocationInput {
bucket: oss_client.config_bucket().to_string(),
object_key: object_key.to_string(),
})
.await
.map_err(map_editor_project_error)?
{
@@ -32,6 +32,13 @@ pub struct AssetHistoryListInput {
pub limit: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetObjectLocationInput {
pub bucket: String,
pub object_key: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetObjectUpsertInput {
@@ -99,6 +99,73 @@ pub struct AssetObjectRecord {
pub updated_at: String,
}
/// 已发布且可见作品派生出的精确资产读取授权。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublicAssetReadGrant {
pub owner_user_id: String,
pub asset_object_id: Option<String>,
pub object_key: Option<String>,
}
/// 为公开资产授权生成可由订阅缓存点查的 owner-scoped 主键。
pub fn public_asset_read_grant_id(grant: &PublicAssetReadGrant) -> Option<String> {
let owner_user_id = grant.owner_user_id.trim();
if owner_user_id.is_empty() {
return None;
}
let owner_prefix = format!("owner:{}:{owner_user_id}", owner_user_id.len());
if let Some(asset_object_id) = grant
.asset_object_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Some(format!("{owner_prefix}|asset-object-id:{asset_object_id}"));
}
grant
.object_key
.as_deref()
.map(str::trim)
.map(|value| value.trim_start_matches('/'))
.filter(|value| !value.is_empty())
.map(|object_key| format!("{owner_prefix}|object-key:{object_key}"))
}
/// 判断资产对象是否命中同 owner 公开作品派生出的精确授权。
pub fn asset_object_matches_public_read_grant(
asset_object: &AssetObjectRecord,
grant: &PublicAssetReadGrant,
) -> bool {
let Some(asset_owner_user_id) = asset_object
.owner_user_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return false;
};
let grant_owner_user_id = grant.owner_user_id.trim();
if grant_owner_user_id.is_empty() || asset_owner_user_id != grant_owner_user_id {
return false;
}
grant
.asset_object_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some_and(|value| value == asset_object.asset_object_id.trim())
|| grant
.object_key
.as_deref()
.map(str::trim)
.map(|value| value.trim_start_matches('/'))
.filter(|value| !value.is_empty())
.is_some_and(|value| value == asset_object.object_key.trim().trim_start_matches('/'))
}
/// 面向 API 与前端展示的资产历史记录。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AssetHistoryEntryRecord {
@@ -126,3 +193,117 @@ pub struct AssetEntityBindingRecord {
pub created_at: String,
pub updated_at: String,
}
#[cfg(test)]
mod tests {
use super::*;
fn asset_object(owner_user_id: Option<&str>) -> AssetObjectRecord {
AssetObjectRecord {
asset_object_id: "assetobj_public_reference".to_string(),
bucket: "genarrative-assets".to_string(),
object_key: "generated-puzzle-assets/public-reference.png".to_string(),
access_policy: AssetObjectAccessPolicy::Private,
content_type: Some("image/png".to_string()),
content_length: 1,
content_hash: None,
version: 1,
source_job_id: None,
owner_user_id: owner_user_id.map(ToOwned::to_owned),
profile_id: None,
entity_id: Some("puzzle-session-001".to_string()),
asset_kind: "puzzle_cover_image".to_string(),
created_at: "2026-07-13T00:00:00Z".to_string(),
updated_at: "2026-07-13T00:00:00Z".to_string(),
}
}
fn grant(
owner_user_id: &str,
asset_object_id: Option<&str>,
object_key: Option<&str>,
) -> PublicAssetReadGrant {
PublicAssetReadGrant {
owner_user_id: owner_user_id.to_string(),
asset_object_id: asset_object_id.map(ToOwned::to_owned),
object_key: object_key.map(ToOwned::to_owned),
}
}
#[test]
fn public_work_grant_authorizes_exact_historical_object_key() {
let asset = asset_object(Some("user-owner"));
assert!(asset_object_matches_public_read_grant(
&asset,
&grant(
"user-owner",
None,
Some("generated-puzzle-assets/public-reference.png")
)
));
}
#[test]
fn public_work_grant_authorizes_exact_asset_object_id() {
let asset = asset_object(Some("user-owner"));
assert!(asset_object_matches_public_read_grant(
&asset,
&grant("user-owner", Some("assetobj_public_reference"), None)
));
}
#[test]
fn public_work_grant_rejects_cross_owner_and_unrelated_assets() {
let asset = asset_object(Some("user-owner"));
assert!(!asset_object_matches_public_read_grant(
&asset,
&grant("user-other", Some("assetobj_public_reference"), None)
));
assert!(!asset_object_matches_public_read_grant(
&asset,
&grant("user-owner", Some("assetobj_other"), None)
));
assert!(!asset_object_matches_public_read_grant(
&asset_object(None),
&grant("user-owner", Some("assetobj_public_reference"), None)
));
}
#[test]
fn public_work_grant_id_is_owner_scoped_and_normalizes_object_key() {
assert_eq!(
public_asset_read_grant_id(&grant(
"user-owner",
Some("assetobj_public_reference"),
None
)),
Some("owner:10:user-owner|asset-object-id:assetobj_public_reference".to_string())
);
assert_eq!(
public_asset_read_grant_id(&grant(
"user-owner",
None,
Some("/generated-puzzle-assets/public-reference.png")
)),
Some(
"owner:10:user-owner|object-key:generated-puzzle-assets/public-reference.png"
.to_string()
)
);
assert_ne!(
public_asset_read_grant_id(&grant(
"user-owner",
Some("assetobj_public_reference"),
None
)),
public_asset_read_grant_id(&grant(
"user-other",
Some("assetobj_public_reference"),
None
))
);
}
}
+4 -2
View File
@@ -17,13 +17,15 @@ pub use asset_object_service::{
AssetObjectService, ConfirmAssetObjectError, InMemoryAssetObjectStore,
};
pub use commands::{
AssetEntityBindingInput, AssetHistoryListInput, AssetObjectUpsertInput, ConfirmAssetObjectInput,
AssetEntityBindingInput, AssetHistoryListInput, AssetObjectLocationInput,
AssetObjectUpsertInput, ConfirmAssetObjectInput,
};
pub use domain::{
ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingRecord,
AssetEntityBindingSnapshot, AssetHistoryEntryRecord, AssetHistoryEntrySnapshot,
AssetObjectAccessPolicy, AssetObjectRecord, AssetObjectUpsertSnapshot,
INITIAL_ASSET_OBJECT_VERSION,
INITIAL_ASSET_OBJECT_VERSION, PublicAssetReadGrant, asset_object_matches_public_read_grant,
public_asset_read_grant_id,
};
pub use errors::AssetObjectFieldError;
pub use events::{AssetDomainEvent, AssetEntityBindingChangedEvent, AssetObjectConfirmedEvent};
+74 -18
View File
@@ -50,29 +50,85 @@ impl SpacetimeClient {
&self,
asset_object_id: String,
) -> Result<Option<AssetObjectRecord>, SpacetimeClientError> {
self.read_after_connect("get_asset_object", move |connection| {
Ok(connection
.db()
.asset_object()
.asset_object_id()
.find(&asset_object_id)
.map(map_asset_object_row))
})
self.call_after_connect(
"get_asset_object_by_id_and_return",
move |connection, sender| {
connection
.procedures()
.get_asset_object_by_id_and_return_then(asset_object_id, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_optional_asset_object_procedure_result);
send_once(&sender, mapped);
});
},
)
.await
}
pub async fn get_asset_object_by_object_key(
pub async fn get_asset_object_by_location(
&self,
object_key: String,
input: module_assets::AssetObjectLocationInput,
) -> Result<Option<AssetObjectRecord>, SpacetimeClientError> {
self.read_after_connect("get_asset_object_by_object_key", move |connection| {
Ok(connection
.db()
.asset_object()
.iter()
.find(|row| row.object_key == object_key)
.map(map_asset_object_row))
})
let procedure_input = input.into();
self.call_after_connect(
"get_asset_object_by_location_and_return",
move |connection, sender| {
connection
.procedures()
.get_asset_object_by_location_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_optional_asset_object_procedure_result);
send_once(&sender, mapped);
},
);
},
)
.await
}
pub async fn is_asset_object_referenced_by_public_work(
&self,
asset_object: AssetObjectRecord,
) -> Result<bool, SpacetimeClientError> {
let owner_user_id = asset_object.owner_user_id.clone().unwrap_or_default();
let grant_candidates = [
module_assets::PublicAssetReadGrant {
owner_user_id: owner_user_id.clone(),
asset_object_id: Some(asset_object.asset_object_id.clone()),
object_key: None,
},
module_assets::PublicAssetReadGrant {
owner_user_id,
asset_object_id: None,
object_key: Some(asset_object.object_key.clone()),
},
];
self.read_after_connect(
"is_asset_object_referenced_by_public_work",
move |connection| {
let grants = connection.db().public_work_asset_read_grant();
Ok(grant_candidates.iter().any(|candidate| {
let Some(grant_id) = module_assets::public_asset_read_grant_id(candidate)
else {
return false;
};
grants.grant_id().find(&grant_id).is_some_and(|row| {
module_assets::asset_object_matches_public_read_grant(
&asset_object,
&module_assets::PublicAssetReadGrant {
owner_user_id: row.owner_user_id,
asset_object_id: row.asset_object_id,
object_key: row.object_key,
},
)
})
}))
},
)
.await
}
+42 -30
View File
@@ -383,6 +383,36 @@ pub enum SpacetimeClientError {
const DEFAULT_PROCEDURE_TIMEOUT: Duration = Duration::from_secs(30);
const PUBLIC_WORK_PLAY_DAY_MICROS: i64 = 86_400_000_000;
const PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS: i64 = 7;
const REQUIRED_CACHED_READ_MODEL_QUERIES: [&str; 13] = [
"SELECT * FROM public_work_gallery_entry",
"SELECT * FROM public_work_detail_entry",
"SELECT * FROM public_work_asset_read_grant",
"SELECT * FROM bark_battle_gallery_view",
"SELECT * FROM puzzle_gallery_card_view",
"SELECT * FROM puzzle_clear_gallery_card_view",
"SELECT * FROM jump_hop_gallery_card_view",
"SELECT * FROM wooden_fish_gallery_card_view",
"SELECT * FROM custom_world_gallery_entry",
"SELECT * FROM match_3_d_gallery_view",
"SELECT * FROM square_hole_gallery_view",
"SELECT * FROM visual_novel_gallery_view",
"SELECT * FROM big_fish_gallery_view",
];
const OPTIONAL_CACHED_READ_MODEL_QUERIES: [&str; 13] = [
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'",
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle-clear'",
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'jump-hop'",
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'wooden-fish'",
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'custom-world'",
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'match3d'",
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'square-hole'",
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'",
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'",
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'bark-battle'",
"SELECT * FROM creation_entry_config",
"SELECT * FROM creation_entry_type_config",
"SELECT * FROM user_account",
];
type ProcedureResultSender<T> =
Arc<Mutex<Option<oneshot::Sender<Result<T, SpacetimeClientError>>>>>;
@@ -882,20 +912,7 @@ impl SpacetimeClient {
operation_timeout: Duration,
) -> Result<Vec<SubscriptionHandle>, SpacetimeStageError> {
let mut subscriptions = Vec::new();
for query in [
"SELECT * FROM public_work_gallery_entry",
"SELECT * FROM public_work_detail_entry",
"SELECT * FROM bark_battle_gallery_view",
"SELECT * FROM puzzle_gallery_card_view",
"SELECT * FROM puzzle_clear_gallery_card_view",
"SELECT * FROM jump_hop_gallery_card_view",
"SELECT * FROM wooden_fish_gallery_card_view",
"SELECT * FROM custom_world_gallery_entry",
"SELECT * FROM match_3_d_gallery_view",
"SELECT * FROM square_hole_gallery_view",
"SELECT * FROM visual_novel_gallery_view",
"SELECT * FROM big_fish_gallery_view",
] {
for query in REQUIRED_CACHED_READ_MODEL_QUERIES {
let subscription = self
.subscribe_cached_read_model_query(
connection,
@@ -908,22 +925,7 @@ impl SpacetimeClient {
subscriptions.push(subscription);
}
for query in [
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'",
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle-clear'",
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'jump-hop'",
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'wooden-fish'",
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'custom-world'",
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'match3d'",
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'square-hole'",
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'",
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'",
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'bark-battle'",
"SELECT * FROM creation_entry_config",
"SELECT * FROM creation_entry_type_config",
"SELECT * FROM user_account",
"SELECT * FROM asset_object",
] {
for query in OPTIONAL_CACHED_READ_MODEL_QUERIES {
if let Ok(subscription) = self
.subscribe_cached_read_model_query(
connection,
@@ -1202,6 +1204,16 @@ mod tests {
));
}
#[test]
fn required_read_model_subscriptions_include_public_asset_grants() {
assert!(
REQUIRED_CACHED_READ_MODEL_QUERIES
.contains(&"SELECT * FROM public_work_asset_read_grant")
);
assert!(!REQUIRED_CACHED_READ_MODEL_QUERIES.contains(&"SELECT * FROM asset_object"));
assert!(!OPTIONAL_CACHED_READ_MODEL_QUERIES.contains(&"SELECT * FROM asset_object"));
}
fn test_client(pool_size: u32, procedure_timeout: Duration) -> SpacetimeClient {
SpacetimeClient::new(SpacetimeClientConfig {
// 指向本机不可达端口:测试只验证连接池行为,不需要真实 SpacetimeDB。
@@ -191,7 +191,8 @@ pub use self::wooden_fish::{
pub(crate) use self::ai::map_ai_task_procedure_result;
pub(crate) use self::assets::{
map_asset_object_row, map_entity_binding_procedure_result, map_procedure_result,
map_entity_binding_procedure_result, map_optional_asset_object_procedure_result,
map_procedure_result,
};
pub(crate) use self::auth::{
map_auth_store_projection_procedure_result, map_auth_store_projection_sync_procedure_result,
@@ -46,6 +46,15 @@ impl From<module_assets::AssetHistoryListInput> for AssetHistoryListInput {
}
}
impl From<module_assets::AssetObjectLocationInput> for AssetObjectLocationInput {
fn from(input: module_assets::AssetObjectLocationInput) -> Self {
Self {
bucket: input.bucket,
object_key: input.object_key,
}
}
}
pub(crate) fn map_procedure_result(
result: AssetObjectProcedureResult,
) -> Result<AssetObjectRecord, SpacetimeClientError> {
@@ -60,6 +69,42 @@ pub(crate) fn map_procedure_result(
Ok(build_asset_object_record(map_snapshot(snapshot)))
}
pub(crate) fn map_optional_asset_object_procedure_result(
result: AssetObjectProcedureResult,
) -> Result<Option<AssetObjectRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
Ok(result
.record
.map(map_snapshot)
.map(build_asset_object_record))
}
#[cfg(test)]
mod asset_object_procedure_result_tests {
use super::*;
#[test]
fn optional_asset_lookup_distinguishes_missing_record_from_failure() {
let missing = map_optional_asset_object_procedure_result(AssetObjectProcedureResult {
ok: true,
record: None,
error_message: None,
})
.expect("authoritative missing record should be a successful lookup");
assert!(missing.is_none());
let failed = map_optional_asset_object_procedure_result(AssetObjectProcedureResult {
ok: false,
record: None,
error_message: Some("runtime identity 不匹配".to_string()),
});
assert!(failed.is_err());
}
}
pub(crate) fn map_entity_binding_procedure_result(
result: AssetEntityBindingProcedureResult,
) -> Result<AssetEntityBindingRecord, SpacetimeClientError> {
@@ -115,26 +160,6 @@ pub(crate) fn map_snapshot(
}
}
pub(crate) fn map_asset_object_row(row: AssetObject) -> AssetObjectRecord {
build_asset_object_record(module_assets::AssetObjectUpsertSnapshot {
asset_object_id: row.asset_object_id,
bucket: row.bucket,
object_key: row.object_key,
access_policy: map_access_policy_back(row.access_policy),
content_type: row.content_type,
content_length: row.content_length,
content_hash: row.content_hash,
version: row.version,
source_job_id: row.source_job_id,
owner_user_id: row.owner_user_id,
profile_id: row.profile_id,
entity_id: row.entity_id,
asset_kind: row.asset_kind,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
})
}
pub(crate) fn map_access_policy(
value: AssetObjectAccessPolicy,
) -> crate::module_bindings::AssetObjectAccessPolicy {
@@ -94,6 +94,7 @@ pub mod asset_history_entry_snapshot_type;
pub mod asset_history_list_input_type;
pub mod asset_history_list_result_type;
pub mod asset_object_access_policy_type;
pub mod asset_object_location_input_type;
pub mod asset_object_procedure_result_type;
pub mod asset_object_table;
pub mod asset_object_type;
@@ -516,6 +517,8 @@ pub mod finish_match_3_d_time_up_procedure;
pub mod finish_square_hole_time_up_procedure;
pub mod finish_wooden_fish_run_procedure;
pub mod generate_big_fish_asset_procedure;
pub mod get_asset_object_by_id_and_return_procedure;
pub mod get_asset_object_by_location_and_return_procedure;
pub mod get_bark_battle_run_procedure;
pub mod get_bark_battle_runtime_config_procedure;
pub mod get_battle_state_procedure;
@@ -775,6 +778,8 @@ pub mod profile_wallet_config_table;
pub mod profile_wallet_config_type;
pub mod profile_wallet_ledger_table;
pub mod profile_wallet_ledger_type;
pub mod public_work_asset_read_grant_table;
pub mod public_work_asset_read_grant_type;
pub mod public_work_detail_entry_table;
pub mod public_work_detail_entry_type;
pub mod public_work_gallery_entry_table;
@@ -1432,6 +1437,7 @@ pub use asset_history_entry_snapshot_type::AssetHistoryEntrySnapshot;
pub use asset_history_list_input_type::AssetHistoryListInput;
pub use asset_history_list_result_type::AssetHistoryListResult;
pub use asset_object_access_policy_type::AssetObjectAccessPolicy;
pub use asset_object_location_input_type::AssetObjectLocationInput;
pub use asset_object_procedure_result_type::AssetObjectProcedureResult;
pub use asset_object_table::*;
pub use asset_object_type::AssetObject;
@@ -1854,6 +1860,8 @@ pub use finish_match_3_d_time_up_procedure::finish_match_3_d_time_up;
pub use finish_square_hole_time_up_procedure::finish_square_hole_time_up;
pub use finish_wooden_fish_run_procedure::finish_wooden_fish_run;
pub use generate_big_fish_asset_procedure::generate_big_fish_asset;
pub use get_asset_object_by_id_and_return_procedure::get_asset_object_by_id_and_return;
pub use get_asset_object_by_location_and_return_procedure::get_asset_object_by_location_and_return;
pub use get_bark_battle_run_procedure::get_bark_battle_run;
pub use get_bark_battle_runtime_config_procedure::get_bark_battle_runtime_config;
pub use get_battle_state_procedure::get_battle_state;
@@ -2113,6 +2121,8 @@ pub use profile_wallet_config_table::*;
pub use profile_wallet_config_type::ProfileWalletConfig;
pub use profile_wallet_ledger_table::*;
pub use profile_wallet_ledger_type::ProfileWalletLedger;
pub use public_work_asset_read_grant_table::*;
pub use public_work_asset_read_grant_type::PublicWorkAssetReadGrant;
pub use public_work_detail_entry_table::*;
pub use public_work_detail_entry_type::PublicWorkDetailEntry;
pub use public_work_gallery_entry_table::*;
@@ -3058,6 +3068,7 @@ pub struct DbUpdate {
profile_task_reward_claim: __sdk::TableUpdate<ProfileTaskRewardClaim>,
profile_wallet_config: __sdk::TableUpdate<ProfileWalletConfig>,
profile_wallet_ledger: __sdk::TableUpdate<ProfileWalletLedger>,
public_work_asset_read_grant: __sdk::TableUpdate<PublicWorkAssetReadGrant>,
public_work_detail_entry: __sdk::TableUpdate<PublicWorkDetailEntry>,
public_work_gallery_entry: __sdk::TableUpdate<PublicWorkGalleryEntry>,
public_work_like: __sdk::TableUpdate<PublicWorkLike>,
@@ -3415,6 +3426,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate {
"profile_wallet_ledger" => db_update.profile_wallet_ledger.append(
profile_wallet_ledger_table::parse_table_update(table_update)?,
),
"public_work_asset_read_grant" => db_update.public_work_asset_read_grant.append(
public_work_asset_read_grant_table::parse_table_update(table_update)?,
),
"public_work_detail_entry" => db_update.public_work_detail_entry.append(
public_work_detail_entry_table::parse_table_update(table_update)?,
),
@@ -4258,6 +4272,12 @@ impl __sdk::DbUpdate for DbUpdate {
"match_3_d_gallery_view",
&self.match_3_d_gallery_view,
);
diff.public_work_asset_read_grant = cache
.apply_diff_to_table::<PublicWorkAssetReadGrant>(
"public_work_asset_read_grant",
&self.public_work_asset_read_grant,
)
.with_updates_by_pk(|row| &row.grant_id);
diff.public_work_detail_entry = cache.apply_diff_to_table::<PublicWorkDetailEntry>(
"public_work_detail_entry",
&self.public_work_detail_entry,
@@ -4577,6 +4597,9 @@ impl __sdk::DbUpdate for DbUpdate {
"profile_wallet_ledger" => db_update
.profile_wallet_ledger
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"public_work_asset_read_grant" => db_update
.public_work_asset_read_grant
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"public_work_detail_entry" => db_update
.public_work_detail_entry
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
@@ -5007,6 +5030,9 @@ impl __sdk::DbUpdate for DbUpdate {
"profile_wallet_ledger" => db_update
.profile_wallet_ledger
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"public_work_asset_read_grant" => db_update
.public_work_asset_read_grant
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"public_work_detail_entry" => db_update
.public_work_detail_entry
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
@@ -5264,6 +5290,7 @@ pub struct AppliedDiff<'r> {
profile_task_reward_claim: __sdk::TableAppliedDiff<'r, ProfileTaskRewardClaim>,
profile_wallet_config: __sdk::TableAppliedDiff<'r, ProfileWalletConfig>,
profile_wallet_ledger: __sdk::TableAppliedDiff<'r, ProfileWalletLedger>,
public_work_asset_read_grant: __sdk::TableAppliedDiff<'r, PublicWorkAssetReadGrant>,
public_work_detail_entry: __sdk::TableAppliedDiff<'r, PublicWorkDetailEntry>,
public_work_gallery_entry: __sdk::TableAppliedDiff<'r, PublicWorkGalleryEntry>,
public_work_like: __sdk::TableAppliedDiff<'r, PublicWorkLike>,
@@ -5765,6 +5792,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
&self.profile_wallet_ledger,
event,
);
callbacks.invoke_table_row_callbacks::<PublicWorkAssetReadGrant>(
"public_work_asset_read_grant",
&self.public_work_asset_read_grant,
event,
);
callbacks.invoke_table_row_callbacks::<PublicWorkDetailEntry>(
"public_work_detail_entry",
&self.public_work_detail_entry,
@@ -6752,6 +6784,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
profile_task_reward_claim_table::register_table(client_cache);
profile_wallet_config_table::register_table(client_cache);
profile_wallet_ledger_table::register_table(client_cache);
public_work_asset_read_grant_table::register_table(client_cache);
public_work_detail_entry_table::register_table(client_cache);
public_work_gallery_entry_table::register_table(client_cache);
public_work_like_table::register_table(client_cache);
@@ -6893,6 +6926,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
"profile_task_reward_claim",
"profile_wallet_config",
"profile_wallet_ledger",
"public_work_asset_read_grant",
"public_work_detail_entry",
"public_work_gallery_entry",
"public_work_like",
@@ -0,0 +1,16 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct AssetObjectLocationInput {
pub bucket: String,
pub object_key: String,
}
impl __sdk::InModule for AssetObjectLocationInput {
type Module = super::RemoteModule;
}
@@ -0,0 +1,58 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::asset_object_procedure_result_type::AssetObjectProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct GetAssetObjectByIdAndReturnArgs {
pub asset_object_id: String,
}
impl __sdk::InModule for GetAssetObjectByIdAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `get_asset_object_by_id_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait get_asset_object_by_id_and_return {
fn get_asset_object_by_id_and_return(&self, asset_object_id: String) {
self.get_asset_object_by_id_and_return_then(asset_object_id, |_, _| {});
}
fn get_asset_object_by_id_and_return_then(
&self,
asset_object_id: String,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<AssetObjectProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl get_asset_object_by_id_and_return for super::RemoteProcedures {
fn get_asset_object_by_id_and_return_then(
&self,
asset_object_id: String,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<AssetObjectProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, AssetObjectProcedureResult>(
"get_asset_object_by_id_and_return",
GetAssetObjectByIdAndReturnArgs { asset_object_id },
__callback,
);
}
}
@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::asset_object_location_input_type::AssetObjectLocationInput;
use super::asset_object_procedure_result_type::AssetObjectProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct GetAssetObjectByLocationAndReturnArgs {
pub input: AssetObjectLocationInput,
}
impl __sdk::InModule for GetAssetObjectByLocationAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `get_asset_object_by_location_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait get_asset_object_by_location_and_return {
fn get_asset_object_by_location_and_return(&self, input: AssetObjectLocationInput) {
self.get_asset_object_by_location_and_return_then(input, |_, _| {});
}
fn get_asset_object_by_location_and_return_then(
&self,
input: AssetObjectLocationInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<AssetObjectProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl get_asset_object_by_location_and_return for super::RemoteProcedures {
fn get_asset_object_by_location_and_return_then(
&self,
input: AssetObjectLocationInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<AssetObjectProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, AssetObjectProcedureResult>(
"get_asset_object_by_location_and_return",
GetAssetObjectByLocationAndReturnArgs { input },
__callback,
);
}
}
@@ -0,0 +1,166 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use super::public_work_asset_read_grant_type::PublicWorkAssetReadGrant;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `public_work_asset_read_grant`.
///
/// Obtain a handle from the [`PublicWorkAssetReadGrantTableAccess::public_work_asset_read_grant`] method on [`super::RemoteTables`],
/// like `ctx.db.public_work_asset_read_grant()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.public_work_asset_read_grant().on_insert(...)`.
pub struct PublicWorkAssetReadGrantTableHandle<'ctx> {
imp: __sdk::TableHandle<PublicWorkAssetReadGrant>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `public_work_asset_read_grant`.
///
/// Implemented for [`super::RemoteTables`].
pub trait PublicWorkAssetReadGrantTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`PublicWorkAssetReadGrantTableHandle`], which mediates access to the table `public_work_asset_read_grant`.
fn public_work_asset_read_grant(&self) -> PublicWorkAssetReadGrantTableHandle<'_>;
}
impl PublicWorkAssetReadGrantTableAccess for super::RemoteTables {
fn public_work_asset_read_grant(&self) -> PublicWorkAssetReadGrantTableHandle<'_> {
PublicWorkAssetReadGrantTableHandle {
imp: self
.imp
.get_table::<PublicWorkAssetReadGrant>("public_work_asset_read_grant"),
ctx: std::marker::PhantomData,
}
}
}
pub struct PublicWorkAssetReadGrantInsertCallbackId(__sdk::CallbackId);
pub struct PublicWorkAssetReadGrantDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for PublicWorkAssetReadGrantTableHandle<'ctx> {
type Row = PublicWorkAssetReadGrant;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = PublicWorkAssetReadGrant> + '_ {
self.imp.iter()
}
type InsertCallbackId = PublicWorkAssetReadGrantInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> PublicWorkAssetReadGrantInsertCallbackId {
PublicWorkAssetReadGrantInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: PublicWorkAssetReadGrantInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = PublicWorkAssetReadGrantDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> PublicWorkAssetReadGrantDeleteCallbackId {
PublicWorkAssetReadGrantDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: PublicWorkAssetReadGrantDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct PublicWorkAssetReadGrantUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for PublicWorkAssetReadGrantTableHandle<'ctx> {
type UpdateCallbackId = PublicWorkAssetReadGrantUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> PublicWorkAssetReadGrantUpdateCallbackId {
PublicWorkAssetReadGrantUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: PublicWorkAssetReadGrantUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `grant_id` unique index on the table `public_work_asset_read_grant`,
/// which allows point queries on the field of the same name
/// via the [`PublicWorkAssetReadGrantGrantIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.public_work_asset_read_grant().grant_id().find(...)`.
pub struct PublicWorkAssetReadGrantGrantIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<PublicWorkAssetReadGrant, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> PublicWorkAssetReadGrantTableHandle<'ctx> {
/// Get a handle on the `grant_id` unique index on the table `public_work_asset_read_grant`.
pub fn grant_id(&self) -> PublicWorkAssetReadGrantGrantIdUnique<'ctx> {
PublicWorkAssetReadGrantGrantIdUnique {
imp: self.imp.get_unique_constraint::<String>("grant_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> PublicWorkAssetReadGrantGrantIdUnique<'ctx> {
/// Find the subscribed row whose `grant_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<PublicWorkAssetReadGrant> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table =
client_cache.get_or_make_table::<PublicWorkAssetReadGrant>("public_work_asset_read_grant");
_table.add_unique_constraint::<String>("grant_id", |row| &row.grant_id);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<PublicWorkAssetReadGrant>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<PublicWorkAssetReadGrant>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `PublicWorkAssetReadGrant`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait public_work_asset_read_grantQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `PublicWorkAssetReadGrant`.
fn public_work_asset_read_grant(
&self,
) -> __sdk::__query_builder::Table<PublicWorkAssetReadGrant>;
}
impl public_work_asset_read_grantQueryTableAccess for __sdk::QueryTableAccessor {
fn public_work_asset_read_grant(
&self,
) -> __sdk::__query_builder::Table<PublicWorkAssetReadGrant> {
__sdk::__query_builder::Table::new("public_work_asset_read_grant")
}
}
@@ -0,0 +1,46 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct PublicWorkAssetReadGrant {
pub grant_id: String,
pub source_type: String,
pub profile_id: String,
pub owner_user_id: String,
pub asset_object_id: Option<String>,
pub object_key: Option<String>,
}
impl __sdk::InModule for PublicWorkAssetReadGrant {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `PublicWorkAssetReadGrant`.
///
/// Provides typed access to columns for query building.
pub struct PublicWorkAssetReadGrantCols {
pub grant_id: __sdk::__query_builder::Col<PublicWorkAssetReadGrant, String>,
pub source_type: __sdk::__query_builder::Col<PublicWorkAssetReadGrant, String>,
pub profile_id: __sdk::__query_builder::Col<PublicWorkAssetReadGrant, String>,
pub owner_user_id: __sdk::__query_builder::Col<PublicWorkAssetReadGrant, String>,
pub asset_object_id: __sdk::__query_builder::Col<PublicWorkAssetReadGrant, Option<String>>,
pub object_key: __sdk::__query_builder::Col<PublicWorkAssetReadGrant, Option<String>>,
}
impl __sdk::__query_builder::HasCols for PublicWorkAssetReadGrant {
type Cols = PublicWorkAssetReadGrantCols;
fn cols(table_name: &'static str) -> Self::Cols {
PublicWorkAssetReadGrantCols {
grant_id: __sdk::__query_builder::Col::new(table_name, "grant_id"),
source_type: __sdk::__query_builder::Col::new(table_name, "source_type"),
profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"),
owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"),
asset_object_id: __sdk::__query_builder::Col::new(table_name, "asset_object_id"),
object_key: __sdk::__query_builder::Col::new(table_name, "object_key"),
}
}
}
@@ -96,6 +96,66 @@ pub fn confirm_asset_object_and_return(
}
}
// ACL 等安全判断必须读取事务内真相,不能把订阅 cache miss 当成对象不存在。
#[spacetimedb::procedure]
pub fn get_asset_object_by_location_and_return(
ctx: &mut ProcedureContext,
input: AssetObjectLocationInput,
) -> AssetObjectProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
find_asset_object_by_location(tx, &input)
}) {
Ok(record) => AssetObjectProcedureResult {
ok: true,
record,
error_message: None,
},
Err(message) => AssetObjectProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn get_asset_object_by_id_and_return(
ctx: &mut ProcedureContext,
asset_object_id: String,
) -> AssetObjectProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
let asset_object_id = asset_object_id.trim();
if asset_object_id.is_empty() {
return Err("asset_object_id 不能为空".to_string());
}
Ok(tx
.db
.asset_object()
.asset_object_id()
.find(&asset_object_id.to_string())
.map(asset_object_snapshot))
}) {
Ok(record) => AssetObjectProcedureResult {
ok: true,
record,
error_message: None,
},
Err(message) => AssetObjectProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 历史素材只返回编辑器复用所需的脱敏字段,asset_object 本表继续保持 private。
#[spacetimedb::procedure]
pub fn list_asset_history_and_return(
@@ -197,6 +257,54 @@ pub(crate) fn upsert_asset_object(
Ok(snapshot)
}
fn find_asset_object_by_location(
ctx: &ReducerContext,
input: &AssetObjectLocationInput,
) -> Result<Option<AssetObjectUpsertSnapshot>, String> {
let bucket = input.bucket.trim();
let object_key = input.object_key.trim().trim_start_matches('/');
if bucket.is_empty() || object_key.is_empty() {
return Err("bucket 与 object_key 不能为空".to_string());
}
let mut matches = ctx
.db
.asset_object()
.by_bucket_object_key()
.filter((bucket, object_key));
resolve_unique_asset_object_location(&mut matches)
}
fn resolve_unique_asset_object_location(
matches: &mut impl Iterator<Item = AssetObject>,
) -> Result<Option<AssetObjectUpsertSnapshot>, String> {
let record = matches.next();
if matches.next().is_some() {
return Err("asset_object 的 bucket + object_key 存在重复记录".to_string());
}
Ok(record.map(asset_object_snapshot))
}
fn asset_object_snapshot(row: AssetObject) -> AssetObjectUpsertSnapshot {
AssetObjectUpsertSnapshot {
asset_object_id: row.asset_object_id,
bucket: row.bucket,
object_key: row.object_key,
access_policy: row.access_policy,
content_type: row.content_type,
content_length: row.content_length,
content_hash: row.content_hash,
version: row.version,
source_job_id: row.source_job_id,
owner_user_id: row.owner_user_id,
profile_id: row.profile_id,
entity_id: row.entity_id,
asset_kind: row.asset_kind,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn require_matching_asset_object_owner(
existing_owner_user_id: Option<&str>,
next_owner_user_id: Option<&str>,
@@ -333,6 +441,46 @@ pub(crate) fn emit_asset_object_confirmed_event(
mod tests {
use super::*;
fn asset_object(asset_object_id: &str) -> AssetObject {
AssetObject {
asset_object_id: asset_object_id.to_string(),
bucket: "genarrative-assets".to_string(),
object_key: "generated-puzzle-assets/published.png".to_string(),
access_policy: AssetObjectAccessPolicy::Private,
content_type: Some("image/png".to_string()),
content_length: 1,
content_hash: None,
version: 1,
source_job_id: None,
owner_user_id: Some("owner-a".to_string()),
profile_id: Some("profile-a".to_string()),
entity_id: None,
asset_kind: "puzzle_cover_image".to_string(),
created_at: Timestamp::from_micros_since_unix_epoch(1),
updated_at: Timestamp::from_micros_since_unix_epoch(1),
}
}
#[test]
fn authoritative_location_lookup_fails_closed_on_duplicate_rows() {
let record = asset_object("assetobj-first");
let mut one = vec![record].into_iter();
assert_eq!(
resolve_unique_asset_object_location(&mut one)
.expect("one exact row should resolve")
.expect("one exact row should exist")
.asset_object_id,
"assetobj-first"
);
let mut duplicates = vec![
asset_object("assetobj-first"),
asset_object("assetobj-second"),
]
.into_iter();
assert!(resolve_unique_asset_object_location(&mut duplicates).is_err());
}
#[test]
fn registered_asset_object_owner_is_immutable() {
assert!(require_matching_asset_object_owner(Some("owner-a"), Some("owner-a")).is_ok());
@@ -39,6 +39,7 @@ mod gameplay;
mod jump_hop;
mod match3d;
mod migration;
mod public_asset_access;
mod public_work;
mod puzzle;
mod puzzle_clear;
@@ -63,6 +64,7 @@ pub use gameplay::*;
pub use jump_hop::*;
pub use match3d::*;
pub use migration::*;
pub use public_asset_access::*;
pub use public_work::*;
pub use puzzle_clear::*;
pub use runtime::*;
File diff suppressed because it is too large Load Diff