@aochuang/client-sdk 1.0.379 → 1.0.381-dev-1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/wechat-chat-control/location/location.vue +11 -3
- package/docs//346/266/210/346/201/257/345/273/266/350/277/237/344/270/216/346/234/252/350/257/273/345/274/202/345/270/270/346/216/222/346/237/245//345/210/206/346/236/220/344/270/216/344/274/230/345/214/226/346/226/271/346/241/210.md +303 -0
- package/package.json +1 -1
- package/services/client-chat/client-chat-wechat/client-chat-message.js +132 -14
- package/services/client-chat/client-chat-wechat/client-chat-socket.js +49 -17
- package/services/client-chat/client-chat-wechat/client-chat.js +57 -11
- package/services/socket/socket.js +66 -11
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="sdk-chat-control-location" :class="['sdk-chat-control-location--' + type]">
|
|
3
|
-
<template v-if="type === 'default'">
|
|
3
|
+
<template v-if="type === 'default' || type === 'thumb'">
|
|
4
4
|
<div class="sdk-chat-control-location__main">
|
|
5
5
|
<div class="sdk-chat-control-location__head">
|
|
6
6
|
<h4 class="sdk-chat-control-location__title">{{address}}</h4>
|
|
@@ -46,7 +46,7 @@ export default {
|
|
|
46
46
|
type: String,
|
|
47
47
|
default: 'default',
|
|
48
48
|
validator (value) {
|
|
49
|
-
return ['quote', 'default'].includes(value)
|
|
49
|
+
return ['quote', 'default', 'thumb'].includes(value)
|
|
50
50
|
}
|
|
51
51
|
},
|
|
52
52
|
/**
|
|
@@ -89,7 +89,8 @@ export default {
|
|
|
89
89
|
}
|
|
90
90
|
</script>
|
|
91
91
|
<style lang="less">
|
|
92
|
-
.sdk-chat-control-location--default
|
|
92
|
+
.sdk-chat-control-location--default,
|
|
93
|
+
.sdk-chat-control-location--thumb {
|
|
93
94
|
.sdk-chat-control-location__body{
|
|
94
95
|
line-height: 0;
|
|
95
96
|
}
|
|
@@ -149,6 +150,13 @@ export default {
|
|
|
149
150
|
z-index: 3;
|
|
150
151
|
}
|
|
151
152
|
}
|
|
153
|
+
.sdk-chat-control-location--thumb {
|
|
154
|
+
display: inline-block;
|
|
155
|
+
overflow: hidden;
|
|
156
|
+
background-color: #fff;
|
|
157
|
+
border: 1px solid #eee;
|
|
158
|
+
border-radius: 4px;
|
|
159
|
+
}
|
|
152
160
|
.sdk-chat-control-location__body{
|
|
153
161
|
overflow: hidden;
|
|
154
162
|
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# 消息延迟与未读异常:分析与优化方案
|
|
2
|
+
|
|
3
|
+
## 1. 分析范围与结论
|
|
4
|
+
|
|
5
|
+
本文基于以下当前工作区代码复核:
|
|
6
|
+
|
|
7
|
+
- `client-sdk`:分支 `master-补充日志`,HEAD `0d5e859`,包版本 `1.0.381`,含未提交的修复和日志改动。
|
|
8
|
+
- 极速客服端 `JavaMain-combine`:依赖文件仍锁定 `@aochuang/client-sdk@1.0.378`,本机 `node_modules` 为 `1.0.381`。
|
|
9
|
+
- 后端 `workphone-java`:确认新消息采用“先推送客户端,finally 中再异步入库”的顺序。
|
|
10
|
+
|
|
11
|
+
针对客户反馈:
|
|
12
|
+
|
|
13
|
+
1. “有些消息刷新网页才出现”:当前修改已经修复两条可直接造成该现象的确定路径,即断线补拉水位提前推进和 Socket 在特定失败状态下停止重连。剩余代码中仍有“会话补建轮询耗尽后无持续补偿”和“乱序消息不触发缓存/会话事件”两条可达路径。
|
|
14
|
+
2. “显示有消息,点进去没有,过一会又出现未读”:最强的剩余前端证据链是首次进入会话时 `reloadMessageData` 先清内存,历史接口又处于未入库窗口,同时本地补偿缓存的首次读取和写入发生竞态。三者必须叠加;不能把“先推后存”或 `reload` 单独认定为必现缺陷。
|
|
15
|
+
|
|
16
|
+
建议先修复两个前端闭环问题:进入会话改为按当前会话原子替换,并让本地补偿合并等待同会话已排队的缓存写入;会话轮询超时后进入低频持续补建。乱序消息事件拆分随后处理。ACK、初始化门禁和已读时机先依赖现有日志确认线上是否命中,再决定是否调整协议或交互语义。
|
|
17
|
+
|
|
18
|
+
## 2. 已确认且当前代码已修复的问题
|
|
19
|
+
|
|
20
|
+
### 2.1 断线补拉提前推进水位
|
|
21
|
+
|
|
22
|
+
原路径中 `loadLossMessageData()` 没有返回异步请求 Promise,`onSocketOpen` 的等待会提前完成,并在请求成功前把水位推进到当前时间。补拉失败时,更早的消息可能永久越过下一次补拉区间,只能在页面刷新后的全量初始化中恢复。
|
|
23
|
+
|
|
24
|
+
当前修改:
|
|
25
|
+
|
|
26
|
+
- `client-chat-message.js:586-610` 返回真实 Promise,并统计返回、插入、重复数量。
|
|
27
|
+
- `client-chat.js:1139-1170` 分好友/群等待补拉;只在对应补拉成功后推进水位,失败保留旧水位。
|
|
28
|
+
|
|
29
|
+
这是明确缺陷,修改方向正确。主要影响是重连后的未读校准会等待补拉任务结束;单类补拉失败时该类会在下一次连接继续从旧水位补拉。
|
|
30
|
+
|
|
31
|
+
### 2.2 Socket 特定失败状态下停死
|
|
32
|
+
|
|
33
|
+
原路径中 Connecting 阶段发生 close 时,连接 Promise 可能无法正确结束并启动下一轮;原生连接成功但 signin 失败时,状态也可能停住。此后实时消息不再进入,刷新重建实例后才恢复。
|
|
34
|
+
|
|
35
|
+
当前修改位于 `services/socket/socket.js`:
|
|
36
|
+
|
|
37
|
+
- close 会结束当前连接等待,并对非手动关闭进入受控重连。
|
|
38
|
+
- signin 失败把状态置为 Error,并调用已有 `reconnect()`。
|
|
39
|
+
- `_isReconnecting` 继续限制并发重连。
|
|
40
|
+
|
|
41
|
+
影响范围仅为异常连接恢复路径;手动关闭仍不重连,历史构造方式和正常消息路径不变。
|
|
42
|
+
|
|
43
|
+
## 3. 仍存在的高优先级问题
|
|
44
|
+
|
|
45
|
+
### 3.1 进入会话预清空与本地补偿首次读写竞态
|
|
46
|
+
|
|
47
|
+
证据等级:**确定存在可达竞态;是否为客户本次样本原因需日志关联。**
|
|
48
|
+
|
|
49
|
+
#### 原因与证据链
|
|
50
|
+
|
|
51
|
+
1. 后端先推送再异步入库:`NewMessage1007MessageHandler.java:178-197`(群消息)及 `327-360`(好友消息)。因此 Socket 已收到而历史接口暂时为空是设计允许的窗口。
|
|
52
|
+
2. SDK 收到消息后会先插入内存:`client-chat-message.js:111-140`。
|
|
53
|
+
3. 客服端仅监听 `add-message` 后异步调用 `cacheMessage`,调用方不等待:`src/entries/chat/services/client-chat/index.js:484-515`。
|
|
54
|
+
4. 首次进入好友/群详情分别调用 `reloadMessageData`:`chat-panel-friend.vue:943-959`、`chat-panel-room.vue:1165-1180`。
|
|
55
|
+
5. `reloadMessageData` 在请求前执行 `clearMessage()`:`client-chat-message.js:423-425`;而 `clearMessage()` 遍历整个实例的 `messageMap`:`316-320`,不是只清当前会话。
|
|
56
|
+
6. 本地补偿的 `cacheMessage` 和 `mergeMessages` 都会调用 `getMemoryCacheItems`。首次访问同一个 key 时共享 `loadPromiseMap[cacheKey]`:`messageLocalCompensateService.js:232-257`。
|
|
57
|
+
7. 若缓存写入和历史合并同时等待同一份 IndexedDB 旧快照,写入 continuation 会在随后更新 `cacheMap`(`365-395`),但合并 continuation 已持有旧的 `originItems`(`446-454`),本次合并可能仍得到 0 条。
|
|
58
|
+
|
|
59
|
+
完整可达顺序为:Socket 消息已进入内存 -> 缓存写任务刚开始 -> 用户立即进入会话 -> reload 清掉内存 -> 历史接口因尚未入库返回空 -> merge 读取到写入前快照 -> 页面暂时为空。后端落库或本地缓存完成后,再次加载会恢复。
|
|
60
|
+
|
|
61
|
+
这也回答了“后端入库后是否还会推送”的疑问:当前 1007 路径展示的是入站时先推一次、随后异步入库,没有证据表明“入库完成后必定再推同一条消息”。正常情况下依靠内存和本地补偿即可,不应把二次推送当成恢复保证。
|
|
62
|
+
|
|
63
|
+
#### 推荐方案
|
|
64
|
+
|
|
65
|
+
第一步采用最小兼容方案:
|
|
66
|
+
|
|
67
|
+
- `mergeMessages` 在读取缓存前,等待该 `cacheKey` 当前已经排队的 `queueMap[cacheKey]` 任务完成;需避免 merge 自身加入同一队列后自等造成死锁。
|
|
68
|
+
- `cacheMessage` 在异步读取 IndexedDB 前先登记会话级 pending message;merge 同时合并 pending memory,缓存写完再清理 pending。
|
|
69
|
+
- 已增加 `chat_reload_result` 聚合日志,记录清理前后内存数量、前后最后消息、加载数量、请求边界和耗时;结合 `message_merge_result` 的 API 数及本地补入数判断,不记录正文。
|
|
70
|
+
|
|
71
|
+
第二步解决 reload 语义:
|
|
72
|
+
|
|
73
|
+
- 先请求和合并到临时结果,成功后只对当前 target 原子替换。
|
|
74
|
+
- 请求失败时保留当前 Socket 内存消息。
|
|
75
|
+
- 接口返回空时仍保留当前 target 中、且服务端尚未覆盖的实时消息。
|
|
76
|
+
- 不再清空好友或群实例中的所有会话消息。
|
|
77
|
+
|
|
78
|
+
#### 影响点
|
|
79
|
+
|
|
80
|
+
- `reloadMessageData` 从“清整个实例后重载”改成“按 target 原子替换”,需要复核好友、群、筛选和上下文共 6 个调用点。
|
|
81
|
+
- 原子替换要保护临时发送消息、发送状态轮询、Vue 响应式对象引用和分页边界。
|
|
82
|
+
- 不能只做“取消 clear”而一直追加,否则旧查询窗口之外的数据可能残留。
|
|
83
|
+
- 同一条 Socket/接口消息仍需按稳定 ID 去重。
|
|
84
|
+
- 必须增加竞态测试:Socket 到达、历史为空、IndexedDB 首读未完成、缓存写排队、快速切换会话。
|
|
85
|
+
|
|
86
|
+
### 3.2 会话补建轮询 60 秒耗尽后无持续补偿
|
|
87
|
+
|
|
88
|
+
证据等级:**确定存在失败后的断点;后端是否曾超过 60 秒需线上日志确认。**
|
|
89
|
+
|
|
90
|
+
#### 原因与证据链
|
|
91
|
+
|
|
92
|
+
- 新消息找不到本地会话时,好友和群消息分别调用 `getSession`:`client-chat.js:440-473`、`565-610`。
|
|
93
|
+
- `getSession` 复用同会话轮询并聚合消息回调:`client-chat-session.js:245-344`。
|
|
94
|
+
- 默认每 5 秒请求,最长 60 秒:`utils/util.js:35-129`。
|
|
95
|
+
- 失败后 `finally` 删除 `pollRequestMap[sessionId]`,调用方 `.catch(() => {})` 吞掉失败;当前批次结束后没有定时重试。
|
|
96
|
+
|
|
97
|
+
因此 Socket 消息可以已经存在于 `messageMap`,但会话列表没有入口。若 60 秒内后端会话始终不可查,后续只有新消息、显式刷新或页面刷新才会重新触发加载,符合“刷新后才出现”的表象。
|
|
98
|
+
|
|
99
|
+
#### 推荐方案
|
|
100
|
+
|
|
101
|
+
- 增加 `pendingSessionMap`,轮询超时后每 30 秒低频重试,设置总时长上限,例如 10 分钟。
|
|
102
|
+
- Socket 重连成功、页面重新可见、会话列表主动刷新时立即重试 pending 会话。
|
|
103
|
+
- 同一会话多条消息聚合,成功后按 `messageId/lastMsgTime` 去重合并,确保未读只累计一次。
|
|
104
|
+
- 若业务要求消息入口立即可见,可进一步创建 provisional session;后端正式会话出现后原子替换。
|
|
105
|
+
|
|
106
|
+
#### 影响点
|
|
107
|
+
|
|
108
|
+
- 低频重试会增加少量接口请求,必须按会话聚合并设置上限。
|
|
109
|
+
- provisional session 缺少 mute、sticky、权限、协作和任务等完整字段,点击能力需降级或等待正式数据。
|
|
110
|
+
- 正式会话替换时要保护列表排序、当前选中项和账号/会话未读,不能重复累加。
|
|
111
|
+
- 删除、拉黑、群发等本就不应建立会话的消息不能进入 pending。
|
|
112
|
+
|
|
113
|
+
### 3.3 乱序消息进入内存,但不触发缓存、会话和提醒事件
|
|
114
|
+
|
|
115
|
+
证据等级:**代码行为确定;线上是否存在异常乱序或未来时间需日志确认。**
|
|
116
|
+
|
|
117
|
+
#### 原因与证据链
|
|
118
|
+
|
|
119
|
+
- `addMessage` 在判断 `isBeforeLastMessage` 后仍把消息写入 `messageData/messageMap`:`client-chat-message.js:124-140`。
|
|
120
|
+
- 好友/群 `onAddMessageSuccess` 遇到乱序消息直接返回:`client-chat.js:430-432`、`554-555`。
|
|
121
|
+
- `add-message` 只在非乱序消息时触发:`client-chat-message.js:164-169`。
|
|
122
|
+
- 客服端本地补偿只监听 `add-message`:`src/entries/chat/services/client-chat/index.js:513-515`。
|
|
123
|
+
- 最后消息时间和接收水位只允许增大:`client-chat-message.js:616-638`。若某条消息时间异常偏未来,后续正常消息可能连续被判定为旧消息。
|
|
124
|
+
|
|
125
|
+
因此乱序消息在当前页面可能已存在于内存,但不会更新会话、不提醒、也不会写入本地补偿;一旦随后 reload 清内存,只能等待历史接口可查后恢复。
|
|
126
|
+
|
|
127
|
+
#### 推荐方案
|
|
128
|
+
|
|
129
|
+
- 新增内部 `message-accepted` 或 `message-cache` 事件:所有成功插入的普通入站消息都触发缓存。
|
|
130
|
+
- 保留 `add-message` 作为“允许更新会话、未读和提醒”的事件,避免历史乱序消息产生声音或重复未读。
|
|
131
|
+
- 对明显未来时间只记录异常,不让它无限抬高会话乱序基准;补拉水位优先考虑服务端接收时间或带容差的客户端消息时间。
|
|
132
|
+
|
|
133
|
+
#### 影响点
|
|
134
|
+
|
|
135
|
+
- 必须检查全部 `add-message` 监听者,不能简单放开现有条件,否则会产生旧消息提醒和未读重复。
|
|
136
|
+
- 新缓存事件要继续排除临时发送、自动已读、删除、拉黑及不建会话消息,具体口径需与业务确认。
|
|
137
|
+
- 未来时间容差必须与后端时间字段语义统一。
|
|
138
|
+
|
|
139
|
+
## 4. 中优先级风险
|
|
140
|
+
|
|
141
|
+
### 4.1 同步业务异常可能阻断 Socket ACK
|
|
142
|
+
|
|
143
|
+
证据等级:**防护缺口确定;是否线上触发需日志确认。**
|
|
144
|
+
|
|
145
|
+
`services/socket/socket.js:406-451` 在 ACK 前执行 JSON 解析、通用接收事件和具体 cmd 事件;任一同步异常都会跳过 `CommandResponse`。`client-chat-socket.js:79-109` 虽记录新消息业务异常,但随后重新抛出,仍会回到该路径。
|
|
146
|
+
|
|
147
|
+
方案取决于协议:若 ACK 表示“帧已收到”,应在受保护的 `finally` 中 ACK,业务处理失败另记并触发补偿;若 ACK 表示“业务处理成功”,应返回失败 ACK,而不是无响应。还应保护 JSON 解析和 cmd 分发边界。
|
|
148
|
+
|
|
149
|
+
影响是后端重试语义可能改变:过早成功 ACK 会失去依赖重推恢复业务失败的机会。修改前需确认服务端 ACK 定义,并测试 malformed frame、监听器异常和重复推送去重。
|
|
150
|
+
|
|
151
|
+
### 4.2 初始化失败仍渲染 Chat 并连接 Socket
|
|
152
|
+
|
|
153
|
+
证据等级:**代码路径确定;是否造成客户样本需日志确认。**
|
|
154
|
+
|
|
155
|
+
`root.vue:99-147` 捕获用户或配置初始化失败后,仍在 `finally` 设置 `ready = true`。Chat 创建后,移动端和 PC 分别在 `chat.vue:81-90`、`chat-pc.vue:415-417` 直接连接 Socket。用户身份、token 或补偿库名称未准备好时,页面仍可能进入可操作状态。
|
|
156
|
+
|
|
157
|
+
建议把用户身份、token、补偿 DB 初始化定义为关键门禁;失败时显示可重试状态,不创建 Chat。配置类初始化可按业务允许降级。Socket 连接前校验 accountId、token 和 dbReady。
|
|
158
|
+
|
|
159
|
+
影响是初始化失败时用户会看到错误/重试页,而不是当前的空页面;需要防止重复初始化和重复建连,并明确哪些配置允许降级。
|
|
160
|
+
|
|
161
|
+
### 4.3 标记已读早于首屏消息加载完成
|
|
162
|
+
|
|
163
|
+
证据等级:**时序确定,但不能单独解释“稍后又未读”。**
|
|
164
|
+
|
|
165
|
+
`setCurrentSession` 立即触发回调:`client-chat-session-manage.js:46-64`;`client-chat.js:405-413` 随即调用 `setSessionRead`。服务端成功后才本地清零:`client-chat-session.js:938-950`,因此“点击即先本地清零”并不成立。详情首屏 reload 是另一条异步链,用户尚未看到消息时服务端标记已读可能已经成功。
|
|
166
|
+
|
|
167
|
+
后端 `Conversation.markAsRead` 同时写 `unreadMsgCount=0` 和 `markAsReadTime`:`Conversation.java:123-127`;会话 upsert 会结合消息时间更新。因此“稍后又未读”还必须结合新消息时间、`markAsReadTime` 和会话 upsert 才能判断,不能只归因于点击。
|
|
168
|
+
|
|
169
|
+
建议在首屏历史和本地补偿完成、至少完成首次渲染后再标记已读。更稳妥的协议是提交用户实际看到的最大 `messageId/messageTime`,避免把加载期间新到的消息一并读掉。
|
|
170
|
+
|
|
171
|
+
影响是未读徽标会多保留短时间;首屏加载失败时应维持未读。PC、H5、Pad 和页面可见性触发的自动已读需统一口径。
|
|
172
|
+
|
|
173
|
+
## 5. 已排除的单点归因
|
|
174
|
+
|
|
175
|
+
- **“后端先推后存,所以一定丢消息”不成立。** SDK 内存消息和本地补偿正常情况下会覆盖该窗口;问题需要叠加 reload 清空、补偿竞态或其他失败路径。
|
|
176
|
+
- **“reload 一定丢消息”不成立。** 若历史接口已入库,或本地补偿已经写入并被 merge 读到,消息会正常恢复。
|
|
177
|
+
- **“进入会话立刻在本地清零”不成立。** 当前代码等待服务端 `updateSessionRead` 成功后才本地清零。
|
|
178
|
+
- **“getSession 没有轮询”不成立。** 已有 5 秒一次、最长 60 秒的轮询;缺口是耗尽后的持续补偿。
|
|
179
|
+
- **“Socket 建连早于消息监听注册”暂无证据。** 当前 ClientChat 构造时先注册监听,Chat 组件创建后再 connect。
|
|
180
|
+
|
|
181
|
+
## 6. 完整阶段计划与当前状态
|
|
182
|
+
|
|
183
|
+
状态说明:`[x]` 已在当前工作区完成;`[ ]` 尚未实施;“仅日志”表示本次只增加诊断能力,没有改变业务行为。
|
|
184
|
+
|
|
185
|
+
### 阶段 1:证据链复核与确定缺陷修复
|
|
186
|
+
|
|
187
|
+
- [x] 复核前端、SDK、后端的新消息、会话、详情、已读和补拉链路。
|
|
188
|
+
- [x] 确认后端好友和群消息均为先推送、后异步入库。
|
|
189
|
+
- [x] 确认本地补偿、会话 60 秒轮询和 reload 的实际边界,排除不成立的单点归因。
|
|
190
|
+
- [x] 修复断线补拉 Promise 未返回及水位提前推进。
|
|
191
|
+
- [x] 修复 Socket Connecting close 和 signin 失败后不再重连。
|
|
192
|
+
- [x] 完成 Socket 重连和断线补拉最小运行测试。
|
|
193
|
+
|
|
194
|
+
### 阶段 2:前端诊断日志与日志交付
|
|
195
|
+
|
|
196
|
+
- [x] 页面生命周期:boot、用户初始化、配置初始化、ready、pageshow/pagehide、visibilitychange。
|
|
197
|
+
- [x] Socket 状态:连接代次、open、signin、close/error、重连计划。
|
|
198
|
+
- [x] 新消息接收:消息 ID、目标、时间、同步处理成功/失败;正文截断,token 删除。
|
|
199
|
+
- [x] 会话补建:轮询创建、请求/空响应/错误次数、最终成功或 60 秒失败。
|
|
200
|
+
- [x] 本地补偿:缓存 `success/skipped/failed`、跳过原因、合并前 API 数、本地补入数和最终数。
|
|
201
|
+
- [x] 进入会话:reload 清理前当前会话/全实例数量、清理数、加载数、最终数量、前后最后消息 ID/时间。
|
|
202
|
+
- [x] 消息过滤:消息已入内存但未触发 `add-message` 时记录具体原因。
|
|
203
|
+
- [x] 历史失败、已读请求、断线补拉水位和结果摘要。
|
|
204
|
+
- [x] Socket、SDK、业务层复用唯一日志实例;SDK 未传实例时保留自行创建日志的兼容路径。
|
|
205
|
+
- [x] 下载日志改为点击后直接生成 ZIP,包含微号、已加载会话、统一运行日志和网络日志。
|
|
206
|
+
- [x] 日志实现归并到客服端 `src/entries/chat/utils/log.js`。
|
|
207
|
+
- [ ] 后端推送、MQ、落库、会话 upsert 和 ACK 日志。本轮按要求只增加前端日志。
|
|
208
|
+
|
|
209
|
+
### 阶段 3:第一批业务一致性优化(建议优先,尚未实施)
|
|
210
|
+
|
|
211
|
+
- [ ] 本地补偿增加会话级 pending memory。
|
|
212
|
+
- [ ] `mergeMessages` 等待同会话已经排队的缓存写入,消除首次 IndexedDB 旧快照竞态。
|
|
213
|
+
- [ ] 增加 Socket 到达、历史为空、缓存首次读写并发的自动化测试。
|
|
214
|
+
- [ ] 增加 `pendingSessionMap`,会话轮询 60 秒耗尽后低频持续补建。
|
|
215
|
+
- [ ] Socket 重连成功、页面恢复可见、会话列表主动刷新时立即重试 pending 会话。
|
|
216
|
+
|
|
217
|
+
这一阶段不改变 ACK 和已读协议,也不创建字段不完整的临时会话,兼容风险相对较低。
|
|
218
|
+
|
|
219
|
+
### 阶段 4:消息列表一致性优化(尚未实施)
|
|
220
|
+
|
|
221
|
+
- [ ] 将 reload 改为先加载和合并,成功后只原子替换当前 target。
|
|
222
|
+
- [ ] reload 失败或服务端暂时为空时,保留未被远端覆盖的 Socket 内存消息。
|
|
223
|
+
- [ ] 不再由进入一个会话清空好友或群实例中的全部会话消息。
|
|
224
|
+
- [ ] 拆分“消息接受/缓存事件”和“会话未读/提醒事件”,让乱序有效消息仍进入补偿缓存。
|
|
225
|
+
- [ ] 对明显未来的消息时间增加容差或隔离,防止持续抬高乱序基准。
|
|
226
|
+
- [ ] 覆盖好友、群、筛选、上下文 6 个 reload 调用点的回归测试。
|
|
227
|
+
|
|
228
|
+
### 阶段 5:协议与交互语义优化(需确认后实施)
|
|
229
|
+
|
|
230
|
+
- [ ] 与后端确认 ACK 代表“帧已收到”还是“业务处理成功”,再决定 finally ACK 或失败 ACK。
|
|
231
|
+
- [ ] 保护 JSON 解析和 cmd 分发边界,并测试异常帧、监听器异常及重复推送。
|
|
232
|
+
- [ ] 将用户身份、token、补偿 DB 设为 Chat 渲染和 Socket 建连门禁。
|
|
233
|
+
- [ ] 将已读调整为首屏历史和本地补偿完成后上报。
|
|
234
|
+
- [ ] 评估按“用户看到的最大 messageId/messageTime”标记已读的协议升级。
|
|
235
|
+
- [ ] 若业务要求会话立即可见,再评估 provisional session 及正式会话替换。
|
|
236
|
+
|
|
237
|
+
### 阶段 6:发布与线上验证(尚未完成)
|
|
238
|
+
|
|
239
|
+
- [ ] 发布包含当前修改的新 SDK 版本。
|
|
240
|
+
- [ ] 将客服端依赖和 lock 从 `1.0.378` 升级到新版本。
|
|
241
|
+
- [ ] 重新执行 SDK 运行测试、客服端完整构建和目标环境回归。
|
|
242
|
+
- [ ] 灰度后使用真实 ZIP 日志验证两个客户现象的判定链。
|
|
243
|
+
|
|
244
|
+
## 7. 日志覆盖审计
|
|
245
|
+
|
|
246
|
+
### 7.1 覆盖结论
|
|
247
|
+
|
|
248
|
+
当前前端日志已经可以定位到“问题发生在页面初始化、Socket、消息分发、会话补建、reload、本地缓存、历史合并、已读请求或断线补拉中的哪一段”。对于前端竞态和过滤路径,可以形成较完整证据链。
|
|
249
|
+
|
|
250
|
+
但只靠前端日志不能证明后端的精确推送时间、消息落库时间、会话 upsert 时间和 ACK 接收结果。因此可以判断“前端已收到但接口仍返回空”或“会话接口连续 60 秒为空”,不能继续断言是 MQ 延迟、数据库失败还是后端会话更新失败。最终端到端定责仍需以后补充后端 messageId 关联日志。
|
|
251
|
+
|
|
252
|
+
| 场景 | 当前日志 | 能否定位 | 判定依据 |
|
|
253
|
+
|---|---|---|---|
|
|
254
|
+
| 页面初始化失败后仍进入 Chat | `page_boot/user_init_result/common_setting_init_result/page_init_failed/page_ready/sdk_ready` | 可以 | 直接看到失败阶段、`dbReady` 和是否继续 ready |
|
|
255
|
+
| Socket 断开或 signin 失败后未恢复 | `diagnostic:socket_state` | 可以 | 按 `connectionGeneration` 检查 close/error 后是否出现 reconnect、native_open、signin_success |
|
|
256
|
+
| Socket 根本没有收到目标消息 | `client-chat-socket:receiveMessage` | 前端边界可定位 | 日志中没有 messageId;需后端推送日志判断是后端未推还是网络层未达 |
|
|
257
|
+
| Socket 收到但同步处理抛错 | `client-chat-socket:receiveMessage.processing` | 可以 | 同一条消息直接记录 `failed/errorType` |
|
|
258
|
+
| 消息因乱序或业务规则不触发缓存/提醒 | `diagnostic:message_event_suppressed` | 可以 | `reason` 区分 `out_of_order/auto_read/ignore_session/deleted/blocked`,同时记录 messageId 和时间基准 |
|
|
259
|
+
| 消息缓存没有真正写入 | `diagnostic:message_cache_result` | 可以 | 区分 `success/skipped/failed`;skipped 带 `missing_cache_key/missing_message_time/db_unavailable/send_or_temp_message` |
|
|
260
|
+
| reload 清掉内存后详情为空 | `chat_reload_result` + `message_merge_result` | 可以 | `targetMemoryBefore>0`、`clearedCount>0`、`apiCount=0`、`localMergedCount=0`、`finalTargetCount=0` 构成直接证据 |
|
|
261
|
+
| cache/merge 首次读写竞态 | receive + cache + merge + reload | 可以形成强证据 | 同 messageId reload 前存在;merge 先得到 0;cache 随后成功。该顺序命中当前已证明的竞态窗口 |
|
|
262
|
+
| 历史接口请求失败 | `message_history_failed` + `chat_reload_result` | 可以 | 记录 target、错误类型,reload 同时记录失败及清理后的最终数量 |
|
|
263
|
+
| 会话 60 秒轮询耗尽 | `client-chat-session:getSession` | 可以 | 最终失败日志包含请求次数、空响应数、错误数和约 60 秒耗时 |
|
|
264
|
+
| 会话为何在后端 60 秒仍不可查 | 前端会话轮询日志 | 不完全 | 能证明接口持续空或失败,不能证明后端会话何时 upsert;需后端日志 |
|
|
265
|
+
| 断线补拉失败或水位错误 | `diagnostic:loss_sync_result` | 可以 | 失败保留旧水位;成功记录 returned/inserted/duplicate 和新水位 |
|
|
266
|
+
| 点击会话已读请求失败 | `diagnostic:mark_read_result` | 可以 | 记录 conversationId、`unreadBefore`、请求结果和耗时 |
|
|
267
|
+
| 已读后又出现未读 | mark-read + receive + getSession +账号未读日志 | 大部分可以 | 可判断已读后是否又收到新消息,或 getSession 返回了非零未读;后端 `markAsReadTime/upsert` 精确顺序仍需后端日志 |
|
|
268
|
+
| cmd 异常是否阻断 ACK | processing failed + Socket 发送日志 | 不完全 | 可证明业务异常发生;当前没有 ACK 独立结果和服务端收 ACK 日志,不能端到端确认 |
|
|
269
|
+
| 刷新后恢复 | 两个页面实例的生命周期日志 + messageId 链路 | 可以 | 以 `pageInstanceId` 和 page boot 为边界,对比刷新前 receive/cache/reload 与刷新后 merge/reload |
|
|
270
|
+
|
|
271
|
+
### 7.2 两个客户现象的排查顺序
|
|
272
|
+
|
|
273
|
+
对于“刷新后才出现消息”:
|
|
274
|
+
|
|
275
|
+
1. 用消息 ID 或 msgSvrId 查 `client-chat-socket:receiveMessage`。
|
|
276
|
+
2. 未找到时检查 `socket_state`;Socket 正常则只能定位到前端未收到,需要后端推送日志继续判断。
|
|
277
|
+
3. 已收到时检查 `processing` 和 `message_event_suppressed`。
|
|
278
|
+
4. 本地无会话时检查 `client-chat-session:getSession` 是否成功或 60 秒耗尽。
|
|
279
|
+
5. 发生过断线时检查 `loss_sync_result` 的水位和补拉结果。
|
|
280
|
+
6. 对比刷新前后 page boot,确认消息最终从历史合并还是新的 Socket 推送恢复。
|
|
281
|
+
|
|
282
|
+
对于“显示有消息,点进去为空,稍后又出现未读”:
|
|
283
|
+
|
|
284
|
+
1. 查 `mark_read_result`,确认点击前未读数和已读请求是否成功。
|
|
285
|
+
2. 查 `chat_reload_result`,确认进入前内存是否有消息、是否执行全实例清理及最终是否为空。
|
|
286
|
+
3. 查同 target 的 `message_merge_result`,确认 API 返回数、本地补入数和首尾消息。
|
|
287
|
+
4. 查同 messageId 的 `message_cache_result`;若它晚于空的 merge/reload 成功,支持首次缓存竞态。
|
|
288
|
+
5. 查空详情之后的 receive/getSession/账号未读日志,确认后续未读是新消息、会话延迟更新还是重新加载旧消息。
|
|
289
|
+
6. 若需要判断后端为何迟到,必须结合后端落库、会话 upsert 和 markAsReadTime 日志。
|
|
290
|
+
|
|
291
|
+
### 7.3 日志体量与安全
|
|
292
|
+
|
|
293
|
+
- `chat_reload_result` 每次 reload 一条;`message_event_suppressed` 只在消息不触发外部事件时记录,不增加正常消息的重复日志。
|
|
294
|
+
- 缓存、合并、已读和补拉均是一项操作一条结果,不记录轮询每一轮和心跳成功。
|
|
295
|
+
- 正文只在原 Socket 调试摘要中最多保留前 100 字并记录长度;诊断事件不记录正文。
|
|
296
|
+
- accessToken/token 会删除;不记录完整 URL。
|
|
297
|
+
- 日志使用 3 MB 环形容量,并通过统一 ZIP 一次性导出。
|
|
298
|
+
|
|
299
|
+
## 8. 发布边界
|
|
300
|
+
|
|
301
|
+
- 当前修复只存在于 SDK 工作区,客服端依赖声明仍为 `1.0.378`。需要先发布包含本次修改的新 SDK 版本,再升级客服端依赖并重新构建,线上才会生效。
|
|
302
|
+
- 本机 `node_modules` 为 `1.0.381` 只能用于当前本地验证,不能代表正式构建一定使用该版本。
|
|
303
|
+
- 后续业务逻辑优化应拆分提交和灰度:先 cache/merge 竞态,再会话持续补建,再 reload 原子替换,便于日志判断每一步实际收益。
|
package/package.json
CHANGED
|
@@ -72,6 +72,45 @@ export default class ClientChatMessage {
|
|
|
72
72
|
this.event = new Event()
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
addDiagnosticLog (event, data = {}) {
|
|
76
|
+
if (!this.clientChat || !this.clientChat.socket || !this.clientChat.socket.addLog) {
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
this.clientChat.socket.addLog({
|
|
81
|
+
group: `diagnostic:${event}`,
|
|
82
|
+
content: Object.assign({
|
|
83
|
+
event
|
|
84
|
+
}, data)
|
|
85
|
+
})
|
|
86
|
+
} catch (error) {
|
|
87
|
+
// 诊断日志失败不能影响消息处理。
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
getGroupMessageSummary (groupId) {
|
|
92
|
+
const summary = {
|
|
93
|
+
count: 0,
|
|
94
|
+
latestMessageId: null,
|
|
95
|
+
latestMessageTime: null
|
|
96
|
+
}
|
|
97
|
+
if (!groupId) {
|
|
98
|
+
return summary
|
|
99
|
+
}
|
|
100
|
+
Object.keys(this.messageMap).forEach(id => {
|
|
101
|
+
const message = this.messageMap[id]
|
|
102
|
+
if (!message || message[this.groupField] !== groupId) {
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
summary.count++
|
|
106
|
+
if (!summary.latestMessageTime || message[this.sortField] > summary.latestMessageTime) {
|
|
107
|
+
summary.latestMessageId = message.id
|
|
108
|
+
summary.latestMessageTime = message[this.sortField]
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
return summary
|
|
112
|
+
}
|
|
113
|
+
|
|
75
114
|
getSortField (time) {
|
|
76
115
|
const step = 1000 * 60 * 60 * 24
|
|
77
116
|
const day = Math.floor(time / step)
|
|
@@ -161,14 +200,39 @@ export default class ClientChatMessage {
|
|
|
161
200
|
error
|
|
162
201
|
})
|
|
163
202
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
})
|
|
203
|
+
// 如果消息包含这种情况,就不能往外发送add-message事件
|
|
204
|
+
// 因为例如群发的消息,他没有会话,导致前端在处理来了新消息通知铃声提醒,获取会话一直获取不到,然后会总是发送请求
|
|
205
|
+
let suppressedReason = null
|
|
206
|
+
if (isBeforeLastMessage) {
|
|
207
|
+
suppressedReason = 'out_of_order'
|
|
208
|
+
} else if (isAutoReadMessage(data)) {
|
|
209
|
+
suppressedReason = 'auto_read'
|
|
210
|
+
} else if (shouldIgnoreSessionByMessage(data)) {
|
|
211
|
+
suppressedReason = 'ignore_session'
|
|
212
|
+
} else if (isDeletedMsg(data)) {
|
|
213
|
+
suppressedReason = 'deleted'
|
|
214
|
+
} else if (isBlockedMsg(data)) {
|
|
215
|
+
suppressedReason = 'blocked'
|
|
216
|
+
}
|
|
217
|
+
if (!suppressedReason) {
|
|
218
|
+
this.event && this.event.emit('add-message', {
|
|
219
|
+
data
|
|
220
|
+
})
|
|
221
|
+
} else {
|
|
222
|
+
this.addDiagnosticLog('message_event_suppressed', {
|
|
223
|
+
reason: suppressedReason,
|
|
224
|
+
messageId: data.id,
|
|
225
|
+
msgSvrId: data.msgSvrId,
|
|
226
|
+
wechatAccountId: data.wechatAccountId,
|
|
227
|
+
targetField: this.groupField,
|
|
228
|
+
targetId: groupField,
|
|
229
|
+
messageTime: data[this.sortField],
|
|
230
|
+
lastMessageTime,
|
|
231
|
+
memoryInserted: true,
|
|
232
|
+
addMessageEventEmitted: false
|
|
233
|
+
})
|
|
234
|
+
}
|
|
170
235
|
}
|
|
171
|
-
}
|
|
172
236
|
}
|
|
173
237
|
|
|
174
238
|
removeMessage (id) {
|
|
@@ -421,8 +485,54 @@ export default class ClientChatMessage {
|
|
|
421
485
|
}
|
|
422
486
|
|
|
423
487
|
reloadMessageData (params) {
|
|
488
|
+
const startedAt = Date.now()
|
|
489
|
+
const targetId = params && params[this.groupField]
|
|
490
|
+
const totalMemoryBefore = Object.keys(this.messageMap).length
|
|
491
|
+
const targetBefore = this.getGroupMessageSummary(targetId)
|
|
424
492
|
this.clearMessage()
|
|
425
|
-
return this.loadMessageData(params)
|
|
493
|
+
return this.loadMessageData(params).then(data => {
|
|
494
|
+
const targetAfter = this.getGroupMessageSummary(targetId)
|
|
495
|
+
this.addDiagnosticLog('chat_reload_result', {
|
|
496
|
+
result: 'success',
|
|
497
|
+
targetField: this.groupField,
|
|
498
|
+
targetId,
|
|
499
|
+
totalMemoryBefore,
|
|
500
|
+
targetMemoryBefore: targetBefore.count,
|
|
501
|
+
latestMessageIdBefore: targetBefore.latestMessageId,
|
|
502
|
+
latestMessageTimeBefore: targetBefore.latestMessageTime,
|
|
503
|
+
clearedCount: totalMemoryBefore,
|
|
504
|
+
loadedCount: Array.isArray(data) ? data.length : 0,
|
|
505
|
+
finalTargetCount: targetAfter.count,
|
|
506
|
+
latestMessageIdAfter: targetAfter.latestMessageId,
|
|
507
|
+
latestMessageTimeAfter: targetAfter.latestMessageTime,
|
|
508
|
+
upward: params && params.upward,
|
|
509
|
+
smallWechatTime: params && params.smallWechatTime,
|
|
510
|
+
bigWechatTime: params && params.bigWechatTime,
|
|
511
|
+
durationMs: Date.now() - startedAt
|
|
512
|
+
})
|
|
513
|
+
return data
|
|
514
|
+
}, error => {
|
|
515
|
+
const targetAfter = this.getGroupMessageSummary(targetId)
|
|
516
|
+
this.addDiagnosticLog('chat_reload_result', {
|
|
517
|
+
result: 'failed',
|
|
518
|
+
targetField: this.groupField,
|
|
519
|
+
targetId,
|
|
520
|
+
totalMemoryBefore,
|
|
521
|
+
targetMemoryBefore: targetBefore.count,
|
|
522
|
+
latestMessageIdBefore: targetBefore.latestMessageId,
|
|
523
|
+
latestMessageTimeBefore: targetBefore.latestMessageTime,
|
|
524
|
+
clearedCount: totalMemoryBefore,
|
|
525
|
+
finalTargetCount: targetAfter.count,
|
|
526
|
+
latestMessageIdAfter: targetAfter.latestMessageId,
|
|
527
|
+
latestMessageTimeAfter: targetAfter.latestMessageTime,
|
|
528
|
+
upward: params && params.upward,
|
|
529
|
+
smallWechatTime: params && params.smallWechatTime,
|
|
530
|
+
bigWechatTime: params && params.bigWechatTime,
|
|
531
|
+
durationMs: Date.now() - startedAt,
|
|
532
|
+
errorType: error && (error.name || error.code) || typeof error
|
|
533
|
+
})
|
|
534
|
+
return Promise.reject(error)
|
|
535
|
+
})
|
|
426
536
|
}
|
|
427
537
|
|
|
428
538
|
/***
|
|
@@ -587,18 +697,26 @@ export default class ClientChatMessage {
|
|
|
587
697
|
if (!this.loadLossMessageDataMethod) {
|
|
588
698
|
throw new Error('[ClientChatWechat]缺少loadLossMessageDataMethod方法定义')
|
|
589
699
|
}
|
|
590
|
-
this.loadLossMessageDataMethod({
|
|
700
|
+
return this.loadLossMessageDataMethod({
|
|
591
701
|
lastReceiveMessageTime: this.data.lastReceiveMessageTime
|
|
592
702
|
}).then((rs) => {
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
703
|
+
const result = {
|
|
704
|
+
returnedCount: 0,
|
|
705
|
+
insertedCount: 0,
|
|
706
|
+
duplicateCount: 0
|
|
596
707
|
}
|
|
597
|
-
|
|
598
|
-
|
|
708
|
+
const messages = rs && Array.isArray(rs.data) ? rs.data : []
|
|
709
|
+
result.returnedCount = messages.length
|
|
710
|
+
// 做个去重
|
|
711
|
+
const nextMessages = messages.filter(v => !this.messageMap[v.id])
|
|
712
|
+
result.insertedCount = nextMessages.length
|
|
713
|
+
result.duplicateCount = messages.length - nextMessages.length
|
|
714
|
+
if (nextMessages.length) {
|
|
715
|
+
nextMessages.forEach(item => {
|
|
599
716
|
this.addMessage(item)
|
|
600
717
|
})
|
|
601
718
|
}
|
|
719
|
+
return result
|
|
602
720
|
})
|
|
603
721
|
}
|
|
604
722
|
|
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
import Socket from '../../socket'
|
|
2
2
|
|
|
3
|
+
const LOG_CONTENT_MAX_LENGTH = 100
|
|
4
|
+
|
|
5
|
+
function cloneLogData (data) {
|
|
6
|
+
const cloneData = Object.assign({}, data)
|
|
7
|
+
;['content', 'contBody'].forEach(field => {
|
|
8
|
+
if (typeof cloneData[field] === 'string') {
|
|
9
|
+
const contentLength = cloneData[field].length
|
|
10
|
+
cloneData[field] = cloneData[field].substr(0, LOG_CONTENT_MAX_LENGTH)
|
|
11
|
+
cloneData[`${field}Length`] = contentLength
|
|
12
|
+
cloneData[`${field}Truncated`] = contentLength > LOG_CONTENT_MAX_LENGTH
|
|
13
|
+
} else if (cloneData[field] !== undefined) {
|
|
14
|
+
cloneData[`${field}Type`] = typeof cloneData[field]
|
|
15
|
+
delete cloneData[field]
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
delete cloneData.accessToken
|
|
19
|
+
delete cloneData.token
|
|
20
|
+
return cloneData
|
|
21
|
+
}
|
|
22
|
+
|
|
3
23
|
export default class Websocket {
|
|
4
24
|
get connectStatus () {
|
|
5
25
|
return this.socket.connectStatus
|
|
@@ -12,6 +32,7 @@ export default class Websocket {
|
|
|
12
32
|
constructor (options) {
|
|
13
33
|
options = Object.assign({
|
|
14
34
|
socket: null,
|
|
35
|
+
log: null,
|
|
15
36
|
// 检查socket事件通知方法,有些场景不需要后端推送的命令执行,比如管理后台
|
|
16
37
|
checkSocketEventNotifyMethod: null,
|
|
17
38
|
// 执行回调事件前的方法
|
|
@@ -40,7 +61,8 @@ export default class Websocket {
|
|
|
40
61
|
throw new Error('socket实例不能为空')
|
|
41
62
|
}
|
|
42
63
|
this.socket = options.socket
|
|
43
|
-
this.
|
|
64
|
+
this.log = options.log || this.socket.log || null
|
|
65
|
+
this.log && this.log.onBeforeAdd && this.log.onBeforeAdd(({ data }) => {
|
|
44
66
|
// 收消息需要拦截一些,因为下面有了
|
|
45
67
|
if (data && ['socket:sendMessage'].includes(data.group)) {
|
|
46
68
|
return false
|
|
@@ -56,22 +78,35 @@ export default class Websocket {
|
|
|
56
78
|
})
|
|
57
79
|
this.socket.event.on('onCmdNewMessage', (evt) => {
|
|
58
80
|
if (!options.checkSocketEventNotifyMethod || options.checkSocketEventNotifyMethod(evt) !== false) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
81
|
+
let processingError
|
|
82
|
+
try {
|
|
83
|
+
options.onCmdNewMessage(evt)
|
|
84
|
+
} catch (error) {
|
|
85
|
+
processingError = error
|
|
63
86
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
87
|
+
const cloneData = cloneLogData(evt.data)
|
|
88
|
+
if (evt.data.friendMessage) {
|
|
89
|
+
cloneData.friendMessage = cloneLogData(evt.data.friendMessage)
|
|
90
|
+
}
|
|
91
|
+
if (evt.data.chatroomMessage) {
|
|
92
|
+
cloneData.chatroomMessage = cloneLogData(evt.data.chatroomMessage)
|
|
93
|
+
}
|
|
94
|
+
this.addLog({
|
|
68
95
|
group: 'client-chat-socket:receiveMessage',
|
|
69
96
|
content: {
|
|
70
97
|
title: 'socket收到消息',
|
|
71
|
-
data:
|
|
98
|
+
data: cloneData,
|
|
99
|
+
processing: processingError ? {
|
|
100
|
+
result: 'failed',
|
|
101
|
+
errorType: processingError && (processingError.name || processingError.code) || typeof processingError
|
|
102
|
+
} : {
|
|
103
|
+
result: 'success'
|
|
104
|
+
}
|
|
72
105
|
}
|
|
73
106
|
})
|
|
74
|
-
|
|
107
|
+
if (processingError) {
|
|
108
|
+
throw processingError
|
|
109
|
+
}
|
|
75
110
|
}
|
|
76
111
|
})
|
|
77
112
|
this.socket.event.on('onCmdSendMessageResult', (evt) => {
|
|
@@ -134,14 +169,11 @@ export default class Websocket {
|
|
|
134
169
|
options.onHeartbeatSuccess && options.onHeartbeatSuccess(evt)
|
|
135
170
|
})
|
|
136
171
|
this.socket.event.on('onSendMessage', (evt) => {
|
|
137
|
-
const cloneData =
|
|
138
|
-
if (cloneData && typeof cloneData.content === 'string') {
|
|
139
|
-
cloneData.content = cloneData.content.substr(0, 100)
|
|
140
|
-
}
|
|
172
|
+
const cloneData = cloneLogData(evt.data)
|
|
141
173
|
if (evt.data.cmdType === 'CmdRequestWechatAccountsAliveStatus') {
|
|
142
174
|
delete cloneData.wechatAccountIds
|
|
143
175
|
}
|
|
144
|
-
this.
|
|
176
|
+
this.addLog({
|
|
145
177
|
group: 'client-chat-socket:sendMessage',
|
|
146
178
|
content: {
|
|
147
179
|
title: '发送消息',
|
|
@@ -222,7 +254,7 @@ export default class Websocket {
|
|
|
222
254
|
}
|
|
223
255
|
|
|
224
256
|
addLog (data) {
|
|
225
|
-
this.socket.logEnable && this.
|
|
257
|
+
this.socket.logEnable && this.log && this.log.addLog(data)
|
|
226
258
|
}
|
|
227
259
|
|
|
228
260
|
exportLogData () {
|
|
@@ -55,6 +55,7 @@ class ClientChat {
|
|
|
55
55
|
constructor (options) {
|
|
56
56
|
options = Object.assign({
|
|
57
57
|
socket: null,
|
|
58
|
+
log: null,
|
|
58
59
|
checkSocketEventNotifyMethod: null,
|
|
59
60
|
getLoginUserIdMethod: null,
|
|
60
61
|
getLoginUserTokenMethod: null,
|
|
@@ -101,6 +102,7 @@ class ClientChat {
|
|
|
101
102
|
if (!options.loadRoomMessageDataMethod) {
|
|
102
103
|
throw new Error('loadRoomMessageDataMethod不能为空')
|
|
103
104
|
}
|
|
105
|
+
this.log = options.log || (options.socket && options.socket.log) || null
|
|
104
106
|
this.onOverwriteFriendInfoChange = options.onOverwriteFriendInfoChange
|
|
105
107
|
this.shouldMarkCurrentSessionReadMethod = options.shouldMarkCurrentSessionReadMethod
|
|
106
108
|
this.event = new Event({
|
|
@@ -803,6 +805,7 @@ class ClientChat {
|
|
|
803
805
|
})
|
|
804
806
|
this.socket = new ClientChatSocket({
|
|
805
807
|
socket: options.socket,
|
|
808
|
+
log: this.log,
|
|
806
809
|
getLoginUserIdMethod: options.getLoginUserIdMethod,
|
|
807
810
|
getLoginUserTokenMethod: options.getLoginUserTokenMethod,
|
|
808
811
|
checkSocketEventNotifyMethod: options.checkSocketEventNotifyMethod,
|
|
@@ -1134,22 +1137,65 @@ class ClientChat {
|
|
|
1134
1137
|
|
|
1135
1138
|
},
|
|
1136
1139
|
onSocketOpen: () => {
|
|
1137
|
-
const
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1140
|
+
const reconnectTime = Date.now()
|
|
1141
|
+
const syncTasks = []
|
|
1142
|
+
const syncLossMessages = (type, messageInstance) => {
|
|
1143
|
+
const watermarkBefore = messageInstance.lastReceiveMessageTime
|
|
1144
|
+
if (!watermarkBefore) {
|
|
1145
|
+
messageInstance.setLastReceiveMessageTime(reconnectTime)
|
|
1146
|
+
this.socket.addLog({
|
|
1147
|
+
group: 'diagnostic:loss_sync_result',
|
|
1148
|
+
content: {
|
|
1149
|
+
event: 'loss_sync_result',
|
|
1150
|
+
type,
|
|
1151
|
+
result: 'initialized',
|
|
1152
|
+
watermarkBefore,
|
|
1153
|
+
watermarkAfter: messageInstance.lastReceiveMessageTime
|
|
1154
|
+
}
|
|
1155
|
+
})
|
|
1156
|
+
return
|
|
1157
|
+
}
|
|
1158
|
+
const startedAt = Date.now()
|
|
1159
|
+
const task = messageInstance.loadLossMessageData().then(result => {
|
|
1160
|
+
messageInstance.setLastReceiveMessageTime(reconnectTime)
|
|
1161
|
+
this.socket.addLog({
|
|
1162
|
+
group: 'diagnostic:loss_sync_result',
|
|
1163
|
+
content: Object.assign({
|
|
1164
|
+
event: 'loss_sync_result',
|
|
1165
|
+
type,
|
|
1166
|
+
result: 'success',
|
|
1167
|
+
watermarkBefore,
|
|
1168
|
+
watermarkAfter: messageInstance.lastReceiveMessageTime,
|
|
1169
|
+
durationMs: Date.now() - startedAt
|
|
1170
|
+
}, result)
|
|
1171
|
+
})
|
|
1172
|
+
return true
|
|
1173
|
+
}, error => {
|
|
1174
|
+
this.socket.addLog({
|
|
1175
|
+
group: 'diagnostic:loss_sync_result',
|
|
1176
|
+
content: {
|
|
1177
|
+
event: 'loss_sync_result',
|
|
1178
|
+
type,
|
|
1179
|
+
result: 'failed',
|
|
1180
|
+
watermarkBefore,
|
|
1181
|
+
watermarkAfter: messageInstance.lastReceiveMessageTime,
|
|
1182
|
+
durationMs: Date.now() - startedAt,
|
|
1183
|
+
errorType: error && (error.name || error.code) || typeof error
|
|
1184
|
+
}
|
|
1185
|
+
})
|
|
1186
|
+
return false
|
|
1187
|
+
})
|
|
1188
|
+
syncTasks.push(task)
|
|
1143
1189
|
}
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1190
|
+
syncLossMessages('friend', this.friendMessage)
|
|
1191
|
+
syncLossMessages('room', this.roomMessage)
|
|
1192
|
+
Promise.all(syncTasks).then(() => {
|
|
1147
1193
|
// 断线期间本地增量可能和服务端产生偏差(其他端已读、消息延迟等),重连后以服务端为准全量校准一次
|
|
1148
|
-
if (
|
|
1194
|
+
if (syncTasks.length) {
|
|
1149
1195
|
this.account.refreshAllAccountUnreadMesageNum()
|
|
1150
1196
|
}
|
|
1151
1197
|
})
|
|
1152
|
-
},
|
|
1198
|
+
},
|
|
1153
1199
|
onSocketClose: (evt) => {
|
|
1154
1200
|
},
|
|
1155
1201
|
onSocketKickOut: () => {
|
|
@@ -25,6 +25,8 @@ export default class Socket {
|
|
|
25
25
|
|
|
26
26
|
maxReconnectInterval = 60000
|
|
27
27
|
|
|
28
|
+
connectionGeneration = 0
|
|
29
|
+
|
|
28
30
|
data = Vue.observable({
|
|
29
31
|
connectIpChange: false, // 连接设备是否变化,如果变化需要提示用户退出重新登录
|
|
30
32
|
connectMessage: null,
|
|
@@ -47,6 +49,7 @@ export default class Socket {
|
|
|
47
49
|
url: null,
|
|
48
50
|
client: null,
|
|
49
51
|
logEnable: false,
|
|
52
|
+
log: null,
|
|
50
53
|
logName: 'socket',
|
|
51
54
|
logMaxSize: 3 * 1024 * 1024, // 非网页版本最多记3M日志
|
|
52
55
|
socketAdapter: null, // 支持从外面传一个socket构造函数,但是要实现socket标准的使用方式
|
|
@@ -73,7 +76,7 @@ export default class Socket {
|
|
|
73
76
|
this.logEnable = !!options.logEnable
|
|
74
77
|
this.url = options.url
|
|
75
78
|
this.event = new Event()
|
|
76
|
-
this.log = new Log({
|
|
79
|
+
this.log = options.log || new Log({
|
|
77
80
|
maxSize: options.logMaxSize, // 非网页版本日志大小限制
|
|
78
81
|
name: options.logName || 'socket'
|
|
79
82
|
})
|
|
@@ -105,6 +108,17 @@ export default class Socket {
|
|
|
105
108
|
return !!socket && socket === this._ws
|
|
106
109
|
}
|
|
107
110
|
|
|
111
|
+
_addSocketStateLog (event, data = {}) {
|
|
112
|
+
this.logEnable && this.log.addLog({
|
|
113
|
+
group: 'diagnostic:socket_state',
|
|
114
|
+
content: Object.assign({
|
|
115
|
+
event,
|
|
116
|
+
connectionGeneration: this.connectionGeneration,
|
|
117
|
+
connectStatus: this.connectStatus
|
|
118
|
+
}, data)
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
108
122
|
_handleReconnectLimitExceeded () {
|
|
109
123
|
const err = new Error('socket重连次数已超过上限')
|
|
110
124
|
clearTimeout(this._reconnectTimer)
|
|
@@ -141,14 +155,20 @@ export default class Socket {
|
|
|
141
155
|
this.readyPromise = createPromise()
|
|
142
156
|
this.data.connectUrl = this.url + `?client=${this.client}&accountId=${this.accountId}&accessToken=${this.token}`
|
|
143
157
|
let socket = null
|
|
158
|
+
this.connectionGeneration++
|
|
144
159
|
this.data.connectStatus = CONNECT_STATUS.Connecting
|
|
145
160
|
this.logEnable && this.log.addLog({
|
|
146
161
|
group: 'socket:connect',
|
|
147
162
|
content: {
|
|
148
163
|
title: '开始连接',
|
|
149
|
-
|
|
164
|
+
client: this.client,
|
|
165
|
+
accountId: this.accountId,
|
|
166
|
+
connectionGeneration: this.connectionGeneration
|
|
150
167
|
}
|
|
151
168
|
})
|
|
169
|
+
this._addSocketStateLog('connect_start', {
|
|
170
|
+
to: CONNECT_STATUS.Connecting
|
|
171
|
+
})
|
|
152
172
|
this._ws = null
|
|
153
173
|
if (this.socketAdapter) {
|
|
154
174
|
socket = new this.socketAdapter(this.data.connectUrl)
|
|
@@ -275,6 +295,9 @@ export default class Socket {
|
|
|
275
295
|
title: 'socket连接中'
|
|
276
296
|
}
|
|
277
297
|
})
|
|
298
|
+
this._addSocketStateLog('native_open', {
|
|
299
|
+
to: CONNECT_STATUS.Success
|
|
300
|
+
})
|
|
278
301
|
this.sendMessage(params).then(() => {
|
|
279
302
|
if (socket && !this._isCurrentSocket(socket)) {
|
|
280
303
|
return
|
|
@@ -286,6 +309,9 @@ export default class Socket {
|
|
|
286
309
|
}
|
|
287
310
|
})
|
|
288
311
|
this._startHeartbeat() // 注释掉,因为现在心跳的命令改了,用账号在线状态控制,减少命令发送频率
|
|
312
|
+
this._addSocketStateLog('signin_success', {
|
|
313
|
+
result: 'success'
|
|
314
|
+
})
|
|
289
315
|
successFn && successFn()
|
|
290
316
|
this.event.emit('onOpen')
|
|
291
317
|
}, (err) => {
|
|
@@ -293,6 +319,13 @@ export default class Socket {
|
|
|
293
319
|
return
|
|
294
320
|
}
|
|
295
321
|
errorFn && errorFn(err)
|
|
322
|
+
this.data.connectStatus = CONNECT_STATUS.Error
|
|
323
|
+
this._addSocketStateLog('signin_failed', {
|
|
324
|
+
result: 'failed',
|
|
325
|
+
errorType: err && (err.name || err.code) || typeof err,
|
|
326
|
+
willReconnect: true
|
|
327
|
+
})
|
|
328
|
+
this.reconnect().catch(() => {})
|
|
296
329
|
})
|
|
297
330
|
}
|
|
298
331
|
|
|
@@ -324,11 +357,15 @@ export default class Socket {
|
|
|
324
357
|
reason: evt.reason,
|
|
325
358
|
}
|
|
326
359
|
})
|
|
360
|
+
this._addSocketStateLog('socket_close', {
|
|
361
|
+
from: prevConnectStatus,
|
|
362
|
+
to: CONNECT_STATUS.Close,
|
|
363
|
+
closeCode: evt.code,
|
|
364
|
+
reason: evt.reason,
|
|
365
|
+
willReconnect: !evt.reason || evt.reason.indexOf('(手动关闭)') < 0
|
|
366
|
+
})
|
|
327
367
|
if (!evt.reason || evt.reason.indexOf('(手动关闭)') < 0) {
|
|
328
|
-
|
|
329
|
-
return
|
|
330
|
-
}
|
|
331
|
-
this.reconnect()
|
|
368
|
+
this.reconnect().catch(() => {})
|
|
332
369
|
}
|
|
333
370
|
this.event.emit('onClose', {
|
|
334
371
|
code: evt.code,
|
|
@@ -355,7 +392,11 @@ export default class Socket {
|
|
|
355
392
|
if (this.readyPromise && typeof this.readyPromise.reject === 'function') {
|
|
356
393
|
this.readyPromise.reject(new Error('socket连接错误'))
|
|
357
394
|
}
|
|
358
|
-
this.
|
|
395
|
+
this._addSocketStateLog('socket_error', {
|
|
396
|
+
to: CONNECT_STATUS.Error,
|
|
397
|
+
willReconnect: true
|
|
398
|
+
})
|
|
399
|
+
this.reconnect().catch(() => {})
|
|
359
400
|
}
|
|
360
401
|
|
|
361
402
|
mockSocketMessage (evt) {
|
|
@@ -373,11 +414,13 @@ export default class Socket {
|
|
|
373
414
|
if (this.messageCallbackMap[messageData.seq]) {
|
|
374
415
|
if (messageData.code === -1) {
|
|
375
416
|
this.messageCallbackMap[messageData.seq].reject({
|
|
376
|
-
errMsg: messageData.extra
|
|
417
|
+
errMsg: messageData.extra,
|
|
418
|
+
response: messageData
|
|
377
419
|
})
|
|
378
420
|
} else {
|
|
379
421
|
this.messageCallbackMap[messageData.seq].resolve({
|
|
380
|
-
data: messageData.extra
|
|
422
|
+
data: messageData.extra,
|
|
423
|
+
response: messageData
|
|
381
424
|
})
|
|
382
425
|
}
|
|
383
426
|
}
|
|
@@ -443,6 +486,10 @@ export default class Socket {
|
|
|
443
486
|
// 指数退避 + 随机抖动
|
|
444
487
|
const jitter = Math.random() * 500 * 2 - 500
|
|
445
488
|
const actualInterval = Math.min(this.reconnectInterval + jitter, this.maxReconnectInterval)
|
|
489
|
+
this._addSocketStateLog('reconnect_scheduled', {
|
|
490
|
+
retryCount: this.reconnectCount,
|
|
491
|
+
delayMs: Math.max(0, Math.round(actualInterval))
|
|
492
|
+
})
|
|
446
493
|
// 先翻倍再等待,避免定时器被取消导致倍数丢失
|
|
447
494
|
this.reconnectInterval = Math.min(this.reconnectInterval * 2, this.maxReconnectInterval)
|
|
448
495
|
clearTimeout(this._reconnectTimer)
|
|
@@ -552,10 +599,18 @@ export default class Socket {
|
|
|
552
599
|
messageId,
|
|
553
600
|
message: nextMessage,
|
|
554
601
|
resolve: (data) => {
|
|
555
|
-
|
|
602
|
+
const resolveData = {
|
|
603
|
+
data: data.data,
|
|
604
|
+
response: data.response
|
|
605
|
+
}
|
|
606
|
+
messagePromise.resolve(resolveData)
|
|
556
607
|
messageObject.destroy()
|
|
557
608
|
callback && callback({
|
|
558
|
-
data
|
|
609
|
+
data: resolveData
|
|
610
|
+
})
|
|
611
|
+
this.event.emit('onSendMessageSuccess', {
|
|
612
|
+
data: data.response,
|
|
613
|
+
message: nextMessage
|
|
559
614
|
})
|
|
560
615
|
},
|
|
561
616
|
reject: (err) => {
|