diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/pom.xml b/dev/RuoYi-Vue-master/ruoyi-apt/pom.xml index 1aeb152..b5c3e5f 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/pom.xml +++ b/dev/RuoYi-Vue-master/ruoyi-apt/pom.xml @@ -41,6 +41,12 @@ spring-tx + + + org.springframework.boot + spring-boot-starter-websocket + + org.springframework.boot diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyCommandServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyCommandServiceImpl.java index aac0e98..f2b32e9 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyCommandServiceImpl.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyCommandServiceImpl.java @@ -13,6 +13,7 @@ import com.ruoyi.apt.dispatch.domain.AptDutyCommand; import com.ruoyi.apt.dispatch.domain.AptDutyCommandResult; import com.ruoyi.apt.dispatch.mapper.AptJcxxMapper; import com.ruoyi.apt.framework.audit.AptAuditWriter; +import com.ruoyi.apt.framework.realtime.IAptRealtimePublisher; import com.ruoyi.apt.ops.domain.AptOperationLog; import com.ruoyi.common.utils.SecurityUtils; import org.springframework.dao.DuplicateKeyException; @@ -48,13 +49,15 @@ public class AptDutyCommandServiceImpl implements IAptDutyCommandService private final AptJcxxMapper jcxxMapper; private final IAptDutyStateService stateService; private final AptAuditWriter auditWriter; + private final IAptRealtimePublisher realtimePublisher; public AptDutyCommandServiceImpl(AptJcxxMapper jcxxMapper, IAptDutyStateService stateService, - AptAuditWriter auditWriter) + AptAuditWriter auditWriter, IAptRealtimePublisher realtimePublisher) { this.jcxxMapper = jcxxMapper; this.stateService = stateService; this.auditWriter = auditWriter; + this.realtimePublisher = realtimePublisher; } @Override @@ -118,6 +121,8 @@ public class AptDutyCommandServiceImpl implements IAptDutyCommandService result.setDuplicated(false); result.setStateAfter(afterLabel); result.setActionId(actionId); + // 事务提交后推送(回滚不发),按 apt:jcxx:list 投递 + realtimePublisher.callStateChanged(jcid, action, afterLabel, false); return result; } diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyStateServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyStateServiceImpl.java index fb24cdf..6ad3e62 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyStateServiceImpl.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/service/AptDutyStateServiceImpl.java @@ -7,6 +7,7 @@ import com.ruoyi.apt.common.AptErrorCode; import com.ruoyi.apt.common.AptException; import com.ruoyi.apt.dispatch.domain.AptStateSnapshot; import com.ruoyi.apt.dispatch.mapper.AptDutyStateMapper; +import com.ruoyi.apt.framework.realtime.IAptRealtimePublisher; import com.ruoyi.apt.job.service.IAptJobService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -39,11 +40,14 @@ public class AptDutyStateServiceImpl implements IAptDutyStateService private final AptDutyStateMapper stateMapper; private final IAptJobService jobService; + private final IAptRealtimePublisher realtimePublisher; - public AptDutyStateServiceImpl(AptDutyStateMapper stateMapper, IAptJobService jobService) + public AptDutyStateServiceImpl(AptDutyStateMapper stateMapper, IAptJobService jobService, + IAptRealtimePublisher realtimePublisher) { this.stateMapper = stateMapper; this.jobService = jobService; + this.realtimePublisher = realtimePublisher; } @Override @@ -70,7 +74,10 @@ public class AptDutyStateServiceImpl implements IAptDutyStateService @Transactional public int callFailed(String jcid) { - return require(stateMapper.updateCallFailed(gyid(), jcid), jcid, "CALL_FAILED"); + int rows = require(stateMapper.updateCallFailed(gyid(), jcid), jcid, "CALL_FAILED"); + // 无应答 / 超时转叫乘报警:P0,值班台需立即打断 + realtimePublisher.alarm(jcid, "CALL_FAILED", "无应答转叫乘报警"); + return rows; } @Override diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/AptTabletResultServiceImpl.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/AptTabletResultServiceImpl.java index 16ba47c..b385109 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/AptTabletResultServiceImpl.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/dispatch/tablet/service/AptTabletResultServiceImpl.java @@ -19,6 +19,7 @@ import com.ruoyi.apt.framework.device.AptDeviceSigner; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.dao.DuplicateKeyException; +import com.ruoyi.apt.framework.realtime.IAptRealtimePublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -61,11 +62,14 @@ public class AptTabletResultServiceImpl implements IAptTabletResultService private final AptTabletMapper tabletMapper; private final IAptDutyStateService stateService; + private final IAptRealtimePublisher realtimePublisher; - public AptTabletResultServiceImpl(AptTabletMapper tabletMapper, IAptDutyStateService stateService) + public AptTabletResultServiceImpl(AptTabletMapper tabletMapper, IAptDutyStateService stateService, + IAptRealtimePublisher realtimePublisher) { this.tabletMapper = tabletMapper; this.stateService = stateService; + this.realtimePublisher = realtimePublisher; } @Override @@ -136,7 +140,11 @@ public class AptTabletResultServiceImpl implements IAptTabletResultService List warnings = new ArrayList<>(); warnings.add("未刷脸:保持待确认并提示复叫(Q-22),值班员确认前不判完成"); result.put("warnings", warnings); + // 未刷脸/跳过需值班员介入:P0 报警 + 复叫提示 + realtimePublisher.alarm(jcid, "FACE_UNVERIFIED", "刷脸未通过,需值班员确认"); } + // 平板结果受理完成(状态机已提交):P1 状态变更 + realtimePublisher.callStateChanged(jcid, "TABLET_RESULT", appliedState, false); return result; } diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/config/AptProperties.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/config/AptProperties.java index 08aeb6c..ad1c88a 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/config/AptProperties.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/config/AptProperties.java @@ -23,6 +23,7 @@ public class AptProperties private Dispatch dispatch = new Dispatch(); private Voice voice = new Voice(); private Report report = new Report(); + private Realtime realtime = new Realtime(); public String getGyId() { @@ -114,6 +115,16 @@ public class AptProperties this.report = report; } + public Realtime getRealtime() + { + return realtime; + } + + public void setRealtime(Realtime realtime) + { + this.realtime = realtime; + } + public static class Probe { private boolean enabled = true; @@ -650,4 +661,62 @@ public class AptProperties this.resumeBatchLimit = resumeBatchLimit; } } + + /** + * 值班员实时通道(WebSocket)。 + * + *

浏览器无法设置握手 Header,令牌走 query;权限在连接建立时快照, + * 角色调整后需重连生效。

+ */ + public static class Realtime + { + /** 总开关;关闭后不注册 /apt/ws,也不影响任何既有 HTTP 接口 */ + private boolean enabled = true; + /** 允许的来源;生产应收敛为值班台域名 */ + private java.util.List allowedOrigins = new java.util.ArrayList<>(java.util.List.of("*")); + /** 客户端心跳间隔(秒),服务端在 CONNECTED 中下发 */ + private int heartbeatIntervalSeconds = 25; + /** 建立连接所需权限点;超管 *:*:* 直接放行 */ + private String connectPermission = "apt:realtime:connect"; + + public boolean isEnabled() + { + return enabled; + } + + public void setEnabled(boolean enabled) + { + this.enabled = enabled; + } + + public java.util.List getAllowedOrigins() + { + return allowedOrigins; + } + + public void setAllowedOrigins(java.util.List allowedOrigins) + { + this.allowedOrigins = allowedOrigins; + } + + public int getHeartbeatIntervalSeconds() + { + return heartbeatIntervalSeconds; + } + + public void setHeartbeatIntervalSeconds(int heartbeatIntervalSeconds) + { + this.heartbeatIntervalSeconds = heartbeatIntervalSeconds; + } + + public String getConnectPermission() + { + return connectPermission; + } + + public void setConnectPermission(String connectPermission) + { + this.connectPermission = connectPermission; + } + } } diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/realtime/AptRealtimeConfig.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/realtime/AptRealtimeConfig.java new file mode 100644 index 0000000..226743e --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/realtime/AptRealtimeConfig.java @@ -0,0 +1,43 @@ +package com.ruoyi.apt.framework.realtime; + +import com.ruoyi.apt.framework.config.AptProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +/** + * WebSocket 注册:{@code /apt/ws?token=xxx}。 + * + *

握手路径在 SecurityConfig 中放行,鉴权由 {@link AptRealtimeHandshakeInterceptor} 完成 + * (JWT + Redis 会话 + {@code apt:realtime:connect} 权限点)。

+ * + * @author apt-rev01 + */ +@Configuration +@EnableWebSocket +@ConditionalOnProperty(prefix = "apt.realtime", name = "enabled", havingValue = "true", matchIfMissing = true) +public class AptRealtimeConfig implements WebSocketConfigurer +{ + private final AptRealtimeHandler handler; + private final AptRealtimeHandshakeInterceptor interceptor; + private final AptProperties properties; + + public AptRealtimeConfig(AptRealtimeHandler handler, AptRealtimeHandshakeInterceptor interceptor, + AptProperties properties) + { + this.handler = handler; + this.interceptor = interceptor; + this.properties = properties; + } + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) + { + registry.addHandler(handler, "/apt/ws") + .addInterceptors(interceptor) + .setAllowedOriginPatterns( + properties.getRealtime().getAllowedOrigins().toArray(new String[0])); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/realtime/AptRealtimeHandler.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/realtime/AptRealtimeHandler.java new file mode 100644 index 0000000..92d62ec --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/realtime/AptRealtimeHandler.java @@ -0,0 +1,123 @@ +package com.ruoyi.apt.framework.realtime; + +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.apt.framework.config.AptProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +/** + * 值班员实时通道。 + * + *

心跳:客户端每 25s 发送 {@code {"type":"PING"}}(或纯文本 PING),服务端回 + * {@code {"type":"PONG","serverTime":...}};服务端不自造业务消息。断线由客户端指数退避重连。

+ * + * @author apt-rev01 + */ +@Component +public class AptRealtimeHandler extends TextWebSocketHandler +{ + private static final Logger log = LoggerFactory.getLogger(AptRealtimeHandler.class); + + private final AptRealtimeSessionRegistry registry; + private final AptProperties properties; + + public AptRealtimeHandler(AptRealtimeSessionRegistry registry, AptProperties properties) + { + this.registry = registry; + this.properties = properties; + } + + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception + { + Map attributes = session.getAttributes(); + Long userId = (Long) attributes.get("userId"); + String username = (String) attributes.get("username"); + String gyid = (String) attributes.get("gyid"); + @SuppressWarnings("unchecked") + Set permissions = (Set) attributes.get("permissions"); + if (userId == null || gyid == null) + { + session.close(CloseStatus.NOT_ACCEPTABLE); + return; + } + registry.register(session, userId, username, gyid, permissions); + + Map data = new LinkedHashMap<>(); + data.put("sessionId", session.getId()); + data.put("userId", userId); + data.put("username", username); + data.put("gyid", gyid); + data.put("serverTime", new Date()); + data.put("heartbeatIntervalSec", properties.getRealtime().getHeartbeatIntervalSeconds()); + session.sendMessage(new TextMessage(JSON.toJSONString(envelope(AptRealtimeTypes.CONNECTED, data)))); + } + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception + { + String payload = message.getPayload(); + if (payload == null || payload.isBlank()) + { + return; + } + registry.touch(session.getId()); + String type = null; + try + { + JSONObject json = JSON.parseObject(payload); + type = json == null ? null : json.getString("type"); + } + catch (Exception e) + { + if ("PING".equalsIgnoreCase(payload.trim())) + { + type = "PING"; + } + } + if ("PING".equalsIgnoreCase(type) || "PING".equalsIgnoreCase(payload.trim())) + { + Map data = new LinkedHashMap<>(); + data.put("serverTime", new Date()); + session.sendMessage(new TextMessage(JSON.toJSONString(envelope(AptRealtimeTypes.PONG, data)))); + } + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) + { + registry.remove(session.getId()); + } + + @Override + public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception + { + log.warn("WebSocket 传输异常:sessionId={}, cause={}", session.getId(), exception.toString()); + registry.remove(session.getId()); + if (session.isOpen()) + { + session.close(CloseStatus.SERVER_ERROR); + } + } + + private Map envelope(String type, Map data) + { + Map message = new LinkedHashMap<>(); + message.put("type", type); + message.put("category", AptRealtimeTypes.CATEGORY_SYSTEM); + message.put("priority", AptRealtimeTypes.PRIORITY_P2); + message.put("occurredAt", System.currentTimeMillis()); + message.put("data", data); + return message; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/realtime/AptRealtimeHandshakeInterceptor.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/realtime/AptRealtimeHandshakeInterceptor.java new file mode 100644 index 0000000..f8fdb66 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/realtime/AptRealtimeHandshakeInterceptor.java @@ -0,0 +1,125 @@ +package com.ruoyi.apt.framework.realtime; + +import java.util.Map; +import java.util.Set; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import com.ruoyi.apt.framework.config.AptProperties; +import com.ruoyi.common.core.domain.model.LoginUser; +import com.ruoyi.framework.web.service.TokenService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * WebSocket 握手鉴权。 + * + *

浏览器无法自定义握手 Header,因此支持 {@code /apt/ws?token=xxx};同时保留 + * {@code Authorization: Bearer}(非浏览器客户端)。令牌校验复用若依 {@link TokenService} + * (JWT + Redis 会话),权限在连接建立时快照,角色变更需重连生效。

+ * + * @author apt-rev01 + */ +@Component +public class AptRealtimeHandshakeInterceptor implements HandshakeInterceptor +{ + private static final Logger log = LoggerFactory.getLogger(AptRealtimeHandshakeInterceptor.class); + + private final TokenService tokenService; + private final AptProperties properties; + + public AptRealtimeHandshakeInterceptor(TokenService tokenService, AptProperties properties) + { + this.tokenService = tokenService; + this.properties = properties; + } + + @Override + public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Map attributes) + { + String token = resolveToken(request); + if (!StringUtils.hasText(token)) + { + response.setStatusCode(HttpStatus.UNAUTHORIZED); + return false; + } + LoginUser loginUser = tokenService.getLoginUser(withBearerToken(request, token)); + if (loginUser == null) + { + log.warn("WebSocket 握手拒绝:令牌无效或已过期"); + response.setStatusCode(HttpStatus.UNAUTHORIZED); + return false; + } + Set permissions = loginUser.getPermissions(); + String required = properties.getRealtime().getConnectPermission(); + if (StringUtils.hasText(required) && !permissions.contains("*:*:*") + && !permissions.contains(required)) + { + log.warn("WebSocket 握手拒绝:userId={} 缺少权限点 {}", loginUser.getUserId(), required); + response.setStatusCode(HttpStatus.FORBIDDEN); + return false; + } + String gyid = properties.getGyId(); + if (!StringUtils.hasText(gyid)) + { + response.setStatusCode(HttpStatus.FORBIDDEN); + return false; + } + attributes.put("userId", loginUser.getUserId()); + attributes.put("username", loginUser.getUsername()); + attributes.put("gyid", gyid); + attributes.put("permissions", permissions); + return true; + } + + @Override + public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Exception exception) + { + // 无需处理 + } + + private String resolveToken(ServerHttpRequest request) + { + String token = UriComponentsBuilder.fromUri(request.getURI()).build() + .getQueryParams().getFirst("token"); + if (StringUtils.hasText(token)) + { + return token.trim(); + } + String header = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION); + if (StringUtils.hasText(header) && header.startsWith("Bearer ")) + { + return header.substring(7).trim(); + } + return null; + } + + /** TokenService 只从 Header 读取令牌,这里把 query 参数包装成 Authorization 头 */ + private HttpServletRequest withBearerToken(ServerHttpRequest request, String token) + { + HttpServletRequest original = ((ServletServerHttpRequest) request).getServletRequest(); + return new HttpServletRequestWrapper(original) + { + @Override + public String getHeader(String name) + { + if (HttpHeaders.AUTHORIZATION.equalsIgnoreCase(name)) + { + return "Bearer " + token; + } + return super.getHeader(name); + } + }; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/realtime/AptRealtimeMessage.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/realtime/AptRealtimeMessage.java new file mode 100644 index 0000000..74f9018 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/realtime/AptRealtimeMessage.java @@ -0,0 +1,148 @@ +package com.ruoyi.apt.framework.realtime; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 实时推送消息信封(公寓后端 → 值班员前端)。 + * + *

字段稳定,便于前端统一分发:{@code type} 决定处理分支,{@code category} 决定声音/角标, + * {@code priority} 决定提示强度,{@code target} 由服务端在连接时校验后投递。

+ * + * @author apt-rev01 + */ +public class AptRealtimeMessage +{ + private String id; + + private String type; + + private String category; + + private String priority; + + private String gyid; + + private long occurredAt; + + private String traceId; + + /** PERMISSION / USER / ALL */ + private String targetScope = "PERMISSION"; + + private String targetPermission; + + private Long targetUserId; + + private Map data = new LinkedHashMap<>(); + + public String getId() + { + return id; + } + + public void setId(String id) + { + this.id = id; + } + + public String getType() + { + return type; + } + + public void setType(String type) + { + this.type = type; + } + + public String getCategory() + { + return category; + } + + public void setCategory(String category) + { + this.category = category; + } + + public String getPriority() + { + return priority; + } + + public void setPriority(String priority) + { + this.priority = priority; + } + + public String getGyid() + { + return gyid; + } + + public void setGyid(String gyid) + { + this.gyid = gyid; + } + + public long getOccurredAt() + { + return occurredAt; + } + + public void setOccurredAt(long occurredAt) + { + this.occurredAt = occurredAt; + } + + public String getTraceId() + { + return traceId; + } + + public void setTraceId(String traceId) + { + this.traceId = traceId; + } + + public String getTargetScope() + { + return targetScope; + } + + public void setTargetScope(String targetScope) + { + this.targetScope = targetScope; + } + + public String getTargetPermission() + { + return targetPermission; + } + + public void setTargetPermission(String targetPermission) + { + this.targetPermission = targetPermission; + } + + public Long getTargetUserId() + { + return targetUserId; + } + + public void setTargetUserId(Long targetUserId) + { + this.targetUserId = targetUserId; + } + + public Map getData() + { + return data; + } + + public void setData(Map data) + { + this.data = data == null ? new LinkedHashMap<>() : data; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/realtime/AptRealtimePublisher.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/realtime/AptRealtimePublisher.java new file mode 100644 index 0000000..9306597 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/realtime/AptRealtimePublisher.java @@ -0,0 +1,192 @@ +package com.ruoyi.apt.framework.realtime; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import com.alibaba.fastjson2.JSON; +import com.ruoyi.apt.common.AptContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** + * 实时推送实现。 + * + *

铁律:推送失败绝不影响业务;事务内调用一律延迟到 afterCommit, + * 避免前端看到未提交状态(回滚后仍收到"已变更")。

+ * + * @author apt-rev01 + */ +@Component +public class AptRealtimePublisher implements IAptRealtimePublisher +{ + private static final Logger log = LoggerFactory.getLogger(AptRealtimePublisher.class); + + private static final String SCOPE_PERMISSION = "PERMISSION"; + private static final String SCOPE_USER = "USER"; + private static final String SCOPE_ALL = "ALL"; + + private final AptRealtimeSessionRegistry registry; + + public AptRealtimePublisher(AptRealtimeSessionRegistry registry) + { + this.registry = registry; + } + + @Override + public void callStateChanged(String jcid, String command, String stateAfter, boolean duplicated) + { + AptRealtimeMessage message = base(AptRealtimeTypes.CALL_STATE_CHANGED, + AptRealtimeTypes.CATEGORY_CALL); + message.getData().put("jcid", jcid); + message.getData().put("command", command); + message.getData().put("stateAfter", stateAfter); + message.getData().put("duplicated", duplicated); + dispatch(message); + } + + @Override + public void alarm(String jcid, String alarmType, String detail) + { + AptRealtimeMessage message = base(AptRealtimeTypes.CALL_ALARM, + AptRealtimeTypes.CATEGORY_ALARM); + message.getData().put("jcid", jcid); + message.getData().put("alarmType", alarmType); + message.getData().put("detail", detail); + message.getData().put("sound", true); + dispatch(message); + } + + @Override + public void jobChanged(String eventType, String bizType, String bizId, String roomNo, String state) + { + AptRealtimeMessage message = base(eventType, AptRealtimeTypes.CATEGORY_JOB); + message.getData().put("bizType", bizType); + message.getData().put("bizId", bizId); + message.getData().put("roomNo", roomNo); + message.getData().put("state", state); + dispatch(message); + } + + @Override + public void checkinApplied(String applyId, String workNo, String name) + { + AptRealtimeMessage message = base(AptRealtimeTypes.CHECKIN_APPLIED, + AptRealtimeTypes.CATEGORY_CHECKIN); + message.getData().put("applyId", applyId); + message.getData().put("workNo", workNo); + message.getData().put("name", name); + dispatch(message); + } + + @Override + public void systemNotice(String title, Map data) + { + AptRealtimeMessage message = base(AptRealtimeTypes.SYSTEM_NOTICE, + AptRealtimeTypes.CATEGORY_SYSTEM); + message.setTargetScope(SCOPE_ALL); + message.getData().put("title", title); + if (data != null && !data.isEmpty()) + { + message.getData().putAll(data); + } + dispatch(message); + } + + @Override + public void publish(AptRealtimeMessage message) + { + if (message != null) + { + dispatch(message); + } + } + + private AptRealtimeMessage base(String type, String category) + { + AptRealtimeMessage message = new AptRealtimeMessage(); + message.setType(type); + message.setCategory(category); + message.setPriority(AptRealtimeTypes.priorityOf(category)); + message.setTargetPermission(AptRealtimeTypes.permissionOf(category)); + return message; + } + + private void dispatch(AptRealtimeMessage message) + { + try + { + String gyid = AptContext.getGyIdOrNull(); + if (gyid == null || gyid.isBlank()) + { + // 无租户上下文(如未绑定公寓的调度线程)不推送,避免跨域泄露 + return; + } + message.setGyid(gyid); + if (message.getId() == null) + { + message.setId(UUID.randomUUID().toString().replace("-", "")); + } + if (message.getOccurredAt() == 0L) + { + message.setOccurredAt(System.currentTimeMillis()); + } + if (message.getTraceId() == null) + { + message.setTraceId(AptContext.getTraceId()); + } + Runnable task = () -> send(message); + if (TransactionSynchronizationManager.isSynchronizationActive()) + { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() + { + @Override + public void afterCommit() + { + task.run(); + } + }); + } + else + { + task.run(); + } + } + catch (Exception e) + { + log.warn("实时推送登记失败,type={}, cause={}", message.getType(), e.toString()); + } + } + + private void send(AptRealtimeMessage message) + { + try + { + String payload = JSON.toJSONString(message); + String scope = message.getTargetScope() == null ? SCOPE_PERMISSION : message.getTargetScope(); + int sent; + if (SCOPE_ALL.equals(scope)) + { + sent = registry.broadcast(message.getGyid(), payload); + } + else if (SCOPE_USER.equals(scope)) + { + sent = registry.sendToUser(message.getGyid(), message.getTargetUserId(), payload); + } + else + { + sent = registry.sendToPermission(message.getGyid(), message.getTargetPermission(), payload); + } + if (sent == 0) + { + log.debug("实时推送无在线订阅者:type={}, scope={}", message.getType(), scope); + } + } + catch (Exception e) + { + log.warn("实时推送失败,type={}, cause={}", message.getType(), e.toString()); + } + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/realtime/AptRealtimeSessionRegistry.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/realtime/AptRealtimeSessionRegistry.java new file mode 100644 index 0000000..9df14fa --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/realtime/AptRealtimeSessionRegistry.java @@ -0,0 +1,196 @@ +package com.ruoyi.apt.framework.realtime; + +import java.io.IOException; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +/** + * 在线值班员会话注册表(单进程内存)。 + * + *

键为 WebSocket sessionId;投递前按 gyid 与权限点过滤,避免跨公寓/越权推送。 + * 发送串行化到单个 session(WebSocketSession 非线程安全),失败即摘除会话。

+ * + * @author apt-rev01 + */ +@Component +public class AptRealtimeSessionRegistry +{ + private static final Logger log = LoggerFactory.getLogger(AptRealtimeSessionRegistry.class); + + private final Map connections = new ConcurrentHashMap<>(); + + public void register(WebSocketSession session, Long userId, String username, String gyid, + Set permissions) + { + Connection connection = new Connection(session, userId, username, gyid, permissions); + connections.put(session.getId(), connection); + log.info("WebSocket 已连接:sessionId={}, userId={}, gyid={}, 在线={}", + session.getId(), userId, gyid, connections.size()); + } + + public void remove(String sessionId) + { + Connection removed = connections.remove(sessionId); + if (removed != null) + { + log.info("WebSocket 已断开:sessionId={}, userId={}, 在线={}", + sessionId, removed.userId, connections.size()); + } + } + + public void touch(String sessionId) + { + Connection connection = connections.get(sessionId); + if (connection != null) + { + connection.lastActiveAt.set(System.currentTimeMillis()); + } + } + + public int online(String gyid) + { + int count = 0; + for (Connection connection : connections.values()) + { + if (connection.gyid.equals(gyid)) + { + count++; + } + } + return count; + } + + /** 按 gyid 全员广播 */ + public int broadcast(String gyid, String payload) + { + int sent = 0; + for (Connection connection : connections.values()) + { + if (connection.gyid.equals(gyid) && send(connection, payload)) + { + sent++; + } + } + return sent; + } + + /** 按权限点投递(超管 *:*:* 命中) */ + public int sendToPermission(String gyid, String permission, String payload) + { + int sent = 0; + for (Connection connection : connections.values()) + { + if (!connection.gyid.equals(gyid)) + { + continue; + } + if (permission != null && !connection.permissions.contains(permission) + && !connection.permissions.contains("*:*:*")) + { + continue; + } + if (send(connection, payload)) + { + sent++; + } + } + return sent; + } + + /** 定向推送(如派单给具体值班员) */ + public int sendToUser(String gyid, Long userId, String payload) + { + int sent = 0; + for (Connection connection : connections.values()) + { + if (connection.gyid.equals(gyid) && connection.userId.equals(userId) + && send(connection, payload)) + { + sent++; + } + } + return sent; + } + + public int sendToSession(String sessionId, String payload) + { + Connection connection = connections.get(sessionId); + return connection != null && send(connection, payload) ? 1 : 0; + } + + /** 关闭超过 idleMs 未发心跳的会话(供后续定时任务调用) */ + public int sweepIdle(long idleMs) + { + long now = System.currentTimeMillis(); + int closed = 0; + for (Connection connection : connections.values()) + { + if (now - connection.lastActiveAt.get() > idleMs && connection.session.isOpen()) + { + try + { + connection.session.close(); + } + catch (IOException e) + { + log.debug("关闭空闲 WebSocket 失败:{}", e.toString()); + } + connections.remove(connection.session.getId()); + closed++; + } + } + return closed; + } + + private boolean send(Connection connection, String payload) + { + WebSocketSession session = connection.session; + if (!session.isOpen()) + { + connections.remove(session.getId()); + return false; + } + try + { + synchronized (session) + { + session.sendMessage(new TextMessage(payload)); + } + connection.lastActiveAt.set(System.currentTimeMillis()); + return true; + } + catch (Exception e) + { + log.warn("WebSocket 推送失败,摘除会话:sessionId={}, cause={}", session.getId(), e.toString()); + connections.remove(session.getId()); + return false; + } + } + + private static final class Connection + { + private final WebSocketSession session; + private final Long userId; + private final String username; + private final String gyid; + private final Set permissions; + private final AtomicLong lastActiveAt = new AtomicLong(System.currentTimeMillis()); + + private Connection(WebSocketSession session, Long userId, String username, String gyid, + Set permissions) + { + this.session = session; + this.userId = userId; + this.username = username; + this.gyid = gyid; + this.permissions = permissions == null ? Set.of() : permissions; + } + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/realtime/AptRealtimeTypes.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/realtime/AptRealtimeTypes.java new file mode 100644 index 0000000..8311030 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/realtime/AptRealtimeTypes.java @@ -0,0 +1,87 @@ +package com.ruoyi.apt.framework.realtime; + +/** + * 实时消息类型 / 分类 / 优先级 / 默认投递权限。 + * + *

优先级口径:P0 需立即打断(报警类);P1 值班业务变化(叫乘、入住审批、工单); + * P2 状态可视(设备、上报水位、系统通告),允许合并/降频。

+ * + * @author apt-rev01 + */ +public final class AptRealtimeTypes +{ + private AptRealtimeTypes() + { + } + + // ---------------------------------------------------------------- 消息类型 + public static final String CONNECTED = "CONNECTED"; + public static final String PONG = "PONG"; + public static final String CALL_STATE_CHANGED = "CALL_STATE_CHANGED"; + public static final String CALL_ALARM = "CALL_ALARM"; + public static final String CHECKIN_APPLIED = "CHECKIN_APPLIED"; + public static final String CHECKIN_REVIEWED = "CHECKIN_REVIEWED"; + public static final String FACE_REG_APPLIED = "FACE_REG_APPLIED"; + public static final String JOB_CREATED = "JOB_CREATED"; + public static final String JOB_STATE_CHANGED = "JOB_STATE_CHANGED"; + public static final String DEVICE_OFFLINE = "DEVICE_OFFLINE"; + public static final String REPORT_BACKLOG = "REPORT_BACKLOG"; + public static final String SYSTEM_NOTICE = "SYSTEM_NOTICE"; + + // ---------------------------------------------------------------- 分类 + public static final String CATEGORY_CALL = "CALL"; + public static final String CATEGORY_ALARM = "ALARM"; + public static final String CATEGORY_CHECKIN = "CHECKIN"; + public static final String CATEGORY_FACE = "FACE"; + public static final String CATEGORY_JOB = "JOB"; + public static final String CATEGORY_DEVICE = "DEVICE"; + public static final String CATEGORY_REPORT = "REPORT"; + public static final String CATEGORY_SYSTEM = "SYSTEM"; + + // ---------------------------------------------------------------- 优先级 + public static final String PRIORITY_P0 = "P0"; + public static final String PRIORITY_P1 = "P1"; + public static final String PRIORITY_P2 = "P2"; + + /** 分类默认投递权限;返回 null 表示按 gyid 广播 */ + public static String permissionOf(String category) + { + if (category == null) + { + return null; + } + switch (category) + { + case CATEGORY_CALL: + case CATEGORY_ALARM: + return "apt:jcxx:list"; + case CATEGORY_CHECKIN: + return "duty:checkin:list"; + case CATEGORY_FACE: + return "duty:person:reg"; + case CATEGORY_JOB: + return "apt:workorder:list"; + case CATEGORY_DEVICE: + return "apt:device:list"; + case CATEGORY_REPORT: + return "apt:report:list"; + default: + return null; + } + } + + /** 分类默认优先级 */ + public static String priorityOf(String category) + { + if (CATEGORY_ALARM.equals(category)) + { + return PRIORITY_P0; + } + if (CATEGORY_CALL.equals(category) || CATEGORY_CHECKIN.equals(category) + || CATEGORY_JOB.equals(category) || CATEGORY_FACE.equals(category)) + { + return PRIORITY_P1; + } + return PRIORITY_P2; + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/realtime/IAptRealtimePublisher.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/realtime/IAptRealtimePublisher.java new file mode 100644 index 0000000..f79644c --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/realtime/IAptRealtimePublisher.java @@ -0,0 +1,29 @@ +package com.ruoyi.apt.framework.realtime; + +import java.util.Map; + +/** + * 实时推送发布器(事务提交后投递,业务无感)。 + * + * @author apt-rev01 + */ +public interface IAptRealtimePublisher +{ + /** 叫乘状态变更(P1,按 apt:jcxx:list 投递) */ + void callStateChanged(String jcid, String command, String stateAfter, boolean duplicated); + + /** 叫乘/离寓报警(P0,值班台需立即打断) */ + void alarm(String jcid, String alarmType, String detail); + + /** 工单创建 / 状态变更(P1) */ + void jobChanged(String eventType, String bizType, String bizId, String roomNo, String state); + + /** 入住申请待审(P1,按 duty:checkin:list 投递) */ + void checkinApplied(String applyId, String workNo, String name); + + /** 通用发布(自定义 type/category/priority/目标) */ + void publish(AptRealtimeMessage message); + + /** 系统通告(P2,gyid 广播) */ + void systemNotice(String title, Map data); +} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/web/AptContextInterceptor.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/web/AptContextInterceptor.java index b9deb31..a58dcf5 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/web/AptContextInterceptor.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/java/com/ruoyi/apt/framework/web/AptContextInterceptor.java @@ -53,6 +53,13 @@ public class AptContextInterceptor implements HandlerInterceptor { AptContext.initTraceId(); String uri = request.getRequestURI(); + // WebSocket 握手:浏览器无法带 Authorization 头,由 AptRealtimeHandshakeInterceptor + // 用 query token 完成 JWT + 权限点校验;此处只注入 gyid,不做会话断言(IT-6) + if (uri.equals("/apt/ws") || uri.startsWith("/apt/ws/")) + { + AptContext.setGyId(properties.getGyId()); + return true; + } Prefix prefix = AptPrefixResolver.resolve(uri); boolean anonymous = isAnonymous(handler); diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/apt-defaults.yml b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/apt-defaults.yml index 9716faf..716f574 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/apt-defaults.yml +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/main/resources/apt-defaults.yml @@ -150,3 +150,13 @@ apt: # 超过该秒数无心跳视为离线(不据此伪造 ACKED) heartbeat-timeout-seconds: ${APT_REPORT_HEARTBEAT_TIMEOUT_SEC:90} resume-batch-limit: ${APT_REPORT_RESUME_BATCH:200} + # --------------------------------------------------------------------------- + # 值班员实时通道(WebSocket /apt/ws) + # 浏览器无法设置握手 Header,令牌通过 ?token= 传入;生产应收敛 allowed-origins。 + # 未订阅者不影响既有 HTTP 接口,关闭 enabled 即完全退化到轮询。 + # --------------------------------------------------------------------------- + realtime: + enabled: ${APT_REALTIME_ENABLED:true} + allowed-origins: ${APT_REALTIME_ALLOWED_ORIGINS:*} + heartbeat-interval-seconds: ${APT_REALTIME_HEARTBEAT_SEC:25} + connect-permission: ${APT_REALTIME_CONNECT_PERM:apt:realtime:connect} diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyCommandServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyCommandServiceTest.java index 75d13a1..50f8139 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyCommandServiceTest.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyCommandServiceTest.java @@ -41,7 +41,8 @@ class AptDutyCommandServiceTest mapper = mock(AptJcxxMapper.class); stateService = mock(IAptDutyStateService.class); auditWriter = mock(AptAuditWriter.class); - service = new AptDutyCommandServiceImpl(mapper, stateService, auditWriter); + service = new AptDutyCommandServiceImpl(mapper, stateService, auditWriter, + mock(com.ruoyi.apt.framework.realtime.IAptRealtimePublisher.class)); AptContext.clear(); AptContext.setGyId("APT-A"); } diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyStateServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyStateServiceTest.java index f73b581..14b6042 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyStateServiceTest.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptDutyStateServiceTest.java @@ -35,7 +35,8 @@ class AptDutyStateServiceTest { mapper = mock(AptDutyStateMapper.class); jobService = mock(IAptJobService.class); - service = new AptDutyStateServiceImpl(mapper, jobService); + service = new AptDutyStateServiceImpl(mapper, jobService, + mock(com.ruoyi.apt.framework.realtime.IAptRealtimePublisher.class)); AptContext.clear(); AptContext.setGyId("APT-A"); } diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptTabletResultServiceTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptTabletResultServiceTest.java index 25e779d..8fd5230 100644 --- a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptTabletResultServiceTest.java +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/dispatch/AptTabletResultServiceTest.java @@ -42,7 +42,8 @@ class AptTabletResultServiceTest { mapper = mock(AptTabletMapper.class); stateService = mock(IAptDutyStateService.class); - service = new AptTabletResultServiceImpl(mapper, stateService); + service = new AptTabletResultServiceImpl(mapper, stateService, + mock(com.ruoyi.apt.framework.realtime.IAptRealtimePublisher.class)); AptContext.clear(); AptContext.setGyId("APT-A"); } diff --git a/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptRealtimeTest.java b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptRealtimeTest.java new file mode 100644 index 0000000..e3184c5 --- /dev/null +++ b/dev/RuoYi-Vue-master/ruoyi-apt/src/test/java/com/ruoyi/apt/framework/AptRealtimeTest.java @@ -0,0 +1,108 @@ +package com.ruoyi.apt.framework; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.util.Map; +import java.util.Set; +import com.ruoyi.apt.common.AptContext; +import com.ruoyi.apt.framework.realtime.AptRealtimePublisher; +import com.ruoyi.apt.framework.realtime.AptRealtimeSessionRegistry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +/** + * WebSocket 路由与投递回归:权限过滤、广播、报警优先级、关闭会话摘除。 + */ +class AptRealtimeTest +{ + @BeforeEach + void setUp() + { + AptContext.clear(); + AptContext.setGyId("APT-A"); + } + + @AfterEach + void tearDown() + { + AptContext.clear(); + } + + @Test + void alarmRoutesToCallPermissionWithP0() + { + AptRealtimeSessionRegistry registry = mock(AptRealtimeSessionRegistry.class); + AptRealtimePublisher publisher = new AptRealtimePublisher(registry); + + publisher.alarm("JC-1", "CALL_FAILED", "无应答"); + + verify(registry).sendToPermission(eq("APT-A"), eq("apt:jcxx:list"), + contains("\"CALL_ALARM\"")); + verify(registry).sendToPermission(eq("APT-A"), eq("apt:jcxx:list"), contains("\"P0\"")); + } + + @Test + void systemNoticeBroadcastsWholeGyid() + { + AptRealtimeSessionRegistry registry = mock(AptRealtimeSessionRegistry.class); + AptRealtimePublisher publisher = new AptRealtimePublisher(registry); + + publisher.systemNotice("系统维护", Map.of("window", "02:00-03:00")); + + verify(registry).broadcast(eq("APT-A"), contains("SYSTEM_NOTICE")); + } + + @Test + void permissionFilterBlocksUnrelatedSubscribers() throws Exception + { + AptRealtimeSessionRegistry registry = new AptRealtimeSessionRegistry(); + WebSocketSession session = mock(WebSocketSession.class); + when(session.getId()).thenReturn("S-1"); + when(session.isOpen()).thenReturn(true); + registry.register(session, 7L, "duty01", "APT-A", Set.of("apt:workorder:list")); + + assertEquals(0, registry.sendToPermission("APT-A", "apt:jcxx:list", "{}")); + verify(session, never()).sendMessage(any(TextMessage.class)); + + assertEquals(1, registry.sendToPermission("APT-A", "apt:workorder:list", "{}")); + verify(session).sendMessage(any(TextMessage.class)); + } + + @Test + void closedSessionIsDropped() throws Exception + { + AptRealtimeSessionRegistry registry = new AptRealtimeSessionRegistry(); + WebSocketSession session = mock(WebSocketSession.class); + when(session.getId()).thenReturn("S-2"); + when(session.isOpen()).thenReturn(false); + registry.register(session, 8L, "duty02", "APT-A", Set.of("*:*:*")); + + assertEquals(1, registry.online("APT-A")); + assertEquals(0, registry.broadcast("APT-A", "{}")); + assertEquals(0, registry.online("APT-A")); + verify(session, never()).sendMessage(any(TextMessage.class)); + } + + @Test + void adminWildcardReceivesEverything() throws Exception + { + AptRealtimeSessionRegistry registry = new AptRealtimeSessionRegistry(); + WebSocketSession session = mock(WebSocketSession.class); + when(session.getId()).thenReturn("S-3"); + when(session.isOpen()).thenReturn(true); + registry.register(session, 1L, "admin", "APT-A", Set.of("*:*:*")); + + assertEquals(1, registry.sendToPermission("APT-A", "apt:report:list", "{}")); + verify(session).sendMessage(any(TextMessage.class)); + } +} diff --git a/dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java b/dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java index 704e4eb..33160c4 100644 --- a/dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java +++ b/dev/RuoYi-Vue-master/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java @@ -121,6 +121,9 @@ public class SecurityConfig // 时间窗 / nonce)。此处若不放行,anyRequest().authenticated() 会提前返回 401, // 四前缀拦截器永远无法生效(IT-1 / M0)。 .requestMatchers("/open/v1/**").permitAll() + // 值班员 WebSocket 握手:浏览器无法设置 Authorization 头,令牌走 query, + // 由 AptRealtimeHandshakeInterceptor 完成 JWT + 权限点校验(IT-6) + .requestMatchers("/apt/ws", "/apt/ws/**").permitAll() // 静态资源,可匿名访问 .requestMatchers(HttpMethod.GET, "/", "/*.html", "/**.html", "/**.css", "/**.js", "/profile/**").permitAll() .requestMatchers("/swagger-ui.html", "/v3/api-docs/**", "/swagger-ui/**", "/druid/**").permitAll() diff --git a/dev/sql/apt_rev01/30_realtime.sql b/dev/sql/apt_rev01/30_realtime.sql new file mode 100644 index 0000000..7e810d5 --- /dev/null +++ b/dev/sql/apt_rev01/30_realtime.sql @@ -0,0 +1,28 @@ +-- ============================================================================= +-- IT-6 WebSocket 实时推送:连接权限点(可重放) +-- 口径:新增 1 个 F 权限点;不新增表、不改业务列。 +-- 握手时由 AptRealtimeHandshakeInterceptor 校验(超管 *:*:* 直接放行)。 +-- ============================================================================= +SET NAMES utf8mb4; + +INSERT INTO sys_menu + (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +VALUES + (3259, '实时通道连接', 3000, 39, '', '', '', '', 1, 0, 'F', '0', '0', + 'apt:realtime:connect', '#', 'IT-6', NOW(3), + 'WebSocket /apt/ws 握手权限;未授权用户不允许建立长连接') +ON DUPLICATE KEY UPDATE menu_name = VALUES(menu_name), parent_id = VALUES(parent_id), + order_num = VALUES(order_num), menu_type = VALUES(menu_type), status = VALUES(status), + perms = VALUES(perms), remark = VALUES(remark); + +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT 1, 3259 FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu WHERE role_id = 1 AND menu_id = 3259); + +-- 校验 +SELECT '=== 66. 实时通道权限点(应 1)===' AS section; +SELECT menu_id, perms, menu_name, status FROM sys_menu WHERE menu_id = 3259; + +SELECT '=== 67. 超管绑定(应 1)===' AS section; +SELECT COUNT(*) AS admin_bound FROM sys_role_menu WHERE role_id = 1 AND menu_id = 3259; diff --git a/docs/公寓端/21-WebSocket实时推送设计.md b/docs/公寓端/21-WebSocket实时推送设计.md new file mode 100644 index 0000000..c1bdada --- /dev/null +++ b/docs/公寓端/21-WebSocket实时推送设计.md @@ -0,0 +1,258 @@ +# 21 WebSocket 实时推送设计(值班员通道) + +> 适用版本:apt-rev01(IT-6 增量) +> 代码落点:`ruoyi-apt/.../framework/realtime/**`、`framework/web/AptContextInterceptor`、`ruoyi-framework/config/SecurityConfig` +> 库变更:`dev/sql/apt_rev01/30_realtime.sql`(已执行:权限点 `apt:realtime:connect`) +> 目标:叫乘状态变更、报警事件等值班业务在 1s 内到达值班台;断线/重连不丢业务事实(以 HTTP 快照补齐) + +## 1. 设计边界 + +| 项 | 结论 | +| --- | --- | +| 通道形态 | 原生 WebSocket(`TextWebSocketHandler`),**不引入 STOMP/SockJS**,避免与若依前端动态路由/网关冲突 | +| 进程模型 | 一公寓一进程(D5),会话注册表放进程内 `ConcurrentHashMap`;多实例需换 Redis Pub/Sub | +| 终端 | 值班员浏览器(`/apt/**` 登录态);平板/设备仍走 HTTP,不接 WS | +| 可靠性 | WS 只做"低延迟通知",**不作为业务事实来源**;每条消息可由 HTTP 查询复核 | +| 降级 | `apt.realtime.enabled=false` 时不注册 `/apt/ws`,前端自动退回轮询,既有接口零改动 | +| 兼容 | 不改任何既有 HTTP 接口出参与状态机;推送失败仅告警,不影响事务 | + +## 2. 连接建立与鉴权 + +### 2.1 连接地址 + +``` +wss:///apt/ws?token= +``` + +- 浏览器 `WebSocket` 无法自定义 `Authorization` 头,因此令牌走 query;非浏览器客户端仍支持 `Authorization: Bearer `。 +- 生产必须使用 `wss`,并由反向代理限制 Origin。 + +### 2.2 握手流程 + +``` +WebSocket 握手 GET /apt/ws?token=... + → Spring Security:/apt/ws 放行(不做表单登录) + → AptContextInterceptor:仅注入 gyid,跳过 HTTP 会话断言 + → AptRealtimeHandshakeInterceptor: + 1) TokenService 校验 JWT + Redis 会话(复用若依登录态,30 分钟续期规则不变) + 2) 校验权限点 apt:realtime:connect(超管 *:*:* 直接放行) + 3) 将 userId / username / gyid / permissions 写入握手 attributes + → 建立连接,服务端下发 CONNECTED(含 heartbeatIntervalSec / serverTime) +``` + +关键约束: + +| 约束 | 说明 | +| --- | --- | +| gyid 来源 | 只取部署配置 `apt.gy-id`,**绝不采信 query/header**(D5) | +| 权限快照 | 权限在连接时快照;角色变更需断开重连才生效(避免每帧查库) | +| 拒绝响应 | 无令牌 401;令牌失效 401;缺权限点 403;前端据此停止重试并跳登录 | +| 会话数量 | 同一账号允许多标签页/多终端,注册表按 sessionId 独立记录 | + +## 3. 心跳保活与断线重连 + +| 项 | 策略 | +| --- | --- | +| 心跳方向 | 客户端主动 PING(服务端不主动探测,避免空转) | +| 间隔 | 服务端在 `CONNECTED.data.heartbeatIntervalSec` 下发,默认 25s | +| 报文 | `{"type":"PING"}` → `{"type":"PONG","data":{"serverTime":...}}` | +| 超时 | 连续 3 次(约 75s)未收到 PONG,客户端主动 `close()` 并重连 | +| 重连退避 | 1s → 2s → 4s → 8s → 16s → 30s 封顶,±20% 抖动;握手 401/403 不重试 | +| 重连后补齐 | 重连成功先拉一次 HTTP 快照(叫乘列表/统计、工单列表),WS 只补增量,不要求服务端补发历史 | +| 半开连接 | `AptRealtimeSessionRegistry.sweepIdle(idleMs)` 已提供,可挂 Quartz 每 30s 清理(本期未挂,见 §10) | +| 代理要求 | Nginx `proxy_read_timeout ≥ 120s`;`Upgrade`/`Connection` 头透传 | + +## 4. 消息格式与分类标准 + +### 4.1 信封字段 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | string | 消息唯一 ID(幂等/去重,前端可按 id 丢弃重复) | +| `type` | string | 业务消息类型,见 §4.2 | +| `category` | string | 分类:`CALL/ALARM/CHECKIN/FACE/JOB/DEVICE/REPORT/SYSTEM` | +| `priority` | string | `P0/P1/P2`,决定前端声音/角标/是否置顶 | +| `gyid` | string | 公寓标识,服务端填充 | +| `occurredAt` | long | 事件时间(毫秒);前端按此排序,不按到达时间 | +| `traceId` | string | 与 HTTP 响应/审计日志关联 | +| `targetScope` | string | `PERMISSION/USER/ALL` | +| `targetPermission` | string | 权限点(PERMISSION 时有效) | +| `targetUserId` | long | 指定值班员(USER 时有效) | +| `data` | object | 业务载荷,按 type 约定 | + +### 4.2 分类与优先级 + +| category | 优先级 | 默认投递权限 | 场景 | +| --- | --- | --- | --- | +| `ALARM` | P0 | `apt:jcxx:list` | 叫乘报警、未刷脸需确认、离寓超时报警 | +| `CALL` | P1 | `apt:jcxx:list` | 叫乘状态迁移(开始/应答/停止/复叫/强结/离寓/取消) | +| `CHECKIN` | P1 | `duty:checkin:list` | 入住申请待审、审核结果 | +| `FACE` | P1 | `duty:person:reg` | 人脸注册申请待确认 | +| `JOB` | P1 | `apt:workorder:list` | 保洁自动建单、报修建单、派单、完成 | +| `DEVICE` | P2 | `apt:device:list` | 设备离线/指令入队(不含心跳逐条) | +| `REPORT` | P2 | `apt:report:list` | outbox 积压、进入 DEAD | +| `SYSTEM` | P2 | 广播 | 维护通告、配置刷新 | + +### 4.3 示例 + +```json +{ + "id": "8f2c9e0d4b7a4c1e9d3f6a2b5c8e1d4f", + "type": "CALL_ALARM", + "category": "ALARM", + "priority": "P0", + "gyid": "APT-A", + "occurredAt": 1794451200123, + "traceId": "7b1f...", + "targetScope": "PERMISSION", + "targetPermission": "apt:jcxx:list", + "data": { + "jcid": "JC-20261112-0001", + "alarmType": "CALL_FAILED", + "detail": "无应答转叫乘报警", + "sound": true + } +} +``` + +## 5. 推送目标与路由 + +| scope | 路由规则 | 典型场景 | +| --- | --- | --- | +| `PERMISSION`(默认) | 同 gyid 且权限包含 `targetPermission`(或 `*:*:*`)的会话 | 叫乘、报警、工单、设备 | +| `USER` | 同 gyid 且 `userId` 匹配 | "这条工单派给你"、审核结果回给提交人 | +| `ALL` | 同 gyid 全部在线会话 | 系统维护、配置刷新 | + +- 权限点由分类映射(§4.2),也可由调用方显式覆盖。 +- 无在线订阅者时只记录 debug 日志,不重试、不落库(WS 不是可靠通道)。 +- 同一用户多会话全部投递;会话发送串行化,失败即摘除。 + +## 6. 推送要点清单(含代码位置) + +### 6.1 本轮已接入(4 条) + +| # | 触发条件 | 代码位置 | 消息 type / 分类 / 优先级 | 数据内容 | 目标 | +| --- | --- | --- | --- | --- | --- | +| 1 | 值班台命令执行成功(STOP / RE_CALL / FORCE_FINISH / MARK_LEAVE / CANCEL) | `dispatch/service/AptDutyCommandServiceImpl#execute` | `CALL_STATE_CHANGED` / CALL / P1 | `jcid`、`command`、`stateAfter`、`duplicated` | `apt:jcxx:list` | +| 2 | 平板结果受理成功(ANSWERED / NO_ANSWER / FAILED / CANCELLED) | `dispatch/tablet/service/AptTabletResultServiceImpl#accept` | `CALL_STATE_CHANGED` / CALL / P1 | `jcid`、`command="TABLET_RESULT"`、`stateAfter` | `apt:jcxx:list` | +| 3 | 叫乘失败(SIP 超时 / 平板无应答转报警) | `dispatch/service/AptDutyStateServiceImpl#callFailed` | `CALL_ALARM` / ALARM / P0 | `jcid`、`alarmType="CALL_FAILED"`、`detail`、`sound=true` | `apt:jcxx:list` | +| 4 | 平板刷脸未通过 / 跳过(保持待确认) | `AptTabletResultServiceImpl#accept` 的 `UNVERIFIED/SKIPPED` 分支 | `CALL_ALARM` / ALARM / P0 | `jcid`、`alarmType="FACE_UNVERIFIED"`、`detail` | `apt:jcxx:list` | + +> `MARK_LEAVE` 归属说明:登记离寓的唯一入口是值班台命令,已由第 1 条覆盖 +> (`AptDutyCommandServiceImpl#execute` 调 `AptDutyStateServiceImpl#markLeave` 后于提交前注册 `afterCommit` 推送)。 +> 因此 `AptDutyStateServiceImpl#markLeave` **不重复挂点**;若后续出现非命令入口的调用方, +> 再在 `#markLeave` 内补发,并同步去掉命令侧的重复,避免一条离寓推两次。 + +### 6.2 建议下一批接入(代码已定位,本轮未挂) + +| # | 触发条件 | 代码位置 | 建议消息 | 数据内容 | 目标 / 优先级 | +| --- | --- | --- | --- | --- | --- | +| 5 | 离寓超时报警(`is_lybj` 置 1) | `dispatch/mapper/AptDutyStateMapper.xml` 对应更新 + `AptDispatchScanServiceImpl#scan` | `CALL_ALARM` | `jcid`、`roomNo`、`alarmType="LEAVE_ALARM"` | `apt:jcxx:list` / P0 | +| 6 | SIP 事件(入会/挂断/超时) | `voice/service/AptVoiceEventServiceImpl#handle` | `CALL_STATE_CHANGED` | `jcid`、`event`、`channel`、`stateAfter` | `apt:jcxx:list` / P1 | +| 7 | 扫描任务产生超时/补生成 | `dispatch/service/AptDispatchScanServiceImpl#scan` | `SYSTEM_NOTICE` + `CALL_ALARM` | 统计数、受影响 `jcid[]` | `apt:jcxx:list` / P1 | +| 8 | 入住申请提交 | `stay/checkin/service/AptCheckinServiceImpl#apply` | `CHECKIN_APPLIED` | `applyId`、`workNo`、`name`、`roomNo` | `duty:checkin:list` / P1 | +| 9 | 入住审核 / 换房结果 | `AptCheckinServiceImpl#review`、`#roomAdjust` | `CHECKIN_REVIEWED` | `applyId`、`state`、`roomNo`、`operator` | `duty:checkin:list` / P1 | +| 10 | 人脸注册申请待确认 | `face/service/AptFaceRegServiceImpl#apply` | `FACE_REG_APPLIED` | `regId`、`workNo`、`name` | `duty:person:reg` / P1 | +| 11 | 保洁自动建单 / 平板上报待清扫 | `job/service/AptJobServiceImpl#autoCreateCleanOnVacated`、`#reportClean` | `JOB_CREATED` | `taskId`、`roomNo`、`source(AUTO/TABLET)`、`state` | `apt:workorder:list` / P1 | +| 12 | 维修建单 / 派单 / 完成 / 取消 | `AptJobServiceImpl#reportRepair`、`#assignRepair`、`#finishRepair`、`#cancelRepair` | `JOB_STATE_CHANGED` | `orderId`、`roomNo`、`state`、`staffId` | `apt:workorder:list`(派单可 USER 定向)/ P1 | +| 13 | 设备离线(心跳超时) | `device/service/AptDeviceServiceImpl#heartbeat` 的离线判定任务(待补) | `DEVICE_OFFLINE` | `deviceCode`、`deviceType`、`lastHeartbeatAt` | `apt:device:list` / P1 | +| 14 | outbox 积压超阈值 / 进入 DEAD | `report/service/AptReportServiceImpl#stats`、`#requeue`(阈值判定待补) | `REPORT_BACKLOG` | `pending`、`dead`、`lag`、`maxWatermark` | `apt:report:list` / P2 | +| 15 | 房间房态变化(2/5/3/6/4) | `AptJobServiceImpl#syncRoomOccupancy`、`stay/service/AptRoomServiceImpl` | `SYSTEM_NOTICE`(房态) | `roomNo`、`from`、`to`、`reason` | `apt:workorder:list` / P2 | + +## 7. 现有模块逐项评估(是否适合实时推送) + +| 模块 / 类 | 触发点 | 是否适合 | 结论与理由 | +| --- | --- | --- | --- | +| `AptDutyStateServiceImpl` | 8 个状态迁移方法 | **适合** | 状态机唯一写入口,天然事件源;`callFailed` 已接报警,`markLeave` 由命令侧覆盖(§6.1),其余建议按 §6.2 #5、#6 接入 | +| `AptDutyCommandServiceImpl` | `execute` | **适合(已接)** | 值班员主动操作,需即时反馈 | +| `AptTabletResultServiceImpl` | `accept` | **适合(已接)** | 平板结果到达后值班台须秒级可见 | +| `AptVoiceEventServiceImpl` | `handle`(AMI 事件) | **适合** | SIP 应答/挂断/超时是值班台核心状态源,下一批接入 | +| `AptDispatchScanServiceImpl` | `scan` | **适合(聚合推送)** | 单条超时不必逐条推,聚合为一条统计+受影响清单,避免风暴 | +| `AptCheckinServiceImpl` | `apply` / `review` / `roomAdjust` | **适合** | 待审申请需即时提醒;审核结果回给提交人可 USER 定向 | +| `AptFaceRegServiceImpl` | `apply` / `confirm` / `reject` | **适合** | 值班员确认环节,P1 | +| `AptJobServiceImpl` | 建单/派单/开始/完成/取消、自动建单 | **适合** | 工单是值班台/前台关注点;自动建单必须即时可见 | +| `AptRoomServiceImpl` | 房态编辑/删除/重算 | 部分适合 | 仅在房态实际变化时推 P2;纯配置修改不推 | +| `AptStaffServiceImpl` | 新增/启停/删除 | 不适合 | 低频管理操作,页面刷新即可;启停影响平板由 HTTP 拦截即时生效 | +| `AptDeviceServiceImpl` | `heartbeat` | 部分适合 | 心跳高频,**绝不逐条推**;只推"离线/恢复"跃变事件 | +| `AptDeviceServiceImpl` | `enqueue` | 可选 | 指令入队是后台动作,P2;对值班台价值低 | +| `AptReportServiceImpl` | 每条入队 / 重试 | 不适合 | 量大且非值班关注;改为"积压阈值/DEAD"聚合事件 | +| `AptPersonServiceImpl` | 人员查询/只读拒绝 | 不适合 | 纯查询;只读拒绝是管理端错误提示,HTTP 足够 | +| `AptMasterVersionServiceImpl` | `resync` | 不适合 | 同步进度低频、耗时长,轮询更稳 | +| `AptRouteServiceImpl` | 交路增改停 | 不适合 | 低频配置,页面刷新 | +| `AptSipEndpointServiceImpl` / `AptCallFlowServiceImpl` | 分机、拨号、挂断 | 部分适合 | 分机配置不推;拨号/挂断状态已由状态机覆盖 | +| `AptWorkOrderController` / `AptJcxxController` | 列表/统计/详情 | 不适合 | 纯读接口,前端按需拉取 | + +## 8. 实时推送 vs 轮询的边界 + +| 场景 | 选择 | 理由 | +| --- | --- | --- | +| 叫乘状态、报警、待审入住、工单建单/派单 | **实时** | 秒级影响值班动作,延迟直接转化为业务风险 | +| 列表/统计/详情/时间线 | **轮询或按需** | 数据量大、可复核;WS 只发"有变化"信号,页面再拉 | +| 设备心跳、outbox 每条记录 | **轮询/聚合** | 高频,逐条推送会造成消息风暴与前端渲染压力 | +| 主数据同步进度、自检结果 | **轮询** | 低频长任务,轮询实现简单且可重试 | +| 系统维护通告 | **实时(ALL)** | 需要所有人立即看到 | + +原则:**WS 负责"提醒",HTTP 负责"事实"**。前端收到消息后按 `type` 决定是否立即刷新对应列表;刷新失败不影响告警展示。 + +## 9. 与现有架构的兼容性 + +| 关注点 | 处理 | +| --- | --- | +| 事务一致性 | `AptRealtimePublisher` 在事务内自动注册 `afterCommit` 投递;回滚不发消息 | +| 业务可靠性 | 推送异常全部 catch + WARN,绝不冒泡影响状态机/工单事务 | +| gyid 隔离 | 握手用部署配置,投递按 `gyid` 过滤,杜绝跨公寓泄露 | +| 权限体系 | 复用若依 `LoginUser.getPermissions()`;新增权限点 `apt:realtime:connect`(3259,已建) | +| 既有接口 | 零改动;WS 关闭时系统行为与当前完全一致 | +| 前端路由 | 不新增页面、不改 `permission.js`;WS 客户端为独立工具模块(待接) | +| 安全 | 令牌走 query 有日志泄露风险,需 Nginx 关闭 query 日志或改用一次性 ticket(见 §10) | +| 集群 | 当前一公寓一进程;多实例需 Redis Pub/Sub 广播,注册表改分布式 | + +## 10. 交付物与未就绪 + +### 10.1 本轮交付 + +| 项 | 位置 | +| --- | --- | +| 消息信封 / 类型常量 | `framework/realtime/AptRealtimeMessage.java`、`AptRealtimeTypes.java` | +| 会话注册表(权限过滤/广播/定向/摘除/空闲清理) | `AptRealtimeSessionRegistry.java` | +| 握手鉴权(JWT + 权限点 + query token) | `AptRealtimeHandshakeInterceptor.java` | +| 连接处理(CONNECTED / PING-PONG / 断开) | `AptRealtimeHandler.java` | +| 注册配置(`/apt/ws`、Origin、开关) | `AptRealtimeConfig.java` | +| 发布器(afterCommit、失败隔离) | `IAptRealtimePublisher.java`、`AptRealtimePublisher.java` | +| 配置项 | `AptProperties.Realtime`、`apt-defaults.yml`(`APT_REALTIME_*`) | +| 安全放行 / 上下文 | `SecurityConfig`、`AptContextInterceptor` | +| 权限 SQL(已执行) | `30_realtime.sql`(3259,超管已绑定) | +| 单元测试 | `AptRealtimeTest`(5 例:路由/权限/广播/关闭摘除/超管) | + +验证:`mvn -o -pl ruoyi-apt -am test` → 251 passed(246 + 5)。 + +### 10.2 未就绪 + +| # | 未就绪项 | 影响 | 建议 | +| --- | --- | --- | --- | +| 1 | 前端 WS 客户端(`RuoYi-Vue2`) | 值班台尚不能实际接收 | 新增 `src/utils/ws.js`:连接/心跳/退避重连/按 type 分发 | +| 2 | 前端消息中心(声音、角标、置顶) | P0 报警无声音提示 | 与值班台页面一起做 | +| 3 | 下一批推送点(§6.2 #5~#15) | 只覆盖叫乘命令/平板结果/报警 | 按业务优先级分批接入 | +| 4 | 多实例广播 | 仅单进程有效 | 一公寓一进程下不阻塞;扩容前换 Redis Pub/Sub | +| 5 | 空闲会话定时清理 | 半开连接依赖 TCP/代理超时 | 挂 Quartz 每 30s 调用 `sweepIdle` | +| 6 | 令牌 query 泄露防护 | 日志可能记录 token | Nginx 对该路径关闭 query 日志,或改一次性 ticket | +| 7 | 压测与断网演练 | 并发连接数/重连风暴未验证 | 试点前 500 连接 + 断网 10 分钟演练 | +| 8 | WS 消息审计 | 目前只有业务审计,无推送审计 | 如需取证,落 `duty_operation_log`(量可控时) | + +## 11. 前端接入约定(供实现方) + +```js +// 伪代码:连接 → 心跳 → 重连 → 分发 +const ws = new WebSocket(`${wsBase}/apt/ws?token=${encodeURIComponent(token)}`) +ws.onmessage = e => { + const msg = JSON.parse(e.data) + if (msg.type === 'CONNECTED') startHeartbeat(msg.data.heartbeatIntervalSec) + if (msg.type === 'PONG') return + if (msg.type === 'CALL_ALARM') playAlarm() // P0:声音 + 置顶 + if (msg.category === 'CALL') refreshJcxxList() // P1:拉快照 + if (msg.category === 'JOB') refreshWorkOrders() +} +``` + +- 重连成功后必须重新拉一次快照,WS 不补发历史消息。 +- `type=CONNECTED` 是唯一可用的"连接就绪"信号;不要依赖 `onopen` 判断鉴权结果。