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, "&") | ||
| 353 | + .replace(/</g, "<") | ||
| 354 | + .replace(/>/g, ">") | ||
| 355 | + .replace(/\"/g, """) | ||
| 356 | + .replace(/'/g, "'"); | ||
| 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> |