mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-14 04:08:16 +00:00
fix(avatar): refresh owner and review caches after mutations
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
14cc3f11eb
commit
d9c658a459
6 changed files with 124 additions and 6 deletions
|
|
@ -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,并验证无关技能的所有者缓存不被失效。
|
||||
|
|
|
|||
39
web/src/features/publish/publish-avatar-cache.test.tsx
Normal file
39
web/src/features/publish/publish-avatar-cache.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
66
web/src/features/skill/skill-avatar-cache.test.tsx
Normal file
66
web/src/features/skill/skill-avatar-cache.test.tsx
Normal 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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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')),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) ?? []
|
||||
|
|
|
|||
|
|
@ -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'] }),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue