From eb0a6eba7e1441ce98bf45082ac33f842ee54781 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 24 Sep 2026 20:59:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BD=AC=E5=8F=91=E5=90=ABfl?= =?UTF-8?q?at=E9=9D=9EDrafty=E5=86=85=E5=AE=B9=E7=9A=84=E6=B6=88=E6=81=AF(?= =?UTF-8?q?=E6=89=8B=E6=9C=BA=E7=AB=AF=E6=96=87=E6=9C=AC/=E5=9B=BE?= =?UTF-8?q?=E7=89=87/=E8=AF=AD=E9=9F=B3/=E8=A7=86=E9=A2=91/=E5=90=8D?= =?UTF-8?q?=E7=89=87/=E9=80=9A=E8=AF=9D=E7=BB=93=E6=9E=9C)=E5=AF=BC?= =?UTF-8?q?=E8=87=B4content=E4=B8=A2=E5=A4=B1=E6=98=BE=E7=A4=BA"=E5=B7=B2?= =?UTF-8?q?=E5=88=A0=E9=99=A4"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真实数据库排查确认根因:forwardMessage()原来无条件把 msg.content 喂给 Drafty.forwardedContent(toDraftyContent(...)),假设内容永远是纯字符串或真 Drafty文档({txt,fmt,ent})。但手机端把文本/图片/语音/视频/名片都发成扁平非 Drafty JSON(如 {"text":"11"}),只靠 head['x-im-type']/head.webrtc 标记类型 ——对这类对象跑 Drafty 处理会退化成空内容,SDK 出站的 simplify() 又会把空 content 整个从包里剥掉,服务端收到的 {pub} 根本没有 content 字段,落库后 content/content_nonce 都是真正的 NULL,客户端据此正确地(从自己的角度)把 它当成"已撤回"渲染成"⚠ 消息已删除"。 用 SSH+kubectl exec 直接查了k3s上的真实MySQL(meeting_im库, im_003_group_ messages_202609/im_003_messages两张表),确认了具体是哪几条消息、它们的 head/content 真实长什么样,而不是猜测。 修复:flatForwardPayload() 识别这几种扁平内容形状,文本类重建成真正的 Drafty文本,附件/名片/通话结果类原样保留内容和 x-im-type/webrtc 标记(接收 端已有的对应dispatch逻辑本来就认识这些形状),只有真正的纯字符串/Drafty 内容才继续走原来的 Drafty.forwardedContent() 路径。 Co-Authored-By: Claude Sonnet 5 --- web/src/lib/tinode/send.ts | 68 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/web/src/lib/tinode/send.ts b/web/src/lib/tinode/send.ts index c9f6ca91..d06ea769 100644 --- a/web/src/lib/tinode/send.ts +++ b/web/src/lib/tinode/send.ts @@ -7,6 +7,64 @@ function toDraftyContent(content: DraftyContent | string | null | undefined): Dr return typeof content === 'string' ? Drafty.init(content) : content; } +/** + * Real, DB-confirmed root cause of a reported bug ("转发消息显示已经删除"): this backend's mobile + * apps send several message kinds as FLAT, non-Drafty JSON objects tagged only by `head['x-im-type']` + * (plain text as `{text:"..."}`, not `{txt:"..."}` -- same "flat object, not a real Drafty document" + * shape dispatch.ts's mobileAttachmentContentOf/contactCardContentOf already document for + * image/file/voice/video/contact_card) or by `head.webrtc` (a call result, `{callId,callType,...}`). + * The original `forwardMessage` fed `msg.content` straight into `Drafty.forwardedContent()` assuming + * it was always a plain string or a real `{txt,fmt,ent}` document -- for any of these flat shapes, + * `draftyToTree()` on an object with none of those fields degenerates to empty content, which the + * SDK's own outbound `simplify()` (every `{pub}` packet gets this pass -- see this file's other + * comments on it) then strips entirely before the packet reaches the server: confirmed live via a + * direct DB query on a real reported case (a real "seq测试" group, tenant 3) -- the persisted row had + * `head.forwarded` set correctly but `content`/`content_nonce` genuinely NULL, which the client + * (correctly, from its own perspective) then renders as "already recalled" (`isRecalled()` in + * dispatch.ts treats null content exactly that way; the original source message, one seq earlier in + * the chain, was itself an already-forwarded flat `{text:"11"}` mobile message, `head:{"x-im-type": + * "text",...}` -- confirmed via `DESCRIBE`+`SELECT` against the real `im_003_group_messages_202609`/ + * `im_003_messages` tables on the k3s MySQL cluster, not inferred). + * + * Returns the right `{content, headOverrides}` to forward with, or `null` for an ordinary + * string/real-Drafty message (the pre-existing `Drafty.forwardedContent(toDraftyContent(...))` path + * still handles that case, unchanged). Duplicates dispatch.ts's own tiny shape checks inline rather + * than importing them: dispatch.ts imports from lib/tinode/calls.ts, which itself imports this file's + * own newClientMid/publishWithReattach, so importing dispatch.ts here would create a 3-way cycle + * (send.ts -> dispatch.ts -> calls.ts -> send.ts) for what's otherwise a handful of stable, + * unlikely-to-drift structural checks. + */ +function flatForwardPayload(msg: StoredMessage): { content: DraftyContent | Record; headOverrides: Record } | null { + const c = msg.content as unknown; + if (!c || typeof c !== 'object' || Array.isArray(c)) return null; + const headType = msg.head?.['x-im-type']; + // A real Drafty document (this app's own composer never produces any of the flat shapes below) + // always has `ent`/`fmt` -- bail out to the normal Drafty path rather than misclassifying it. + if ('ent' in c || 'fmt' in c) return null; + if (typeof (c as { text?: unknown }).text === 'string') { + // Flat plain text (mobile-sent, or a group-announcement auto-post) -- rebuild real Drafty from + // the extracted string instead of preserving the flat object verbatim, so it reads and searches + // like any other text message on this shape's next hop. + return { content: Drafty.forwardedContent(Drafty.init((c as { text: string }).text)), headOverrides: {} }; + } + if (typeof headType === 'string' && (headType === 'contact_card' || headType === 'image' || headType === 'file' || headType === 'voice' || headType === 'video')) { + // Flat attachment/contact-card content -- Drafty has no equivalent entity to rebuild (these were + // never real Drafty entities to begin with), so preserve the original content AND its `x-im-type` + // tag verbatim; the receiving client's own dispatch (mobileAttachmentContentOf/ + // contactCardContentOf) already renders this shape correctly regardless of which topic it's in. + const headOverrides: Record = { 'x-im-type': headType }; + if (typeof msg.head?.mime === 'string') headOverrides.mime = msg.head.mime; + return { content: c as Record, headOverrides }; + } + if (typeof msg.head?.webrtc === 'string' && typeof (c as { callId?: unknown }).callId === 'string') { + // A call-result message -- forwarding one is a low-value edge case (nobody has asked for it), + // but preserving it verbatim is still strictly better than silently losing it the way every + // other flat shape here used to. + return { content: c as Record, headOverrides: { webrtc: msg.head.webrtc } }; + } + return null; +} + /** * docs/IM-前后端对接文档.md:486/952/1128 -- every `{pub}` must carry a client-generated * `x-im-client-mid` (scoped to "tenant + sender"), and a disconnect/failure retry MUST resend the @@ -132,8 +190,12 @@ export function sendReply(topic: Topic, text: string, quoted: StoredMessage): Pr * has no reader anywhere yet, matching the old app (no confirmed call site reads it back either). */ export function forwardMessage(sourceTopic: string, msg: StoredMessage, target: Topic): Promise { - const content = Drafty.forwardedContent(toDraftyContent(msg.content)); - const newMsg = target.createMessage(content, false); - newMsg.head = Object.assign(newMsg.head || {}, { forwarded: `${sourceTopic}:${msg.seq}`, 'x-im-client-mid': newClientMid() }); + const flat = flatForwardPayload(msg); + const content = flat ? flat.content : Drafty.forwardedContent(toDraftyContent(msg.content)); + // `createMessage`'s own type only admits Drafty content, but its real implementation (see + // lib/tinode/calls.ts's own identical cast) passes a plain object straight through as literal + // `content` -- true for the attachment/contact-card/call-result branches of `flatForwardPayload`. + const newMsg = target.createMessage(content as unknown as DraftyContent, false); + newMsg.head = Object.assign(newMsg.head || {}, flat?.headOverrides, { forwarded: `${sourceTopic}:${msg.seq}`, 'x-im-client-mid': newClientMid() }); return publishWithReattach(target, newMsg); }