fix(aihr): harden broadcast and mobile assistant flows

This commit is contained in:
2026-07-23 03:35:36 +08:00
parent 62c8b41672
commit 4b31848e3d
30 changed files with 1127 additions and 208 deletions
@@ -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);');
});
});
+112
View File
@@ -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.
}
};