第三轮深度测试:修复7个并行agent发现的前端问题,含REASON_MESSAGES系统性补全

派了Ch1-2/3-4/5-6/7/8/9/10-11共7个agent对全部11章重新深度测试。后端问题已核实追加进
docs/IM-后端问题清单-2026-09-04.md(新增6-9条,发给用户);本次是前端问题的修复。

**REASON_MESSAGES系统性补全(最高优先级,多个agent独立发现同一个缺口)**
im-api.js的REASON_MESSAGES表新增22个条目,最重要的是PERMISSION_DENIED——后端
server/im_http.go里403响应统一用这个reason(30余处调用),但表里原来只有一个从未
被后端实际发送过的死条目FORBIDDEN,导致全站几乎每一个403从工程一开始就在展示原始
英文"permission denied"。一并补上VERSION_CONFLICT/MALFORMED_REQUEST(Ch9 agent实测
命中)、NOT_FOUND、VERIFICATION_RATE_LIMITED(动态文案,同TOO_MANY_ATTEMPTS模式)等。
新增translateReason()导出函数并用于send-message.jsx的sendFavorite——favorites/forward
等几个批量接口恒定200、真实成败包在items[].reason里,imRequest的自动翻译够不到这种
per-item场景,之前直接显示原始reason字符串。tinode-web.jsx同理补了CTRL_ERROR_TEXT_CN
(原生WS {ctrl}协议错误,同样绕开REASON_MESSAGES)并修了原表从未真正匹配过的bug——
CommError会把状态码拼进message变成"authentication failed (401)",原表按裸文本精确
匹配,看着正常纯粹是因为401/403兜底文案凑巧一样,一旦命中"unknown authentication
scheme"这类会被兜底文案误导、"policy violation"这类会直接漏出英文。uploadLargeFile/
createChatUploader/createFileDownloader同样绕开REASON_MESSAGES(走原生{ctrl}协议),
补了UPLOAD_CTRL_TEXT_CN小词典。已用真实账号端到端验证:favorites/forward到P2P目标
触发的PERMISSION_DENIED现在正确显示"没有权限执行此操作";draft接口版本冲突正确显示
"内容已被修改,请刷新后重试"。

**编辑消息时打开附件面板会静默覆盖被编辑消息(Ch3-4 agent发现,数据损坏级别)**
sendMessage对editing状态一律用head.replace,但onAttachFile/onAttachImage/onAttachAudio
原来只在forwardMessage时置空,编辑态没有同款门禁——编辑一条消息不改文字、点"+"选张图片
或名片,会直接把附件内容替换掉被编辑消息的原文,无任何确认。messages-view.jsx新增
editingMessage门禁,和forwardMessage一样把三个attach回调置空(等价于隐藏整个"+"按钮)。
已用真实浏览器验证:编辑态下"+"按钮消失,取消编辑后正常恢复。

**草稿防抖单一共享定时器丢字(Ch1-2、Ch3-4两个agent独立发现)**
handleComposerChange用单个this.draftSaveTimer,话题A打字触发600ms保存后300ms内切到
话题B,会把A的定时器clearTimeout掉,那次输入永久丢失;version也在定时器触发时才重新
读state,此时可能已经在别的话题上,退化成硬编码0——叠加后端draft接口对version=0"无条件
跳过乐观锁"的行为(已记入后端清单新增6),可能静默覆盖别的标签页写入的更新草稿。改为
按话题分开的定时器map,version在调用的当下(保证仍在该话题)同步捕获进闭包。

**呼叫状态机两处真实bug(Ch8 agent此前发现,本次重新核实live代码后确认+修复)**
1. 通话中导航离开messages-view.jsx整个组件树(切到工作台/个人设置,或移动端切回会话
   列表)时,没有任何hangup清理——componentSetup已有的"切换话题时正确挂断"逻辑只在
   componentDidUpdate里,组件真正卸载时不会触发。CallPanel自己的componentWillUnmount
   只做本地清理,不通知对方也不重置tinode-web.jsx顶层callState,导致下次切回通话话题
   时remount出一个callState卡在CALL_STATE_IN_PROGRESS的"僵尸"面板——start()走"我们刚
   接听"分支直接return,从不调getUserMedia,本地媒体永久拿不到。补了对称的
   componentWillUnmount挂断逻辑。
2. this.callTimeoutTimer声明后从未被真正setTimeout赋值——呼出无人接听或accept信令
   (fire-and-forget的{note}包)被丢会永久卡在"呼叫中"。补了armCallRingTimeout(),
   60秒未接通按跟手动取消完全一样的路径挂断(不发明新的通话结果语义),在收到对方
   accept信号的两处分支都正确清timer。

**其余前端修复(按文件)**
- messages-view.jsx: @提醒mentionedUsers只在切话题时清空,同一话题内后续消息里字面
  文本"@张三"会被静默解析成真实提醒(发送后清空);jumpToMessage点开已撤回/删除的
  搜索结果原来只console.error静默失败,补了用户可见的错误提示;批量收藏对
  favorites/batch共享Idempotency-Key导致的静默丢失(已知后端问题)不再照单全收
  "收藏成功",改为发送后重新拉取真实收藏列表核对每条是否真的落地。
- audio-recorder.jsx: getUserMedia权限弹窗未决出结果时组件被卸载,补_unmounted
  标记避免在死实例上继续跑mediaRecorder.start()。
- send-message.jsx: disabled变为true时(对方拉黑/被禁言等实时ACS变化)没有强制结束
  正在进行的按住录音,可能remount出无用户手势的"自动录音";mouseup/touchend覆盖不到
  拖出窗口/切换标签页/权限弹窗抢焦点的释放场景,补了blur/visibilitychange兜底。
- tinode-web.jsx: messages-menu视图和messages-home共用MessageCenterView实例无key,
  从messages-home以非整体刷新方式切到messages-menu时菜单打不开,补key={view}。
- message-center-view.jsx: 实名认证驳回提醒的dismiss key只按用户+状态区分,同一状态
  的两次独立驳回会互相误压制,改按submission_id区分。
- contact-management-view.jsx: Profile/Settings靠getUserProfile刷新拉黑状态,但该
  接口对被拉黑对象直接403,导致"之前会话就已拉黑"的场景完全感知不到——叠加
  getConversationContext兜底(该接口对拉黑目标正确返回200+relationship)。
- group-management-view.jsx: AdminList显示群主/管理员姓名漏了group_nickname兜底;
  JoinRequests从聊天页横幅进入时返回按钮硬编码回group-settings(应回聊天本身),
  从Governance进入时返回group-settings而非group-governance(与AdminList/Muted不一致)。
- workplace-collaboration-view.jsx: OrganizationDirectory/DeptNode的部门树加载失败
  被无条件当成"暂无数据"渲染,补loadError区分+重试链接。
- personal-settings-view.jsx: CacheClear对navigator.storage/caches整个不存在的情况
  只有react出了范围的.catch()覆盖不到的同步抛错,加了特性检测+try/catch;
  YouthModeVerify触发TOO_MANY_ATTEMPTS锁定后确定按钮没有禁用/倒计时,会继续发出
  注定失败的请求。
- security-support-view.jsx: 意见反馈category字段在config未加载完成前可为空但没有
  提交前校验,会打到后端400。
- use-cached-data.js: bootstrap等首次拉取失败且无缓存兜底时永久停在undefined,加了
  一次3秒后重试(仅在完全没有缓存值时触发,已有缓存值维持原"显示旧数据"行为不变)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-04 23:29:52 +08:00
co-authored by Claude Sonnet 5
parent 505a6d7d56
commit 93b856aec8
20 changed files with 662 additions and 140 deletions
+94 -26
View File
@@ -30,7 +30,7 @@ import { CALL_STATE_OUTGOING_INITATED, CALL_STATE_IN_PROGRESS } from '../constan
import { blobToBase64, fileToBase64, imageScaled, importVCard, makeImageUrl } from '../lib/blob-helpers.js';
import { topicDisplayName } from '../lib/utils.js';
import { checkURL, confirmRiskChallenge, createChatUploader, favoriteMessages, getBootstrap, getConversationContext,
getGroupJoinRequests, getGroupMembers, getLatestGroupAnnouncement, getTopicDraft, recallMessage,
getFavorites, getGroupJoinRequests, getGroupMembers, getLatestGroupAnnouncement, getTopicDraft, recallMessage,
updateTopicDraft } from '../lib/im-api.js';
import HashNavigation from '../lib/navigation.js';
import { bytesToHumanSize, relativeDateFormat, shortDateFormat } from '../lib/strformat.js';
@@ -288,6 +288,18 @@ class MessagesView extends React.Component {
}
componentWillUnmount() {
// componentSetup below has an identical guard for switching from one topic to another during an
// active call -- but componentDidUpdate/componentSetup never runs on an actual unmount (leaving
// the messaging section entirely for another chapter, or on mobile switching to the conversation
// list while displayMobile). Without this, CallPanel's own componentWillUnmount only tears down
// local media: it never notifies the peer or resets tinode-web.jsx's top-level callState/
// callTopic, which then stay stuck at CALL_STATE_IN_PROGRESS. Navigating back to the call's topic
// later remounts a "zombie" CallPanel -- its start() sees that stale CALL_STATE_IN_PROGRESS and
// takes the "we just accepted" branch, which never calls getUserMedia(): no local media, every
// control disabled, only hang-up works.
if (this.state.rtcPanel) {
this.handleCallHangup(this.state.topic, this.props.callSeq);
}
if (this.messagesScroller) {
this.messagesScroller.removeEventListener('scroll', this.handleScrollEvent);
}
@@ -1186,6 +1198,18 @@ class MessagesView extends React.Component {
// 输入框每次变化都会调这个(不在这里节流——防抖延迟统一放这一处,跟日报/月报草稿已有的
// 600ms 防抖是同一个数值,方便以后统一调整)。编辑已有消息、转发预填内容这两种情况下
// SendMessage 的文本框也会触发 onChange,但那些不是"新写的草稿",不应该覆盖真正的草稿。
//
// 定时器按话题分开存(this.draftSaveTimers 是个 map),不能像原来那样用单个共享句柄——
// 那样话题A打字触发的600ms保存,会在300ms后切到话题B打字时被 clearTimeout 直接取消掉,
// A刚打的字就再也不会发给服务器了。
//
// version 在这里(调用的当下,this.state.topic 必然还是 topicName,因为 onMessageChange
// 只有当前激活话题的 SendMessage 实例会触发)同步捕获进闭包,不能等定时器触发时才去读
// this.state.draftVersion——那时用户很可能已经切到别的话题,届时重新读到的要么是无关话题
// 的版本号,要么在判断不匹配时退化成硬编码的 0。后端 PUT .../draft 对 version=0 是"无条件
// 跳过乐观锁校验直接覆盖"而不是"仅当草稿真的不存在时才创建"(见
// docs/IM-后端问题清单-2026-09-04.md),这个 0 一旦被真的发出去,可能无声覆盖掉这段时间里
// 别的标签页/设备写入的更新草稿。
handleComposerChange(text) {
if (this.state.reply && this.state.reply.editing) {
return;
@@ -1197,12 +1221,13 @@ class MessagesView extends React.Component {
if (!topicName) {
return;
}
if (this.draftSaveTimer) {
window.clearTimeout(this.draftSaveTimer);
const version = this.state.draftVersion || 0;
this.draftSaveTimers = this.draftSaveTimers || {};
if (this.draftSaveTimers[topicName]) {
window.clearTimeout(this.draftSaveTimers[topicName]);
}
this.draftSaveTimer = window.setTimeout(() => {
this.draftSaveTimer = null;
const version = (this.state.topic == topicName) ? (this.state.draftVersion || 0) : 0;
this.draftSaveTimers[topicName] = window.setTimeout(() => {
delete this.draftSaveTimers[topicName];
updateTopicDraft(topicName, text, [], version).then(d => {
if (this.state.topic == topicName) {
this.setState({draftVersion: d.version});
@@ -1214,12 +1239,15 @@ class MessagesView extends React.Component {
// 消息真的发出去之后,把服务端存的草稿清空——不然重新打开这个会话会看到已经发送过的旧文字。
// 单纯等下一次防抖自然把空字符串存上去也能达到同样效果,但那样在"发送后立刻切走"这个真实
// 会发生的场景里会有一个几百毫秒的窗口,草稿还是旧内容,不如发送成功后立刻清。
clearDraft(topicName) {
if (this.draftSaveTimer) {
window.clearTimeout(this.draftSaveTimer);
this.draftSaveTimer = null;
// knownVersion 由调用方(sendMessage)在发送发起的那一刻同步捕获传入,原因同上——避免在
// 发送成功回调触发时(用户很可能已经切换话题)重新派生出不可靠的版本号。
clearDraft(topicName, knownVersion) {
if (this.draftSaveTimers && this.draftSaveTimers[topicName]) {
window.clearTimeout(this.draftSaveTimers[topicName]);
delete this.draftSaveTimers[topicName];
}
const version = (this.state.topic == topicName) ? (this.state.draftVersion || 0) : 0;
const version = knownVersion != null ? knownVersion :
(this.state.topic == topicName) ? (this.state.draftVersion || 0) : 0;
updateTopicDraft(topicName, '', [], version).then(d => {
if (this.state.topic == topicName) {
this.setState({draftText: '', draftVersion: d.version});
@@ -1244,8 +1272,11 @@ class MessagesView extends React.Component {
handleShowJoinRequests(e) {
e.preventDefault();
HashNavigation.navigateTo(HashNavigation.addUrlParam(
HashNavigation.setUrlSidePanel('', 'group-join-requests'), 'topic', this.state.topic));
// `from=chat` mirrors handleShowAnnouncement above -- lets JoinRequests (group-management-view.jsx)
// return to this chat instead of always landing on group-governance.
let hash = HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel('', 'group-join-requests'), 'topic', this.state.topic);
hash = HashNavigation.addUrlParam(hash, 'from', 'chat');
HashNavigation.navigateTo(hash);
}
handleSendFriendVerification(e) {
@@ -1285,15 +1316,28 @@ class MessagesView extends React.Component {
return;
}
const topicName = this.state.topic;
favoriteMessages(this.props.selectedSeqs.map(seq => ({topic: topicName, seq}))).then(result => {
const failed = (result && result.failed) || [];
if (failed.length && failed.length === this.props.selectedSeqs.length) {
this.props.onError('收藏失败', 'err');
} else if (failed.length) {
this.showToast(`已收藏,${failed.length} 条失败`);
} else {
this.showToast('收藏成功');
}
const seqs = this.props.selectedSeqs.slice();
favoriteMessages(seqs.map(seq => ({topic: topicName, seq}))).then(() => {
// `result.failed` can't be trusted here: the batch-create endpoint reuses one request-level
// Idempotency-Key across every message in the batch (docs/IM-后端问题清单-2026-09-04.md,
// 新增1), so every item after the first silently no-ops via ON DUPLICATE KEY UPDATE and gets
// folded into `existing_count` -- indistinguishable, from this response alone, from a message
// that really was already favorited. Re-reading the actual favorites list and checking which
// of the messages we just asked for really landed sidesteps that ambiguity entirely.
return getFavorites({type: 'all', limit: 100}).then(data => {
const items = (data && data.items) || [];
const landed = new Set(items
.filter(it => it.source && it.source.topic == topicName)
.map(it => String(it.source.seq)));
const missing = seqs.filter(seq => !landed.has(String(seq)));
if (missing.length === seqs.length) {
this.props.onError('收藏失败', 'err');
} else if (missing.length) {
this.showToast(`已收藏 ${seqs.length - missing.length}/${seqs.length} 条`);
} else {
this.showToast('收藏成功');
}
});
}).catch(err => {
this.props.onError(err.message, 'err');
});
@@ -1384,17 +1428,28 @@ class MessagesView extends React.Component {
this.applyMentionResolution(parsed);
msg = parsed;
}
// Was only cleared on topic switch -- picking someone from the @提醒 picker once left them
// resolvable for every later message in the same topic too, so literal text "@张三" typed by
// hand, pasted, or quoted from someone else in a LATER, unrelated message would silently turn
// into a real mention of whoever was last picked under that name. A retry of a failed send
// (retrySend) reuses the already-resolved Drafty content stored on the failed pub, not this
// array, so clearing it here doesn't affect retries.
this.mentionedUsers = [];
if (typeHint) {
head = {...(head || {}), 'x-im-type': typeHint};
}
const topicForDraft = this.state.topic;
// Captured now, not re-read inside the .then() below -- by the time send actually resolves
// the user may have switched topics, and clearDraft falling back to a hardcoded 0 runs into
// the same backend version=0-skips-the-lock issue explained in handleComposerChange above.
const draftVersionAtSend = this.state.draftVersion || 0;
const doSend = () => {
const result = this.props.sendMessage(msg, uploadCompletionPromise, uploader, head);
// Only a plain-text compose (not a reply/edit/forward, all of which pass a Drafty object or
// reuse contentToEdit rather than the persisted draft) has a server-side draft worth clearing.
if (originalText != null && result && typeof result.then == 'function' && topicForDraft) {
result.then(() => this.clearDraft(topicForDraft));
result.then(() => this.clearDraft(topicForDraft, draftVersionAtSend));
}
return result;
};
@@ -1882,7 +1937,13 @@ class MessagesView extends React.Component {
ref.current.classList.add('flash');
setTimeout(_ => {ref.current.classList.remove('flash')} , 1000);
} else {
// Most commonly hit when the target message was deleted/recalled after being found by
// Ch.7 chat-history search but before this click -- <MetaMessage deleted> never gets a ref
// (see jumpToMessage below), so without this the click did nothing visible at all.
console.error("Unresolved message ref", replyToSeq);
if (this.props.onError) {
this.props.onError('该消息已被删除或撤回', 'err');
}
}
}
@@ -2219,6 +2280,13 @@ class MessagesView extends React.Component {
const titleClass = 'panel-title' + (this.state.deleted ? ' deleted' : '');
const darkModeClass = this.props.colorSchema == 'dark' ? 'dark' : '';
// sendMessage() unconditionally sends whatever content it's given with head={replace:...}
// while this.state.reply.editing is set (see sendMessage above) -- it has no way to tell
// "the user is still editing the original text" apart from "the user just picked a photo/
// file/card/favorite from the attachment panel while that edit was still open". Gating the
// attach entry points closed here (same mechanism already used for forwardMessage below) is
// simpler and safer than trying to teach sendMessage to distinguish those cases after the fact.
const editingMessage = !!(this.state.reply && this.state.reply.editing);
let messagesComponent = (
<>
@@ -2321,9 +2389,9 @@ class MessagesView extends React.Component {
onMentionSearch={(this.props.forwardMessage || !groupTopic) ? null : this.mentionSearch}
onMentionSelected={this.mentionSelected}
onSendMessage={this.sendMessage}
onAttachFile={this.props.forwardMessage ? null : this.handleAttachFile}
onAttachImage={this.props.forwardMessage ? null : this.handleAttachImageOrVideo}
onAttachAudio={this.props.forwardMessage ? null : this.sendAudioAttachment}
onAttachFile={(this.props.forwardMessage || editingMessage) ? null : this.handleAttachFile}
onAttachImage={(this.props.forwardMessage || editingMessage) ? null : this.handleAttachImageOrVideo}
onAttachAudio={(this.props.forwardMessage || editingMessage) ? null : this.sendAudioAttachment}
canStartCall={!this.props.forwardMessage && this.props.canStartCall}
onStartAudioCall={this.props.onStartAudioCall}
onStartVideoCall={this.props.onStartVideoCall}