fix(aihr): harden broadcast and mobile assistant flows
This commit is contained in:
@@ -3,6 +3,7 @@ import { getToken, removeToken, setToken } from '@/utils/auth';
|
||||
import { login as loginApi, logout as logoutApi, getInfo as getUserInfo } from '@/api/login';
|
||||
import { LoginData } from '@/api/types';
|
||||
import defAva from '@/assets/images/profile.jpg';
|
||||
import { clearPendingBroadcastPublishesForUser } from '@/utils/broadcast-pending';
|
||||
import { clearDynamicTenantContextState } from '@/utils/tenant-context-state';
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
@@ -64,6 +65,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
try {
|
||||
await logoutApi();
|
||||
} finally {
|
||||
clearPendingBroadcastPublishesForUser(userId.value);
|
||||
token.value = '';
|
||||
roles.value = [];
|
||||
permissions.value = [];
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import {
|
||||
broadcastPendingStorageKey,
|
||||
createPendingBroadcastStorage,
|
||||
type PendingBroadcastPublish
|
||||
} from './broadcast-pending';
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
private readonly values = new Map<string, string>();
|
||||
|
||||
get length() {
|
||||
return this.values.size;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.values.clear();
|
||||
}
|
||||
|
||||
getItem(key: string) {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
key(index: number) {
|
||||
return [...this.values.keys()][index] ?? null;
|
||||
}
|
||||
|
||||
removeItem(key: string) {
|
||||
this.values.delete(key);
|
||||
}
|
||||
|
||||
setItem(key: string, value: string) {
|
||||
this.values.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const pending: PendingBroadcastPublish = {
|
||||
tenantId: 'tenant-a',
|
||||
requestId: 'request-1',
|
||||
title: '夏季服务标准通知',
|
||||
content: '请按最新标准执行。'
|
||||
};
|
||||
|
||||
describe('pending broadcast publish storage', () => {
|
||||
it('writes a retryable publish only into session storage', () => {
|
||||
const sessionStorage = new MemoryStorage();
|
||||
const legacyLocalStorage = new MemoryStorage();
|
||||
const storage = createPendingBroadcastStorage({ sessionStorage, legacyLocalStorage });
|
||||
|
||||
storage.write('operator-1', pending);
|
||||
|
||||
const key = broadcastPendingStorageKey(pending.tenantId, 'operator-1');
|
||||
expect(sessionStorage.getItem(key)).toContain(pending.content);
|
||||
expect(legacyLocalStorage.getItem(key)).toBeNull();
|
||||
});
|
||||
|
||||
it('moves an existing legacy payload into the current tab and removes its persistent copy', () => {
|
||||
const sessionStorage = new MemoryStorage();
|
||||
const legacyLocalStorage = new MemoryStorage();
|
||||
const storage = createPendingBroadcastStorage({ sessionStorage, legacyLocalStorage });
|
||||
const key = broadcastPendingStorageKey(pending.tenantId, 'operator-1');
|
||||
legacyLocalStorage.setItem(key, JSON.stringify(pending));
|
||||
|
||||
expect(storage.read('operator-1', pending.tenantId)).toEqual(pending);
|
||||
expect(sessionStorage.getItem(key)).toContain(pending.content);
|
||||
expect(legacyLocalStorage.getItem(key)).toBeNull();
|
||||
});
|
||||
|
||||
it('removes unreadable legacy data instead of leaving a message body persisted', () => {
|
||||
const sessionStorage = new MemoryStorage();
|
||||
const legacyLocalStorage = new MemoryStorage();
|
||||
const storage = createPendingBroadcastStorage({ sessionStorage, legacyLocalStorage });
|
||||
const key = broadcastPendingStorageKey(pending.tenantId, 'operator-1');
|
||||
legacyLocalStorage.setItem(key, '{not-json');
|
||||
|
||||
expect(storage.read('operator-1', pending.tenantId)).toBeUndefined();
|
||||
expect(legacyLocalStorage.getItem(key)).toBeNull();
|
||||
});
|
||||
|
||||
it('clears current account pending records from both session and legacy storage on logout', () => {
|
||||
const sessionStorage = new MemoryStorage();
|
||||
const legacyLocalStorage = new MemoryStorage();
|
||||
const storage = createPendingBroadcastStorage({ sessionStorage, legacyLocalStorage });
|
||||
const currentKey = broadcastPendingStorageKey('tenant-a', 'operator-1');
|
||||
const otherUserKey = broadcastPendingStorageKey('tenant-a', 'operator-2');
|
||||
const otherTenantKey = broadcastPendingStorageKey('tenant-b', 'operator-1');
|
||||
|
||||
sessionStorage.setItem(currentKey, JSON.stringify(pending));
|
||||
sessionStorage.setItem(otherUserKey, JSON.stringify(pending));
|
||||
legacyLocalStorage.setItem(otherTenantKey, JSON.stringify(pending));
|
||||
legacyLocalStorage.setItem(otherUserKey, JSON.stringify(pending));
|
||||
|
||||
storage.clearForUser('operator-1');
|
||||
|
||||
expect(sessionStorage.getItem(currentKey)).toBeNull();
|
||||
expect(legacyLocalStorage.getItem(otherTenantKey)).toBeNull();
|
||||
expect(sessionStorage.getItem(otherUserKey)).toContain(pending.content);
|
||||
expect(legacyLocalStorage.getItem(otherUserKey)).toContain(pending.content);
|
||||
});
|
||||
|
||||
it('connects management logout to pending publish cleanup', () => {
|
||||
const userStore = readFileSync(resolve(process.cwd(), 'src/store/modules/user.ts'), 'utf8');
|
||||
expect(userStore).toContain("import { clearPendingBroadcastPublishesForUser } from '@/utils/broadcast-pending';");
|
||||
expect(userStore).toContain('clearPendingBroadcastPublishesForUser(userId.value);');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
export const pendingBroadcastStoragePrefix = 'aihr.broadcast.pending.v2';
|
||||
|
||||
export interface PendingBroadcastPublish {
|
||||
tenantId: string;
|
||||
requestId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface BroadcastPendingStorageOptions {
|
||||
sessionStorage: Storage;
|
||||
legacyLocalStorage?: Storage;
|
||||
}
|
||||
|
||||
const accountKey = (userId: string | number) => String(userId).trim();
|
||||
|
||||
export const broadcastPendingStorageKey = (tenantId: string, userId: string | number) => {
|
||||
const normalizedTenantId = String(tenantId).trim();
|
||||
const normalizedUserId = accountKey(userId);
|
||||
return normalizedTenantId && normalizedUserId
|
||||
? `${pendingBroadcastStoragePrefix}:${normalizedTenantId}:${normalizedUserId}`
|
||||
: '';
|
||||
};
|
||||
|
||||
const parsePendingPublish = (raw: string | null): PendingBroadcastPublish | undefined => {
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
const candidate = JSON.parse(raw) as Partial<PendingBroadcastPublish>;
|
||||
if (
|
||||
typeof candidate.tenantId !== 'string'
|
||||
|| typeof candidate.requestId !== 'string'
|
||||
|| typeof candidate.title !== 'string'
|
||||
|| typeof candidate.content !== 'string'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
tenantId: candidate.tenantId,
|
||||
requestId: candidate.requestId,
|
||||
title: candidate.title,
|
||||
content: candidate.content
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const removeUserEntries = (storage: Storage | undefined, userId: string | number) => {
|
||||
const normalizedUserId = accountKey(userId);
|
||||
if (!storage || !normalizedUserId) return;
|
||||
const suffix = `:${normalizedUserId}`;
|
||||
const keys = Array.from({ length: storage.length }, (_, index) => storage.key(index))
|
||||
.filter((key): key is string => Boolean(key && key.startsWith(`${pendingBroadcastStoragePrefix}:`) && key.endsWith(suffix)));
|
||||
keys.forEach((key) => storage.removeItem(key));
|
||||
};
|
||||
|
||||
/**
|
||||
* Keeps retryable broadcast publishes in the current tab only. Legacy local
|
||||
* storage entries are migrated once, then removed so message content does not
|
||||
* remain on a shared browser after the tab or session ends.
|
||||
*/
|
||||
export const createPendingBroadcastStorage = ({ sessionStorage, legacyLocalStorage }: BroadcastPendingStorageOptions) => ({
|
||||
write(userId: string | number, pending: PendingBroadcastPublish) {
|
||||
const key = broadcastPendingStorageKey(pending.tenantId, userId);
|
||||
if (!key) throw new Error('missing broadcast pending storage key');
|
||||
sessionStorage.setItem(key, JSON.stringify(pending));
|
||||
},
|
||||
|
||||
read(userId: string | number, tenantId: string) {
|
||||
const key = broadcastPendingStorageKey(tenantId, userId);
|
||||
if (!key) return undefined;
|
||||
const currentRaw = sessionStorage.getItem(key);
|
||||
const current = parsePendingPublish(currentRaw);
|
||||
if (current) return current;
|
||||
|
||||
if (currentRaw) sessionStorage.removeItem(key);
|
||||
|
||||
const legacyRaw = legacyLocalStorage?.getItem(key) ?? null;
|
||||
const legacy = parsePendingPublish(legacyRaw);
|
||||
if (!legacy) {
|
||||
if (legacyRaw) legacyLocalStorage?.removeItem(key);
|
||||
return undefined;
|
||||
}
|
||||
sessionStorage.setItem(key, JSON.stringify(legacy));
|
||||
legacyLocalStorage?.removeItem(key);
|
||||
return legacy;
|
||||
},
|
||||
|
||||
remove(userId: string | number, tenantId: string) {
|
||||
const key = broadcastPendingStorageKey(tenantId, userId);
|
||||
if (!key) return;
|
||||
sessionStorage.removeItem(key);
|
||||
legacyLocalStorage?.removeItem(key);
|
||||
},
|
||||
|
||||
clearForUser(userId: string | number) {
|
||||
removeUserEntries(sessionStorage, userId);
|
||||
removeUserEntries(legacyLocalStorage, userId);
|
||||
}
|
||||
});
|
||||
|
||||
export const clearPendingBroadcastPublishesForUser = (userId: string | number) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
createPendingBroadcastStorage({
|
||||
sessionStorage: window.sessionStorage,
|
||||
legacyLocalStorage: window.localStorage
|
||||
}).clearForUser(userId);
|
||||
} catch {
|
||||
// Browser privacy mode can deny storage access. Logout must still complete.
|
||||
}
|
||||
};
|
||||
@@ -147,6 +147,11 @@ import {
|
||||
type BroadcastStatus
|
||||
} from '@/api/aihr/broadcast';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import {
|
||||
broadcastPendingStorageKey,
|
||||
createPendingBroadcastStorage,
|
||||
type PendingBroadcastPublish
|
||||
} from '@/utils/broadcast-pending';
|
||||
|
||||
const messages = ref<BroadcastAdminMessage[]>([]);
|
||||
const total = ref(0);
|
||||
@@ -173,10 +178,8 @@ const rules = {
|
||||
|
||||
const displayTime = (value?: string) => (value ? value.replace('T', ' ').slice(0, 16) : '—');
|
||||
const createRequestId = () => globalThis.crypto?.randomUUID?.() ?? `broadcast-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
|
||||
const pendingPublishStoragePrefix = 'aihr.broadcast.pending.v2';
|
||||
type PendingPublish = { tenantId: string; requestId: string; title: string; content: string };
|
||||
|
||||
const isValidPendingPublish = (candidate: Partial<PendingPublish>): candidate is PendingPublish =>
|
||||
const isValidPendingPublish = (candidate: Partial<PendingBroadcastPublish>): candidate is PendingBroadcastPublish =>
|
||||
typeof candidate.tenantId === 'string' && candidate.tenantId === scopeTenantId.value &&
|
||||
candidate.tenantId === candidate.tenantId.trim() && candidate.tenantId.length <= 64 &&
|
||||
typeof candidate.requestId === 'string' && candidate.requestId === candidate.requestId.trim() &&
|
||||
@@ -184,42 +187,35 @@ const isValidPendingPublish = (candidate: Partial<PendingPublish>): candidate is
|
||||
typeof candidate.title === 'string' && !!candidate.title.trim() && candidate.title.length <= 200 &&
|
||||
typeof candidate.content === 'string' && !!candidate.content.trim() && candidate.content.length <= 10_000;
|
||||
|
||||
const pendingPublishUserId = () => String(userStore.userId || '').trim();
|
||||
const pendingPublishStorageKey = (tenantId = pendingTenantId.value || scopeTenantId.value) =>
|
||||
tenantId ? `${pendingPublishStoragePrefix}:${tenantId}:${userStore.userId || 'unknown'}` : '';
|
||||
broadcastPendingStorageKey(tenantId, pendingPublishUserId());
|
||||
|
||||
const safelyReadStorage = (storageKey: string) => {
|
||||
const pendingStorage = () => {
|
||||
if (typeof window === 'undefined') throw new Error('browser storage unavailable');
|
||||
let legacyLocalStorage: Storage | undefined;
|
||||
try {
|
||||
return storageKey ? localStorage.getItem(storageKey) : null;
|
||||
legacyLocalStorage = window.localStorage;
|
||||
} catch {
|
||||
storageAvailable.value = false;
|
||||
ElMessage.warning('浏览器暂时无法读取待确认发布记录,请保持本页面打开后重试。');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const safelyRemoveStorage = (storageKey: string) => {
|
||||
try {
|
||||
if (storageKey) localStorage.removeItem(storageKey);
|
||||
return true;
|
||||
} catch {
|
||||
storageAvailable.value = false;
|
||||
ElMessage.warning('浏览器暂时无法清理待确认发布记录,请勿在其他窗口重复发布。');
|
||||
return false;
|
||||
// A browser may deny persistent storage while still allowing session storage.
|
||||
}
|
||||
return createPendingBroadcastStorage({
|
||||
sessionStorage: window.sessionStorage,
|
||||
legacyLocalStorage
|
||||
});
|
||||
};
|
||||
|
||||
const persistPendingPublish = () => {
|
||||
pendingTenantId.value = draftTenantId.value;
|
||||
const storageKey = pendingPublishStorageKey();
|
||||
const pending: PendingPublish = {
|
||||
const pending: PendingBroadcastPublish = {
|
||||
tenantId: pendingTenantId.value,
|
||||
requestId: form.requestId,
|
||||
title: form.title,
|
||||
content: form.content
|
||||
};
|
||||
try {
|
||||
if (!storageKey) throw new Error('missing tenant scope');
|
||||
localStorage.setItem(storageKey, JSON.stringify(pending));
|
||||
if (!pendingPublishStorageKey()) throw new Error('missing tenant scope');
|
||||
pendingStorage().write(pendingPublishUserId(), pending);
|
||||
return true;
|
||||
} catch {
|
||||
storageAvailable.value = false;
|
||||
@@ -230,33 +226,38 @@ const persistPendingPublish = () => {
|
||||
};
|
||||
|
||||
const clearPendingPublish = () => {
|
||||
const cleared = safelyRemoveStorage(pendingPublishStorageKey());
|
||||
if (cleared) pendingTenantId.value = '';
|
||||
return cleared;
|
||||
try {
|
||||
const tenantId = pendingTenantId.value || scopeTenantId.value;
|
||||
if (!pendingPublishStorageKey(tenantId)) throw new Error('missing tenant scope');
|
||||
pendingStorage().remove(pendingPublishUserId(), tenantId);
|
||||
pendingTenantId.value = '';
|
||||
return true;
|
||||
} catch {
|
||||
storageAvailable.value = false;
|
||||
ElMessage.warning('浏览器暂时无法清理待确认发布记录,请勿在其他窗口重复发布。');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const readPendingPublish = (): PendingPublish | undefined => {
|
||||
const storageKey = pendingPublishStorageKey(scopeTenantId.value);
|
||||
const stored = safelyReadStorage(storageKey);
|
||||
if (!stored) return undefined;
|
||||
const readPendingPublish = (): PendingBroadcastPublish | undefined => {
|
||||
const tenantId = scopeTenantId.value;
|
||||
try {
|
||||
const candidate = JSON.parse(stored) as Partial<PendingPublish>;
|
||||
if (!pendingPublishStorageKey(tenantId)) return undefined;
|
||||
const candidate = pendingStorage().read(pendingPublishUserId(), tenantId);
|
||||
if (!candidate) return undefined;
|
||||
if (!isValidPendingPublish(candidate)) {
|
||||
throw new Error('invalid pending publish');
|
||||
pendingStorage().remove(pendingPublishUserId(), tenantId);
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
tenantId: candidate.tenantId,
|
||||
requestId: candidate.requestId,
|
||||
title: candidate.title,
|
||||
content: candidate.content
|
||||
};
|
||||
return candidate;
|
||||
} catch {
|
||||
safelyRemoveStorage(storageKey);
|
||||
storageAvailable.value = false;
|
||||
ElMessage.warning('浏览器暂时无法读取待确认发布记录,请保持本页面打开后重试。');
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const applyPendingPublish = (candidate: PendingPublish) => {
|
||||
const applyPendingPublish = (candidate: PendingBroadcastPublish) => {
|
||||
draftTenantId.value = candidate.tenantId;
|
||||
pendingTenantId.value = candidate.tenantId;
|
||||
form.requestId = candidate.requestId;
|
||||
|
||||
Reference in New Issue
Block a user