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:
文睿 张 2026-10-09 10:34:19 +08:00
parent 6cc3cd5dda
commit ffcf7748fb
7 changed files with 202 additions and 37 deletions

View File

@ -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>

View File

@ -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()

View File

@ -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
)
// 仓库、库位摘要直接来自服务端返回,普通用户无需调用管理员仓库接口。

View File

@ -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,46 +100,67 @@ 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() {
const res = await authApi.listOrganizations()
const data = res.data || {}
organizations.value = data.organizations || []
loadError.value = false
try {
const res = await authApi.listOrganizations()
const data = res.data || {}
organizations.value = data.organizations || []
if (organizations.value.length === 0) {
// 无授权公司:空状态,不发起任何校验请求。
applyEmptyContext()
initialized.value = true
return
}
// 本标签页上次选择优先,其次默认公司;两者都必须重新校验授权。
const defaultId = data.default_organization_id ?? null
const candidates = [readStoredOrganizationId(), defaultId]
const seen = new Set()
for (const candidate of candidates) {
if (candidate == null || seen.has(candidate)) continue
seen.add(candidate)
if (!organizations.value.some(org => org.id === candidate)) continue
if (await validateCandidate(candidate)) {
if (organizations.value.length === 0) {
// 无授权公司:空状态,不发起任何校验请求。
applyEmptyContext()
initialized.value = true
return
}
}
// 候选都失效但仍有授权公司:保留列表,要求用户重新选择。
applyEmptyContext()
initialized.value = true
// 本标签页上次选择优先,其次默认公司;两者都必须重新校验授权。
const defaultId = data.default_organization_id ?? null
const candidates = [readStoredOrganizationId(), defaultId]
const seen = new Set()
for (const candidate of candidates) {
if (candidate == null || seen.has(candidate)) continue
seen.add(candidate)
if (!organizations.value.some(org => org.id === candidate)) continue
if (await validateCandidate(candidate)) {
initialized.value = true
return
}
}
// 候选都失效但仍有授权公司:保留列表,要求用户重新选择。
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
}
})

View File

@ -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))

View File

@ -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()
}
// 捕获发起请求那一刻的上下文快照,响应回来时用它判断是否已被切换作废。

View File

@ -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()
})