修复精选请求并发与缓存响应头
累计追加 Authorization 到 Vary 并保留已有响应字段 将精选请求 generation 绑定到已提交 effect 并在 cleanup 失效 补充中间件与 Suspense 放弃渲染回归测试 同步精选点赞决策与排障文档
This commit is contained in:
@@ -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 契约。
|
||||
|
||||
@@ -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` 字段被保留。
|
||||
|
||||
@@ -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<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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
@@ -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<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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user