diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 33f5743bb..51a1c1dd6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -31,6 +31,14 @@ - 验证:`creationShowcaseModel.test.ts` 覆盖同源同媒体副本只展示原件;`ImageCanvasEditorAssetsIntegration.test.tsx` 覆盖拖拽生成素材到画布时继续提交 `sourceResourceId`;`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 确认后端资源复用 / 精选过滤逻辑可编译。 - 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`src/components/creation-home/creationShowcaseModel.ts`、`src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`。 +## 陶泥儿精选作者丢失先查公开资源 owner 字段 + +- 现象:`/creation` 的 `陶泥儿精选` 卡片和预览弹窗中,素材下方作者 id 消失或只显示占位。 +- 原因:精选数据源来自公开 `editor_project_resource` 快照;如果 SpacetimeDB read model、`spacetime-client` mapper 或 `api-server` payload 任一层漏传 `owner_user_id` / `ownerUserId`,前端作者兜底就没有真实值可显示。 +- 处理:`EditorProjectResourceSnapshot`、`EditorProjectResourceRecord` 和 `EditorProjectResourcePayload` 需要一路保留 owner 字段;前端 `creationShowcaseModel` 在没有作者昵称字段时读取 `ownerUserId` 作为作者兜底。 +- 验证:`creationShowcaseModel.test.ts` 覆盖 owner id 作者兜底;`editorProjectClient.test.ts` 覆盖公开精选接口客户端保留 `ownerUserId`;后端改 read model 后运行 `npm run spacetime:generate`、`cargo check -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml` 和 `npm run check:spacetime-schema`。 +- 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`server-rs/crates/spacetime-client/src/mapper/editor_project.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`src/components/creation-home/creationShowcaseModel.ts`。 + ## 画板外部生成排队超时不是失败 - 现象:画板发起付费图片生成后,前端弹出 `生成任务仍在队列中,请稍后刷新画布查看结果`,但后端任务仍在队列或执行中,后续可能正常完成。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 848cb1884..67d49adc6 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -468,7 +468,7 @@ npm run check:server-rs-ddd - Rust 结构体:`EditorProjectResource` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind`、`generation_inputs_json` 和 `public_showcase_enabled`。`asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复;`public_showcase_enabled` 控制该生成资源是否进入 `/creation` 的 `陶泥儿精选`,新增和旧行默认 `true`,但通过 `source_resource_id` 指向同一 owner 源资源且媒体引用相同的画布副本默认不公开。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot`、`source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 `editor_canvas.layers_json` 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。公开精选读取走 `GET /api/editor/showcase/resources`,只返回 `source_type = generated`、图片源非空且 `public_showcase_enabled = true` 的全站资源,并跳过同源同媒体副本,按 `created_at` 倒序取前 36 条;公开开关修改走登录鉴权 `PATCH /api/editor/project-resources/{resource_id}/showcase`,只允许资源 owner 更新。账号级素材删除不级联删除该表,避免历史画布丢图。`editor_canvas.layers_json` 只保存图层几何、层级、分组、资源引用和生成器对象;新写入不再把素材生成输入快照作为图层布局真相保存,旧布局字段只作为兼容兜底读取。 +- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind`、`generation_inputs_json` 和 `public_showcase_enabled`。`asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复;`public_showcase_enabled` 控制该生成资源是否进入 `/creation` 的 `陶泥儿精选`,新增和旧行默认 `true`,但通过 `source_resource_id` 指向同一 owner 源资源且媒体引用相同的画布副本默认不公开。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot`、`source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 `editor_canvas.layers_json` 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。公开精选读取走 `GET /api/editor/showcase/resources`,只返回 `source_type = generated`、图片源非空且 `public_showcase_enabled = true` 的全站资源,并跳过同源同媒体副本,按 `created_at` 倒序取前 36 条;公开响应必须透传 `ownerUserId`,供精选卡片在缺少作者昵称时显示作者 id。公开开关修改走登录鉴权 `PATCH /api/editor/project-resources/{resource_id}/showcase`,只允许资源 owner 更新。账号级素材删除不级联删除该表,避免历史画布丢图。`editor_canvas.layers_json` 只保存图层几何、层级、分组、资源引用和生成器对象;新写入不再把素材生成输入快照作为图层布局真相保存,旧布局字段只作为兼容兜底读取。 - 索引:`by_editor_project_resource_project_id`、`by_editor_project_resource_owner_user_id`。 ### `editor_asset_folder` diff --git a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md index f968603d5..347aca58c 100644 --- a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md +++ b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md @@ -63,7 +63,7 @@ `陶泥儿精选` 是页面底部的全站公开画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/` 工作台使用,但不作为本页精选区内容。 -精选内容只使用 `editor_project_resource` 中 `sourceType="generated"` 且 `public_showcase_enabled = true` 的项目资源,代表用户通过图片画布生成、已经落入某个项目并允许公开展示的素材。账号级 `editor_asset` 只表示跨项目素材库,不单独作为精选来源;上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。从项目素材把同一个生成素材再次拖入画布时,只是新增图层实例或复用同源同媒体项目资源,不应额外生成新的精选候选;历史上已经产生的同源同媒体副本也需要在精选读取时跳过。若现有数据缺少提示词、作者名或成本字段,v1 显示保守占位,不伪造内容。 +精选内容只使用 `editor_project_resource` 中 `sourceType="generated"` 且 `public_showcase_enabled = true` 的项目资源,代表用户通过图片画布生成、已经落入某个项目并允许公开展示的素材。账号级 `editor_asset` 只表示跨项目素材库,不单独作为精选来源;上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。从项目素材把同一个生成素材再次拖入画布时,只是新增图层实例或复用同源同媒体项目资源,不应额外生成新的精选候选;历史上已经产生的同源同媒体副本也需要在精选读取时跳过。公开 BFF 必须返回资源 `ownerUserId`,前端在没有作者昵称字段时用该 id 作为作者兜底。若现有数据缺少提示词、作者标识或成本字段,v1 显示保守占位,不伪造内容。 Tab: @@ -100,7 +100,7 @@ Tab: 数据来源: -- 读取公开 BFF `GET /api/editor/showcase/resources` 返回的全站公开 `editor_project_resource` 快照,只保留 `sourceType="generated"` 且 `publicShowcaseEnabled !== false` 的画布生成资源;若某条资源通过 `sourceResourceId` 指回同一 owner 的源资源且媒体引用相同,则视为拖拽画布副本,不进入精选。 +- 读取公开 BFF `GET /api/editor/showcase/resources` 返回的全站公开 `editor_project_resource` 快照,快照包含 `ownerUserId` 作为作者展示兜底;前端只保留 `sourceType="generated"` 且 `publicShowcaseEnabled !== false` 的画布生成资源;若某条资源通过 `sourceResourceId` 指回同一 owner 的源资源且媒体引用相同,则视为拖拽画布副本,不进入精选。 - 素材包、角色、UI、音乐、音效、视频和美宣都必须来自已存在项目资源;素材库里尚未放入任何项目的账号级素材不展示。 - 未登录用户也可读取公开精选;当没有公开画布生成资源时,不再用公开作品图片补充,只显示简洁空态。 - 上传素材、公开作品图片、mock 资源和假组合都不进入精选。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 25b3c27ec..eab945768 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -478,6 +478,7 @@ pub struct EditorCanvasPayload { pub struct EditorProjectResourcePayload { resource_id: String, project_id: String, + owner_user_id: String, image_src: String, object_key: Option, asset_object_id: Option, @@ -2480,6 +2481,7 @@ pub(crate) fn editor_project_resource_payload_from_record( EditorProjectResourcePayload { resource_id: record.resource_id, project_id: record.project_id, + owner_user_id: record.owner_user_id, image_src, object_key: record.object_key, asset_object_id: record.asset_object_id, @@ -3852,11 +3854,42 @@ mod tests { ); } + #[test] + fn editor_project_resource_payload_includes_owner_user_id() { + let payload = editor_project_resource_payload_from_record(EditorProjectResourceRecord { + resource_id: "resource-1".to_string(), + project_id: "project-1".to_string(), + owner_user_id: "user-author-1".to_string(), + asset_object_id: None, + image_src: "/generated-character-drafts/editor/spec.png".to_string(), + object_key: None, + width: 512, + height: 512, + source_type: "generated".to_string(), + prompt: None, + actual_prompt: None, + model: None, + provider: None, + task_id: None, + source_resource_id: None, + asset_kind: None, + generation_inputs: None, + public_showcase_enabled: true, + created_at: "2026-06-23T00:00:00.000Z".to_string(), + updated_at: "2026-06-23T00:00:00.000Z".to_string(), + }); + + let value = serde_json::to_value(payload).expect("payload should serialize"); + + assert_eq!(value["ownerUserId"], json!("user-author-1")); + } + #[test] fn editor_payload_media_sanitizer_replaces_inline_layer_src_from_resource() { let resources = vec![EditorProjectResourcePayload { resource_id: "resource-1".to_string(), project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), image_src: "/generated-character-drafts/editor/spec.png".to_string(), object_key: Some("generated-character-drafts/editor/spec.png".to_string()), asset_object_id: Some("asset-object-1".to_string()), @@ -3928,6 +3961,7 @@ mod tests { let existing_resources = vec![EditorProjectResourcePayload { resource_id: "resource-spec".to_string(), project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), image_src: "/generated-character-drafts/editor/spec.png".to_string(), object_key: Some("generated-character-drafts/editor/spec.png".to_string()), asset_object_id: Some("asset-object-spec".to_string()), @@ -3995,6 +4029,7 @@ mod tests { let generated_resource = EditorProjectResourcePayload { resource_id: "editor-resource-character".to_string(), project_id: "editor-project-1".to_string(), + owner_user_id: "user-1".to_string(), image_src: "/generated-character-drafts/editor/character.png".to_string(), object_key: Some("generated-character-drafts/editor/character.png".to_string()), asset_object_id: Some("asset-object-character".to_string()), @@ -4452,6 +4487,7 @@ mod tests { let resource = EditorProjectResourcePayload { resource_id: "editor-resource-character".to_string(), project_id: "editor-project-1".to_string(), + owner_user_id: "user-1".to_string(), image_src: "data:image/png;base64,character".to_string(), object_key: Some("generated-character-drafts/editor/character.png".to_string()), asset_object_id: Some("asset-object-1".to_string()), @@ -4552,6 +4588,7 @@ mod tests { let resource = EditorProjectResourcePayload { resource_id: "editor-resource-character".to_string(), project_id: "editor-project-1".to_string(), + owner_user_id: "user-1".to_string(), image_src: "data:image/png;base64,character".to_string(), object_key: None, asset_object_id: None, @@ -4633,6 +4670,7 @@ mod tests { let resource = EditorProjectResourcePayload { resource_id: "editor-resource-character".to_string(), project_id: "editor-project-1".to_string(), + owner_user_id: "user-1".to_string(), image_src: "data:image/png;base64,character".to_string(), object_key: None, asset_object_id: None, @@ -4755,6 +4793,7 @@ mod tests { let resource = EditorProjectResourcePayload { resource_id: "editor-resource-character".to_string(), project_id: "editor-project-1".to_string(), + owner_user_id: "user-1".to_string(), image_src: "data:image/png;base64,character".to_string(), object_key: None, asset_object_id: None, diff --git a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs index c17b47969..c2893f781 100644 --- a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs @@ -43,6 +43,7 @@ pub struct EditorProjectLayoutSaveAckRecord { pub struct EditorProjectResourceRecord { pub resource_id: String, pub project_id: String, + pub owner_user_id: String, pub asset_object_id: Option, pub image_src: String, pub object_key: Option, @@ -677,6 +678,7 @@ fn map_editor_project_resource_snapshot( Ok(EditorProjectResourceRecord { resource_id: snapshot.resource_id, project_id: snapshot.project_id, + owner_user_id: snapshot.owner_user_id, asset_object_id: snapshot.asset_object_id, image_src: snapshot.image_src, object_key: snapshot.object_key, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs index b1af43048..b74e44b80 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_resource_snapshot_type.rs @@ -9,6 +9,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; pub struct EditorProjectResourceSnapshot { pub resource_id: String, pub project_id: String, + pub owner_user_id: String, pub asset_object_id: Option, pub image_src: String, pub object_key: Option, 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 3d8585bab..03b550d26 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -229,6 +229,7 @@ pub struct EditorProjectResourcePublicShowcaseListInput { pub struct EditorProjectResourceSnapshot { pub resource_id: String, pub project_id: String, + pub owner_user_id: String, pub asset_object_id: Option, pub image_src: String, pub object_key: Option, @@ -1956,6 +1957,7 @@ fn resource_snapshot_from_row(row: EditorProjectResource) -> EditorProjectResour EditorProjectResourceSnapshot { resource_id: row.resource_id, project_id: row.project_id, + owner_user_id: row.owner_user_id, asset_object_id: row.asset_object_id, image_src: row.image_src, object_key: row.object_key, diff --git a/src/components/creation-home/creationShowcaseModel.test.ts b/src/components/creation-home/creationShowcaseModel.test.ts index 0bfe74cc1..c4aee3a37 100644 --- a/src/components/creation-home/creationShowcaseModel.test.ts +++ b/src/components/creation-home/creationShowcaseModel.test.ts @@ -76,6 +76,23 @@ describe('creationShowcaseModel', () => { expect(items[0]?.label).toBe('公开素材'); }); + it('uses the public project resource owner user id as showcase author fallback', () => { + const items = buildCreationShowcaseItems({ + activeTab: 'characters', + projectResources: [ + createResource({ + resourceId: 'owner-resource', + label: '作者兜底素材', + assetKind: 'character', + ownerUserId: 'user-author-1', + }), + ], + }); + + expect(items).toHaveLength(1); + expect(items[0]?.author).toBe('user-author-1'); + }); + it('deduplicates canvas copies that point back to the same generated resource', () => { const items = buildCreationShowcaseItems({ activeTab: 'characters', diff --git a/src/components/creation-home/creationShowcaseModel.ts b/src/components/creation-home/creationShowcaseModel.ts index 22fc6f608..e7ace3a37 100644 --- a/src/components/creation-home/creationShowcaseModel.ts +++ b/src/components/creation-home/creationShowcaseModel.ts @@ -68,6 +68,7 @@ const SHOWCASE_AUTHOR_KEYS = [ 'ownerLabel', 'ownerName', 'authorDisplayName', + 'ownerUserId', ] as const; export const SHOWCASE_TABS: Array<{ id: ShowcaseTabId; label: string }> = [ diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index 449a9b34a..75e00ed44 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -220,6 +220,7 @@ describe('editorProjectClient', () => { { resourceId: 'resource-public-1', projectId: 'editor-project-1', + ownerUserId: 'user-public-author', label: '公开角色素材', imageSrc: '/generated-editor-assets/public.png', width: 512, @@ -234,6 +235,7 @@ describe('editorProjectClient', () => { expect(resources).toHaveLength(1); expect(resources[0]?.resourceId).toBe('resource-public-1'); + expect(resources[0]?.ownerUserId).toBe('user-public-author'); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/showcase/resources', { method: 'GET' }, diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index b0ce14151..d3aa441b4 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -59,6 +59,7 @@ export type EditorProjectResourceSourceType = export type EditorProjectResourceSnapshot = { resourceId: string; projectId: string; + ownerUserId?: string | null; imageSrc: string; objectKey?: string | null; assetObjectId?: string | null;