fix(messages): generate send request IDs on LAN HTTP pages

This commit is contained in:
2026-10-07 12:40:50 +08:00
parent a5c23d8081
commit 1ee5669027
2 changed files with 60 additions and 10 deletions
+9 -1
View File
@@ -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);
+51 -9
View File
@@ -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()));
});
}