修复精选请求并发与缓存响应头
Project CI / Frontend tests (pull_request) Successful in 3m18s
Project CI / Native shell tests (pull_request) Successful in 13m34s
Project CI / Repository checks (pull_request) Successful in 1m5s
Project CI / Backend tests (pull_request) Successful in 3m45s

累计追加 Authorization 到 Vary 并保留已有响应字段
将精选请求 generation 绑定到已提交 effect 并在 cleanup 失效
补充中间件与 Suspense 放弃渲染回归测试
同步精选点赞决策与排障文档
This commit is contained in:
2026-08-10 14:19:03 +08:00
parent 95fa252933
commit 79336a13d5
5 changed files with 145 additions and 14 deletions
@@ -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(