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>