fix(messages): generate send request IDs on LAN HTTP pages
This commit is contained in:
@@ -273,7 +273,15 @@ export default function PrivateMessagesPage() {
|
||||
}
|
||||
// An unresolved submission keeps its original request ID; editing a draft never resends it.
|
||||
if ((attemptsRef.current[key] || []).some((attempt) => attempt.text === text && attempt.state !== 'succeeded' && attempt.state !== 'failed')) return;
|
||||
const request_id = crypto.randomUUID();
|
||||
let request_id: string;
|
||||
try {
|
||||
request_id = Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
} catch (cause) {
|
||||
const reason = conflictMessage(cause, '随机数接口不可用');
|
||||
console.error('private-messages request id failed', { account_id: selected.account_id, peer_uid: selected.peer_uid, reason });
|
||||
message.error(`发送编号生成失败,消息未发送:${reason}`);
|
||||
return;
|
||||
}
|
||||
const snapshot: SendAttempt = { request_id, text, version: draftsRef.current[key]?.version ?? 0, state: 'sending', error: '', pending: true };
|
||||
inFlight.current.add(key);
|
||||
saveAttempt(key, snapshot);
|
||||
|
||||
@@ -20,6 +20,8 @@ const contacts = [
|
||||
{ account_id: 'a', account_name: '账号甲', peer_uid: '100', peer_name: '联系人', last_text: '甲的最近消息', last_message_at: '2026-06-01T12:00:00Z', message_count: 2 },
|
||||
{ account_id: 'b', account_name: '', peer_uid: '100', peer_name: '', last_text: '乙的最近消息', last_message_at: '2026-06-01T11:00:00Z', message_count: 1 },
|
||||
];
|
||||
function requestID(n = 1) { return n.toString(16).padStart(32, '0'); }
|
||||
|
||||
function record(id, overrides = {}) {
|
||||
return { id: String(id), account_id: 'a', peer_uid: '100', direction: 'inbound', message_type: 'text', text: `消息${id}`, state: 'succeeded', error: '', message_at: `2026-06-01T12:00:0${id}Z`, created_at: '', ...overrides };
|
||||
}
|
||||
@@ -31,7 +33,7 @@ function deferred() {
|
||||
function harness(options = {}) {
|
||||
const states = [], effects = [], queued = [], calls = [], sendCalls = [], logs = [], notices = [];
|
||||
const timers = new Map();
|
||||
let cursor = 0, uuid = 0, timerID = 0;
|
||||
let cursor = 0, generatedIDs = 0, timerID = 0;
|
||||
const antd = {}, x = {};
|
||||
for (const name of ['Alert', 'Button', 'Card', 'Flex', 'Modal', 'Select', 'Table', 'Tag', 'Empty', 'Spin', 'Splitter', 'Tooltip']) {
|
||||
antd[name] = () => null;
|
||||
@@ -113,7 +115,7 @@ function harness(options = {}) {
|
||||
function globals(callback) {
|
||||
const prior = [globalThis.__dmTimers, globalThis.__dmCrypto, globalThis.__dmConsole, globalThis.__dmDOM];
|
||||
globalThis.__dmTimers = { set(fn) { const id = ++timerID; timers.set(id, fn); return id; }, clear(id) { timers.delete(id); } };
|
||||
globalThis.__dmCrypto = { randomUUID: () => `request-${++uuid}` };
|
||||
globalThis.__dmCrypto = options.crypto || { getRandomValues(bytes) { bytes.fill(0); bytes[bytes.length - 1] = ++generatedIDs; return bytes; } };
|
||||
globalThis.__dmConsole = { error: (...args) => logs.push(args) };
|
||||
globalThis.__dmDOM = options.dom;
|
||||
try { return callback(); } finally {
|
||||
@@ -437,13 +439,53 @@ test('drafts are independent even for the same peer and typing never sends', asy
|
||||
await h.poll(); assert.equal(h.sendCalls.length, 0);
|
||||
});
|
||||
|
||||
test('HTTP-compatible sending generates distinct request IDs without randomUUID', async (t) => {
|
||||
const native = require('node:crypto').webcrypto;
|
||||
const crypto = { getRandomValues: (bytes) => native.getRandomValues(bytes) };
|
||||
assert.equal(crypto.randomUUID, undefined);
|
||||
const h = harness({ crypto }); t.after(h.dispose);
|
||||
await h.flush(); await draft(h, '第一条');
|
||||
await sender(h, await h.flush()).props.onSubmit('第一条');
|
||||
await h.flush(); await draft(h, '第二条');
|
||||
await sender(h, await h.flush()).props.onSubmit('第二条');
|
||||
assert.equal(h.sendCalls.length, 2);
|
||||
const ids = h.sendCalls.map((call) => call.payload.request_id);
|
||||
assert.ok(ids.every((id) => /^[0-9a-f]{32}$/.test(id)));
|
||||
assert.notEqual(ids[0], ids[1]);
|
||||
assert.equal(h.logs.length, 0);
|
||||
});
|
||||
|
||||
test('request ID generation failure is handled before sending, preserves draft, and allows a manual retry', async (t) => {
|
||||
let calls = 0;
|
||||
const crypto = { getRandomValues(bytes) {
|
||||
if (calls++ === 0) throw new Error('随机数接口不可用');
|
||||
return require('node:crypto').webcrypto.getRandomValues(bytes);
|
||||
} };
|
||||
const h = harness({ crypto }); t.after(h.dispose);
|
||||
await h.flush(); let tree = await draft(h, '未发送的私密内容');
|
||||
await assert.doesNotReject(() => sender(h, tree).props.onSubmit('未发送的私密内容'));
|
||||
tree = await h.flush();
|
||||
assert.equal(h.sendCalls.length, 0);
|
||||
assert.equal(sender(h, tree).props.value, '未发送的私密内容');
|
||||
assert.equal(sender(h, tree).props.disabled, false);
|
||||
assert.doesNotMatch(alerts(h, tree), /结果未确认/);
|
||||
assert.match(h.notices.join(' '), /发送编号.*未发送.*随机数接口不可用/);
|
||||
assert.match(JSON.stringify(h.logs), /account_id.*a.*peer_uid.*100/);
|
||||
assert.doesNotMatch(JSON.stringify(h.logs), /未发送的私密内容/);
|
||||
await sender(h, tree).props.onSubmit('未发送的私密内容');
|
||||
tree = await h.flush();
|
||||
assert.equal(h.sendCalls.length, 1);
|
||||
assert.match(h.sendCalls[0].payload.request_id, /^[0-9a-f]{32}$/);
|
||||
assert.equal(sender(h, tree).props.value, '');
|
||||
});
|
||||
|
||||
test('send snapshots the source/peer; switching chats never sends to the new selection or clears its draft', async (t) => {
|
||||
const late = deferred(); const h = harness({ send: () => late.promise }); t.after(h.dispose);
|
||||
await h.flush(); let tree = await draft(h, '甲的发送内容');
|
||||
const sending = sender(h, tree).props.onSubmit('甲的发送内容');
|
||||
tree = await choose(h, 'b:100'); await draft(h, '乙的草稿');
|
||||
assert.equal(h.sendCalls.length, 1);
|
||||
assert.deepEqual(h.sendCalls[0], { path: '/creator/private-messages/send', payload: { account_id: 'a', peer_uid: '100', text: '甲的发送内容', request_id: 'request-1' } });
|
||||
assert.deepEqual(h.sendCalls[0], { path: '/creator/private-messages/send', payload: { account_id: 'a', peer_uid: '100', text: '甲的发送内容', request_id: requestID() } });
|
||||
late.resolve(record('sent', { direction: 'outbound', state: 'succeeded' })); await sending; tree = await h.flush();
|
||||
assert.equal(sender(h, tree).props.value, '乙的草稿');
|
||||
assert.equal(list(h, tree).props.items.some((m) => m.key === 'sent'), false);
|
||||
@@ -466,7 +508,7 @@ for (const state of ['failed', 'unknown', 'sending']) {
|
||||
await sender(h, tree).props.onSubmit('不能丢的正文'); tree = await h.flush();
|
||||
assert.equal(sender(h, tree).props.value, '不能丢的正文');
|
||||
assert.match(alerts(h, tree), /完整发送错误/);
|
||||
assert.match(alerts(h, tree), /request-1/);
|
||||
assert.ok(alerts(h, tree).includes(requestID()));
|
||||
if (state !== 'failed') {
|
||||
await sender(h, tree).props.onSubmit('不能丢的正文');
|
||||
assert.equal(h.sendCalls.length, 1, 'uncertain or in-flight attempts must not be resent');
|
||||
@@ -495,11 +537,11 @@ test('network errors remain visible and preserve uncertain request IDs without e
|
||||
await h.flush(); let tree = await draft(h, '私密正文');
|
||||
await sender(h, tree).props.onSubmit('私密正文'); tree = await h.flush();
|
||||
assert.match(alerts(h, tree), /网络中断,结果未确认/);
|
||||
assert.match(alerts(h, tree), /request-1/);
|
||||
assert.ok(alerts(h, tree).includes(requestID()));
|
||||
assert.equal(sender(h, tree).props.value, '私密正文');
|
||||
await sender(h, tree).props.onSubmit('私密正文'); await h.poll();
|
||||
assert.equal(h.sendCalls.length, 1);
|
||||
assert.match(JSON.stringify(h.logs), /account_id.*a.*peer_uid.*100.*request_id.*request-1/);
|
||||
assert.match(JSON.stringify(h.logs), new RegExp(`account_id.*a.*peer_uid.*100.*request_id.*${requestID()}`));
|
||||
assert.doesNotMatch(JSON.stringify(h.logs), /私密正文/);
|
||||
});
|
||||
|
||||
@@ -532,7 +574,7 @@ test('new text conversation uses the sole modal and locks its explicit source in
|
||||
const uid = all(form, (n) => n.type === h.antd.Form.Item && n.props.name === 'peer_uid')[0];
|
||||
assert.equal(uid.props.rules[1].pattern.test('invalid'), false);
|
||||
await form.props.onFinish({ account_id: 'b', peer_uid: '999', text: '首次消息' }); tree = await h.flush();
|
||||
assert.deepEqual(h.sendCalls[0].payload, { request_id: 'request-1', account_id: 'b', peer_uid: '999', text: '首次消息' });
|
||||
assert.deepEqual(h.sendCalls[0].payload, { request_id: requestID(), account_id: 'b', peer_uid: '999', text: '首次消息' });
|
||||
assert.match(text(tree), /发送账号:未命名账号/);
|
||||
assert.equal(all(tree, (n) => n.type === h.antd.Modal).length, 0);
|
||||
assert.equal(conversations(h, tree).props.activeKey, 'b:999');
|
||||
@@ -600,12 +642,12 @@ for (const editWhileWaiting of [false, true]) {
|
||||
if (sent && path.startsWith('/creator/private-messages/messages?')) return { data: [record('attempt', { direction: 'outbound', state: 'succeeded' })], total: 1 };
|
||||
} }); t.after(h.dispose);
|
||||
await h.flush(); let tree = await draft(h, '原草稿'); await sender(h, tree).props.onSubmit('原草稿'); tree = await h.flush();
|
||||
assert.match(alerts(h, tree), /request-1/);
|
||||
assert.ok(alerts(h, tree).includes(requestID()));
|
||||
if (editWhileWaiting) await draft(h, '修改后的草稿');
|
||||
tree = await h.poll();
|
||||
assert.equal(h.sendCalls.length, 1);
|
||||
assert.equal(sender(h, tree).props.value, editWhileWaiting ? '修改后的草稿' : '');
|
||||
assert.doesNotMatch(alerts(h, tree), /request-1/);
|
||||
assert.ok(!alerts(h, tree).includes(requestID()));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user