fix: 补全公司切换的保存互斥与旧读请求中止
- 保存请求执行期间禁止切换:organization store 新增 saving/busy 与 beginSaving/endSaving,切换守卫与切换器禁用条件均由 switching 改为 busy - 切换提交时 abort 旧读请求:commitOrganizationId 内调用 abortReadRequests, 不再只在 resetOrganizationContext 里取消 - validateCandidate 仅将 403 视为候选失效,网络异常/500 等向上抛出; initialize 置 loadError,App 据此区分“加载失败”与“请先选择公司”并支持重试 - 补竞态与中止测试;需要写请求时旧响应仍被版本丢弃的用例改用 POST 覆盖
This commit is contained in:
parent
6cc3cd5dda
commit
ffcf7748fb
|
|
@ -7,7 +7,12 @@
|
|||
<main class="app-main">
|
||||
<!-- 公司尚未确定时用屏障占位,不挂载业务页面,避免组件 onMounted 发出缺少组织头的请求 -->
|
||||
<div v-if="organizationBarrier" class="organization-barrier">
|
||||
<el-empty description="请先选择要操作的公司" />
|
||||
<el-empty v-if="organizationStore.loadError" description="公司信息加载失败,请重试">
|
||||
<el-button type="primary" :loading="retrying" @click="retryInitialize">
|
||||
重新加载
|
||||
</el-button>
|
||||
</el-empty>
|
||||
<el-empty v-else description="请先选择要操作的公司" />
|
||||
</div>
|
||||
<router-view v-else :key="viewKey" />
|
||||
</main>
|
||||
|
|
@ -20,7 +25,7 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { useOrganizationStore } from './stores/organization'
|
||||
|
|
@ -32,6 +37,9 @@ const router = useRouter()
|
|||
const authStore = useAuthStore()
|
||||
const organizationStore = useOrganizationStore()
|
||||
|
||||
// 加载失败后用户手动重试;重试本身不应被误认为公司切换,故用独立标记。
|
||||
const retrying = ref(false)
|
||||
|
||||
const currentRoute = computed(() => route.path)
|
||||
|
||||
// 组织级页面在公司未确定时不挂载:由 AppHeader 的切换器引导用户先选公司。
|
||||
|
|
@ -49,6 +57,19 @@ const viewKey = computed(() => {
|
|||
function handleMenuSelect(path) {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
// 手动重试公司初始化:成功后 currentOrganizationId 变为非空,屏障自动撤下;
|
||||
// 仍失败时保持“加载失败”提示,等待用户再次重试。
|
||||
async function retryInitialize() {
|
||||
retrying.value = true
|
||||
try {
|
||||
await organizationStore.initialize()
|
||||
} catch {
|
||||
// 错误已由 axios 拦截器提示,且 store 已置 loadError,这里不再重复弹提示。
|
||||
} finally {
|
||||
retrying.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ vi.mock('../../stores/organization', async () => {
|
|||
currentOrganizationId: null,
|
||||
currentWarehouse: null,
|
||||
switching: false,
|
||||
saving: false,
|
||||
busy: false,
|
||||
switchOrganization: vi.fn()
|
||||
})
|
||||
return { useOrganizationStore: () => state }
|
||||
|
|
@ -47,6 +49,8 @@ describe('OrganizationSwitcher 公司切换器', () => {
|
|||
store.currentOrganizationId = null
|
||||
store.currentWarehouse = null
|
||||
store.switching = false
|
||||
store.saving = false
|
||||
store.busy = false
|
||||
store.switchOrganization.mockReset()
|
||||
})
|
||||
|
||||
|
|
@ -75,6 +79,17 @@ describe('OrganizationSwitcher 公司切换器', () => {
|
|||
test('切换进行中下拉禁用', () => {
|
||||
store.organizations = [{ id: 1, name: '北京' }]
|
||||
store.switching = true
|
||||
store.busy = true
|
||||
|
||||
const wrapper = mountSwitcher()
|
||||
|
||||
expect(wrapper.findComponent(SelectStub).props('disabled')).toBe(true)
|
||||
})
|
||||
|
||||
test('保存请求进行中下拉禁用', () => {
|
||||
store.organizations = [{ id: 1, name: '北京' }]
|
||||
store.saving = true
|
||||
store.busy = true
|
||||
|
||||
const wrapper = mountSwitcher()
|
||||
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@ const placeholder = computed(() =>
|
|||
organizationStore.organizations.length === 0 ? '暂无可用公司' : '请选择公司'
|
||||
)
|
||||
|
||||
// 没有授权公司或正在切换时禁止操作,避免空选择和无意义的并发切换。
|
||||
// 没有授权公司、正在切换或保存请求进行中时禁止操作:保存飞行中切换会让保存结果被静默丢弃。
|
||||
const selectDisabled = computed(
|
||||
() => organizationStore.switching || organizationStore.organizations.length === 0
|
||||
() => organizationStore.busy || organizationStore.organizations.length === 0
|
||||
)
|
||||
|
||||
// 仓库、库位摘要直接来自服务端返回,普通用户无需调用管理员仓库接口。
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { authApi } from '../api/auth'
|
||||
import {
|
||||
captureOrganizationContext,
|
||||
|
|
@ -42,8 +42,17 @@ export const useOrganizationStore = defineStore('organization', () => {
|
|||
const currentWarehouse = ref(null)
|
||||
const contextVersion = ref(0)
|
||||
const switching = ref(false)
|
||||
// 写请求(保存)进行中标记:业务 store 在发起保存前 beginSaving()、结束后 endSaving()。
|
||||
// 保存飞行中若切换公司,保存的成功/失败响应会因版本变化被视为作废并静默丢弃,
|
||||
// 用户既看不到成功也看不到失败,所以保存期间必须禁止切换。
|
||||
const saving = ref(false)
|
||||
// 任何会导致“公司上下文变更或归属不明”的操作进行中都算忙:切换中或保存中。
|
||||
const busy = computed(() => switching.value || saving.value)
|
||||
// 是否已完成一次公司列表加载,路由屏障据此决定是否需要先初始化。
|
||||
const initialized = ref(false)
|
||||
// 最近一次 initialize 是否因临时故障失败:用于把“加载失败”与“确实没有可用公司”区分开,
|
||||
// 避免把网络异常误导成“请先选择要操作的公司”。
|
||||
const loadError = ref(false)
|
||||
|
||||
// 把 store 内展示用的版本号同步成上下文工具里的真实版本,两者始终一致。
|
||||
function syncVersion() {
|
||||
|
|
@ -91,13 +100,18 @@ export const useOrganizationStore = defineStore('organization', () => {
|
|||
const res = await authApi.switchOrganizationContext(organizationId, getOrganizationId() ?? 0)
|
||||
applyContext(res.data || {})
|
||||
return true
|
||||
} catch {
|
||||
// 候选失效交给调用方继续尝试下一个;这里不弹提示,错误由拦截器统一处理。
|
||||
return false
|
||||
} catch (error) {
|
||||
// 只有 403(该用户对该候选公司确实无授权)才算“候选失效”,返回 false 交给调用方尝试下一个。
|
||||
if (error?.response?.status === 403) return false
|
||||
// 网络异常、500 等临时故障必须向上抛出:若被当成候选失效,initialize 会落入空状态并
|
||||
// 提示“请先选择要操作的公司”,把一次性故障误导成需要重新选公司。
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
loadError.value = false
|
||||
try {
|
||||
const res = await authApi.listOrganizations()
|
||||
const data = res.data || {}
|
||||
organizations.value = data.organizations || []
|
||||
|
|
@ -126,11 +140,27 @@ export const useOrganizationStore = defineStore('organization', () => {
|
|||
// 候选都失效但仍有授权公司:保留列表,要求用户重新选择。
|
||||
applyEmptyContext()
|
||||
initialized.value = true
|
||||
} catch (error) {
|
||||
// 临时故障(列表拉取失败或候选校验非 403)不降级成“无公司”空状态:
|
||||
// 标记加载失败供 App 显示“加载失败”而非“请先选择公司”,并向上抛出交由调用方重试。
|
||||
loadError.value = true
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 业务 store 复用的“写请求进行中”开关:发起保存前置位、结束后复位(务必放在 finally)。
|
||||
function beginSaving() {
|
||||
saving.value = true
|
||||
}
|
||||
|
||||
function endSaving() {
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
async function switchOrganization(organizationId) {
|
||||
// 切换或保存进行中禁止再次切换:避免并发切换互相覆盖,以及写请求归属不明确。
|
||||
if (switching.value) return
|
||||
// 保存飞行中切换会让保存的响应被版本判定作废并被静默吞掉,用户既看不到成功也看不到失败。
|
||||
if (busy.value) return
|
||||
if (!organizationId || organizationId === currentOrganizationId.value) return
|
||||
switching.value = true
|
||||
try {
|
||||
|
|
@ -154,13 +184,16 @@ export const useOrganizationStore = defineStore('organization', () => {
|
|||
currentWarehouseId.value = null
|
||||
currentLocationId.value = null
|
||||
switching.value = false
|
||||
saving.value = false
|
||||
initialized.value = false
|
||||
loadError.value = false
|
||||
syncVersion()
|
||||
}
|
||||
|
||||
return {
|
||||
organizations, currentOrganizationId, currentWarehouseId, currentLocationId,
|
||||
currentWarehouse, contextVersion, switching, initialized,
|
||||
currentWarehouse, contextVersion, switching, saving, busy, initialized, loadError,
|
||||
beginSaving, endSaving,
|
||||
initialize, switchOrganization, reset
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { createPinia, setActivePinia } from 'pinia'
|
|||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { authApi } from '../api/auth'
|
||||
import { useOrganizationStore } from './organization'
|
||||
import { resetOrganizationContext } from '../utils/organizationContext'
|
||||
import { currentReadSignal, resetOrganizationContext } from '../utils/organizationContext'
|
||||
|
||||
vi.mock('../api/auth', () => ({
|
||||
authApi: {
|
||||
|
|
@ -72,7 +72,8 @@ describe('organization store', () => {
|
|||
test('默认公司失效时保留列表并要求重新选择', async () => {
|
||||
loginAs(1)
|
||||
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1, name: '北京' }], 99))
|
||||
authApi.switchOrganizationContext.mockRejectedValue(new Error('公司不可访问'))
|
||||
// 候选公司确实无授权时服务端返回 403,才判定为“失效”。
|
||||
authApi.switchOrganizationContext.mockRejectedValue({ response: { status: 403 } })
|
||||
|
||||
const store = useOrganizationStore()
|
||||
await store.initialize()
|
||||
|
|
@ -82,6 +83,28 @@ describe('organization store', () => {
|
|||
expect(store.initialized).toBe(true)
|
||||
})
|
||||
|
||||
test('候选校验遇到非 403 的临时故障时向上抛出,不误报空状态', async () => {
|
||||
loginAs(1)
|
||||
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1, name: '北京' }], 1))
|
||||
// 网络异常/500 没有 response.status===403,不能被当成候选失效。
|
||||
authApi.switchOrganizationContext.mockRejectedValue(new Error('网络异常'))
|
||||
|
||||
const store = useOrganizationStore()
|
||||
|
||||
await expect(store.initialize()).rejects.toThrow('网络异常')
|
||||
// 不进入空状态:标记加载失败、保留未初始化,供 App 区分“加载失败”并允许重试。
|
||||
expect(store.currentOrganizationId).toBeNull()
|
||||
expect(store.initialized).toBe(false)
|
||||
expect(store.loadError).toBe(true)
|
||||
|
||||
// 故障恢复后重试成功,加载失败标记清除。
|
||||
authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(1))
|
||||
await store.initialize()
|
||||
expect(store.initialized).toBe(true)
|
||||
expect(store.loadError).toBe(false)
|
||||
expect(store.currentOrganizationId).toBe(1)
|
||||
})
|
||||
|
||||
test('没有授权公司时进入空状态且不发起校验', async () => {
|
||||
loginAs(1)
|
||||
authApi.listOrganizations.mockResolvedValue(orgList([], null))
|
||||
|
|
@ -222,6 +245,50 @@ describe('organization store', () => {
|
|||
expect(store.currentOrganizationId).toBe(1)
|
||||
})
|
||||
|
||||
test('写请求未完成时切换被拒绝', async () => {
|
||||
loginAs(1)
|
||||
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1 }, { id: 2 }], 1))
|
||||
authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(1))
|
||||
const store = useOrganizationStore()
|
||||
await store.initialize()
|
||||
authApi.switchOrganizationContext.mockClear()
|
||||
|
||||
// 模拟某个业务 store 的保存请求仍在飞行中。
|
||||
store.beginSaving()
|
||||
expect(store.busy).toBe(true)
|
||||
|
||||
await store.switchOrganization(2)
|
||||
|
||||
// 保存期间切换会让保存响应被版本判定作废并静默丢弃,必须直接拒绝且不发起校验。
|
||||
expect(authApi.switchOrganizationContext).not.toHaveBeenCalled()
|
||||
expect(store.currentOrganizationId).toBe(1)
|
||||
|
||||
// 保存结束后重新允许切换,避免忙标志被永久卡住。
|
||||
store.endSaving()
|
||||
expect(store.busy).toBe(false)
|
||||
authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(2))
|
||||
await store.switchOrganization(2)
|
||||
expect(store.currentOrganizationId).toBe(2)
|
||||
})
|
||||
|
||||
test('切换公司会中止旧公司尚未返回的读请求', async () => {
|
||||
loginAs(1)
|
||||
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1 }, { id: 2 }], 1))
|
||||
authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(1))
|
||||
const store = useOrganizationStore()
|
||||
await store.initialize()
|
||||
|
||||
// 切换前取到的读请求信号,代表旧公司尚未返回的读请求。
|
||||
const oldSignal = currentReadSignal()
|
||||
expect(oldSignal.aborted).toBe(false)
|
||||
|
||||
authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(2))
|
||||
await store.switchOrganization(2)
|
||||
|
||||
expect(oldSignal.aborted).toBe(true)
|
||||
expect(store.currentOrganizationId).toBe(2)
|
||||
})
|
||||
|
||||
test('reset 清空公司状态与上下文', async () => {
|
||||
loginAs(1)
|
||||
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1 }], 1))
|
||||
|
|
|
|||
|
|
@ -13,9 +13,12 @@ export function getOrganizationId() {
|
|||
|
||||
// 提交新的公司上下文:写入公司 ID 并递增版本。旧响应靠版本号即可识别并丢弃,
|
||||
// 因为 A→B→A 时公司 ID 又变回相同值,仅比较 ID 无法区分“第一次 A”的响应。
|
||||
// 提交新上下文同时取消旧公司尚未返回的读请求:版本判定只能丢弃迟到的响应,
|
||||
// 真正中止在途读请求要靠 AbortController,避免旧公司的数据在切换后继续占用连接。
|
||||
export function commitOrganizationId(organizationId) {
|
||||
currentOrganizationId = organizationId ?? null
|
||||
currentVersion += 1
|
||||
abortReadRequests()
|
||||
}
|
||||
|
||||
// 捕获发起请求那一刻的上下文快照,响应回来时用它判断是否已被切换作废。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import request from '../api/index'
|
||||
|
|
@ -57,6 +58,15 @@ describe('公司上下文快照', () => {
|
|||
expect(newSignal).not.toBe(oldSignal)
|
||||
})
|
||||
|
||||
test('提交公司上下文即中止旧读请求', () => {
|
||||
const oldSignal = currentReadSignal()
|
||||
|
||||
commitOrganizationId(2)
|
||||
|
||||
expect(oldSignal.aborted).toBe(true)
|
||||
expect(currentReadSignal().aborted).toBe(false)
|
||||
})
|
||||
|
||||
test('作废错误可被识别,区别于普通失败', () => {
|
||||
expect(isOrganizationContextSuperseded(new OrganizationContextSupersededError())).toBe(true)
|
||||
expect(isOrganizationContextSuperseded(new Error('普通错误'))).toBe(false)
|
||||
|
|
@ -107,6 +117,8 @@ describe('axios 组织级请求拦截', () => {
|
|||
})
|
||||
|
||||
test('切换公司后旧成功响应被丢弃且不弹提示', async () => {
|
||||
// 用 POST(写请求)验证版本判定这条兜底:写请求不挂共享读信号,切换不会把它 abort,
|
||||
// 因此旧响应仍会到达响应拦截器,必须靠上下文版本识别并丢弃。
|
||||
commitOrganizationId(1)
|
||||
const errorSpy = vi.spyOn(ElMessage, 'error').mockImplementation(() => {})
|
||||
request.defaults.adapter = respondsWith({ code: 0, data: { ok: true } }, 200, () => {
|
||||
|
|
@ -114,7 +126,7 @@ describe('axios 组织级请求拦截', () => {
|
|||
commitOrganizationId(2)
|
||||
})
|
||||
|
||||
await expect(request.get('/materials')).rejects.toBeInstanceOf(OrganizationContextSupersededError)
|
||||
await expect(request.post('/materials')).rejects.toBeInstanceOf(OrganizationContextSupersededError)
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
@ -125,7 +137,21 @@ describe('axios 组织级请求拦截', () => {
|
|||
commitOrganizationId(2)
|
||||
})
|
||||
|
||||
await expect(request.get('/materials')).rejects.toBeInstanceOf(OrganizationContextSupersededError)
|
||||
await expect(request.post('/materials')).rejects.toBeInstanceOf(OrganizationContextSupersededError)
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('切换公司会中止在途的读请求且不弹提示', async () => {
|
||||
// GET(读请求)挂共享信号:切换提交时 commitOrganizationId 会 abort,请求以取消错误结束,
|
||||
// 拦截器静默处理、不弹提示,页面也不会把作废当失败。
|
||||
commitOrganizationId(1)
|
||||
const errorSpy = vi.spyOn(ElMessage, 'error').mockImplementation(() => {})
|
||||
request.defaults.adapter = respondsWith({ code: 0, data: { ok: true } }, 200, () => {
|
||||
commitOrganizationId(2)
|
||||
})
|
||||
|
||||
const error = await request.get('/materials').catch(err => err)
|
||||
expect(axios.isCancel(error)).toBe(true)
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user