fix: 接线公司实时连接生命周期并钉住角色撤销语义
- organization store 接线 companySocket:切换后为当前公司重连,登出/空状态只关闭不重连 - companySocket 延迟解析 WebSocket 并静默吞掉取票/建连失败,不阻断公司切换 - authApi 新增 createWebSocketTicket(组织级请求) - 补 store 三例接线测试:切换触发重连、登出触发关闭、空公司不连接 - 补 Go 用例钉住复核边界:纯业务角色撤销、公司授权仍在时不断流
This commit is contained in:
parent
5021b039c9
commit
d580efebe5
|
|
@ -17,5 +17,9 @@ export const authApi = {
|
||||||
organization_id: organizationId,
|
organization_id: organizationId,
|
||||||
previous_organization_id: previousOrganizationId
|
previous_organization_id: previousOrganizationId
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
// 换取 WebSocket 短期一次性票据:属于组织级请求,拦截器会自动带上当前公司头。
|
||||||
|
createWebSocketTicket() {
|
||||||
|
return request.post('/auth/websocket-ticket')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { authApi } from '../api/auth'
|
import { authApi } from '../api/auth'
|
||||||
|
import { createCompanySocket } from '../utils/companySocket'
|
||||||
import {
|
import {
|
||||||
captureOrganizationContext,
|
captureOrganizationContext,
|
||||||
commitOrganizationId,
|
commitOrganizationId,
|
||||||
|
|
@ -8,6 +9,21 @@ import {
|
||||||
resetOrganizationContext
|
resetOrganizationContext
|
||||||
} from '../utils/organizationContext'
|
} from '../utils/organizationContext'
|
||||||
|
|
||||||
|
// 库存等实时事件处理器:默认空实现,页面通过 setStockUpdateHandler 注册。
|
||||||
|
// 放在模块级而非 store 内,保证整个应用只有一条公司级实时连接。
|
||||||
|
let stockUpdateHandler = () => {}
|
||||||
|
|
||||||
|
export function setStockUpdateHandler(handler) {
|
||||||
|
stockUpdateHandler = typeof handler === 'function' ? handler : () => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公司级实时连接:切换公司时 connect 会先关旧连、再按当前公司重新取票重连;
|
||||||
|
// 登出与空状态只 close,不重连。connect 内部对网络/取票失败静默降级,不会阻断切换。
|
||||||
|
const organizationSocket = createCompanySocket({
|
||||||
|
fetchTicket: () => authApi.createWebSocketTicket(),
|
||||||
|
onMessage: (payload, snapshot) => stockUpdateHandler(payload, snapshot)
|
||||||
|
})
|
||||||
|
|
||||||
// 本标签页上次选择的公司按用户分开存:同一浏览器不同账号登录时互不继承选择。
|
// 本标签页上次选择的公司按用户分开存:同一浏览器不同账号登录时互不继承选择。
|
||||||
function selectionStorageKey(userId) {
|
function selectionStorageKey(userId) {
|
||||||
return `wmms_organization_id:${userId ?? 'anonymous'}`
|
return `wmms_organization_id:${userId ?? 'anonymous'}`
|
||||||
|
|
@ -83,6 +99,8 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||||
applyWarehouseContext(data?.warehouse || null)
|
applyWarehouseContext(data?.warehouse || null)
|
||||||
syncVersion()
|
syncVersion()
|
||||||
if (organizationId) writeStoredOrganizationId(organizationId)
|
if (organizationId) writeStoredOrganizationId(organizationId)
|
||||||
|
// 当前公司已确定:关闭旧公司连接并为新公司取得票据重连。connect 不阻塞、不抛错。
|
||||||
|
organizationSocket.connect()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 没有可用公司(或候选公司都失效)时进入空状态:公司 ID 保持为空,
|
// 没有可用公司(或候选公司都失效)时进入空状态:公司 ID 保持为空,
|
||||||
|
|
@ -92,6 +110,8 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||||
currentOrganizationId.value = null
|
currentOrganizationId.value = null
|
||||||
applyWarehouseContext(null)
|
applyWarehouseContext(null)
|
||||||
syncVersion()
|
syncVersion()
|
||||||
|
// 无可用公司:不建立实时连接。
|
||||||
|
organizationSocket.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用切换校验接口验证候选公司:该接口会返回目标公司的仓库摘要,顺带完成授权校验。
|
// 用切换校验接口验证候选公司:该接口会返回目标公司的仓库摘要,顺带完成授权校验。
|
||||||
|
|
@ -178,6 +198,8 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||||
// 登出或切换账号时清空:上下文、公司列表、仓库库位与初始化标记全部复位。
|
// 登出或切换账号时清空:上下文、公司列表、仓库库位与初始化标记全部复位。
|
||||||
function reset() {
|
function reset() {
|
||||||
resetOrganizationContext()
|
resetOrganizationContext()
|
||||||
|
// 登出/账号变化:关闭实时连接且不重连。
|
||||||
|
organizationSocket.close()
|
||||||
organizations.value = []
|
organizations.value = []
|
||||||
currentOrganizationId.value = null
|
currentOrganizationId.value = null
|
||||||
currentWarehouse.value = null
|
currentWarehouse.value = null
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,17 @@ import { currentReadSignal, resetOrganizationContext } from '../utils/organizati
|
||||||
vi.mock('../api/auth', () => ({
|
vi.mock('../api/auth', () => ({
|
||||||
authApi: {
|
authApi: {
|
||||||
listOrganizations: vi.fn(),
|
listOrganizations: vi.fn(),
|
||||||
switchOrganizationContext: vi.fn()
|
switchOrganizationContext: vi.fn(),
|
||||||
|
createWebSocketTicket: vi.fn()
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// 公司级实时连接在 store 内接线:这里用桩替换,断言切换触发 connect、登出/空状态触发 close。
|
||||||
|
const socketMock = vi.hoisted(() => ({ connect: vi.fn(), close: vi.fn() }))
|
||||||
|
vi.mock('../utils/companySocket', () => ({
|
||||||
|
createCompanySocket: () => socketMock
|
||||||
|
}))
|
||||||
|
|
||||||
// 模拟某个账号已登录:公司选择按用户 id 分开存储,这里写入对应账号。
|
// 模拟某个账号已登录:公司选择按用户 id 分开存储,这里写入对应账号。
|
||||||
function loginAs(id) {
|
function loginAs(id) {
|
||||||
localStorage.setItem('wmms_user', JSON.stringify({ id, username: `user${id}` }))
|
localStorage.setItem('wmms_user', JSON.stringify({ id, username: `user${id}` }))
|
||||||
|
|
@ -39,6 +46,8 @@ describe('organization store', () => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
authApi.listOrganizations.mockReset()
|
authApi.listOrganizations.mockReset()
|
||||||
authApi.switchOrganizationContext.mockReset()
|
authApi.switchOrganizationContext.mockReset()
|
||||||
|
socketMock.connect.mockReset()
|
||||||
|
socketMock.close.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('默认公司进入并自动选中唯一启用的仓库与库位', async () => {
|
test('默认公司进入并自动选中唯一启用的仓库与库位', async () => {
|
||||||
|
|
@ -306,3 +315,58 @@ describe('organization store', () => {
|
||||||
expect(store.initialized).toBe(false)
|
expect(store.initialized).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 公司级实时连接的运行时接线:计划要求“切换公司关旧连并为新公司重连、登出关闭、空状态不连接”。
|
||||||
|
// 这些用例钉住 store 在上述生命周期节点确实调用了 connect/close。
|
||||||
|
describe('公司级实时连接接线', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
sessionStorage.clear()
|
||||||
|
resetOrganizationContext()
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
authApi.listOrganizations.mockReset()
|
||||||
|
authApi.switchOrganizationContext.mockReset()
|
||||||
|
socketMock.connect.mockReset()
|
||||||
|
socketMock.close.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('切换公司时为当前公司重连(connect 内部先关旧连)', async () => {
|
||||||
|
loginAs(1)
|
||||||
|
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1 }, { id: 2 }], 1))
|
||||||
|
authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(1))
|
||||||
|
const store = useOrganizationStore()
|
||||||
|
await store.initialize()
|
||||||
|
const afterInit = socketMock.connect.mock.calls.length
|
||||||
|
expect(afterInit).toBeGreaterThanOrEqual(1)
|
||||||
|
|
||||||
|
authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(2))
|
||||||
|
await store.switchOrganization(2)
|
||||||
|
|
||||||
|
expect(socketMock.connect.mock.calls.length).toBeGreaterThan(afterInit)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('登出(reset)关闭连接且不重连', async () => {
|
||||||
|
loginAs(1)
|
||||||
|
authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1 }], 1))
|
||||||
|
authApi.switchOrganizationContext.mockResolvedValue(switchResult(1))
|
||||||
|
const store = useOrganizationStore()
|
||||||
|
await store.initialize()
|
||||||
|
const connectBefore = socketMock.connect.mock.calls.length
|
||||||
|
|
||||||
|
store.reset()
|
||||||
|
|
||||||
|
expect(socketMock.close).toHaveBeenCalled()
|
||||||
|
// reset 之后不再建立新连接。
|
||||||
|
expect(socketMock.connect.mock.calls.length).toBe(connectBefore)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('无可用公司时不建立连接,只关闭', async () => {
|
||||||
|
loginAs(1)
|
||||||
|
authApi.listOrganizations.mockResolvedValue(orgList([], null))
|
||||||
|
const store = useOrganizationStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
expect(socketMock.connect).not.toHaveBeenCalled()
|
||||||
|
expect(socketMock.close).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,17 @@ export function createCompanySocket(options = {}) {
|
||||||
const {
|
const {
|
||||||
fetchTicket,
|
fetchTicket,
|
||||||
onMessage = () => {},
|
onMessage = () => {},
|
||||||
WebSocketImpl = globalThis.WebSocket,
|
WebSocketImpl,
|
||||||
location = globalThis.location,
|
location = globalThis.location,
|
||||||
log = () => {}
|
log = () => {}
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
|
// WebSocket 实现延迟解析:共享实例可能在模块导入时构造,此时 globalThis.WebSocket
|
||||||
|
// 未必就绪;测试也可在 connect 之前覆盖 globalThis.WebSocket。
|
||||||
|
function resolveWebSocketImpl() {
|
||||||
|
return typeof WebSocketImpl === 'function' ? WebSocketImpl : globalThis.WebSocket
|
||||||
|
}
|
||||||
|
|
||||||
let socket = null
|
let socket = null
|
||||||
// generation 每次 connect/close 都递增:任何一个回调只要发现自己的代次与当前不符,
|
// generation 每次 connect/close 都递增:任何一个回调只要发现自己的代次与当前不符,
|
||||||
// 就说明连接已被替换或已关闭,不得再更新页面。
|
// 就说明连接已被替换或已关闭,不得再更新页面。
|
||||||
|
|
@ -62,20 +68,22 @@ export function createCompanySocket(options = {}) {
|
||||||
// 没有当前公司(登出或尚未选择)时不建立连接。
|
// 没有当前公司(登出或尚未选择)时不建立连接。
|
||||||
if (snapshot.organizationId == null) return
|
if (snapshot.organizationId == null) return
|
||||||
const gen = ++generation
|
const gen = ++generation
|
||||||
if (typeof WebSocketImpl !== 'function') {
|
const WebSocketCtor = resolveWebSocketImpl()
|
||||||
|
if (typeof WebSocketCtor !== 'function') {
|
||||||
log('当前环境不支持 WebSocket,跳过建连')
|
log('当前环境不支持 WebSocket,跳过建连')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let response
|
let ticket
|
||||||
try {
|
try {
|
||||||
response = await fetchTicket()
|
const response = await fetchTicket()
|
||||||
|
const data = response?.data ?? response
|
||||||
|
ticket = data?.ticket
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 取票失败静默降级:不抛出、不阻断公司切换等调用方流程。
|
||||||
log('取得 WebSocket 票据失败', error)
|
log('取得 WebSocket 票据失败', error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data = response?.data ?? response
|
|
||||||
const ticket = data?.ticket
|
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
log('WebSocket 票据为空,跳过建连')
|
log('WebSocket 票据为空,跳过建连')
|
||||||
return
|
return
|
||||||
|
|
@ -83,9 +91,16 @@ export function createCompanySocket(options = {}) {
|
||||||
// 取票期间公司可能已切换或登出:快照失效则放弃这次连接,交给新的 connect 处理。
|
// 取票期间公司可能已切换或登出:快照失效则放弃这次连接,交给新的 connect 处理。
|
||||||
if (!isCurrent(gen, snapshot)) return
|
if (!isCurrent(gen, snapshot)) return
|
||||||
|
|
||||||
const ws = new WebSocketImpl(
|
let ws
|
||||||
|
try {
|
||||||
|
ws = new WebSocketCtor(
|
||||||
`${buildSocketUrl(location)}?ticket=${encodeURIComponent(ticket)}`
|
`${buildSocketUrl(location)}?ticket=${encodeURIComponent(ticket)}`
|
||||||
)
|
)
|
||||||
|
} catch (error) {
|
||||||
|
// 建连失败同样静默处理,绝不让实时连接问题影响页面主流程。
|
||||||
|
log('建立 WebSocket 连接失败', error)
|
||||||
|
return
|
||||||
|
}
|
||||||
socket = ws
|
socket = ws
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
if (isCurrent(gen, snapshot)) log('WebSocket 已连接(公司 %s)', snapshot.organizationId)
|
if (isCurrent(gen, snapshot)) log('WebSocket 已连接(公司 %s)', snapshot.organizationId)
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,27 @@ func TestOrganizationAccessVerifierReloadsFromDatabase(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOrganizationAccessVerifierDoesNotDropBusinessRoleRevocation:显式钉住复核边界。
|
||||||
|
// 复核的隔离边界是“用户仍存在 + 公司授权仍可访问”;仅撤销某个非管理员业务角色、
|
||||||
|
// 而公司授权仍在时,Verify 仍通过,订阅者不会被断开。角色级断流需要完整的会话/权限
|
||||||
|
// 体系,本期未实现,故用本用例固定该语义,避免日后被误认为已实现角色级撤销。
|
||||||
|
func TestOrganizationAccessVerifierDoesNotDropBusinessRoleRevocation(t *testing.T) {
|
||||||
|
mock := contextMock(t)
|
||||||
|
// 用户仍存在,有效角色只剩一个普通业务角色(原管理员角色已被撤销)。
|
||||||
|
mock.ExpectQuery("SELECT u.id, r.code FROM users u").WithArgs(int64(7)).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "code"}).AddRow(7, "purchaser"))
|
||||||
|
// 公司授权仍然保留。
|
||||||
|
mock.ExpectQuery("SELECT EXISTS").WithArgs(int64(2), int64(7), int64(7)).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"allowed"}).AddRow(true))
|
||||||
|
|
||||||
|
if err := (organizationAccessVerifier{}).Verify(7, 2); err != nil {
|
||||||
|
t.Fatalf("仅业务角色变化、公司授权仍在时不应断流,实际 err=%v", err)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// fakeVerifier 是 Hub 测试用的可注入复核器。
|
// fakeVerifier 是 Hub 测试用的可注入复核器。
|
||||||
type fakeVerifier struct {
|
type fakeVerifier struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user