Commit c505683bbbf18c5b5f06f269b52cc62973b905de

Authored by 房远帅
1 parent 375ec3ee

采购:业务员报价看板-同行报价信息

1 -# 同行报价信息 WebSocket 联调说明  
2 -  
3 -## 1. 功能说明  
4 -  
5 -本功能用于支撑业务员报价实时看板,采用 WebSocket 协议向前端实时推送“当日同行报价信息全量数据”。  
6 -  
7 -注意:  
8 -  
9 -- 本功能为独立新增能力,不影响原有查询接口  
10 -- 服务端仅推送“当日产生”的同行报价信息  
11 -- 新增、修改成功并事务提交后,会向所有在线客户端广播最新当日全量数据  
12 -- 前端断线重连后,可主动发起补推请求  
13 -  
14 -## 2. WebSocket 地址  
15 -  
16 -服务端路径:  
17 -  
18 -```text  
19 -/ws/procurement/salesmanQuotationPeer/realtime  
20 -```  
21 -  
22 -联调示例:  
23 -  
24 -```text  
25 -ws://localhost:8080/ws/procurement/salesmanQuotationPeer/realtime  
26 -```  
27 -  
28 -请将 `localhost:8080` 替换为实际后端服务地址。  
29 -  
30 -  
31 -## 4. 服务端推送规则  
32 -  
33 -### 4.1 建连后初始化推送  
34 -  
35 -客户端连接成功后,服务端会立即回推一份“当日完整数据”:  
36 -  
37 -- `eventType = INIT`  
38 -- `messageType = FULL_SYNC`  
39 -  
40 -### 4.2 新增后广播  
41 -  
42 -当日同行报价基础数据新增成功并事务提交后:  
43 -  
44 -- 向所有在线客户端广播  
45 -- `eventType = CREATE`  
46 -- `messageType = FULL_SYNC`  
47 -  
48 -### 4.3 修改后广播  
49 -  
50 -当日同行报价基础数据修改成功并事务提交后:  
51 -  
52 -- 向所有在线客户端广播  
53 -- `eventType = UPDATE`  
54 -- `messageType = FULL_SYNC`  
55 -  
56 -### 4.4 断线重连补推  
57 -  
58 -客户端重连成功后,可主动发送以下文本消息之一:  
59 -  
60 -- `SYNC_TODAY`  
61 -- `RESYNC`  
62 -  
63 -服务端收到后,会重新回推“当日完整数据”:  
64 -  
65 -- `eventType = RESYNC`  
66 -- `messageType = FULL_SYNC`  
67 -  
68 -## 5. 消息结构  
69 -  
70 -### 5.1 服务端返回消息体  
71 -  
72 -```json  
73 -{  
74 - "messageType": "FULL_SYNC",  
75 - "eventType": "INIT",  
76 - "businessDate": "2026-07-10",  
77 - "pushTime": "2026-07-10T16:30:00",  
78 - "records": [  
79 - {  
80 - "id": "1900000000000000001",  
81 - "peerName": "某同行",  
82 - "peerProductId": "1900000000000000002",  
83 - "peerProductName": "紫铜",  
84 - "peerProductCode": "ZT001",  
85 - "peerPrice": 76800.50,  
86 - "remark": "测试数据",  
87 - "createById": "1001",  
88 - "createBy": "张三",  
89 - "updateById": "1001",  
90 - "updateBy": "张三",  
91 - "createTime": "2026-07-10T16:28:10",  
92 - "updateTime": "2026-07-10T16:29:45"  
93 - }  
94 - ]  
95 -}  
96 -```  
97 -  
98 -### 5.2 字段说明  
99 -  
100 -| 字段 | 类型 | 说明 |  
101 -| --- | --- | --- |  
102 -| `messageType` | `String` | 当前固定为 `FULL_SYNC`,表示推送的是当日全量数据 |  
103 -| `eventType` | `String` | 事件类型,可能为 `INIT`、`CREATE`、`UPDATE`、`RESYNC` |  
104 -| `businessDate` | `LocalDate` | 当前业务日期 |  
105 -| `pushTime` | `LocalDateTime` | 本次推送时间 |  
106 -| `records` | `Array` | 当日同行报价信息列表 |  
107 -  
108 -`records` 中主要字段如下:  
109 -  
110 -| 字段 | 类型 | 说明 |  
111 -| --- | --- | --- |  
112 -| `id` | `String` | 主键 ID |  
113 -| `peerName` | `String` | 同行名称 |  
114 -| `peerProductId` | `String` | 同行品种 ID |  
115 -| `peerProductName` | `String` | 同行品种名称 |  
116 -| `peerProductCode` | `String` | 同行品种代号 |  
117 -| `peerPrice` | `BigDecimal` | 同行报价 |  
118 -| `remark` | `String` | 备注 |  
119 -| `createById` | `String` | 创建人 ID |  
120 -| `createBy` | `String` | 创建人 |  
121 -| `updateById` | `String` | 更新人 ID |  
122 -| `updateBy` | `String` | 更新人 |  
123 -| `createTime` | `LocalDateTime` | 创建时间 |  
124 -| `updateTime` | `LocalDateTime` | 更新时间 |  
125 -  
126 -## 6. 前端接入建议  
127 -  
128 -建议前端采用“收到即全量覆盖”的方式刷新看板数据,不需要自己做增量合并。  
129 -  
130 -推荐接入流程:  
131 -  
132 -1. 页面加载后先确认用户已登录  
133 -2. 建立 WebSocket 连接  
134 -3. 收到首包 `INIT` 后,用 `records` 初始化看板  
135 -4. 收到 `CREATE`、`UPDATE` 后,直接用最新 `records` 覆盖页面数据  
136 -5. 连接关闭后执行自动重连  
137 -6. 重连成功后主动发送 `RESYNC`  
138 -  
139 -## 7. 联调自测建议  
140 -  
141 -### 7.1 单连接测试  
142 -  
143 -1. 使用已登录且有权限的账号打开测试页  
144 -2. 连接 WebSocket  
145 -3. 确认收到 `INIT` 全量数据  
146 -  
147 -### 7.2 多客户端广播测试  
148 -  
149 -1. 同时打开 2 个或以上客户端连接  
150 -2. 调用新增接口新增一条当日同行报价  
151 -3. 检查所有客户端是否都收到 `CREATE`  
152 -4. 调用修改接口修改一条当日同行报价  
153 -5. 检查所有客户端是否都收到 `UPDATE`  
154 -  
155 -### 7.3 断线重连测试  
156 -  
157 -1. 建立连接并收到初始化数据  
158 -2. 手动断开连接或模拟网络波动  
159 -3. 重连成功后发送 `RESYNC`  
160 -4. 检查是否重新收到当日全量数据  
161 -  
162 -### 7.4 回归验证  
163 -  
164 -联调过程中同步回归以下原有能力:  
165 -  
166 -- 同行报价信息原分页查询接口  
167 -- 同行报价信息详情接口  
168 -- 同行报价信息新增接口  
169 -- 同行报价信息修改接口  
170 -  
171 -确认新增 WebSocket 能力未影响原有接口行为。  
172 -  
173 -## 8. 本地测试页  
174 -  
175 -仓库中已提供本地测试页:  
176 -  
177 -```text  
178 -docs/salesman-quotation-peer-websocket-test.html  
179 -```  
180 -  
181 -可直接使用浏览器打开。  
182 -  
183 -注意:  
184 -  
185 -- 建议使用已登录系统的同一浏览器用户环境测试  
186 -- 如果浏览器环境没有把登录态带到 WebSocket 握手中,连接会返回 `403`  
187 -- 如出现该情况,请优先在已登录业务系统页面同浏览器环境中测试  
1 -<!DOCTYPE html>  
2 -<html lang="zh-CN">  
3 -<head>  
4 - <meta charset="UTF-8">  
5 - <meta name="viewport" content="width=device-width, initial-scale=1.0">  
6 - <title>同行报价信息 WebSocket 测试页</title>  
7 - <style>  
8 - body {  
9 - margin: 0;  
10 - font-family: Arial, sans-serif;  
11 - background: #f5f7fa;  
12 - color: #1f2329;  
13 - }  
14 -  
15 - .page {  
16 - max-width: 1200px;  
17 - margin: 0 auto;  
18 - padding: 24px;  
19 - }  
20 -  
21 - .card {  
22 - background: #fff;  
23 - border-radius: 8px;  
24 - box-shadow: 0 2px 12px rgba(31, 35, 41, 0.08);  
25 - padding: 16px;  
26 - margin-bottom: 16px;  
27 - }  
28 -  
29 - .title {  
30 - margin: 0 0 12px;  
31 - font-size: 20px;  
32 - font-weight: 700;  
33 - }  
34 -  
35 - .desc {  
36 - margin: 0;  
37 - line-height: 1.7;  
38 - color: #4e5969;  
39 - }  
40 -  
41 - .row {  
42 - display: flex;  
43 - gap: 12px;  
44 - flex-wrap: wrap;  
45 - margin-bottom: 12px;  
46 - }  
47 -  
48 - .field {  
49 - flex: 1;  
50 - min-width: 280px;  
51 - }  
52 -  
53 - label {  
54 - display: block;  
55 - margin-bottom: 6px;  
56 - font-size: 14px;  
57 - font-weight: 600;  
58 - }  
59 -  
60 - input,  
61 - textarea,  
62 - button {  
63 - box-sizing: border-box;  
64 - font-size: 14px;  
65 - font-family: Arial, sans-serif;  
66 - }  
67 -  
68 - input,  
69 - textarea {  
70 - width: 100%;  
71 - padding: 10px 12px;  
72 - border: 1px solid #d0d7de;  
73 - border-radius: 6px;  
74 - background: #fff;  
75 - }  
76 -  
77 - textarea {  
78 - min-height: 120px;  
79 - resize: vertical;  
80 - }  
81 -  
82 - .actions {  
83 - display: flex;  
84 - gap: 12px;  
85 - flex-wrap: wrap;  
86 - }  
87 -  
88 - button {  
89 - border: none;  
90 - border-radius: 6px;  
91 - padding: 10px 16px;  
92 - cursor: pointer;  
93 - color: #fff;  
94 - background: #1677ff;  
95 - }  
96 -  
97 - button.secondary {  
98 - background: #00b578;  
99 - }  
100 -  
101 - button.warn {  
102 - background: #ff7d00;  
103 - }  
104 -  
105 - button.danger {  
106 - background: #f53f3f;  
107 - }  
108 -  
109 - button.gray {  
110 - background: #86909c;  
111 - }  
112 -  
113 - .status {  
114 - font-weight: 700;  
115 - }  
116 -  
117 - .status.connected {  
118 - color: #00b578;  
119 - }  
120 -  
121 - .status.closed {  
122 - color: #f53f3f;  
123 - }  
124 -  
125 - .muted {  
126 - color: #86909c;  
127 - font-size: 13px;  
128 - }  
129 -  
130 - pre {  
131 - margin: 0;  
132 - padding: 12px;  
133 - background: #111827;  
134 - color: #e5e7eb;  
135 - border-radius: 6px;  
136 - overflow: auto;  
137 - white-space: pre-wrap;  
138 - word-break: break-word;  
139 - line-height: 1.6;  
140 - }  
141 -  
142 - .log-box {  
143 - max-height: 360px;  
144 - overflow: auto;  
145 - background: #111827;  
146 - color: #e5e7eb;  
147 - border-radius: 6px;  
148 - padding: 12px;  
149 - font-size: 13px;  
150 - line-height: 1.6;  
151 - }  
152 -  
153 - .log-line {  
154 - margin-bottom: 6px;  
155 - }  
156 -  
157 - table {  
158 - width: 100%;  
159 - border-collapse: collapse;  
160 - background: #fff;  
161 - }  
162 -  
163 - th,  
164 - td {  
165 - border-bottom: 1px solid #edf1f5;  
166 - padding: 10px 8px;  
167 - text-align: left;  
168 - font-size: 13px;  
169 - vertical-align: top;  
170 - }  
171 -  
172 - th {  
173 - background: #f7f8fa;  
174 - }  
175 - </style>  
176 -</head>  
177 -<body>  
178 - <div class="page">  
179 - <div class="card">  
180 - <h1 class="title">同行报价信息 WebSocket 测试页</h1>  
181 - <p class="desc">  
182 - 使用说明:请先在同一浏览器中登录业务系统,再打开本页测试。当前服务端握手鉴权依赖现有登录态,  
183 - 如果浏览器没有把登录态带到 WebSocket 握手,请求会返回 403。  
184 - </p>  
185 - </div>  
186 -  
187 - <div class="card">  
188 - <div class="row">  
189 - <div class="field">  
190 - <label for="wsUrl">WebSocket 地址</label>  
191 - <input id="wsUrl" value="ws://localhost:8081/ws/procurement/salesmanQuotationPeer/realtime">  
192 - </div>  
193 - <div class="field">  
194 - <label for="statusText">连接状态</label>  
195 - <input id="statusText" value="未连接" readonly>  
196 - </div>  
197 - </div>  
198 -  
199 - <div class="actions">  
200 - <button id="connectBtn">连接</button>  
201 - <button id="disconnectBtn" class="danger">断开</button>  
202 - <button id="syncBtn" class="secondary">发送 SYNC_TODAY</button>  
203 - <button id="resyncBtn" class="warn">发送 RESYNC</button>  
204 - <button id="clearBtn" class="gray">清空日志</button>  
205 - </div>  
206 -  
207 - <p class="muted" style="margin-top: 12px;">  
208 - 建连成功后,服务端会自动推送一份 <code>INIT</code> 全量数据;重连后可手动发送 <code>RESYNC</code>  
209 - </p>  
210 - </div>  
211 -  
212 - <div class="card">  
213 - <h2 class="title">自定义消息</h2>  
214 - <div class="row">  
215 - <div class="field">  
216 - <label for="customMessage">发送内容</label>  
217 - <textarea id="customMessage">RESYNC</textarea>  
218 - </div>  
219 - </div>  
220 - <div class="actions">  
221 - <button id="sendCustomBtn">发送自定义消息</button>  
222 - </div>  
223 - </div>  
224 -  
225 - <div class="card">  
226 - <h2 class="title">最新消息摘要</h2>  
227 - <div class="row">  
228 - <div class="field">  
229 - <label for="messageType">messageType</label>  
230 - <input id="messageType" readonly>  
231 - </div>  
232 - <div class="field">  
233 - <label for="eventType">eventType</label>  
234 - <input id="eventType" readonly>  
235 - </div>  
236 - <div class="field">  
237 - <label for="businessDate">businessDate</label>  
238 - <input id="businessDate" readonly>  
239 - </div>  
240 - <div class="field">  
241 - <label for="pushTime">pushTime</label>  
242 - <input id="pushTime" readonly>  
243 - </div>  
244 - <div class="field">  
245 - <label for="recordCount">records 数量</label>  
246 - <input id="recordCount" readonly>  
247 - </div>  
248 - </div>  
249 - </div>  
250 -  
251 - <div class="card">  
252 - <h2 class="title">最新原始消息</h2>  
253 - <pre id="rawMessage">暂无数据</pre>  
254 - </div>  
255 -  
256 - <div class="card">  
257 - <h2 class="title">数据预览</h2>  
258 - <table>  
259 - <thead>  
260 - <tr>  
261 - <th>ID</th>  
262 - <th>同行名称</th>  
263 - <th>品种名称</th>  
264 - <th>品种代号</th>  
265 - <th>同行报价</th>  
266 - <th>备注</th>  
267 - <th>创建时间</th>  
268 - <th>更新时间</th>  
269 - </tr>  
270 - </thead>  
271 - <tbody id="recordsBody">  
272 - <tr>  
273 - <td colspan="8">暂无数据</td>  
274 - </tr>  
275 - </tbody>  
276 - </table>  
277 - </div>  
278 -  
279 - <div class="card">  
280 - <h2 class="title">运行日志</h2>  
281 - <div id="logBox" class="log-box"></div>  
282 - </div>  
283 - </div>  
284 -  
285 - <script>  
286 - let socket = null;  
287 -  
288 - const wsUrlInput = document.getElementById("wsUrl");  
289 - const statusText = document.getElementById("statusText");  
290 - const customMessage = document.getElementById("customMessage");  
291 - const rawMessage = document.getElementById("rawMessage");  
292 - const logBox = document.getElementById("logBox");  
293 - const recordsBody = document.getElementById("recordsBody");  
294 - const messageTypeInput = document.getElementById("messageType");  
295 - const eventTypeInput = document.getElementById("eventType");  
296 - const businessDateInput = document.getElementById("businessDate");  
297 - const pushTimeInput = document.getElementById("pushTime");  
298 - const recordCountInput = document.getElementById("recordCount");  
299 -  
300 - function nowText() {  
301 - return new Date().toLocaleString();  
302 - }  
303 -  
304 - function addLog(message) {  
305 - const line = document.createElement("div");  
306 - line.className = "log-line";  
307 - line.textContent = "[" + nowText() + "] " + message;  
308 - logBox.prepend(line);  
309 - }  
310 -  
311 - function setStatus(text, connected) {  
312 - statusText.value = text;  
313 - statusText.className = connected ? "status connected" : "status closed";  
314 - }  
315 -  
316 - function renderSummary(data) {  
317 - messageTypeInput.value = data.messageType || "";  
318 - eventTypeInput.value = data.eventType || "";  
319 - businessDateInput.value = data.businessDate || "";  
320 - pushTimeInput.value = data.pushTime || "";  
321 - recordCountInput.value = Array.isArray(data.records) ? String(data.records.length) : "0";  
322 - }  
323 -  
324 - function renderTable(records) {  
325 - if (!Array.isArray(records) || records.length === 0) {  
326 - recordsBody.innerHTML = "<tr><td colspan=\"8\">暂无数据</td></tr>";  
327 - return;  
328 - }  
329 -  
330 - const rows = records.map(function(record) {  
331 - return "<tr>"  
332 - + "<td>" + escapeHtml(record.id) + "</td>"  
333 - + "<td>" + escapeHtml(record.peerName) + "</td>"  
334 - + "<td>" + escapeHtml(record.peerProductName) + "</td>"  
335 - + "<td>" + escapeHtml(record.peerProductCode) + "</td>"  
336 - + "<td>" + escapeHtml(record.peerPrice) + "</td>"  
337 - + "<td>" + escapeHtml(record.remark) + "</td>"  
338 - + "<td>" + escapeHtml(record.createTime) + "</td>"  
339 - + "<td>" + escapeHtml(record.updateTime) + "</td>"  
340 - + "</tr>";  
341 - });  
342 -  
343 - recordsBody.innerHTML = rows.join("");  
344 - }  
345 -  
346 - function escapeHtml(value) {  
347 - if (value === null || value === undefined) {  
348 - return "";  
349 - }  
350 -  
351 - return String(value)  
352 - .replace(/&/g, "&amp;")  
353 - .replace(/</g, "&lt;")  
354 - .replace(/>/g, "&gt;")  
355 - .replace(/\"/g, "&quot;")  
356 - .replace(/'/g, "&#39;");  
357 - }  
358 -  
359 - function connect() {  
360 - const url = wsUrlInput.value.trim();  
361 - if (!url) {  
362 - addLog("连接失败:WebSocket 地址不能为空");  
363 - return;  
364 - }  
365 -  
366 - if (socket && socket.readyState === WebSocket.OPEN) {  
367 - addLog("当前已连接,无需重复连接");  
368 - return;  
369 - }  
370 -  
371 - addLog("开始连接:" + url);  
372 - socket = new WebSocket(url);  
373 -  
374 - socket.onopen = function() {  
375 - setStatus("已连接", true);  
376 - addLog("连接成功");  
377 - };  
378 -  
379 - socket.onmessage = function(event) {  
380 - addLog("收到消息:" + event.data);  
381 - rawMessage.textContent = event.data;  
382 -  
383 - try {  
384 - const data = JSON.parse(event.data);  
385 - renderSummary(data);  
386 - renderTable(data.records);  
387 - } catch (error) {  
388 - addLog("消息解析失败:" + error.message);  
389 - }  
390 - };  
391 -  
392 - socket.onerror = function() {  
393 - addLog("连接出现错误,请检查登录态、服务地址和网络");  
394 - };  
395 -  
396 - socket.onclose = function(event) {  
397 - setStatus("已断开", false);  
398 - addLog("连接关闭,code=" + event.code + ",reason=" + (event.reason || "无"));  
399 - };  
400 - }  
401 -  
402 - function disconnect() {  
403 - if (!socket) {  
404 - addLog("当前没有可断开的连接");  
405 - return;  
406 - }  
407 - socket.close();  
408 - }  
409 -  
410 - function sendMessage(message) {  
411 - if (!socket || socket.readyState !== WebSocket.OPEN) {  
412 - addLog("发送失败:当前连接未建立");  
413 - return;  
414 - }  
415 -  
416 - socket.send(message);  
417 - addLog("已发送消息:" + message);  
418 - }  
419 -  
420 - document.getElementById("connectBtn").addEventListener("click", connect);  
421 - document.getElementById("disconnectBtn").addEventListener("click", disconnect);  
422 - document.getElementById("syncBtn").addEventListener("click", function() {  
423 - sendMessage("SYNC_TODAY");  
424 - });  
425 - document.getElementById("resyncBtn").addEventListener("click", function() {  
426 - sendMessage("RESYNC");  
427 - });  
428 - document.getElementById("sendCustomBtn").addEventListener("click", function() {  
429 - sendMessage(customMessage.value.trim());  
430 - });  
431 - document.getElementById("clearBtn").addEventListener("click", function() {  
432 - logBox.innerHTML = "";  
433 - rawMessage.textContent = "暂无数据";  
434 - renderSummary({  
435 - messageType: "",  
436 - eventType: "",  
437 - businessDate: "",  
438 - pushTime: "",  
439 - records: []  
440 - });  
441 - renderTable([]);  
442 - addLog("日志已清空");  
443 - });  
444 -  
445 - setStatus("未连接", false);  
446 - addLog("页面已加载,可开始测试");  
447 - </script>  
448 -</body>  
449 -</html>