fix: 接线公司实时连接生命周期并钉住角色撤销语义

- organization store 接线 companySocket:切换后为当前公司重连,登出/空状态只关闭不重连
- companySocket 延迟解析 WebSocket 并静默吞掉取票/建连失败,不阻断公司切换
- authApi 新增 createWebSocketTicket(组织级请求)
- 补 store 三例接线测试:切换触发重连、登出触发关闭、空公司不连接
- 补 Go 用例钉住复核边界:纯业务角色撤销、公司授权仍在时不断流
This commit is contained in:
文睿 张 2026-10-09 13:50:08 +08:00
parent 5021b039c9
commit d580efebe5
5 changed files with 136 additions and 10 deletions

View File

@ -17,5 +17,9 @@ export const authApi = {
organization_id: organizationId,
previous_organization_id: previousOrganizationId
})
},
// 换取 WebSocket 短期一次性票据:属于组织级请求,拦截器会自动带上当前公司头。
createWebSocketTicket() {
return request.post('/auth/websocket-ticket')
}
}

View File

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

View File

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

View File

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

View File

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