fix(avatar): refresh owner and review caches after mutations

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-13 12:54:18 +08:00
parent 14cc3f11eb
commit d9c658a459
6 changed files with 124 additions and 6 deletions

View file

@ -22,7 +22,7 @@
- 原 Issue 的“机器不得直接拒绝”与本轮方案不同。机器单开模式的质量和误判处理需在运行验证中单列,不以外部 API 返回成功代替有效审核验证。
## 已确认的发布交互调整(2026-10-13,待实施)
## 已确认的发布交互调整(2026-10-13,已实施,浏览器验收中)
本节补充原实现,需求以 `specs/skill-avatars/spec.md` 的 REQ-AV-09/10/11 为准。
@ -35,4 +35,6 @@
- 未关联临时图片始终私有,按有界任务定期清理;关联与清理并发须保证不会删除已绑定图片。临时引用使用独立 `skill_avatar_upload` 表,24 小时过期;上传接口为 `/api/web/skills/avatar-uploads`,提交技能携带 `avatarAction`(默认 KEEP,可选 REPLACE/REMOVE)和替换时的 `avatarUploadId`。单独维护使用现有头像路径的 JSON 提交;更新携带 `expectedSlug` 校验技能包目标。清理和引用消费均锁定临时上传记录,发布与独立修改均采用技能→上传记录的锁顺序。不引入业务状态 SQL CHECK 约束。
- 唯一机器供应商的部署选择、后台两个审核开关、PRIVATE 直接使用规则保持不变。
原验收只覆盖详情页独立提交流程,不能证明上述新增/更新流程;实施后须补充对应测试和浏览器验收。真实供应商调用仍未验证。
原验收只覆盖详情页独立提交流程,不能证明上述新增/更新流程;本轮新增/更新及独立维护流程按对应测试和浏览器验收重新验证。真实供应商调用仍未验证。
同浏览器的发布、独立维护和审核决定成功后,须等待刷新技能展示、对应所有者头像状态及头像审核队列缓存;不以等待缓存过期替代状态更新。回归以真实 QueryClient 下旧状态变为提交后的状态为 Oracle,并验证无关技能的所有者缓存不被失效。

View file

@ -0,0 +1,39 @@
/** @vitest-environment jsdom */
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ReactNode } from 'react'
const transport = vi.hoisted(() => ({ fetchJson: vi.fn() }))
vi.mock('@/api/client', async () => ({ ...await vi.importActual<typeof import('@/api/client')>('@/api/client'), fetchJson: transport.fetchJson, getCsrfHeaders: () => ({}) }))
import { usePublishSkill } from './use-publish-skill'
describe('publishing refreshes owner avatar state', () => {
afterEach(() => { cleanup(); transport.fetchJson.mockReset() })
it('refreshes the active owner avatar query before the successful update returns', async () => {
const key = ['skill-avatar', 'team', 'foo']
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
client.setQueryData(key, { avatarUrl: '/old-avatar' })
client.setQueryData(['skill-avatar', 'other-team', 'bar'], { avatarUrl: '/unrelated' })
client.setQueryData(['skill-avatar-review'], [])
const loadQueue = vi.fn().mockResolvedValue([{ candidateId: 42 }])
const loadAvatar = vi.fn().mockResolvedValue({ avatarUrl: null })
transport.fetchJson.mockResolvedValue({ namespace: 'team', slug: 'foo', version: '1.1.0', status: 'PENDING_REVIEW' })
const wrapper = ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}>{children}</QueryClientProvider>
const hook = renderHook(() => {
useQuery({ queryKey: key, queryFn: loadAvatar, staleTime: Infinity })
useQuery({ queryKey: ['skill-avatar-review'], queryFn: loadQueue, staleTime: Infinity })
return usePublishSkill()
}, { wrapper })
expect(loadAvatar).not.toHaveBeenCalled()
await act(() => hook.result.current.mutateAsync({ namespace: 'team', file: new File(['zip'], 'skill.zip'), visibility: 'PUBLIC', avatarAction: 'REMOVE' }))
await waitFor(() => expect(client.getQueryData(key)).toEqual({ avatarUrl: null }))
expect(loadAvatar).toHaveBeenCalledOnce()
expect(client.getQueryData(['skill-avatar-review'])).toEqual([{ candidateId: 42 }])
expect(client.getQueryData(['skill-avatar', 'other-team', 'bar'])).toEqual({ avatarUrl: '/unrelated' })
expect(client.getQueryState(['skill-avatar', 'other-team', 'bar'])?.isInvalidated).toBe(false)
})
})

View file

@ -0,0 +1,66 @@
/** @vitest-environment jsdom */
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
const api = vi.hoisted(() => ({ status: vi.fn(), submit: vi.fn(), queue: vi.fn(), decide: vi.fn() }))
vi.mock('@/api/client', () => ({ skillAvatarApi: api, buildApiUrl: (url: string) => url }))
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
vi.mock('./skill-avatar-editor', () => ({
KEEP_AVATAR: { action: 'KEEP', ready: true },
SkillAvatarEditor: ({ onChange }: { onChange: (value: { action: 'REPLACE'; ready: boolean; uploadId: string }) => void }) => <button onClick={() => onChange({ action: 'REPLACE', ready: true, uploadId: 'staged-image' })}>Use staged image</button>,
}))
import { SkillAvatarManager } from './skill-avatar-manager'
import { SkillAvatarReviewQueue } from './skill-avatar-review'
const ownerKey = ['skill-avatar', 'team', 'foo']
const queueKey = ['skill-avatar-review']
const pending = { namespace: 'team', slug: 'foo', avatarUrl: null, candidateId: 42, candidateStatus: 'PENDING', machineStatus: 'SKIPPED', reviewStatus: 'PENDING', candidateUrl: null }
const active = { ...pending, avatarUrl: '/active', candidateStatus: 'ACTIVE', reviewStatus: 'APPROVED' }
describe('avatar cache across owner and reviewer flows', () => {
afterEach(() => { cleanup(); Object.values(api).forEach(fn => fn.mockReset()) })
it('refreshes a previously empty fresh review queue when the owner submits a staged image', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } })
client.setQueryData(ownerKey, { ...active, candidateId: 20 })
client.setQueryData(queueKey, [])
api.status.mockResolvedValue(pending)
api.submit.mockResolvedValue(pending)
api.queue.mockResolvedValue([pending])
render(<QueryClientProvider client={client}><SkillAvatarManager namespace="team" slug="foo" /><SkillAvatarReviewQueue namespace="team" /></QueryClientProvider>)
expect(screen.getByText('skillAvatar.emptyQueue')).toBeTruthy()
expect(api.queue).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Use staged image'))
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.saveChanges' }))
expect(await screen.findByText('@team/foo')).toBeTruthy()
expect(client.getQueryData(ownerKey)).toEqual(pending)
expect(client.getQueryData(queueKey)).toEqual([pending])
})
it('refreshes the owner status and skill display after the owner approves their own candidate', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } })
const skillKey = ['skills', 'team', 'foo', 'en']
client.setQueryData(ownerKey, pending)
client.setQueryData(queueKey, [pending])
client.setQueryData(skillKey, { avatarUrl: null })
api.status.mockResolvedValue(active)
api.queue.mockResolvedValue([])
api.decide.mockResolvedValue(active)
function OwnerView() {
const status = useQuery({ queryKey: ownerKey, queryFn: () => api.status('team', 'foo') })
const skill = useQuery({ queryKey: skillKey, queryFn: async () => ({ avatarUrl: '/active' }) })
return <p data-testid="owner-view">{status.data?.candidateStatus}/{skill.data?.avatarUrl ?? 'fallback'}</p>
}
render(<QueryClientProvider client={client}><OwnerView /><SkillAvatarReviewQueue namespace="team" /></QueryClientProvider>)
expect(screen.getByTestId('owner-view').textContent).toBe('PENDING/fallback')
fireEvent.change(screen.getByLabelText('skillAvatar.reason'), { target: { value: 'Approved local image' } })
fireEvent.click(screen.getByRole('button', { name: 'skillAvatar.approve' }))
await waitFor(() => expect(screen.getByTestId('owner-view').textContent).toBe('ACTIVE//active'))
expect(screen.queryByText('@team/foo')).toBeNull()
expect(client.getQueryData(ownerKey)).toEqual(active)
expect(client.getQueryData(queueKey)).toEqual([])
})
})

View file

@ -27,7 +27,7 @@ export function SkillAvatarManager({ namespace, slug, name = slug, readOnly = fa
setEdit(KEEP_AVATAR)
setEditorVersion(value => value + 1)
}
await Promise.all([queryClient.invalidateQueries({ queryKey: key }), queryClient.invalidateQueries({ queryKey: ['skill'] }), queryClient.invalidateQueries({ queryKey: ['skills'] })])
await Promise.all([queryClient.invalidateQueries({ queryKey: key }), queryClient.invalidateQueries({ queryKey: ['skill'] }), queryClient.invalidateQueries({ queryKey: ['skills'] }), queryClient.invalidateQueries({ queryKey: ['skill-avatar-review'] })])
},
onError: (cause) => setError(cause instanceof Error ? cause.message : t('skillAvatar.failed')),
})

View file

@ -15,7 +15,14 @@ export function SkillAvatarReviewQueue({ namespace }: { namespace?: string }) {
const decide = useMutation({
mutationFn: (input: { namespace: string; slug: string; candidateId: number; approved: boolean; reason: string }) =>
skillAvatarApi.decide(input.namespace, input.slug, input.candidateId, input.approved, input.reason),
onSuccess: async () => { setError(''); await queryClient.invalidateQueries({ queryKey: ['skill-avatar-review'] }) },
onSuccess: async (_data, input) => {
setError('')
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['skill-avatar-review'] }),
queryClient.invalidateQueries({ queryKey: ['skill-avatar', input.namespace, input.slug] }),
queryClient.invalidateQueries({ queryKey: ['skills'] }),
])
},
onError: (cause) => setError(cause instanceof Error ? cause.message : t('skillAvatar.failed')),
})
const items = queue.data?.filter((item) => !namespace || item.namespace === namespace) ?? []

View file

@ -145,8 +145,12 @@ export function usePublishSkill() {
meta: {
skipGlobalErrorHandler: true,
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['skills'] })
onSuccess: async (result) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['skills'] }),
queryClient.invalidateQueries({ queryKey: ['skill-avatar', result.namespace, result.slug] }),
queryClient.invalidateQueries({ queryKey: ['skill-avatar-review'] }),
])
},
})
}