diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a98cdca2d..83b681d88 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6900,6 +6900,6 @@ ## 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 丢弃旧首屏、分页和写回响应。不做跨 Tab 实时同步。 +- 决策:保留同一个公开 `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 契约。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 6278a6711..5d9a9e6cc 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4475,5 +4475,5 @@ - 现象:陶泥儿精选点赞写入成功、总点赞数也正确,但刷新或重新挂载后图标恢复成未点赞;前端再次点击会发出错误意图或让计数体验混乱。 - 原因:`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。个性化响应禁止共享缓存或错误降级。写入采用服务端确认式更新,账号 / 鉴权 scope 变化后重载并丢弃旧请求回包;输入 user ID 不能代替 runtime service identity 鉴权。 -- 验证:覆盖刷新 / remount 保持已点赞、pending 期间不改图标计数、失败保留旧状态并播报错误、分页保留 viewer state、鉴权恢复不发匿名请求、登录 / 退出 / 换号与旧首屏 / 分页 / POST 回包竞态,以及无效 Bearer 返回 `401 + private,no-store + Vary: Authorization`。 +- 处理:公开列表使用可选鉴权 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` 字段被保留。 diff --git a/server-rs/crates/api-server/src/modules/editor_project.rs b/server-rs/crates/api-server/src/modules/editor_project.rs index 8ef622a81..1abcb5095 100644 --- a/server-rs/crates/api-server/src/modules/editor_project.rs +++ b/server-rs/crates/api-server/src/modules/editor_project.rs @@ -46,7 +46,7 @@ async fn add_showcase_private_response_headers(request: Request, next: Next) -> ); response .headers_mut() - .insert(header::VARY, HeaderValue::from_static("Authorization")); + .append(header::VARY, HeaderValue::from_static("Authorization")); response } @@ -284,3 +284,53 @@ pub fn router(state: AppState) -> Router { )), ) } + +#[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::>(); + + assert_eq!(vary_values, vec!["Origin", "Authorization"]); + } +} diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx index 6bade559a..61c767a87 100644 --- a/src/components/creation-home/CreationLandingView.test.tsx +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -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(() => undefined); + } + return null; +} + describe('CreationLandingView', () => { beforeEach(() => { listPublicEditorProjectResourcesMock.mockResolvedValue([]); @@ -924,6 +931,73 @@ describe('CreationLandingView', () => { 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((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( + 切换中}> + + + + + , + ); + await waitFor(() => + expect(listPublicEditorProjectResourcesMock).toHaveBeenCalledTimes(1), + ); + + act(() => { + startTransition(() => { + result.rerender( + 切换中}> + + + + + , + ); + }); + }); + + 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: { diff --git a/src/components/creation-home/CreationLandingView.tsx b/src/components/creation-home/CreationLandingView.tsx index f2996b402..c991080cf 100644 --- a/src/components/creation-home/CreationLandingView.tsx +++ b/src/components/creation-home/CreationLandingView.tsx @@ -897,12 +897,6 @@ export function CreationLandingView({ scope: showcaseRequestScope, generation: 0, }); - if (showcaseRequestStateRef.current.scope !== showcaseRequestScope) { - showcaseRequestStateRef.current = { - scope: showcaseRequestScope, - generation: showcaseRequestStateRef.current.generation + 1, - }; - } const refreshRecentProjects = useCallback(() => { if (!isAuthenticated) { @@ -938,10 +932,22 @@ export function CreationLandingView({ }, [refreshRecentProjects]); useEffect(() => { - const requestState = { ...showcaseRequestStateRef.current }; + 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); @@ -956,7 +962,7 @@ export function CreationLandingView({ setPendingLikeShowcaseIds(new Set()); setSelectedShowcaseItem(null); if (!canLoadShowcaseViewer) { - return; + return invalidateRequest; } listPublicEditorProjectResources({ @@ -988,6 +994,7 @@ export function CreationLandingView({ setIsLoadingShowcase(false); } }); + return invalidateRequest; }, [canLoadShowcaseViewer, showcaseRequestScope, showcaseViewerUserId]); const loadMoreShowcaseResources = useCallback(