From 1ee5669027e3d8d83d245fbe393505008480b993 Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 7 Oct 2026 12:40:50 +0800 Subject: [PATCH] fix(messages): generate send request IDs on LAN HTTP pages --- web/src/pages/creator/messages/index.tsx | 10 +++- web/tests/private-messages-page.test.cjs | 60 ++++++++++++++++++++---- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/web/src/pages/creator/messages/index.tsx b/web/src/pages/creator/messages/index.tsx index 6287d63..b63cd51 100644 --- a/web/src/pages/creator/messages/index.tsx +++ b/web/src/pages/creator/messages/index.tsx @@ -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); diff --git a/web/tests/private-messages-page.test.cjs b/web/tests/private-messages-page.test.cjs index cf8fd1b..dc747f2 100644 --- a/web/tests/private-messages-page.test.cjs +++ b/web/tests/private-messages-page.test.cjs @@ -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())); }); }