修复陶泥儿精选点赞刷新状态 #156

Merged
kdletters merged 4 commits from showcase-liked into master 2026-08-12 13:53:30 +08:00
26 changed files with 1285 additions and 123 deletions
@@ -7077,6 +7077,13 @@
- 安全边界:动态端口不恢复旧 Vite 复用。无法证明 worktree 归属的监听器仍不复用、不主动终止;同用户多 worktree 通过段内漂移并行,不通过共享未知服务并行。
- 验证:公共端口映射、Linux 默认槽位与段内漂移、非 Linux 兼容漂移、Tauri 动态配置、启动前预检、进程树收束、AGC typecheck / 配置门禁、编码检查和差异检查必须通过。
## 2026-08-10 陶泥儿精选点赞状态使用可选鉴权 viewer 投影
- 背景:`editor_showcase_asset_like` 已持久化用户点赞,`editor_showcase_asset.like_count` 也会事务更新,但公开精选 GET 没有返回当前浏览者状态,前端每次挂载都从空本地集合开始,导致刷新后已点赞图标消失。
- 决策:保留同一个公开 `GET /api/editor/showcase/resources` 并接受可选 Bearer。匿名每项返回 `viewerLiked=false`;登录态只从已验证 claims 派生 viewer user ID,通过现有 runtime service identity 调用专用 SpacetimeDB viewer procedure,在同一事务快照中返回 `viewerLiked + likeCount`。无效 Bearer、viewer procedure 或私有读取失败显式失败,不降级匿名。GET 与点赞 POST 固定 `Cache-Control: private, no-store`,并在保留响应已有 `Vary` 字段的基础上追加 `Authorization`
- 写入与前端:现有 like 去重表继续是用户状态真相,不新增字段、迁移或历史修复。写路径只保留返回 `viewerLiked + likeCount` 的 viewer set procedure,并要求 runtime service identity;删除只返回素材聚合快照的旧 toggle procedure,输入 `user_id` 只表示经 BFF 验证后代入的业务主体,不能充当调用授权。前端不做乐观图标 / 计数更新,成功只采纳服务端权威回包,失败保留原状态并显示可访问短错误;登录、退出、账号切换与鉴权恢复重载列表,并按 viewer scope / request generation 丢弃旧首屏、分页和写回响应。request generation 只在已提交的精选加载 effect 中激活,并在 cleanup 中失效,禁止 render 阶段写 ref 让被放弃的并发渲染污染当前请求。不做跨 Tab 实时同步。
- 影响范围:SpacetimeDB typed procedure 与 bindings、`spacetime-client` mapper / facade、api-server 可选鉴权与 public DTO、`editorProjectClient``CreationLandingView` 和对应回归测试;不修改持久 schema、migration 或 External v1 契约。
## 2026-08-10 完美像素最终持久化逻辑分辨率 PNG
- 背景:SpriteFusion Pixel Snapper 上游在网格采样后直接输出 `(列切线数 - 1) × (行切线数 - 1)` 的逻辑图,每个检测单元恰好对应一个输出像素。Genarrative 首版在此后增加了 nearest 输入尺寸恢复;当输入宽高不能被逻辑列数、行数整除时,最近邻只能把同一逻辑像素分配到 `floor / ceil` 数量不等的目标列或行,最终物理像素块宽窄不一。已有 `128 → 64` 的整数倍测试没有覆盖该问题。
@@ -4661,6 +4661,13 @@
- 处理:所有付费编辑器生成在队列 enqueue 前和 worker / inline 执行前复用只读 `preflight_editor_generation_target_and_return`,按认证 owner 校验可选项目及归一化目录;读取失败和归属不匹配一律失败关闭。helper 返回 canonical 项目与目录并覆写后续入队 / worker / 原子准备使用的 payload,不能校验 trim 后的项目却持久化原始空白值。角色图片、角色动作、图标 spritesheet 与 UI 提取省略目录时按实际默认目录预检;默认目录允许尚未创建,自定义目录必须存在且 owned。预检不替代最终 procedure 复验,也不保证跨外部调用的目录锁定。
- 验证:源码顺序回归必须覆盖图片生成、图片修改、图标 spritesheet、UI 设计图提取、视频、角色动作、SFX 与 BGM 的 enqueue / direct 两层,证明纯本地格式和 `data:` / `blob:` 稳定引用门禁先执行,canonical target 在预检后写回 payload,远端引用解析、generation input rebuild、扣费、入队、provider 与 OSS 均留在预检之后;模块侧扫描证明预检只调用 runtime identity、项目、目录只读校验且不含 insert / update / delete,并覆盖带空白项目、`project`、旧 `folder-*`、默认目录 ID、自定义目录与 `None` 归一化。
## 聚合点赞数不能恢复当前浏览者是否点赞(2026-08-10)
- 现象:陶泥儿精选点赞写入成功、总点赞数也正确,但刷新或重新挂载后图标恢复成未点赞;前端再次点击会发出错误意图或让计数体验混乱。
- 原因:`editor_showcase_asset.like_count` 只表达全局聚合,公开列表未携带 `editor_showcase_asset_like(showcase_id:user_id)` 的 viewer 状态;前端用生命周期内的空 `Set` 充当真相,刷新必然丢失。仅靠 `likeCount > 0` 无法判断其中是否包含当前用户。
- 处理:公开列表使用可选鉴权 viewer 投影;登录态从 Bearer claims 派生 user ID,并在公开列表事务内按确定性 like 主键返回 `viewerLiked`,匿名固定 false。个性化响应禁止共享缓存或错误降级,追加 `Vary: Authorization` 时不得覆盖 handler 或内层中间件已有字段。写入采用服务端确认式更新,账号 / 鉴权 scope 变化后重载并丢弃旧请求回包;request generation 的激活与失效必须跟随已提交 effect,不能在 render 阶段修改 ref;输入 user ID 不能代替 runtime service identity 鉴权。
- 验证:覆盖刷新 / remount 保持已点赞、pending 期间不改图标计数、失败保留旧状态并播报错误、分页保留 viewer state、鉴权恢复不发匿名请求、登录 / 退出 / 换号与旧首屏 / 分页 / POST 回包竞态、被 Suspense 放弃的 viewer 渲染不影响当前已提交请求,以及无效 Bearer 返回 `401 + private,no-store + Vary: Authorization`;中间件测试另需证明已有 `Vary` 字段被保留。
## 非整除 nearest 会让逻辑像素块宽窄不一(2026-08-10)
- 现象:像素规整后的图片虽然保持了源图宽高,放大观察却能看到相邻逻辑块占用的物理列数或行数不同,表现为部分块更宽、部分块更窄;整数倍样例看起来正常,换一张网格数不能整除输入尺寸的图才复现。
@@ -729,7 +729,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- Rust 结构体:`EditorShowcaseAssetLike`
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
- 说明:`陶泥儿精选` 点赞去重表,主键由 `showcase_id:user_id` 组成。点赞 / 取消点赞通过登录接口更新该表,并同步回写 `editor_showcase_asset.like_count`;未通过审核或未展示的精选素材不能点赞
- 说明:`陶泥儿精选` 点赞去重表,主键由 `showcase_id:user_id` 组成,是“某用户是否点赞”的唯一业务真相;`editor_showcase_asset.like_count` 只是全局聚合,不能反推出当前浏览者状态。点赞 / 取消点赞通过登录接口在同一事务中幂等更新去重行与聚合计数,并返回权威 `viewer_liked + like_count`;写路径只保留返回该 viewer 权威状态的 `set_editor_showcase_asset_like_for_viewer_and_return`,不保留只返回素材聚合快照的旧 toggle procedure。未通过审核或未展示的精选素材不能点赞。公开 `GET /api/editor/showcase/resources` 接受可选 Bearer:匿名不读取私有 like 表并返回 `viewerLiked=false`,有效登录态由 api-server 从 claims 派生 `viewer_user_id`,通过 runtime service identity 调用 viewer procedure,在公开排序 / 截断后按确定性主键读取每项状态。viewer 读取与所有 like 写 procedure 都先在 `ProcedureContext` 捕获 `ctx.sender()`,再在事务内校验现有 runtime service identity;不得信任 procedure 输入中的 `user_id` 作为调用方授权。无效 Bearer 或个性化读取失败不降级匿名
- 索引:`by_editor_showcase_asset_like_showcase_id``by_editor_showcase_asset_like_user_id`
### `editor_showcase_campaign_config`
@@ -1164,7 +1164,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
### api-server 长期订阅读模型
> 现役覆盖:`spacetime-client` 不再把旧创作入口配置、公开作品聚合表或逐玩法 gallery view 作为连接池必需订阅。`REQUIRED_CACHED_READ_MODEL_QUERIES` 当前为空,`user_account` 仅作为认证兼容所需的可选缓存。新版 `/creation` 的公开内容通过 `GET /api/editor/showcase/resources` 读取编辑器精选 read model;旧作品、入口配置和玩法统计表只保留 schema 数据壳,不再形成订阅 cache、BFF 路由或前端契约。
> 现役覆盖:`spacetime-client` 不再把旧创作入口配置、公开作品聚合表或逐玩法 gallery view 作为连接池必需订阅。`REQUIRED_CACHED_READ_MODEL_QUERIES` 当前为空,`user_account` 仅作为认证兼容所需的可选缓存。新版 `/creation` 的公开内容通过可选鉴权的 `GET /api/editor/showcase/resources` 读取编辑器精选 read model登录态的 viewer like 投影在请求事务内读取,不进入共享长期订阅 cache,HTTP 响应固定 `private, no-store` 并按 `Authorization` 区分。旧作品、入口配置和玩法统计表只保留 schema 数据壳,不再形成订阅 cache、BFF 路由或前端契约。
#### 退役前历史订阅清单
@@ -79,7 +79,7 @@
精选内容只使用用户从账号级素材库主动提交、后台审核通过且展示状态开启的 `editor_showcase_asset` 快照。新生成素材不会默认公开,审核通过后也不会自动展示;运营可在后台按前台具体 Tab 设置分类(角色、UI、音乐、美宣)并开启展示,未设置分类的素材不会隐藏,会进入前台“全部”。旧 `editor_project_resource.public_showcase_enabled` 只保留历史兼容,不再作为 `/creation` 精选事实源。账号级 `editor_asset` 仍是素材库私有事实;只有 `sourceType="generated"`、有媒体内容、提交审核并通过的素材才可进入精选,上传素材、公开作品图片和 `mock_generated` 资源都不进入精选。审核通过时按生成成本返还 50% 泥点,返还流水使用确定性 `editor-showcase-refund:{showcaseId}` 保证幂等;批准状态、钱包返还流水和 `refund_completed_at` 必须在同一个 SpacetimeDB 事务中完成,任一步失败都保持待审核,历史已通过但未返还记录重复批准时按同一流水补齐且不得重复入账。已通过、已开启展示且返还完成的精选快照必须按同 owner 的精确 `assetObjectId``objectKey` 派生匿名读取授权,不能放开整个 `generated-*` 前缀。公开 BFF 必须返回作者公开展示字段:优先 `authorDisplayName` / `display_name`,没有展示名时兜底 `authorPublicUserCode` / 陶泥号;前端展示绝不能兜底到内部 `ownerUserId` / `user_id`。若现有数据缺少提示词、作者公开标识或成本字段,v1 显示保守占位,不伪造内容。
精选素材流通过 `GET /api/editor/showcase/resources` 按通过审核时间 / `showcaseId` 倒序 cursor 分页读取,每页最多 36 条响应有 `nextCursor` 时,页面滚动到底部继续请求 `?cursor=...` 并追加到现有列表,而不是固定只展示首屏数量。追加、筛选、分类和容器/卡片尺寸变化都必须按当前完整 DOM 顺序重算循环分列;位置未完整时保留正常文档流 fallback,不能让分页 sentinel 因容器短暂零高提前触发。响应可以额外携带后台配置的固定活动卡,用于在列表首位展示运营精选;活动卡作为第一张进入第一列,普通素材从第二张继续同一循环列序。活动卡图片继续保持 private:后台直传 OSS 后必须先确认正式 `asset_object`,公开读取只由已启用 `global` 配置对活动卡专用目录中的当前 exact `imageObjectKey` 派生,禁用或替换图片后旧 key 不再可读;历史缺 metadata 的当前活动卡可通过同一 exact 配置授权恢复,但新上传不能跳过 confirm。
精选素材流通过 `GET /api/editor/showcase/resources` 按通过审核时间 / `showcaseId` 倒序 cursor 分页读取,每页最多 36 条。该 GET 保持公开,但接受可选 Bearer:匿名响应中每项固定 `viewerLiked=false`;有效登录态由 BFF 从已验证 claims 派生用户 ID,并在同一 SpacetimeDB 事务快照中返回该用户的权威 `viewerLiked` 与全局 `likeCount`。只要请求携带无效或过期 Bearer 就返回 `401`,个性化读取失败也显式失败,禁止静默降级成“未点赞”。响应统一发送 `Cache-Control: private, no-store``Vary: Authorization`响应有 `nextCursor` 时,页面滚动到底部继续请求 `?cursor=...` 并追加到现有列表,而不是固定只展示首屏数量。追加、筛选、分类和容器/卡片尺寸变化都必须按当前完整 DOM 顺序重算循环分列;位置未完整时保留正常文档流 fallback,不能让分页 sentinel 因容器短暂零高提前触发。响应可以额外携带后台配置的固定活动卡,用于在列表首位展示运营精选;活动卡作为第一张进入第一列,普通素材从第二张继续同一循环列序。活动卡图片继续保持 private:后台直传 OSS 后必须先确认正式 `asset_object`,公开读取只由已启用 `global` 配置对活动卡专用目录中的当前 exact `imageObjectKey` 派生,禁用或替换图片后旧 key 不再可读;历史缺 metadata 的当前活动卡可通过同一 exact 配置授权恢复,但新上传不能跳过 confirm。
Tab
@@ -116,7 +116,7 @@ Tab
数据来源:
- 读取公开 BFF `GET /api/editor/showcase/resources` 返回的全站公开 `editor_showcase_asset` 快照,快照包含 `showcaseId``assetId`、审核状态、展示状态、`showcaseCategory`点赞数、生成成本、返还泥点、`authorDisplayName` / `display_name``authorPublicUserCode` / 陶泥号用于展示;前端默认进入“全部”,展示所有后端已经筛过的公开结果;角色、UI、音乐或美宣 Tab 只展示对应 `showcaseCategory` 的素材。作者展示优先展示名,没有展示名时展示陶泥号,绝不能展示内部 `ownerUserId` / `user_id`
- 读取公开 BFF `GET /api/editor/showcase/resources` 返回的全站公开 `editor_showcase_asset` 快照,快照包含 `showcaseId``assetId`、审核状态、展示状态、`showcaseCategory``likeCount`、当前浏览者的 `viewerLiked`、生成成本、返还泥点、`authorDisplayName` / `display_name``authorPublicUserCode` / 陶泥号用于展示;前端默认进入“全部”,展示所有后端已经筛过的公开结果;角色、UI、音乐或美宣 Tab 只展示对应 `showcaseCategory` 的素材。作者展示优先展示名,没有展示名时展示陶泥号,绝不能展示内部 `ownerUserId` / `user_id`
- 全部、角色、UI、音乐、音效、视频和美宣都必须来自用户素材库中已提交并通过审核的生成素材;不要求素材当前仍保留在某个项目资源中,已通过审核的快照在原素材删除后仍可保留公开展示。
- 未登录用户也可读取公开精选;当没有公开画布生成资源时,不再用公开作品图片补充,只显示简洁空态。
- 上传素材、公开作品图片、mock 资源和假组合都不进入精选。
@@ -133,7 +133,7 @@ Tab
- 最近项目继续使用编辑器项目接口:`listEditorProjects``createEditorProject``/editor/canvas?projectid=xxx`
- 最近项目和项目页打开画布时,浏览器 history 只保留带 `projectid` 的最终画布路由;新建画布引导使用 `guide=toolbar` 一次性 query,主站九宫格直达生成器使用 `tool=<intent>` 一次性 query,画布消费后通过 `history.replaceState` 清理这两个参数。
- 项目封面逻辑复用 `/project` 项目卡已有的画布中心缩略图算法。
- `陶泥儿精选` 读取后端公开精选接口返回的审核通过素材快照;公开事实以后端 `editor_showcase_asset.review_status``display_enabled` 为准,`showcase_category` 只用于前端具体 Tab 归类,前端只做展示组合、Tab 归类、点赞交互和提交审核的乐观状态回滚
- `陶泥儿精选` 读取后端公开精选接口返回的审核通过素材快照;公开事实以后端 `editor_showcase_asset.review_status``display_enabled` 为准,`showcase_category` 只用于前端具体 Tab 归类。点赞不做乐观图标或计数更新:按钮 pending 期间保持原状态,成功后应用服务端返回的 `viewerLiked + likeCount`,失败时保留原状态并在列表上方给出可访问短错误。登录、退出和账号切换都重载首屏并清空分页 / pending / 错误;首屏、分页和点赞回包必须按账号与请求代际丢弃旧响应。已知用户仍在恢复鉴权时不得先发匿名请求;不要求跨 Tab 实时同步
- `/creation/<play>` 的玩法工作台、草稿、生成页、结果页、发布、运行态和作品架链路保持原状。
## 路由与导航
+32
View File
@@ -419,6 +419,38 @@ mod tests {
.expect("password login request should succeed")
}
#[tokio::test]
async fn showcase_invalid_bearer_is_not_downgraded_and_is_not_cacheable() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.uri("/api/editor/showcase/resources")
.header("authorization", "Bearer invalid-token")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
response
.headers()
.get("cache-control")
.and_then(|value| value.to_str().ok()),
Some("private, no-store")
);
assert_eq!(
response
.headers()
.get("vary")
.and_then(|value| value.to_str().ok()),
Some("Authorization")
);
}
#[cfg(any())]
fn build_internal_creative_agent_app() -> Router {
let mut config = AppConfig::default();
@@ -58,6 +58,7 @@ use spacetime_client::{
EditorReferenceRecord, EditorReferenceResolveRecordInput,
EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput,
EditorShowcaseAssetRecord, EditorShowcaseAssetSubmitRecordInput,
EditorShowcaseAssetViewerListRecordInput, EditorShowcaseAssetViewerRecord,
EditorShowcaseCampaignConfigGetRecordInput, EditorShowcaseCampaignConfigRecord,
ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobPhaseUpdateError,
ExternalGenerationJobPhaseUpdateRecordInput, ExternalGenerationJobRecord, SpacetimeClientError,
@@ -74,7 +75,7 @@ use crate::{
mark_editor_generation_commit_outcome_confirmed,
with_editor_generation_durable_billing_boundary,
},
auth::AuthenticatedAccessToken,
auth::{AuthenticatedAccessToken, optional_access_token_from_headers},
editor_generation_queue::{
EDITOR_BACKGROUND_REMOVAL_JOB_KIND, EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND,
EDITOR_IMAGE_EDIT_JOB_KIND, EDITOR_IMAGE_GENERATION_JOB_KIND,
@@ -731,7 +732,7 @@ pub struct EditorProjectResourceResponse {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectResourceListResponse {
resources: Vec<EditorProjectResourcePayload>,
resources: Vec<EditorPublicShowcaseResourcePayload>,
next_cursor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
campaign: Option<EditorShowcaseCampaignPayload>,
@@ -773,6 +774,20 @@ pub struct EditorShowcaseAssetResponse {
showcase_asset: EditorProjectResourcePayload,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorPublicShowcaseResourcePayload {
#[serde(flatten)]
resource: EditorProjectResourcePayload,
viewer_liked: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorShowcaseAssetViewerResponse {
showcase_asset: EditorPublicShowcaseResourcePayload,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorShowcaseAssetLikeRequest {
@@ -1930,19 +1945,47 @@ pub async fn list_public_editor_project_resources(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Query(query): Query<EditorProjectResourceListQuery>,
headers: HeaderMap,
) -> Result<Json<Value>, AppError> {
let mut resource_records = state
.spacetime_client()
.list_public_editor_showcase_assets(EditorShowcaseAssetPublicListRecordInput {
limit: (EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE + 1) as u32,
cursor: normalize_optional_string(query.cursor),
})
.await
.map_err(map_editor_project_error)?;
let cursor = normalize_optional_string(query.cursor);
let authenticated = optional_access_token_from_headers(
&state,
"/api/editor/showcase/resources".to_string(),
headers,
request_context.request_id().to_string(),
)
.await?;
let mut resource_records = match authenticated {
Some(authenticated) => state
.spacetime_client()
.list_public_editor_showcase_assets_for_viewer(
EditorShowcaseAssetViewerListRecordInput {
limit: (EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE + 1) as u32,
cursor,
viewer_user_id: authenticated.claims().user_id().to_string(),
},
)
.await
.map_err(map_editor_project_error)?,
None => state
.spacetime_client()
.list_public_editor_showcase_assets(EditorShowcaseAssetPublicListRecordInput {
limit: (EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE + 1) as u32,
cursor,
})
.await
.map_err(map_editor_project_error)?
.into_iter()
.map(|asset| EditorShowcaseAssetViewerRecord {
asset,
viewer_liked: false,
})
.collect(),
};
let next_cursor = if resource_records.len() > EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE {
resource_records
.get(EDITOR_SHOWCASE_RESOURCE_PAGE_SIZE - 1)
.and_then(editor_project_resource_showcase_cursor)
.and_then(|record| editor_project_resource_showcase_cursor(&record.asset))
} else {
None
};
@@ -1950,7 +1993,7 @@ pub async fn list_public_editor_project_resources(
let resources = resource_records
.into_iter()
.map(|record| editor_project_resource_public_payload_from_record(&state, record))
.map(|record| editor_public_showcase_resource_payload_from_record(&state, record))
.collect();
let campaign = state
.spacetime_client()
@@ -2442,7 +2485,7 @@ pub async fn toggle_editor_showcase_asset_like(
) -> Result<Json<Value>, AppError> {
let record = state
.spacetime_client()
.toggle_editor_showcase_asset_like(EditorShowcaseAssetLikeToggleRecordInput {
.set_editor_showcase_asset_like_for_viewer(EditorShowcaseAssetLikeToggleRecordInput {
showcase_id,
user_id: current_owner_user_id(&authenticated),
liked: payload.liked,
@@ -2453,8 +2496,8 @@ pub async fn toggle_editor_showcase_asset_like(
Ok(json_success_body(
Some(&request_context),
EditorShowcaseAssetResponse {
showcase_asset: editor_project_resource_payload_from_showcase_record(record),
EditorShowcaseAssetViewerResponse {
showcase_asset: editor_public_showcase_resource_payload_from_record(&state, record),
},
))
}
@@ -8424,6 +8467,16 @@ fn editor_project_resource_public_payload_from_record(
payload
}
fn editor_public_showcase_resource_payload_from_record(
state: &AppState,
record: EditorShowcaseAssetViewerRecord,
) -> EditorPublicShowcaseResourcePayload {
EditorPublicShowcaseResourcePayload {
resource: editor_project_resource_public_payload_from_record(state, record.asset),
viewer_liked: record.viewer_liked,
}
}
fn is_editor_internal_processing_model(model: &str) -> bool {
matches!(
model.trim().to_ascii_lowercase().as_str(),
@@ -13557,6 +13610,23 @@ mod tests {
assert_eq!(value["authorPublicUserCode"], json!("SY-00000042"));
}
#[test]
fn editor_public_showcase_payload_serializes_authoritative_viewer_state() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let payload = editor_public_showcase_resource_payload_from_record(
&state,
EditorShowcaseAssetViewerRecord {
asset: test_editor_showcase_asset_record("user-author-1"),
viewer_liked: true,
},
);
let value = serde_json::to_value(payload).expect("payload should serialize");
assert_eq!(value["viewerLiked"], json!(true));
assert_eq!(value["likeCount"], json!(0));
}
#[test]
fn editor_project_resource_public_payload_removes_internal_generation_metadata() {
let state = AppState::new(AppConfig::default()).expect("state should build");
@@ -1,8 +1,11 @@
use axum::{
Router,
extract::DefaultBodyLimit,
extract::{DefaultBodyLimit, Request},
handler::Handler,
http::{HeaderValue, header},
middleware,
middleware::Next,
response::Response,
routing::{get, patch, post},
};
@@ -35,6 +38,18 @@ use crate::{
const EDITOR_AGENT_MESSAGE_BODY_LIMIT_BYTES: usize = 2 * 1024 * 1024;
async fn add_showcase_private_response_headers(request: Request, next: Next) -> Response {
let mut response = next.run(request).await;
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, no-store"),
);
response
.headers_mut()
.append(header::VARY, HeaderValue::from_static("Authorization"));
response
}
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
@@ -43,7 +58,8 @@ pub fn router(state: AppState) -> Router<AppState> {
)
.route(
"/api/editor/showcase/resources",
get(list_public_editor_project_resources),
get(list_public_editor_project_resources)
.route_layer(middleware::from_fn(add_showcase_private_response_headers)),
)
.route(
"/api/editor/projects/recent",
@@ -183,10 +199,12 @@ pub fn router(state: AppState) -> Router<AppState> {
)
.route(
"/api/editor/showcase/assets/{showcase_id}/likes",
post(toggle_editor_showcase_asset_like).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
post(toggle_editor_showcase_asset_like)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
))
.route_layer(middleware::from_fn(add_showcase_private_response_headers)),
)
.route(
"/api/editor/images/generations",
@@ -266,3 +284,53 @@ pub fn router(state: AppState) -> Router<AppState> {
)),
)
}
#[cfg(test)]
mod tests {
use axum::{
Router,
body::Body,
http::{Request, header},
middleware,
response::{IntoResponse, Response},
routing::get,
};
use tower::ServiceExt;
use super::add_showcase_private_response_headers;
async fn response_with_existing_vary() -> Response {
let mut response = "ok".into_response();
response
.headers_mut()
.insert(header::VARY, "Origin".parse().expect("valid Vary value"));
response
}
#[tokio::test]
async fn showcase_private_headers_preserve_existing_vary_fields() {
let app = Router::new().route(
"/",
get(response_with_existing_vary)
.route_layer(middleware::from_fn(add_showcase_private_response_headers)),
);
let response = app
.oneshot(
Request::builder()
.uri("/")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
let vary_values = response
.headers()
.get_all(header::VARY)
.iter()
.map(|value| value.to_str().expect("Vary should be ASCII"))
.collect::<Vec<_>>();
assert_eq!(vary_values, vec!["Origin", "Authorization"]);
}
}
@@ -62,7 +62,8 @@ pub use self::editor_project::{
EditorShowcaseAssetAdminReviewRecordInput, EditorShowcaseAssetDisplayUpdateRecordInput,
EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput,
EditorShowcaseAssetRecord, EditorShowcaseAssetRefundMarkRecordInput,
EditorShowcaseAssetSubmitRecordInput, EditorShowcaseCampaignConfigGetRecordInput,
EditorShowcaseAssetSubmitRecordInput, EditorShowcaseAssetViewerListRecordInput,
EditorShowcaseAssetViewerRecord, EditorShowcaseCampaignConfigGetRecordInput,
EditorShowcaseCampaignConfigRecord, EditorShowcaseCampaignConfigUpsertRecordInput,
};
pub use self::external_api_key::{
@@ -111,6 +112,8 @@ pub(crate) use self::editor_project::{
map_editor_project_resource_list_procedure_result,
map_editor_project_resource_procedure_result, map_editor_reference_procedure_result,
map_editor_showcase_asset_list_procedure_result, map_editor_showcase_asset_procedure_result,
map_editor_showcase_asset_viewer_list_procedure_result,
map_editor_showcase_asset_viewer_procedure_result,
map_editor_showcase_campaign_config_procedure_result,
map_optional_editor_asset_procedure_result,
};
@@ -474,6 +474,13 @@ pub struct EditorShowcaseAssetPublicListRecordInput {
pub cursor: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EditorShowcaseAssetViewerListRecordInput {
pub limit: u32,
pub cursor: Option<String>,
pub viewer_user_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EditorShowcaseAssetAdminListRecordInput {
pub limit: u32,
@@ -517,6 +524,12 @@ pub struct EditorShowcaseAssetLikeToggleRecordInput {
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct EditorShowcaseAssetViewerRecord {
pub asset: EditorShowcaseAssetRecord,
pub viewer_liked: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EditorShowcaseCampaignConfigGetRecordInput {
pub include_disabled: bool,
@@ -862,6 +875,18 @@ impl From<EditorShowcaseAssetPublicListRecordInput>
}
}
impl From<EditorShowcaseAssetViewerListRecordInput>
for crate::module_bindings::EditorShowcaseAssetViewerListInput
{
fn from(input: EditorShowcaseAssetViewerListRecordInput) -> Self {
Self {
limit: input.limit,
cursor: input.cursor,
viewer_user_id: input.viewer_user_id,
}
}
}
impl From<EditorShowcaseAssetAdminListRecordInput>
for crate::module_bindings::EditorShowcaseAssetAdminListInput
{
@@ -1226,6 +1251,34 @@ pub(crate) fn map_editor_showcase_asset_list_procedure_result(
.collect()
}
pub(crate) fn map_editor_showcase_asset_viewer_procedure_result(
result: EditorShowcaseAssetViewerProcedureResult,
) -> Result<EditorShowcaseAssetViewerRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
result
.item
.map(map_editor_showcase_asset_viewer_snapshot)
.transpose()?
.ok_or_else(|| SpacetimeClientError::missing_snapshot("陶泥儿精选浏览者快照"))
}
pub(crate) fn map_editor_showcase_asset_viewer_list_procedure_result(
result: EditorShowcaseAssetViewerListProcedureResult,
) -> Result<Vec<EditorShowcaseAssetViewerRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
result
.items
.into_iter()
.map(map_editor_showcase_asset_viewer_snapshot)
.collect()
}
pub(crate) fn map_editor_showcase_campaign_config_procedure_result(
result: EditorShowcaseCampaignConfigProcedureResult,
) -> Result<Option<EditorShowcaseCampaignConfigRecord>, SpacetimeClientError> {
@@ -1506,6 +1559,15 @@ fn map_editor_showcase_asset_snapshot(
})
}
fn map_editor_showcase_asset_viewer_snapshot(
snapshot: EditorShowcaseAssetViewerSnapshot,
) -> Result<EditorShowcaseAssetViewerRecord, SpacetimeClientError> {
Ok(EditorShowcaseAssetViewerRecord {
asset: map_editor_showcase_asset_snapshot(snapshot.asset)?,
viewer_liked: snapshot.viewer_liked,
})
}
fn map_editor_showcase_campaign_config_snapshot(
snapshot: EditorShowcaseCampaignConfigSnapshot,
) -> EditorShowcaseCampaignConfigRecord {
@@ -1063,6 +1063,31 @@ impl SpacetimeClient {
.await
}
pub async fn list_public_editor_showcase_assets_for_viewer(
&self,
input: EditorShowcaseAssetViewerListRecordInput,
) -> Result<Vec<EditorShowcaseAssetViewerRecord>, SpacetimeClientError> {
let procedure_input = input.into();
self.call_after_connect(
"list_public_editor_showcase_assets_for_viewer_and_return",
move |connection, sender| {
connection
.procedures()
.list_public_editor_showcase_assets_for_viewer_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_editor_showcase_asset_viewer_list_procedure_result);
send_once(&sender, mapped);
},
);
},
)
.await
}
pub async fn admin_list_editor_showcase_assets(
&self,
input: EditorShowcaseAssetAdminListRecordInput,
@@ -1163,23 +1188,23 @@ impl SpacetimeClient {
.await
}
pub async fn toggle_editor_showcase_asset_like(
pub async fn set_editor_showcase_asset_like_for_viewer(
&self,
input: EditorShowcaseAssetLikeToggleRecordInput,
) -> Result<EditorShowcaseAssetRecord, SpacetimeClientError> {
) -> Result<EditorShowcaseAssetViewerRecord, SpacetimeClientError> {
let procedure_input = input.into();
self.call_after_connect(
"toggle_editor_showcase_asset_like_and_return",
"set_editor_showcase_asset_like_for_viewer_and_return",
move |connection, sender| {
connection
.procedures()
.toggle_editor_showcase_asset_like_and_return_then(
.set_editor_showcase_asset_like_for_viewer_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_editor_showcase_asset_procedure_result);
.and_then(map_editor_showcase_asset_viewer_procedure_result);
send_once(&sender, mapped);
},
);
@@ -56,6 +56,7 @@ pub use mapper::{
EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput,
EditorShowcaseAssetRecord, EditorShowcaseAssetRefundMarkRecordInput,
EditorShowcaseAssetSubmitRecordInput, EditorShowcaseCampaignConfigGetRecordInput,
EditorShowcaseAssetViewerListRecordInput, EditorShowcaseAssetViewerRecord,
EditorShowcaseCampaignConfigRecord, EditorShowcaseCampaignConfigUpsertRecordInput,
ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyCreateRecordInput, ExternalApiKeyRecord,
ExternalApiKeyRevokeRecordInput, ExternalGenerationJobAcknowledgeRecordInput,
@@ -108,6 +108,7 @@ pub use self::editor_project::{
EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput,
EditorShowcaseAssetRecord, EditorShowcaseAssetRefundMarkRecordInput,
EditorShowcaseAssetSubmitRecordInput, EditorShowcaseCampaignConfigGetRecordInput,
EditorShowcaseAssetViewerListRecordInput, EditorShowcaseAssetViewerRecord,
EditorReferenceRecord, EditorReferenceResolveRecordInput, EditorShowcaseCampaignConfigRecord,
EditorShowcaseCampaignConfigUpsertRecordInput,
};
@@ -263,6 +264,8 @@ pub(crate) use self::editor_project::{
map_editor_project_resource_list_procedure_result, map_editor_reference_procedure_result,
map_editor_project_resource_procedure_result, map_editor_showcase_asset_list_procedure_result,
map_editor_showcase_asset_procedure_result,
map_editor_showcase_asset_viewer_list_procedure_result,
map_editor_showcase_asset_viewer_procedure_result,
map_editor_showcase_campaign_config_procedure_result,
map_optional_editor_asset_procedure_result,
};
@@ -473,6 +473,13 @@ pub struct EditorShowcaseAssetPublicListRecordInput {
pub cursor: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EditorShowcaseAssetViewerListRecordInput {
pub limit: u32,
pub cursor: Option<String>,
pub viewer_user_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EditorShowcaseAssetAdminListRecordInput {
pub limit: u32,
@@ -516,6 +523,12 @@ pub struct EditorShowcaseAssetLikeToggleRecordInput {
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct EditorShowcaseAssetViewerRecord {
pub asset: EditorShowcaseAssetRecord,
pub viewer_liked: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EditorShowcaseCampaignConfigGetRecordInput {
pub include_disabled: bool,
@@ -861,6 +874,18 @@ impl From<EditorShowcaseAssetPublicListRecordInput>
}
}
impl From<EditorShowcaseAssetViewerListRecordInput>
for crate::module_bindings::EditorShowcaseAssetViewerListInput
{
fn from(input: EditorShowcaseAssetViewerListRecordInput) -> Self {
Self {
limit: input.limit,
cursor: input.cursor,
viewer_user_id: input.viewer_user_id,
}
}
}
impl From<EditorShowcaseAssetAdminListRecordInput>
for crate::module_bindings::EditorShowcaseAssetAdminListInput
{
@@ -1225,6 +1250,34 @@ pub(crate) fn map_editor_showcase_asset_list_procedure_result(
.collect()
}
pub(crate) fn map_editor_showcase_asset_viewer_procedure_result(
result: EditorShowcaseAssetViewerProcedureResult,
) -> Result<EditorShowcaseAssetViewerRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
result
.item
.map(map_editor_showcase_asset_viewer_snapshot)
.transpose()?
.ok_or_else(|| SpacetimeClientError::missing_snapshot("陶泥儿精选浏览者快照"))
}
pub(crate) fn map_editor_showcase_asset_viewer_list_procedure_result(
result: EditorShowcaseAssetViewerListProcedureResult,
) -> Result<Vec<EditorShowcaseAssetViewerRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
result
.items
.into_iter()
.map(map_editor_showcase_asset_viewer_snapshot)
.collect()
}
pub(crate) fn map_editor_showcase_campaign_config_procedure_result(
result: EditorShowcaseCampaignConfigProcedureResult,
) -> Result<Option<EditorShowcaseCampaignConfigRecord>, SpacetimeClientError> {
@@ -1505,6 +1558,15 @@ fn map_editor_showcase_asset_snapshot(
})
}
fn map_editor_showcase_asset_viewer_snapshot(
snapshot: EditorShowcaseAssetViewerSnapshot,
) -> Result<EditorShowcaseAssetViewerRecord, SpacetimeClientError> {
Ok(EditorShowcaseAssetViewerRecord {
asset: map_editor_showcase_asset_snapshot(snapshot.asset)?,
viewer_liked: snapshot.viewer_liked,
})
}
fn map_editor_showcase_campaign_config_snapshot(
snapshot: EditorShowcaseCampaignConfigSnapshot,
) -> EditorShowcaseCampaignConfigRecord {
@@ -359,6 +359,10 @@ pub mod editor_showcase_asset_snapshot_type;
pub mod editor_showcase_asset_submit_input_type;
pub mod editor_showcase_asset_table;
pub mod editor_showcase_asset_type;
pub mod editor_showcase_asset_viewer_list_input_type;
pub mod editor_showcase_asset_viewer_list_procedure_result_type;
pub mod editor_showcase_asset_viewer_procedure_result_type;
pub mod editor_showcase_asset_viewer_snapshot_type;
pub mod editor_showcase_campaign_config_get_input_type;
pub mod editor_showcase_campaign_config_procedure_result_type;
pub mod editor_showcase_campaign_config_snapshot_type;
@@ -478,6 +482,7 @@ pub mod list_profile_recharge_refunds_for_reconciliation_procedure;
pub mod list_profile_wallet_ledger_procedure;
pub mod list_public_editor_project_resources_and_return_procedure;
pub mod list_public_editor_showcase_assets_and_return_procedure;
pub mod list_public_editor_showcase_assets_for_viewer_and_return_procedure;
pub mod list_unchecked_expired_profile_recharge_orders_procedure;
pub mod mark_editor_showcase_asset_refunded_and_return_procedure;
pub mod mark_profile_recharge_order_expiration_checked_procedure;
@@ -792,6 +797,7 @@ pub mod save_editor_project_layout_and_return_procedure;
pub mod save_editor_project_layout_v_2_ack_procedure;
pub mod save_editor_project_layout_v_2_and_return_procedure;
pub mod seed_analytics_date_dimensions_reducer;
pub mod set_editor_showcase_asset_like_for_viewer_and_return_procedure;
pub mod square_hole_agent_message_row_type;
pub mod square_hole_agent_message_table;
pub mod square_hole_agent_session_row_type;
@@ -811,7 +817,6 @@ pub mod story_session_type;
pub mod submit_editor_showcase_asset_and_return_procedure;
pub mod submit_profile_feedback_and_return_procedure;
pub mod sync_auth_store_projection_procedure;
pub mod toggle_editor_showcase_asset_like_and_return_procedure;
pub mod touch_editor_agent_conversation_and_return_procedure;
pub mod tracking_daily_stat_table;
pub mod tracking_daily_stat_type;
@@ -1208,6 +1213,10 @@ pub use editor_showcase_asset_snapshot_type::EditorShowcaseAssetSnapshot;
pub use editor_showcase_asset_submit_input_type::EditorShowcaseAssetSubmitInput;
pub use editor_showcase_asset_table::*;
pub use editor_showcase_asset_type::EditorShowcaseAsset;
pub use editor_showcase_asset_viewer_list_input_type::EditorShowcaseAssetViewerListInput;
pub use editor_showcase_asset_viewer_list_procedure_result_type::EditorShowcaseAssetViewerListProcedureResult;
pub use editor_showcase_asset_viewer_procedure_result_type::EditorShowcaseAssetViewerProcedureResult;
pub use editor_showcase_asset_viewer_snapshot_type::EditorShowcaseAssetViewerSnapshot;
pub use editor_showcase_campaign_config_get_input_type::EditorShowcaseCampaignConfigGetInput;
pub use editor_showcase_campaign_config_procedure_result_type::EditorShowcaseCampaignConfigProcedureResult;
pub use editor_showcase_campaign_config_snapshot_type::EditorShowcaseCampaignConfigSnapshot;
@@ -1327,6 +1336,7 @@ pub use list_profile_recharge_refunds_for_reconciliation_procedure::list_profile
pub use list_profile_wallet_ledger_procedure::list_profile_wallet_ledger;
pub use list_public_editor_project_resources_and_return_procedure::list_public_editor_project_resources_and_return;
pub use list_public_editor_showcase_assets_and_return_procedure::list_public_editor_showcase_assets_and_return;
pub use list_public_editor_showcase_assets_for_viewer_and_return_procedure::list_public_editor_showcase_assets_for_viewer_and_return;
pub use list_unchecked_expired_profile_recharge_orders_procedure::list_unchecked_expired_profile_recharge_orders;
pub use mark_editor_showcase_asset_refunded_and_return_procedure::mark_editor_showcase_asset_refunded_and_return;
pub use mark_profile_recharge_order_expiration_checked_procedure::mark_profile_recharge_order_expiration_checked;
@@ -1641,6 +1651,7 @@ pub use save_editor_project_layout_and_return_procedure::save_editor_project_lay
pub use save_editor_project_layout_v_2_ack_procedure::save_editor_project_layout_v_2_ack;
pub use save_editor_project_layout_v_2_and_return_procedure::save_editor_project_layout_v_2_and_return;
pub use seed_analytics_date_dimensions_reducer::seed_analytics_date_dimensions;
pub use set_editor_showcase_asset_like_for_viewer_and_return_procedure::set_editor_showcase_asset_like_for_viewer_and_return;
pub use square_hole_agent_message_row_type::SquareHoleAgentMessageRow;
pub use square_hole_agent_message_table::*;
pub use square_hole_agent_session_row_type::SquareHoleAgentSessionRow;
@@ -1660,7 +1671,6 @@ pub use story_session_type::StorySession;
pub use submit_editor_showcase_asset_and_return_procedure::submit_editor_showcase_asset_and_return;
pub use submit_profile_feedback_and_return_procedure::submit_profile_feedback_and_return;
pub use sync_auth_store_projection_procedure::sync_auth_store_projection;
pub use toggle_editor_showcase_asset_like_and_return_procedure::toggle_editor_showcase_asset_like_and_return;
pub use touch_editor_agent_conversation_and_return_procedure::touch_editor_agent_conversation_and_return;
pub use tracking_daily_stat_table::*;
pub use tracking_daily_stat_type::TrackingDailyStat;
@@ -0,0 +1,17 @@
// 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 EditorShowcaseAssetViewerListInput {
pub limit: u32,
pub cursor: Option<String>,
pub viewer_user_id: String,
}
impl __sdk::InModule for EditorShowcaseAssetViewerListInput {
type Module = super::RemoteModule;
}
@@ -0,0 +1,19 @@
// 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::editor_showcase_asset_viewer_snapshot_type::EditorShowcaseAssetViewerSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct EditorShowcaseAssetViewerListProcedureResult {
pub ok: bool,
pub items: Vec<EditorShowcaseAssetViewerSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for EditorShowcaseAssetViewerListProcedureResult {
type Module = super::RemoteModule;
}
@@ -0,0 +1,19 @@
// 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::editor_showcase_asset_viewer_snapshot_type::EditorShowcaseAssetViewerSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct EditorShowcaseAssetViewerProcedureResult {
pub ok: bool,
pub item: Option<EditorShowcaseAssetViewerSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for EditorShowcaseAssetViewerProcedureResult {
type Module = super::RemoteModule;
}
@@ -0,0 +1,18 @@
// 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::editor_showcase_asset_snapshot_type::EditorShowcaseAssetSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct EditorShowcaseAssetViewerSnapshot {
pub asset: EditorShowcaseAssetSnapshot,
pub viewer_liked: bool,
}
impl __sdk::InModule for EditorShowcaseAssetViewerSnapshot {
type Module = super::RemoteModule;
}
@@ -0,0 +1,62 @@
// 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::editor_showcase_asset_viewer_list_input_type::EditorShowcaseAssetViewerListInput;
use super::editor_showcase_asset_viewer_list_procedure_result_type::EditorShowcaseAssetViewerListProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct ListPublicEditorShowcaseAssetsForViewerAndReturnArgs {
pub input: EditorShowcaseAssetViewerListInput,
}
impl __sdk::InModule for ListPublicEditorShowcaseAssetsForViewerAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `list_public_editor_showcase_assets_for_viewer_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait list_public_editor_showcase_assets_for_viewer_and_return {
fn list_public_editor_showcase_assets_for_viewer_and_return(
&self,
input: EditorShowcaseAssetViewerListInput,
) {
self.list_public_editor_showcase_assets_for_viewer_and_return_then(input, |_, _| {});
}
fn list_public_editor_showcase_assets_for_viewer_and_return_then(
&self,
input: EditorShowcaseAssetViewerListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorShowcaseAssetViewerListProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl list_public_editor_showcase_assets_for_viewer_and_return for super::RemoteProcedures {
fn list_public_editor_showcase_assets_for_viewer_and_return_then(
&self,
input: EditorShowcaseAssetViewerListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorShowcaseAssetViewerListProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, EditorShowcaseAssetViewerListProcedureResult>(
"list_public_editor_showcase_assets_for_viewer_and_return",
ListPublicEditorShowcaseAssetsForViewerAndReturnArgs { input },
__callback,
);
}
}
@@ -5,57 +5,57 @@
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::editor_showcase_asset_like_toggle_input_type::EditorShowcaseAssetLikeToggleInput;
use super::editor_showcase_asset_procedure_result_type::EditorShowcaseAssetProcedureResult;
use super::editor_showcase_asset_viewer_procedure_result_type::EditorShowcaseAssetViewerProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct ToggleEditorShowcaseAssetLikeAndReturnArgs {
struct SetEditorShowcaseAssetLikeForViewerAndReturnArgs {
pub input: EditorShowcaseAssetLikeToggleInput,
}
impl __sdk::InModule for ToggleEditorShowcaseAssetLikeAndReturnArgs {
impl __sdk::InModule for SetEditorShowcaseAssetLikeForViewerAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `toggle_editor_showcase_asset_like_and_return`.
/// Extension trait for access to the procedure `set_editor_showcase_asset_like_for_viewer_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait toggle_editor_showcase_asset_like_and_return {
fn toggle_editor_showcase_asset_like_and_return(
pub trait set_editor_showcase_asset_like_for_viewer_and_return {
fn set_editor_showcase_asset_like_for_viewer_and_return(
&self,
input: EditorShowcaseAssetLikeToggleInput,
) {
self.toggle_editor_showcase_asset_like_and_return_then(input, |_, _| {});
self.set_editor_showcase_asset_like_for_viewer_and_return_then(input, |_, _| {});
}
fn toggle_editor_showcase_asset_like_and_return_then(
fn set_editor_showcase_asset_like_for_viewer_and_return_then(
&self,
input: EditorShowcaseAssetLikeToggleInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorShowcaseAssetProcedureResult, __sdk::InternalError>,
Result<EditorShowcaseAssetViewerProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl toggle_editor_showcase_asset_like_and_return for super::RemoteProcedures {
fn toggle_editor_showcase_asset_like_and_return_then(
impl set_editor_showcase_asset_like_for_viewer_and_return for super::RemoteProcedures {
fn set_editor_showcase_asset_like_for_viewer_and_return_then(
&self,
input: EditorShowcaseAssetLikeToggleInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorShowcaseAssetProcedureResult, __sdk::InternalError>,
Result<EditorShowcaseAssetViewerProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, EditorShowcaseAssetProcedureResult>(
"toggle_editor_showcase_asset_like_and_return",
ToggleEditorShowcaseAssetLikeAndReturnArgs { input },
.invoke_procedure_with_callback::<_, EditorShowcaseAssetViewerProcedureResult>(
"set_editor_showcase_asset_like_for_viewer_and_return",
SetEditorShowcaseAssetLikeForViewerAndReturnArgs { input },
__callback,
);
}
@@ -1174,6 +1174,13 @@ pub struct EditorShowcaseAssetPublicListInput {
pub cursor: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct EditorShowcaseAssetViewerListInput {
pub limit: u32,
pub cursor: Option<String>,
pub viewer_user_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct EditorShowcaseAssetAdminListInput {
pub limit: u32,
@@ -1392,6 +1399,26 @@ pub struct EditorShowcaseAssetListProcedureResult {
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct EditorShowcaseAssetViewerSnapshot {
pub asset: EditorShowcaseAssetSnapshot,
pub viewer_liked: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct EditorShowcaseAssetViewerListProcedureResult {
pub ok: bool,
pub items: Vec<EditorShowcaseAssetViewerSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct EditorShowcaseAssetViewerProcedureResult {
pub ok: bool,
pub item: Option<EditorShowcaseAssetViewerSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct EditorShowcaseCampaignConfigProcedureResult {
pub ok: bool,
@@ -1987,6 +2014,21 @@ pub fn list_public_editor_showcase_assets_and_return(
}
}
#[spacetimedb::procedure]
pub fn list_public_editor_showcase_assets_for_viewer_and_return(
ctx: &mut ProcedureContext,
input: EditorShowcaseAssetViewerListInput,
) -> EditorShowcaseAssetViewerListProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
require_editor_generation_runtime_service_identity(tx, caller)?;
list_public_editor_showcase_assets_for_viewer(tx, input.clone())
}) {
Ok(items) => editor_showcase_asset_viewer_list_ok(items),
Err(message) => editor_showcase_asset_viewer_list_error(message),
}
}
#[spacetimedb::procedure]
pub fn admin_list_editor_showcase_assets_and_return(
ctx: &mut ProcedureContext,
@@ -2035,13 +2077,17 @@ pub fn mark_editor_showcase_asset_refunded_and_return(
}
#[spacetimedb::procedure]
pub fn toggle_editor_showcase_asset_like_and_return(
pub fn set_editor_showcase_asset_like_for_viewer_and_return(
ctx: &mut ProcedureContext,
input: EditorShowcaseAssetLikeToggleInput,
) -> EditorShowcaseAssetProcedureResult {
match ctx.try_with_tx(|tx| toggle_editor_showcase_asset_like(tx, input.clone())) {
Ok(asset) => editor_showcase_asset_ok(Some(asset)),
Err(message) => editor_showcase_asset_error(message),
) -> EditorShowcaseAssetViewerProcedureResult {
let caller = ctx.sender();
match ctx.try_with_tx(|tx| {
require_editor_generation_runtime_service_identity(tx, caller)?;
set_editor_showcase_asset_like_for_viewer(tx, input.clone())
}) {
Ok(item) => editor_showcase_asset_viewer_ok(Some(item)),
Err(message) => editor_showcase_asset_viewer_error(message),
}
}
@@ -5747,6 +5793,29 @@ fn list_public_editor_showcase_assets(
Ok(assets)
}
fn list_public_editor_showcase_assets_for_viewer(
ctx: &ReducerContext,
input: EditorShowcaseAssetViewerListInput,
) -> Result<Vec<EditorShowcaseAssetViewerSnapshot>, String> {
let viewer_user_id = normalize_required(
&input.viewer_user_id,
"editor_showcase_asset_like.viewer_user_id",
)?;
list_public_editor_showcase_assets(
ctx,
EditorShowcaseAssetPublicListInput {
limit: input.limit,
cursor: input.cursor,
},
)
.map(|assets| {
assets
.into_iter()
.map(|asset| editor_showcase_asset_viewer_snapshot(ctx, asset, viewer_user_id.as_str()))
.collect()
})
}
pub(crate) fn asset_object_has_public_showcase_read_grant(
ctx: &ReducerContext,
asset_object: &module_assets::AssetObjectUpsertSnapshot,
@@ -6135,7 +6204,7 @@ fn mark_editor_showcase_asset_refunded(
.ok_or_else(|| "精选返还状态更新失败".to_string())
}
fn toggle_editor_showcase_asset_like(
fn apply_editor_showcase_asset_like(
ctx: &ReducerContext,
input: EditorShowcaseAssetLikeToggleInput,
) -> Result<EditorShowcaseAssetSnapshot, String> {
@@ -6170,6 +6239,37 @@ fn toggle_editor_showcase_asset_like(
Ok(showcase_snapshot_from_row(asset))
}
fn set_editor_showcase_asset_like_for_viewer(
ctx: &ReducerContext,
input: EditorShowcaseAssetLikeToggleInput,
) -> Result<EditorShowcaseAssetViewerSnapshot, String> {
let user_id = normalize_required(&input.user_id, "editor_showcase_asset_like.user_id")?;
let asset = apply_editor_showcase_asset_like(ctx, input)?;
Ok(editor_showcase_asset_viewer_snapshot(
ctx,
asset,
user_id.as_str(),
))
}
fn editor_showcase_asset_viewer_snapshot(
ctx: &ReducerContext,
asset: EditorShowcaseAssetSnapshot,
viewer_user_id: &str,
) -> EditorShowcaseAssetViewerSnapshot {
let like_id = editor_showcase_like_id(asset.showcase_id.as_str(), viewer_user_id);
let viewer_liked = ctx
.db
.editor_showcase_asset_like()
.like_id()
.find(&like_id)
.is_some();
EditorShowcaseAssetViewerSnapshot {
asset,
viewer_liked,
}
}
fn get_editor_showcase_campaign_config(
ctx: &ReducerContext,
input: EditorShowcaseCampaignConfigGetInput,
@@ -11642,6 +11742,44 @@ fn editor_showcase_asset_list_error(message: String) -> EditorShowcaseAssetListP
}
}
fn editor_showcase_asset_viewer_ok(
item: Option<EditorShowcaseAssetViewerSnapshot>,
) -> EditorShowcaseAssetViewerProcedureResult {
EditorShowcaseAssetViewerProcedureResult {
ok: true,
item,
error_message: None,
}
}
fn editor_showcase_asset_viewer_error(message: String) -> EditorShowcaseAssetViewerProcedureResult {
EditorShowcaseAssetViewerProcedureResult {
ok: false,
item: None,
error_message: Some(message),
}
}
fn editor_showcase_asset_viewer_list_ok(
items: Vec<EditorShowcaseAssetViewerSnapshot>,
) -> EditorShowcaseAssetViewerListProcedureResult {
EditorShowcaseAssetViewerListProcedureResult {
ok: true,
items,
error_message: None,
}
}
fn editor_showcase_asset_viewer_list_error(
message: String,
) -> EditorShowcaseAssetViewerListProcedureResult {
EditorShowcaseAssetViewerListProcedureResult {
ok: false,
items: Vec::new(),
error_message: Some(message),
}
}
fn editor_showcase_campaign_config_ok(
config: Option<EditorShowcaseCampaignConfigSnapshot>,
) -> EditorShowcaseCampaignConfigProcedureResult {
@@ -14208,6 +14346,55 @@ mod tests {
assert!(validation.contains("validate_editor_generation_registered_object_reference("));
}
#[test]
fn editor_showcase_viewer_reads_and_like_writes_require_runtime_identity() {
let source = include_str!("editor_project_storage.rs");
for procedure_name in [
"pub fn list_public_editor_showcase_assets_for_viewer_and_return(",
"pub fn set_editor_showcase_asset_like_for_viewer_and_return(",
] {
let start = source.find(procedure_name).expect("showcase procedure");
let procedure = &source[start
..source[start..]
.find("\n}\n")
.map(|offset| start + offset + 3)
.expect("showcase procedure end")];
let caller_offset = procedure
.find("let caller = ctx.sender();")
.expect("procedure must capture caller before transaction");
let transaction_offset = procedure
.find("ctx.try_with_tx")
.expect("procedure must use a transaction");
assert!(caller_offset < transaction_offset);
assert!(
procedure
.contains("require_editor_generation_runtime_service_identity(tx, caller)?;")
);
}
}
#[test]
fn editor_showcase_viewer_state_uses_deterministic_like_key_and_idempotent_write() {
assert_eq!(
editor_showcase_like_id("showcase-1", "user-1"),
"showcase-1:user-1"
);
let source = include_str!("editor_project_storage.rs");
let start = source
.find("fn apply_editor_showcase_asset_like(")
.expect("like transaction");
let end = source[start..]
.find("fn set_editor_showcase_asset_like_for_viewer(")
.map(|offset| start + offset)
.expect("like transaction end");
let transaction = &source[start..end];
assert!(transaction.contains("if input.liked && existing_like.is_none()"));
assert!(transaction.contains("if !input.liked {"));
assert!(transaction.contains("if existing_like.is_some()"));
assert!(transaction.contains("Ok(showcase_snapshot_from_row(asset))"));
}
#[test]
fn editor_generation_result_fingerprint_must_be_sha256_hex() {
assert!(validate_editor_generation_operation_fingerprint(&"a".repeat(64)).is_ok());
@@ -9,7 +9,7 @@ import {
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ContextType } from 'react';
import { type ContextType, startTransition, Suspense } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthUiContext } from '../auth/AuthUiContext';
@@ -144,6 +144,13 @@ function triggerIntersection(observer: {
});
}
function SuspendAfterLandingRender({ active }: { active: boolean }) {
if (active) {
throw new Promise<never>(() => undefined);
}
return null;
}
describe('CreationLandingView', () => {
beforeEach(() => {
listPublicEditorProjectResourcesMock.mockResolvedValue([]);
@@ -679,6 +686,7 @@ describe('CreationLandingView', () => {
toggleEditorShowcaseAssetLikeMock.mockResolvedValueOnce({
showcaseId: 'showcase-character',
likeCount: 4,
viewerLiked: true,
});
renderCreationLanding();
@@ -712,6 +720,354 @@ describe('CreationLandingView', () => {
).toBeTruthy();
});
it('restores the server-provided liked state after remount', async () => {
const likedResource = {
resourceId: 'resource-character',
projectId: 'editor-showcase',
showcaseId: 'showcase-character',
label: '角色英雄',
imageSrc: 'data:image/png;base64,character',
width: 512,
height: 512,
sourceType: 'generated',
prompt: 'character hero prompt',
showcaseCategory: 'characters',
likeCount: 4,
viewerLiked: true,
};
listEditorProjectsMock.mockResolvedValue([]);
listPublicEditorProjectResourcesMock.mockResolvedValue([likedResource]);
const firstRender = renderCreationLanding();
expect(
await screen.findByRole('button', { name: '取消点赞:角色英雄' }),
).toBeTruthy();
firstRender.unmount();
renderCreationLanding();
expect(
await screen.findByRole('button', { name: '取消点赞:角色英雄' }),
).toBeTruthy();
expect(listPublicEditorProjectResourcesMock).toHaveBeenCalledTimes(2);
});
it('waits for the server before changing like state and count', async () => {
const user = userEvent.setup();
let resolveLike!: (value: {
showcaseId: string;
likeCount: number;
viewerLiked: boolean;
}) => void;
const likeResult = new Promise<{
showcaseId: string;
likeCount: number;
viewerLiked: boolean;
}>((resolve) => {
resolveLike = resolve;
});
listEditorProjectsMock.mockResolvedValueOnce([]);
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
{
resourceId: 'resource-character',
projectId: 'editor-showcase',
showcaseId: 'showcase-character',
label: '角色英雄',
imageSrc: 'data:image/png;base64,character',
width: 512,
height: 512,
sourceType: 'generated',
prompt: 'character hero prompt',
likeCount: 3,
viewerLiked: false,
},
]);
toggleEditorShowcaseAssetLikeMock.mockReturnValueOnce(likeResult);
renderCreationLanding();
const likeButton = await screen.findByRole('button', {
name: '点赞:角色英雄',
});
await user.click(likeButton);
expect((likeButton as HTMLButtonElement).disabled).toBe(true);
expect(likeButton.getAttribute('aria-busy')).toBe('true');
expect(screen.getByRole('button', { name: '点赞:角色英雄' })).toBeTruthy();
expect(within(likeButton).getByText('3')).toBeTruthy();
await act(async () => {
resolveLike({
showcaseId: 'showcase-character',
likeCount: 4,
viewerLiked: true,
});
await likeResult;
});
expect(
await screen.findByRole('button', { name: '取消点赞:角色英雄' }),
).toBeTruthy();
expect(screen.getByText('4')).toBeTruthy();
});
it('preserves the prior like state and announces a write failure', async () => {
const user = userEvent.setup();
listEditorProjectsMock.mockResolvedValueOnce([]);
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
{
resourceId: 'resource-character',
projectId: 'editor-showcase',
showcaseId: 'showcase-character',
label: '角色英雄',
imageSrc: 'data:image/png;base64,character',
width: 512,
height: 512,
sourceType: 'generated',
prompt: 'character hero prompt',
likeCount: 3,
viewerLiked: false,
},
]);
toggleEditorShowcaseAssetLikeMock.mockRejectedValueOnce(
new Error('write failed'),
);
renderCreationLanding();
await user.click(
await screen.findByRole('button', { name: '点赞:角色英雄' }),
);
expect((await screen.findByRole('alert')).textContent).toContain(
'点赞失败,请重试',
);
expect(
screen.getByRole('button', { name: '点赞:角色英雄' }),
).toBeTruthy();
expect(screen.getByText('3')).toBeTruthy();
});
it('does not fall back to an anonymous showcase read while auth is recovering', async () => {
listEditorProjectsMock.mockResolvedValue([]);
const recoveringAuth = createAuthValue({ canAccessProtectedData: false });
const readyAuth = createAuthValue({ canAccessProtectedData: true });
const result = renderCreationLanding({ authValue: recoveringAuth });
await act(async () => Promise.resolve());
expect(listPublicEditorProjectResourcesMock).not.toHaveBeenCalled();
result.rerender(
<AuthUiContext.Provider value={readyAuth}>
<CreationLandingView
onOpenProject={vi.fn()}
onOpenProjects={vi.fn()}
/>
</AuthUiContext.Provider>,
);
await waitFor(() =>
expect(listPublicEditorProjectResourcesMock).toHaveBeenCalledWith({
viewer: 'authenticated',
}),
);
});
it('ignores an old account showcase response after an account switch', async () => {
let resolveOldPage!: (value: unknown[]) => void;
const oldPage = new Promise<unknown[]>((resolve) => {
resolveOldPage = resolve;
});
listEditorProjectsMock.mockResolvedValue([]);
listPublicEditorProjectResourcesMock
.mockReturnValueOnce(oldPage)
.mockResolvedValueOnce([
{
resourceId: 'new-account-resource',
projectId: 'editor-showcase',
label: '新账号素材',
imageSrc: 'data:image/png;base64,new',
width: 512,
height: 512,
sourceType: 'generated',
prompt: 'new account',
viewerLiked: false,
},
]);
const result = renderCreationLanding();
await waitFor(() =>
expect(listPublicEditorProjectResourcesMock).toHaveBeenCalledTimes(1),
);
const nextAuth = createAuthValue({
user: {
...createAuthValue().user!,
id: 'user-2',
publicUserCode: '100002',
},
});
result.rerender(
<AuthUiContext.Provider value={nextAuth}>
<CreationLandingView
onOpenProject={vi.fn()}
onOpenProjects={vi.fn()}
/>
</AuthUiContext.Provider>,
);
expect(await screen.findByText('新账号素材')).toBeTruthy();
await act(async () => {
resolveOldPage([
{
resourceId: 'old-account-resource',
projectId: 'editor-showcase',
label: '旧账号素材',
imageSrc: 'data:image/png;base64,old',
width: 512,
height: 512,
sourceType: 'generated',
prompt: 'old account',
viewerLiked: true,
},
]);
await oldPage;
});
expect(screen.queryByText('旧账号素材')).toBeNull();
expect(screen.getByText('新账号素材')).toBeTruthy();
});
it('keeps the committed showcase request valid when a viewer render is abandoned', async () => {
let resolveCurrentPage!: (value: unknown[]) => void;
const currentPage = new Promise<unknown[]>((resolve) => {
resolveCurrentPage = resolve;
});
listEditorProjectsMock.mockResolvedValue([]);
listPublicEditorProjectResourcesMock.mockReturnValueOnce(currentPage);
const currentAuth = createAuthValue();
const abandonedAuth = createAuthValue({
user: {
...createAuthValue().user!,
id: 'user-abandoned',
publicUserCode: '100099',
},
});
const result = render(
<Suspense fallback={<span></span>}>
<AuthUiContext.Provider value={currentAuth}>
<CreationLandingView
onOpenProject={vi.fn()}
onOpenProjects={vi.fn()}
/>
<SuspendAfterLandingRender active={false} />
</AuthUiContext.Provider>
</Suspense>,
);
await waitFor(() =>
expect(listPublicEditorProjectResourcesMock).toHaveBeenCalledTimes(1),
);
act(() => {
startTransition(() => {
result.rerender(
<Suspense fallback={<span></span>}>
<AuthUiContext.Provider value={abandonedAuth}>
<CreationLandingView
onOpenProject={vi.fn()}
onOpenProjects={vi.fn()}
/>
<SuspendAfterLandingRender active />
</AuthUiContext.Provider>
</Suspense>,
);
});
});
await act(async () => {
resolveCurrentPage([
{
resourceId: 'current-account-resource',
projectId: 'editor-showcase',
label: '当前账号素材',
imageSrc: 'data:image/png;base64,current',
width: 512,
height: 512,
sourceType: 'generated',
prompt: 'current account',
viewerLiked: false,
},
]);
await currentPage;
});
expect(await screen.findByText('当前账号素材')).toBeTruthy();
expect(screen.queryByText('切换中')).toBeNull();
});
it('ignores an old account like response after an account switch', async () => {
const user = userEvent.setup();
let resolveOldLike!: (value: {
showcaseId: string;
likeCount: number;
viewerLiked: boolean;
}) => void;
const oldLike = new Promise<{
showcaseId: string;
likeCount: number;
viewerLiked: boolean;
}>((resolve) => {
resolveOldLike = resolve;
});
const resource = {
resourceId: 'resource-character',
projectId: 'editor-showcase',
showcaseId: 'showcase-character',
label: '角色英雄',
imageSrc: 'data:image/png;base64,character',
width: 512,
height: 512,
sourceType: 'generated',
prompt: 'character hero prompt',
viewerLiked: false,
};
listEditorProjectsMock.mockResolvedValue([]);
listPublicEditorProjectResourcesMock
.mockResolvedValueOnce([{ ...resource, likeCount: 3 }])
.mockResolvedValueOnce([{ ...resource, likeCount: 10 }]);
toggleEditorShowcaseAssetLikeMock.mockReturnValueOnce(oldLike);
const result = renderCreationLanding();
await user.click(
await screen.findByRole('button', { name: '点赞:角色英雄' }),
);
const nextAuth = createAuthValue({
user: {
...createAuthValue().user!,
id: 'user-2',
publicUserCode: '100002',
},
});
result.rerender(
<AuthUiContext.Provider value={nextAuth}>
<CreationLandingView
onOpenProject={vi.fn()}
onOpenProjects={vi.fn()}
/>
</AuthUiContext.Provider>,
);
const newAccountLikeButton = await screen.findByRole('button', {
name: '点赞:角色英雄',
});
expect(within(newAccountLikeButton).getByText('10')).toBeTruthy();
await act(async () => {
resolveOldLike({
showcaseId: 'showcase-character',
likeCount: 4,
viewerLiked: true,
});
await oldLike;
});
expect(
screen.getByRole('button', { name: '点赞:角色英雄' }),
).toBeTruthy();
expect(within(newAccountLikeButton).getByText('10')).toBeTruthy();
});
it('does not show a like action in the featured asset preview modal', async () => {
const user = userEvent.setup();
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
@@ -1064,6 +1420,7 @@ describe('CreationLandingView', () => {
await waitFor(() =>
expect(listPublicEditorProjectResourcesMock).toHaveBeenLastCalledWith({
cursor: 'cursor-page-2',
viewer: 'authenticated',
}),
);
expect(listPublicEditorProjectResourcesMock).toHaveBeenCalledTimes(2);
@@ -15,6 +15,7 @@ import {
type EditorProjectResourceListPage,
type EditorProjectResourceSnapshot,
type EditorProjectSnapshot,
type EditorPublicShowcaseResourceSnapshot,
type EditorShowcaseCampaignSnapshot,
listEditorProjects,
listPublicEditorProjectResources,
@@ -165,13 +166,22 @@ function normalizeShowcaseResourcePage(
page: EditorProjectResourceListPage | EditorProjectResourceSnapshot[],
): EditorProjectResourceListPage {
return Array.isArray(page)
? { resources: page, nextCursor: null, campaign: null }
? {
resources: page.map((resource) => ({
...resource,
viewerLiked:
(resource as Partial<EditorPublicShowcaseResourceSnapshot>)
.viewerLiked === true,
})),
nextCursor: null,
campaign: null,
}
: page;
}
function mergeShowcaseResources(
current: EditorProjectResourceSnapshot[],
incoming: EditorProjectResourceSnapshot[],
current: EditorPublicShowcaseResourceSnapshot[],
incoming: EditorPublicShowcaseResourceSnapshot[],
) {
const seenResourceIds = new Set(
current.map((resource) => resource.resourceId),
@@ -835,6 +845,13 @@ export function CreationLandingView({
const prefersReducedMotion = usePrefersReducedMotion();
const authUi = useAuthUi();
const isAuthenticated = Boolean(authUi?.user);
const showcaseViewerUserId = authUi?.user?.id.trim() || null;
const canLoadShowcaseViewer =
!showcaseViewerUserId || Boolean(authUi?.canAccessProtectedData);
const showcaseViewerKey = showcaseViewerUserId
? `user:${showcaseViewerUserId}`
: 'anonymous';
const showcaseRequestScope = `${showcaseViewerKey}:${canLoadShowcaseViewer ? 'ready' : 'recovering'}`;
const normalizedSearchKeyword = searchKeyword.trim().toLocaleLowerCase();
const [recentProjects, setRecentProjects] = useState<EditorProjectSnapshot[]>(
[],
@@ -845,7 +862,7 @@ export function CreationLandingView({
const [isCreatingProject, setIsCreatingProject] = useState(false);
const [projectError, setProjectError] = useState<string | null>(null);
const [projectShowcaseResources, setProjectShowcaseResources] = useState<
EditorProjectResourceSnapshot[]
EditorPublicShowcaseResourceSnapshot[]
>([]);
const [showcaseCampaign, setShowcaseCampaign] =
useState<EditorShowcaseCampaignSnapshot | null>(null);
@@ -865,17 +882,21 @@ export function CreationLandingView({
const [focusedShowcaseSequenceCardId, setFocusedShowcaseSequenceCardId] =
useState<string | null>(null);
const [selectedShowcaseIndex, setSelectedShowcaseIndex] = useState(0);
const [likedShowcaseIds, setLikedShowcaseIds] = useState<Set<string>>(
() => new Set(),
);
const [pendingLikeShowcaseIds, setPendingLikeShowcaseIds] = useState<
Set<string>
>(() => new Set());
const [showcaseLikeError, setShowcaseLikeError] = useState<string | null>(
null,
);
const [isPendingFeatureDialogOpen, setIsPendingFeatureDialogOpen] =
useState(false);
const showcaseLoadMoreRef = useRef<HTMLDivElement | null>(null);
const showcaseLoadingMoreCursorRef = useRef<string | null>(null);
const showcaseFailedLoadMoreCursorRef = useRef<string | null>(null);
const showcaseRequestStateRef = useRef({
scope: showcaseRequestScope,
generation: 0,
});
const refreshRecentProjects = useCallback(() => {
if (!isAuthenticated) {
@@ -910,21 +931,56 @@ export function CreationLandingView({
refreshRecentProjects();
}, [refreshRecentProjects]);
const refreshShowcaseResources = useCallback(() => {
useEffect(() => {
const requestState = {
scope: showcaseRequestScope,
generation: showcaseRequestStateRef.current.generation + 1,
};
showcaseRequestStateRef.current = requestState;
const isCurrentRequest = () =>
showcaseRequestStateRef.current.scope === requestState.scope &&
showcaseRequestStateRef.current.generation === requestState.generation;
const invalidateRequest = () => {
if (isCurrentRequest()) {
showcaseRequestStateRef.current = {
scope: requestState.scope,
generation: requestState.generation + 1,
};
}
};
setIsLoadingShowcase(true);
setIsLoadingMoreShowcase(false);
showcaseLoadingMoreCursorRef.current = null;
showcaseFailedLoadMoreCursorRef.current = null;
setProjectShowcaseResources([]);
setShowcaseCampaign(null);
setShowcaseNextCursor(null);
setAssetError(null);
setShowcaseLoadMoreError(null);
listPublicEditorProjectResources()
setShowcaseLikeError(null);
setPendingLikeShowcaseIds(new Set());
setSelectedShowcaseItem(null);
if (!canLoadShowcaseViewer) {
return invalidateRequest;
}
listPublicEditorProjectResources({
viewer: showcaseViewerUserId ? 'authenticated' : 'anonymous',
})
.then((page) => {
if (!isCurrentRequest()) {
return;
}
const normalizedPage = normalizeShowcaseResourcePage(page);
setProjectShowcaseResources(normalizedPage.resources);
setShowcaseCampaign(normalizedPage.campaign ?? null);
setShowcaseNextCursor(normalizedPage.nextCursor);
})
.catch((error: unknown) => {
if (!isCurrentRequest()) {
return;
}
setProjectShowcaseResources([]);
setShowcaseCampaign(null);
setShowcaseNextCursor(null);
@@ -933,12 +989,13 @@ export function CreationLandingView({
error instanceof Error ? error.message : '读取陶泥儿精选失败',
);
})
.finally(() => setIsLoadingShowcase(false));
}, []);
useEffect(() => {
refreshShowcaseResources();
}, [refreshShowcaseResources]);
.finally(() => {
if (isCurrentRequest()) {
setIsLoadingShowcase(false);
}
});
return invalidateRequest;
}, [canLoadShowcaseViewer, showcaseRequestScope, showcaseViewerUserId]);
const loadMoreShowcaseResources = useCallback(
(options: { force?: boolean } = {}) => {
@@ -956,10 +1013,20 @@ export function CreationLandingView({
return;
}
showcaseLoadingMoreCursorRef.current = cursor;
const requestState = { ...showcaseRequestStateRef.current };
const isCurrentRequest = () =>
showcaseRequestStateRef.current.scope === requestState.scope &&
showcaseRequestStateRef.current.generation === requestState.generation;
setIsLoadingMoreShowcase(true);
setShowcaseLoadMoreError(null);
listPublicEditorProjectResources({ cursor })
listPublicEditorProjectResources({
cursor,
viewer: showcaseViewerUserId ? 'authenticated' : 'anonymous',
})
.then((page) => {
if (!isCurrentRequest()) {
return;
}
const normalizedPage = normalizeShowcaseResourcePage(page);
setProjectShowcaseResources((current) =>
mergeShowcaseResources(current, normalizedPage.resources),
@@ -969,19 +1036,30 @@ export function CreationLandingView({
showcaseFailedLoadMoreCursorRef.current = null;
})
.catch((error: unknown) => {
if (!isCurrentRequest()) {
return;
}
showcaseFailedLoadMoreCursorRef.current = cursor;
setShowcaseLoadMoreError(
error instanceof Error ? error.message : '读取陶泥儿精选失败',
);
})
.finally(() => {
if (!isCurrentRequest()) {
return;
}
if (showcaseLoadingMoreCursorRef.current === cursor) {
showcaseLoadingMoreCursorRef.current = null;
}
setIsLoadingMoreShowcase(false);
});
},
[isLoadingMoreShowcase, isLoadingShowcase, showcaseNextCursor],
[
isLoadingMoreShowcase,
isLoadingShowcase,
showcaseNextCursor,
showcaseViewerUserId,
],
);
useEffect(() => {
@@ -1056,19 +1134,19 @@ export function CreationLandingView({
[createProject],
);
const updateShowcaseLikeCount = useCallback(
(showcaseId: string, likeCount: number) => {
const updateShowcaseViewerState = useCallback(
(showcaseId: string, likeCount: number, viewerLiked: boolean) => {
setProjectShowcaseResources((current) =>
current.map((resource) =>
resource.showcaseId === showcaseId ||
resource.resourceId === showcaseId
? { ...resource, likeCount }
? { ...resource, likeCount, viewerLiked }
: resource,
),
);
setSelectedShowcaseItem((current) =>
current?.showcaseId === showcaseId
? { ...current, likeCount }
? { ...current, likeCount, viewerLiked }
: current,
);
},
@@ -1089,44 +1167,38 @@ export function CreationLandingView({
return;
}
const nextLiked = !likedShowcaseIds.has(showcaseId);
const previousLikeCount = item.likeCount ?? 0;
const optimisticLikeCount = Math.max(
0,
previousLikeCount + (nextLiked ? 1 : -1),
);
const nextLiked = item.viewerLiked !== true;
const requestState = { ...showcaseRequestStateRef.current };
const requestViewerKey = showcaseViewerKey;
const isCurrentRequest = () =>
showcaseRequestStateRef.current.scope === requestState.scope &&
showcaseRequestStateRef.current.generation === requestState.generation &&
requestViewerKey === showcaseViewerKey;
setShowcaseLikeError(null);
setPendingLikeShowcaseIds((current) => new Set(current).add(showcaseId));
setLikedShowcaseIds((current) => {
const next = new Set(current);
if (nextLiked) {
next.add(showcaseId);
} else {
next.delete(showcaseId);
}
return next;
});
updateShowcaseLikeCount(showcaseId, optimisticLikeCount);
toggleEditorShowcaseAssetLike(showcaseId, nextLiked)
.then((record) => {
updateShowcaseLikeCount(
if (!isCurrentRequest()) {
return;
}
updateShowcaseViewerState(
showcaseId,
record.likeCount ?? optimisticLikeCount,
record.likeCount ?? item.likeCount ?? 0,
record.viewerLiked,
);
})
.catch(() => {
setLikedShowcaseIds((current) => {
const next = new Set(current);
if (nextLiked) {
next.delete(showcaseId);
} else {
next.add(showcaseId);
}
return next;
});
updateShowcaseLikeCount(showcaseId, previousLikeCount);
if (isCurrentRequest()) {
setShowcaseLikeError(
nextLiked ? '点赞失败,请重试' : '取消点赞失败,请重试',
);
}
})
.finally(() => {
if (!isCurrentRequest()) {
return;
}
setPendingLikeShowcaseIds((current) => {
const next = new Set(current);
next.delete(showcaseId);
@@ -1134,7 +1206,12 @@ export function CreationLandingView({
});
});
},
[authUi, likedShowcaseIds, pendingLikeShowcaseIds, updateShowcaseLikeCount],
[
authUi,
pendingLikeShowcaseIds,
showcaseViewerKey,
updateShowcaseViewerState,
],
);
const showcaseItems = useMemo(
@@ -1227,6 +1304,11 @@ export function CreationLandingView({
return (
<>
{showcaseLikeError ? (
<PlatformStatusMessage tone="error" surface="platform" role="alert">
{showcaseLikeError}
</PlatformStatusMessage>
) : null}
<div
ref={showcaseMasonryRef}
className="creation-landing__asset-waterfall"
@@ -1235,9 +1317,7 @@ export function CreationLandingView({
>
{visibleShowcaseItems.map((item) => {
const showcaseId = item.showcaseId?.trim();
const isLiked = Boolean(
showcaseId && likedShowcaseIds.has(showcaseId),
);
const isLiked = item.viewerLiked === true;
const isLikePending = Boolean(
showcaseId && pendingLikeShowcaseIds.has(showcaseId),
);
@@ -1302,6 +1382,7 @@ export function CreationLandingView({
: 'creation-landing__asset-like-button'
}
aria-label={`${isLiked ? '取消点赞' : '点赞'}${item.label}`}
aria-busy={isLikePending}
disabled={isLikePending}
onClick={() => handleToggleShowcaseLike(item)}
>
@@ -44,6 +44,7 @@ export type ShowcaseAssetItem = {
author: string;
cost: string;
likeCount?: number | null;
viewerLiked?: boolean;
showcaseId?: string | null;
campaign?: boolean;
};
@@ -983,6 +984,7 @@ function toLibraryShowcaseItem(
author: resolveGroupAuthor(sortedAssets, fallbackAuthorName),
cost: resolveGroupCost(sortedAssets),
likeCount: normalizeNumber(assetRecord(primaryAsset).likeCount),
viewerLiked: assetRecord(primaryAsset).viewerLiked === true,
showcaseId: normalizeText(assetRecord(primaryAsset).showcaseId),
};
}
@@ -282,6 +282,7 @@ describe('editorProjectClient', () => {
],
imageSequenceDurationMs: 6_000,
publicShowcaseEnabled: true,
viewerLiked: false,
},
],
nextCursor: 'cursor-next',
@@ -306,6 +307,7 @@ describe('editorProjectClient', () => {
expect(page.resources[0]?.authorPublicUserCode).toBe('SY-00000042');
expect(page.resources[0]?.imageSequenceFrames).toHaveLength(2);
expect(page.resources[0]?.imageSequenceDurationMs).toBe(6_000);
expect(page.resources[0]?.viewerLiked).toBe(false);
expect(page.nextCursor).toBe('cursor-next');
expect(page.campaign?.title).toBe('活动精选');
expect(page.campaign?.imageWidth).toBe(900);
@@ -348,6 +350,34 @@ describe('editorProjectClient', () => {
);
});
it('loads viewer-specific showcase state with authentication enabled', async () => {
requestJsonMock.mockResolvedValueOnce({
resources: [
{
resourceId: 'showcase-1',
projectId: 'editor-showcase',
imageSrc: '/showcase-1.png',
width: 512,
height: 512,
sourceType: 'generated',
viewerLiked: true,
},
],
nextCursor: null,
});
const page = await listPublicEditorProjectResources({
viewer: 'authenticated',
});
expect(page.resources[0]?.viewerLiked).toBe(true);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/showcase/resources',
{ method: 'GET' },
'读取陶泥儿精选素材失败',
);
});
it('loads an explicit project by id', async () => {
requestJsonMock.mockResolvedValueOnce({
project: {
@@ -706,6 +736,7 @@ describe('editorProjectClient', () => {
reviewStatus: 'approved',
displayEnabled: true,
likeCount: 8,
viewerLiked: true,
generationCostMudPoints: 20,
refundMudPoints: 10,
},
@@ -717,6 +748,7 @@ describe('editorProjectClient', () => {
);
expect(showcaseAsset.likeCount).toBe(8);
expect(showcaseAsset.viewerLiked).toBe(true);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/showcase/assets/showcase-asset-1/likes',
expect.objectContaining({

Some files were not shown because too many files have changed in this diff Show More