Commit b135926ce69510fecfb5d1195dbbaf3c35413a8c

Authored by 房远帅
1 parent 72fe299c

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

Showing 15 changed files with 1089 additions and 0 deletions
  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>
@@ -54,5 +54,10 @@ @@ -54,5 +54,10 @@
54 <version>1.18.30</version> 54 <version>1.18.30</version>
55 <scope>provided</scope> 55 <scope>provided</scope>
56 </dependency> 56 </dependency>
  57 +
  58 + <dependency>
  59 + <groupId>org.springframework.boot</groupId>
  60 + <artifactId>spring-boot-starter-websocket</artifactId>
  61 + </dependency>
57 </dependencies> 62 </dependencies>
58 </project> 63 </project>
  1 +package com.lframework.xingyun.sc.events.quotation;
  2 +
  3 +import lombok.Getter;
  4 +import org.springframework.context.ApplicationEvent;
  5 +
  6 +/**
  7 + * 同行报价信息变更事件
  8 + */
  9 +@Getter
  10 +public class SalesmanQuotationPeerChangedEvent extends ApplicationEvent {
  11 +
  12 + private static final long serialVersionUID = 1L;
  13 +
  14 + /**
  15 + * 同行报价信息ID
  16 + */
  17 + private final String id;
  18 +
  19 + /**
  20 + * 事件类型
  21 + */
  22 + private final String eventType;
  23 +
  24 + public SalesmanQuotationPeerChangedEvent(Object source, String id, String eventType) {
  25 + super(source);
  26 + this.id = id;
  27 + this.eventType = eventType;
  28 + }
  29 +}
  1 +package com.lframework.xingyun.sc.listeners.app;
  2 +
  3 +import com.lframework.xingyun.sc.events.quotation.SalesmanQuotationPeerChangedEvent;
  4 +import com.lframework.xingyun.sc.procurement.websocket.quotation.SalesmanQuotationPeerRealtimePushService;
  5 +import javax.annotation.Resource;
  6 +import org.springframework.stereotype.Component;
  7 +import org.springframework.transaction.event.TransactionPhase;
  8 +import org.springframework.transaction.event.TransactionalEventListener;
  9 +
  10 +/**
  11 + * 同行报价信息实时推送监听器
  12 + */
  13 +@Component
  14 +public class SalesmanQuotationPeerRealtimeListener {
  15 +
  16 + @Resource
  17 + private SalesmanQuotationPeerRealtimePushService realtimePushService;
  18 +
  19 + /**
  20 + * 同行报价信息变更后广播当日完整数据
  21 + *
  22 + * @param event 变更事件
  23 + */
  24 + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
  25 + public void handleChanged(SalesmanQuotationPeerChangedEvent event) {
  26 + realtimePushService.broadcastCurrentDayFullData(event.getEventType());
  27 + }
  28 +}
  1 +package com.lframework.xingyun.sc.procurement.dto.quotation;
  2 +
  3 +import com.lframework.xingyun.sc.procurement.entity.SalesmanQuotationPeer;
  4 +import java.io.Serializable;
  5 +import java.time.LocalDate;
  6 +import java.time.LocalDateTime;
  7 +import java.util.List;
  8 +import lombok.Data;
  9 +
  10 +/**
  11 + * 同行报价信息实时推送消息
  12 + */
  13 +@Data
  14 +public class PeerQuotationRealtimeMessage implements Serializable {
  15 +
  16 + private static final long serialVersionUID = 1L;
  17 +
  18 + /**
  19 + * 消息类型
  20 + */
  21 + private String messageType;
  22 +
  23 + /**
  24 + * 事件类型
  25 + */
  26 + private String eventType;
  27 +
  28 + /**
  29 + * 业务日期
  30 + */
  31 + private LocalDate businessDate;
  32 +
  33 + /**
  34 + * 推送时间
  35 + */
  36 + private LocalDateTime pushTime;
  37 +
  38 + /**
  39 + * 数据列表
  40 + */
  41 + private List<SalesmanQuotationPeer> records;
  42 +}
@@ -16,6 +16,7 @@ import com.lframework.starter.web.core.utils.PageResultUtil; @@ -16,6 +16,7 @@ import com.lframework.starter.web.core.utils.PageResultUtil;
16 import com.lframework.starter.web.inner.components.oplog.OtherOpLogType; 16 import com.lframework.starter.web.inner.components.oplog.OtherOpLogType;
17 import com.lframework.xingyun.basedata.entity.BreedRelationship; 17 import com.lframework.xingyun.basedata.entity.BreedRelationship;
18 import com.lframework.xingyun.basedata.service.breed.BreedRelationshipService; 18 import com.lframework.xingyun.basedata.service.breed.BreedRelationshipService;
  19 +import com.lframework.xingyun.sc.events.quotation.SalesmanQuotationPeerChangedEvent;
19 import com.lframework.xingyun.sc.procurement.entity.SalesmanQuotationPeer; 20 import com.lframework.xingyun.sc.procurement.entity.SalesmanQuotationPeer;
20 import com.lframework.xingyun.sc.procurement.mappers.quotation.SalesmanQuotationPeerMapper; 21 import com.lframework.xingyun.sc.procurement.mappers.quotation.SalesmanQuotationPeerMapper;
21 import com.lframework.xingyun.sc.procurement.service.quotation.SalesmanQuotationPeerService; 22 import com.lframework.xingyun.sc.procurement.service.quotation.SalesmanQuotationPeerService;
@@ -23,9 +24,11 @@ import com.lframework.xingyun.sc.procurement.vo.quotation.CreateSalesmanQuotatio @@ -23,9 +24,11 @@ import com.lframework.xingyun.sc.procurement.vo.quotation.CreateSalesmanQuotatio
23 import com.lframework.xingyun.sc.procurement.vo.quotation.QuerySalesmanQuotationPeerVo; 24 import com.lframework.xingyun.sc.procurement.vo.quotation.QuerySalesmanQuotationPeerVo;
24 import com.lframework.xingyun.sc.procurement.vo.quotation.UpdateSalesmanQuotationPeerVo; 25 import com.lframework.xingyun.sc.procurement.vo.quotation.UpdateSalesmanQuotationPeerVo;
25 import java.io.Serializable; 26 import java.io.Serializable;
  27 +import java.time.LocalDate;
26 import java.util.List; 28 import java.util.List;
27 import javax.annotation.Resource; 29 import javax.annotation.Resource;
28 import org.apache.commons.lang3.StringUtils; 30 import org.apache.commons.lang3.StringUtils;
  31 +import org.springframework.context.ApplicationEventPublisher;
29 import org.springframework.stereotype.Service; 32 import org.springframework.stereotype.Service;
30 import org.springframework.transaction.annotation.Transactional; 33 import org.springframework.transaction.annotation.Transactional;
31 34
@@ -39,6 +42,8 @@ public class SalesmanQuotationPeerServiceImpl @@ -39,6 +42,8 @@ public class SalesmanQuotationPeerServiceImpl
39 42
40 @Resource 43 @Resource
41 private BreedRelationshipService breedRelationshipService; 44 private BreedRelationshipService breedRelationshipService;
  45 + @Resource
  46 + private ApplicationEventPublisher applicationEventPublisher;
42 47
43 /** 48 /**
44 * 分页查询 49 * 分页查询
@@ -70,6 +75,17 @@ public class SalesmanQuotationPeerServiceImpl @@ -70,6 +75,17 @@ public class SalesmanQuotationPeerServiceImpl
70 } 75 }
71 76
72 /** 77 /**
  78 + * 查询指定日期创建的同行报价信息
  79 + *
  80 + * @param day 日期
  81 + * @return 同行报价信息列表
  82 + */
  83 + @Override
  84 + public List<SalesmanQuotationPeer> queryCurrentDayList(LocalDate day) {
  85 + return getBaseMapper().queryCurrentDayList(day);
  86 + }
  87 +
  88 + /**
73 * 根据ID查询 89 * 根据ID查询
74 * 90 *
75 * @param id 主键ID 91 * @param id 主键ID
@@ -103,6 +119,7 @@ public class SalesmanQuotationPeerServiceImpl @@ -103,6 +119,7 @@ public class SalesmanQuotationPeerServiceImpl
103 data.setRemark(StringUtils.defaultIfBlank(vo.getRemark(), null)); 119 data.setRemark(StringUtils.defaultIfBlank(vo.getRemark(), null));
104 120
105 getBaseMapper().insert(data); 121 getBaseMapper().insert(data);
  122 + publishChangedEvent(data.getId(), "CREATE");
106 123
107 OpLogUtil.setVariable("id", data.getId()); 124 OpLogUtil.setVariable("id", data.getId());
108 OpLogUtil.setExtra(vo); 125 OpLogUtil.setExtra(vo);
@@ -132,6 +149,7 @@ public class SalesmanQuotationPeerServiceImpl @@ -132,6 +149,7 @@ public class SalesmanQuotationPeerServiceImpl
132 .eq(SalesmanQuotationPeer::getId, vo.getId()); 149 .eq(SalesmanQuotationPeer::getId, vo.getId());
133 150
134 getBaseMapper().update(updateWrapper); 151 getBaseMapper().update(updateWrapper);
  152 + publishChangedEvent(data.getId(), "UPDATE");
135 153
136 OpLogUtil.setVariable("id", data.getId()); 154 OpLogUtil.setVariable("id", data.getId());
137 OpLogUtil.setExtra(vo); 155 OpLogUtil.setExtra(vo);
@@ -157,4 +175,14 @@ public class SalesmanQuotationPeerServiceImpl @@ -157,4 +175,14 @@ public class SalesmanQuotationPeerServiceImpl
157 throw new DefaultClientException("同行品种不存在,请重新选择!"); 175 throw new DefaultClientException("同行品种不存在,请重新选择!");
158 } 176 }
159 } 177 }
  178 +
  179 + /**
  180 + * 发布同行报价信息变更事件
  181 + *
  182 + * @param id 主键ID
  183 + * @param eventType 事件类型
  184 + */
  185 + private void publishChangedEvent(String id, String eventType) {
  186 + applicationEventPublisher.publishEvent(new SalesmanQuotationPeerChangedEvent(this, id, eventType));
  187 + }
160 } 188 }
@@ -3,6 +3,7 @@ package com.lframework.xingyun.sc.procurement.mappers.quotation; @@ -3,6 +3,7 @@ package com.lframework.xingyun.sc.procurement.mappers.quotation;
3 import com.lframework.starter.web.core.mapper.BaseMapper; 3 import com.lframework.starter.web.core.mapper.BaseMapper;
4 import com.lframework.xingyun.sc.procurement.entity.SalesmanQuotationPeer; 4 import com.lframework.xingyun.sc.procurement.entity.SalesmanQuotationPeer;
5 import com.lframework.xingyun.sc.procurement.vo.quotation.QuerySalesmanQuotationPeerVo; 5 import com.lframework.xingyun.sc.procurement.vo.quotation.QuerySalesmanQuotationPeerVo;
  6 +import java.time.LocalDate;
6 import java.util.List; 7 import java.util.List;
7 import org.apache.ibatis.annotations.Param; 8 import org.apache.ibatis.annotations.Param;
8 9
@@ -18,4 +19,12 @@ public interface SalesmanQuotationPeerMapper extends BaseMapper<SalesmanQuotatio @@ -18,4 +19,12 @@ public interface SalesmanQuotationPeerMapper extends BaseMapper<SalesmanQuotatio
18 * @return 同行信息列表 19 * @return 同行信息列表
19 */ 20 */
20 List<SalesmanQuotationPeer> query(@Param("vo") QuerySalesmanQuotationPeerVo vo); 21 List<SalesmanQuotationPeer> query(@Param("vo") QuerySalesmanQuotationPeerVo vo);
  22 +
  23 + /**
  24 + * 查询指定日期创建的同行报价信息
  25 + *
  26 + * @param day 日期
  27 + * @return 同行报价信息列表
  28 + */
  29 + List<SalesmanQuotationPeer> queryCurrentDayList(@Param("day") LocalDate day);
21 } 30 }
@@ -6,6 +6,7 @@ import com.lframework.xingyun.sc.procurement.entity.SalesmanQuotationPeer; @@ -6,6 +6,7 @@ import com.lframework.xingyun.sc.procurement.entity.SalesmanQuotationPeer;
6 import com.lframework.xingyun.sc.procurement.vo.quotation.CreateSalesmanQuotationPeerVo; 6 import com.lframework.xingyun.sc.procurement.vo.quotation.CreateSalesmanQuotationPeerVo;
7 import com.lframework.xingyun.sc.procurement.vo.quotation.QuerySalesmanQuotationPeerVo; 7 import com.lframework.xingyun.sc.procurement.vo.quotation.QuerySalesmanQuotationPeerVo;
8 import com.lframework.xingyun.sc.procurement.vo.quotation.UpdateSalesmanQuotationPeerVo; 8 import com.lframework.xingyun.sc.procurement.vo.quotation.UpdateSalesmanQuotationPeerVo;
  9 +import java.time.LocalDate;
9 import java.util.List; 10 import java.util.List;
10 11
11 /** 12 /**
@@ -32,6 +33,14 @@ public interface SalesmanQuotationPeerService extends BaseMpService<SalesmanQuot @@ -32,6 +33,14 @@ public interface SalesmanQuotationPeerService extends BaseMpService<SalesmanQuot
32 List<SalesmanQuotationPeer> query(QuerySalesmanQuotationPeerVo vo); 33 List<SalesmanQuotationPeer> query(QuerySalesmanQuotationPeerVo vo);
33 34
34 /** 35 /**
  36 + * 查询指定日期创建的同行报价信息
  37 + *
  38 + * @param day 日期
  39 + * @return 同行报价信息列表
  40 + */
  41 + List<SalesmanQuotationPeer> queryCurrentDayList(LocalDate day);
  42 +
  43 + /**
35 * 根据ID查询 44 * 根据ID查询
36 * 45 *
37 * @param id 主键ID 46 * @param id 主键ID
  1 +package com.lframework.xingyun.sc.procurement.websocket.quotation;
  2 +
  3 +import com.lframework.starter.web.core.utils.JsonUtil;
  4 +import com.lframework.xingyun.sc.procurement.dto.quotation.PeerQuotationRealtimeMessage;
  5 +import com.lframework.xingyun.sc.procurement.entity.SalesmanQuotationPeer;
  6 +import com.lframework.xingyun.sc.procurement.service.quotation.SalesmanQuotationPeerService;
  7 +import java.io.IOException;
  8 +import java.time.LocalDate;
  9 +import java.time.LocalDateTime;
  10 +import java.util.List;
  11 +import java.util.Map;
  12 +import java.util.concurrent.ConcurrentHashMap;
  13 +import javax.annotation.Resource;
  14 +import lombok.extern.slf4j.Slf4j;
  15 +import org.springframework.stereotype.Service;
  16 +import org.springframework.web.socket.TextMessage;
  17 +import org.springframework.web.socket.WebSocketSession;
  18 +
  19 +/**
  20 + * 同行报价信息实时推送服务
  21 + */
  22 +@Slf4j
  23 +@Service
  24 +public class SalesmanQuotationPeerRealtimePushService {
  25 +
  26 + /**
  27 + * 当前在线连接
  28 + */
  29 + private final Map<String, WebSocketSession> sessionMap = new ConcurrentHashMap<>();
  30 +
  31 + @Resource
  32 + private SalesmanQuotationPeerService salesmanQuotationPeerService;
  33 +
  34 + /**
  35 + * 注册连接
  36 + *
  37 + * @param session WebSocket 会话
  38 + */
  39 + public void register(WebSocketSession session) {
  40 + sessionMap.put(session.getId(), session);
  41 + }
  42 +
  43 + /**
  44 + * 注销连接
  45 + *
  46 + * @param session WebSocket 会话
  47 + */
  48 + public void unregister(WebSocketSession session) {
  49 + if (session != null) {
  50 + sessionMap.remove(session.getId());
  51 + }
  52 + }
  53 +
  54 + /**
  55 + * 广播当日完整同行报价数据
  56 + *
  57 + * @param eventType 事件类型
  58 + */
  59 + public void broadcastCurrentDayFullData(String eventType) {
  60 + if (sessionMap.isEmpty()) {
  61 + return;
  62 + }
  63 +
  64 + PeerQuotationRealtimeMessage message = buildCurrentDayMessage(eventType);
  65 + String payload = JsonUtil.toJsonString(message);
  66 + for (WebSocketSession session : sessionMap.values()) {
  67 + sendMessage(session, payload);
  68 + }
  69 + }
  70 +
  71 + /**
  72 + * 向单个连接补推当日完整数据
  73 + *
  74 + * @param session WebSocket 会话
  75 + * @param eventType 事件类型
  76 + */
  77 + public void replayCurrentDayFullData(WebSocketSession session, String eventType) {
  78 + if (session == null) {
  79 + return;
  80 + }
  81 + sendMessage(session, JsonUtil.toJsonString(buildCurrentDayMessage(eventType)));
  82 + }
  83 +
  84 + /**
  85 + * 构建当日完整数据消息
  86 + *
  87 + * @param eventType 事件类型
  88 + * @return 推送消息
  89 + */
  90 + private PeerQuotationRealtimeMessage buildCurrentDayMessage(String eventType) {
  91 + LocalDate today = LocalDate.now();
  92 + List<SalesmanQuotationPeer> records = salesmanQuotationPeerService.queryCurrentDayList(today);
  93 +
  94 + PeerQuotationRealtimeMessage message = new PeerQuotationRealtimeMessage();
  95 + message.setMessageType("FULL_SYNC");
  96 + message.setEventType(eventType);
  97 + message.setBusinessDate(today);
  98 + message.setPushTime(LocalDateTime.now());
  99 + message.setRecords(records);
  100 + return message;
  101 + }
  102 +
  103 + /**
  104 + * 发送消息
  105 + *
  106 + * @param session WebSocket 会话
  107 + * @param payload 消息体
  108 + */
  109 + private void sendMessage(WebSocketSession session, String payload) {
  110 + if (session == null || !session.isOpen()) {
  111 + unregister(session);
  112 + return;
  113 + }
  114 +
  115 + try {
  116 + synchronized (session) {
  117 + session.sendMessage(new TextMessage(payload));
  118 + }
  119 + } catch (IOException e) {
  120 + log.warn("同行报价信息实时推送失败,sessionId={}", session.getId(), e);
  121 + unregister(session);
  122 + }
  123 + }
  124 +}
  1 +package com.lframework.xingyun.sc.procurement.websocket.quotation;
  2 +
  3 +import com.lframework.starter.common.exceptions.impl.DefaultClientException;
  4 +import com.lframework.starter.web.core.components.security.SecurityUtil;
  5 +import com.lframework.starter.web.inner.entity.SysUser;
  6 +import com.lframework.starter.web.inner.service.system.SysUserService;
  7 +import javax.annotation.Resource;
  8 +import org.springframework.stereotype.Service;
  9 +
  10 +/**
  11 + * 同行报价信息 WebSocket 鉴权服务
  12 + */
  13 +@Service
  14 +public class SalesmanQuotationPeerWebSocketAuthService {
  15 +
  16 + @Resource
  17 + private SysUserService sysUserService;
  18 +
  19 + /**
  20 + * 校验当前连接用户
  21 + *
  22 + * @return 当前用户
  23 + */
  24 + public SysUser authenticateCurrentUser() {
  25 + String userId;
  26 + try {
  27 + userId = SecurityUtil.getCurrentUser().getId();
  28 + } catch (Exception e) {
  29 + throw new DefaultClientException("未获取到登录信息,不允许建立实时连接!");
  30 + }
  31 +
  32 + SysUser user = sysUserService.findById(userId);
  33 + if (user == null) {
  34 + throw new DefaultClientException("当前用户不存在,不允许建立实时连接!");
  35 + }
  36 +
  37 + return user;
  38 + }
  39 +}
  1 +package com.lframework.xingyun.sc.procurement.websocket.quotation;
  2 +
  3 +import javax.annotation.Resource;
  4 +import org.springframework.context.annotation.Configuration;
  5 +import org.springframework.web.socket.config.annotation.EnableWebSocket;
  6 +import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
  7 +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
  8 +
  9 +/**
  10 + * 同行报价信息 WebSocket 配置
  11 + */
  12 +@Configuration
  13 +@EnableWebSocket
  14 +public class SalesmanQuotationPeerWebSocketConfig implements WebSocketConfigurer {
  15 +
  16 + // 添加这段代码
  17 + static {
  18 + System.out.println("========== WebSocket配置类正在加载 ==========");
  19 + }
  20 +
  21 + /**
  22 + * WebSocket 路径
  23 + */
  24 + public static final String ENDPOINT = "/ws/procurement/salesmanQuotationPeer/realtime";
  25 +
  26 + @Resource
  27 + private SalesmanQuotationPeerWebSocketHandler webSocketHandler;
  28 + @Resource
  29 + private SalesmanQuotationPeerWebSocketHandshakeInterceptor handshakeInterceptor;
  30 +
  31 + @Override
  32 + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
  33 + System.out.println("========== 正在注册 WebSocket 路径 ==========");
  34 + registry.addHandler(webSocketHandler, ENDPOINT)
  35 + .addInterceptors(handshakeInterceptor)
  36 + .setAllowedOrigins("*");
  37 + }
  38 +}
  1 +package com.lframework.xingyun.sc.procurement.websocket.quotation;
  2 +
  3 +import javax.annotation.Resource;
  4 +import lombok.extern.slf4j.Slf4j;
  5 +import org.apache.commons.lang3.StringUtils;
  6 +import org.springframework.stereotype.Component;
  7 +import org.springframework.web.socket.CloseStatus;
  8 +import org.springframework.web.socket.TextMessage;
  9 +import org.springframework.web.socket.WebSocketSession;
  10 +import org.springframework.web.socket.handler.TextWebSocketHandler;
  11 +
  12 +/**
  13 + * 同行报价信息 WebSocket 处理器
  14 + */
  15 +@Slf4j
  16 +@Component
  17 +public class SalesmanQuotationPeerWebSocketHandler extends TextWebSocketHandler {
  18 +
  19 + @Resource
  20 + private SalesmanQuotationPeerRealtimePushService realtimePushService;
  21 +
  22 + @Override
  23 + public void afterConnectionEstablished(WebSocketSession session) {
  24 + realtimePushService.register(session);
  25 + realtimePushService.replayCurrentDayFullData(session, "INIT");
  26 + }
  27 +
  28 + @Override
  29 + protected void handleTextMessage(WebSocketSession session, TextMessage message) {
  30 + String payload = StringUtils.trimToEmpty(message.getPayload());
  31 + if ("SYNC_TODAY".equalsIgnoreCase(payload) || "RESYNC".equalsIgnoreCase(payload)) {
  32 + realtimePushService.replayCurrentDayFullData(session, "RESYNC");
  33 + }
  34 + }
  35 +
  36 + @Override
  37 + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
  38 + realtimePushService.unregister(session);
  39 + }
  40 +
  41 + @Override
  42 + public void handleTransportError(WebSocketSession session, Throwable exception) {
  43 + log.warn("同行报价信息 WebSocket 连接异常,sessionId={}", session.getId(), exception);
  44 + realtimePushService.unregister(session);
  45 + }
  46 +}
  1 +package com.lframework.xingyun.sc.procurement.websocket.quotation;
  2 +
  3 +import com.lframework.starter.web.inner.entity.SysUser;
  4 +import java.util.Map;
  5 +import javax.annotation.Resource;
  6 +import org.springframework.http.HttpStatus;
  7 +import org.springframework.http.server.ServerHttpRequest;
  8 +import org.springframework.http.server.ServerHttpResponse;
  9 +import org.springframework.stereotype.Component;
  10 +import org.springframework.web.socket.WebSocketHandler;
  11 +import org.springframework.web.socket.server.HandshakeInterceptor;
  12 +
  13 +/**
  14 + * 同行报价信息 WebSocket 握手鉴权拦截器
  15 + */
  16 +@Component
  17 +public class SalesmanQuotationPeerWebSocketHandshakeInterceptor implements HandshakeInterceptor {
  18 +
  19 + /**
  20 + * 用户ID属性名
  21 + */
  22 + public static final String ATTR_USER_ID = "userId";
  23 +
  24 + /**
  25 + * 用户名称属性名
  26 + */
  27 + public static final String ATTR_USER_NAME = "userName";
  28 +
  29 + @Resource
  30 + private SalesmanQuotationPeerWebSocketAuthService authService;
  31 +
  32 + @Override
  33 + public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
  34 + WebSocketHandler wsHandler, Map<String, Object> attributes) {
  35 + try {
  36 + SysUser user = authService.authenticateCurrentUser();
  37 + attributes.put(ATTR_USER_ID, user.getId());
  38 + attributes.put(ATTR_USER_NAME, user.getName());
  39 + return true;
  40 + } catch (Exception e) {
  41 + response.setStatusCode(HttpStatus.FORBIDDEN);
  42 + return false;
  43 + }
  44 + }
  45 +
  46 + @Override
  47 + public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
  48 + WebSocketHandler wsHandler, Exception exception) {
  49 + }
  50 +}
@@ -58,4 +58,10 @@ @@ -58,4 +58,10 @@
58 </where> 58 </where>
59 ORDER BY tb.create_time DESC 59 ORDER BY tb.create_time DESC
60 </select> 60 </select>
  61 +
  62 + <select id="queryCurrentDayList" resultMap="SalesmanQuotationPeer">
  63 + <include refid="SalesmanQuotationPeerSql"/>
  64 + WHERE DATE(tb.create_time) = #{day}
  65 + ORDER BY tb.create_time DESC, tb.id DESC
  66 + </select>
61 </mapper> 67 </mapper>