diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js index db74ddad..b5733c36 100644 --- a/frontend/src/api/auth.js +++ b/frontend/src/api/auth.js @@ -17,5 +17,9 @@ export const authApi = { organization_id: organizationId, previous_organization_id: previousOrganizationId }) + }, + // 换取 WebSocket 短期一次性票据:属于组织级请求,拦截器会自动带上当前公司头。 + createWebSocketTicket() { + return request.post('/auth/websocket-ticket') } } diff --git a/frontend/src/stores/organization.js b/frontend/src/stores/organization.js index e096ee2a..91461705 100644 --- a/frontend/src/stores/organization.js +++ b/frontend/src/stores/organization.js @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' import { authApi } from '../api/auth' +import { createCompanySocket } from '../utils/companySocket' import { captureOrganizationContext, commitOrganizationId, @@ -8,6 +9,21 @@ import { resetOrganizationContext } 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) { return `wmms_organization_id:${userId ?? 'anonymous'}` @@ -83,6 +99,8 @@ export const useOrganizationStore = defineStore('organization', () => { applyWarehouseContext(data?.warehouse || null) syncVersion() if (organizationId) writeStoredOrganizationId(organizationId) + // 当前公司已确定:关闭旧公司连接并为新公司取得票据重连。connect 不阻塞、不抛错。 + organizationSocket.connect() } // 没有可用公司(或候选公司都失效)时进入空状态:公司 ID 保持为空, @@ -92,6 +110,8 @@ export const useOrganizationStore = defineStore('organization', () => { currentOrganizationId.value = null applyWarehouseContext(null) syncVersion() + // 无可用公司:不建立实时连接。 + organizationSocket.close() } // 用切换校验接口验证候选公司:该接口会返回目标公司的仓库摘要,顺带完成授权校验。 @@ -178,6 +198,8 @@ export const useOrganizationStore = defineStore('organization', () => { // 登出或切换账号时清空:上下文、公司列表、仓库库位与初始化标记全部复位。 function reset() { resetOrganizationContext() + // 登出/账号变化:关闭实时连接且不重连。 + organizationSocket.close() organizations.value = [] currentOrganizationId.value = null currentWarehouse.value = null diff --git a/frontend/src/stores/organization.test.js b/frontend/src/stores/organization.test.js index 750ac1c3..efc03784 100644 --- a/frontend/src/stores/organization.test.js +++ b/frontend/src/stores/organization.test.js @@ -7,10 +7,17 @@ import { currentReadSignal, resetOrganizationContext } from '../utils/organizati vi.mock('../api/auth', () => ({ authApi: { 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 分开存储,这里写入对应账号。 function loginAs(id) { localStorage.setItem('wmms_user', JSON.stringify({ id, username: `user${id}` })) @@ -39,6 +46,8 @@ describe('organization store', () => { setActivePinia(createPinia()) authApi.listOrganizations.mockReset() authApi.switchOrganizationContext.mockReset() + socketMock.connect.mockReset() + socketMock.close.mockReset() }) test('默认公司进入并自动选中唯一启用的仓库与库位', async () => { @@ -306,3 +315,58 @@ describe('organization store', () => { 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() + }) +}) diff --git a/frontend/src/utils/companySocket.js b/frontend/src/utils/companySocket.js index 88c08019..8b26ec5d 100644 --- a/frontend/src/utils/companySocket.js +++ b/frontend/src/utils/companySocket.js @@ -27,11 +27,17 @@ export function createCompanySocket(options = {}) { const { fetchTicket, onMessage = () => {}, - WebSocketImpl = globalThis.WebSocket, + WebSocketImpl, location = globalThis.location, log = () => {} } = options + // WebSocket 实现延迟解析:共享实例可能在模块导入时构造,此时 globalThis.WebSocket + // 未必就绪;测试也可在 connect 之前覆盖 globalThis.WebSocket。 + function resolveWebSocketImpl() { + return typeof WebSocketImpl === 'function' ? WebSocketImpl : globalThis.WebSocket + } + let socket = null // generation 每次 connect/close 都递增:任何一个回调只要发现自己的代次与当前不符, // 就说明连接已被替换或已关闭,不得再更新页面。 @@ -62,20 +68,22 @@ export function createCompanySocket(options = {}) { // 没有当前公司(登出或尚未选择)时不建立连接。 if (snapshot.organizationId == null) return const gen = ++generation - if (typeof WebSocketImpl !== 'function') { + const WebSocketCtor = resolveWebSocketImpl() + if (typeof WebSocketCtor !== 'function') { log('当前环境不支持 WebSocket,跳过建连') return } - let response + let ticket try { - response = await fetchTicket() + const response = await fetchTicket() + const data = response?.data ?? response + ticket = data?.ticket } catch (error) { + // 取票失败静默降级:不抛出、不阻断公司切换等调用方流程。 log('取得 WebSocket 票据失败', error) return } - const data = response?.data ?? response - const ticket = data?.ticket if (!ticket) { log('WebSocket 票据为空,跳过建连') return @@ -83,9 +91,16 @@ export function createCompanySocket(options = {}) { // 取票期间公司可能已切换或登出:快照失效则放弃这次连接,交给新的 connect 处理。 if (!isCurrent(gen, snapshot)) return - const ws = new WebSocketImpl( - `${buildSocketUrl(location)}?ticket=${encodeURIComponent(ticket)}` - ) + let ws + try { + ws = new WebSocketCtor( + `${buildSocketUrl(location)}?ticket=${encodeURIComponent(ticket)}` + ) + } catch (error) { + // 建连失败同样静默处理,绝不让实时连接问题影响页面主流程。 + log('建立 WebSocket 连接失败', error) + return + } socket = ws ws.onopen = () => { if (isCurrent(gen, snapshot)) log('WebSocket 已连接(公司 %s)', snapshot.organizationId) diff --git a/internal/handler/websocket_test.go b/internal/handler/websocket_test.go index 60b3fca1..8398ee5f 100644 --- a/internal/handler/websocket_test.go +++ b/internal/handler/websocket_test.go @@ -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 测试用的可注入复核器。 type fakeVerifier struct { mu sync.Mutex