修复精选栏用户id显示

公开精选资源接口透传 ownerUserId

精选模型使用 ownerUserId 作为作者兜底

补充前后端回归测试和相关文档
This commit is contained in:
2026-06-24 20:34:30 +08:00
parent db056423de
commit 468bdd5cb5
11 changed files with 76 additions and 3 deletions
@@ -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`
## 画板外部生成排队超时不是失败
- 现象:画板发起付费图片生成后,前端弹出 `生成任务仍在队列中,请稍后刷新画布查看结果`,但后端任务仍在队列或执行中,后续可能正常完成。
@@ -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`
@@ -63,7 +63,7 @@
`陶泥儿精选` 是页面底部的全站公开画布生成素材瀑布流,不承载玩法入口列表。瀑布流卡片按真实素材宽高设置预览比例,同一行允许出现不同高度卡片,不使用固定等高网格。创作入口配置仍继续来自 `/api/creation-entry/config`,供旧创作入口和具体 `/creation/<play>` 工作台使用,但不作为本页精选区内容。
精选内容只使用 `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 资源和假组合都不进入精选。
@@ -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<String>,
asset_object_id: Option<String>,
@@ -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,
@@ -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<String>,
pub image_src: String,
pub object_key: Option<String>,
@@ -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,
@@ -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<String>,
pub image_src: String,
pub object_key: Option<String>,
@@ -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<String>,
pub image_src: String,
pub object_key: Option<String>,
@@ -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,
@@ -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',
@@ -68,6 +68,7 @@ const SHOWCASE_AUTHOR_KEYS = [
'ownerLabel',
'ownerName',
'authorDisplayName',
'ownerUserId',
] as const;
export const SHOWCASE_TABS: Array<{ id: ShowcaseTabId; label: string }> = [
@@ -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' },
@@ -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;