feat: 增加网页后台管理
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
package com.ttstd.signaling.config;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 后台管理接口鉴权过滤器:仅保护 /api/admin 下的接口,
|
||||
* 校验请求头 {@code X-Admin-Token} 是否与配置的令牌一致。
|
||||
* 令牌可通过 {@code admin.token} 配置项或环境变量 {@code ADMIN_TOKEN} 设置。
|
||||
*/
|
||||
@Component
|
||||
public class AdminAuthFilter extends OncePerRequestFilter {
|
||||
@Value("${admin.token:webrtc-admin-token}")
|
||||
private String adminToken;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String uri = request.getRequestURI();
|
||||
if (!uri.startsWith("/api/admin")) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
String token = request.getHeader("X-Admin-Token");
|
||||
if (adminToken != null && !adminToken.isBlank() && adminToken.equals(token)) {
|
||||
filterChain.doFilter(request, response);
|
||||
} else {
|
||||
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write("{\"code\":401,\"message\":\"未授权:无效的管理员令牌\"}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.ttstd.signaling.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* 为后台管理 REST 接口(/api/**)开放跨域,便于 WebRTCSignalServerWeb 在独立端口访问。
|
||||
* WebSocket 握手本身已在 {@link WebSocketConfig} 中允许全部来源。
|
||||
*/
|
||||
@Configuration
|
||||
public class CorsConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/api/**")
|
||||
.allowedOriginPatterns("*")
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.ttstd.signaling.controller;
|
||||
|
||||
import com.ttstd.signaling.manager.ConnectionRequestManager;
|
||||
import com.ttstd.signaling.manager.SessionManager;
|
||||
import com.ttstd.signaling.manager.SignalMetrics;
|
||||
import com.ttstd.signaling.model.DeviceInfo;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 后台管理 REST 接口:为 WebRTCSignalServerWeb 提供服务器运行态查询。
|
||||
* 所有接口均以 {@code /api/admin} 为前缀,并由 {@link AdminAuthFilter} 统一鉴权。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
public class AdminController {
|
||||
|
||||
private final SessionManager sessionManager;
|
||||
private final ConnectionRequestManager connectionRequestManager;
|
||||
private final SignalMetrics metrics;
|
||||
private final long startTime = System.currentTimeMillis();
|
||||
|
||||
public AdminController(SessionManager sessionManager,
|
||||
ConnectionRequestManager connectionRequestManager,
|
||||
SignalMetrics metrics) {
|
||||
this.sessionManager = sessionManager;
|
||||
this.connectionRequestManager = connectionRequestManager;
|
||||
this.metrics = metrics;
|
||||
}
|
||||
|
||||
/** 仪表盘汇总数据:设备在线情况、待确认连接、流量指标与运行时长。 */
|
||||
@GetMapping("/dashboard")
|
||||
public Map<String, Object> dashboard() {
|
||||
List<DeviceInfo> devices = sessionManager.getAllDeviceDetails();
|
||||
long controllers = devices.stream()
|
||||
.filter(d -> "CONTROLLER".equals(d.deviceType()) && d.online()).count();
|
||||
long controlled = devices.stream()
|
||||
.filter(d -> "CONTROLLED".equals(d.deviceType()) && d.online()).count();
|
||||
long online = devices.stream().filter(DeviceInfo::online).count();
|
||||
long offline = devices.size() - online;
|
||||
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("serverStartTime", startTime);
|
||||
data.put("currentTime", System.currentTimeMillis());
|
||||
data.put("uptimeMs", System.currentTimeMillis() - startTime);
|
||||
data.put("totalDevices", devices.size());
|
||||
data.put("onlineDevices", online);
|
||||
data.put("offlineDevices", offline);
|
||||
data.put("onlineControllers", controllers);
|
||||
data.put("onlineControlled", controlled);
|
||||
data.put("pendingConnections", connectionRequestManager.getPendingRequests().size());
|
||||
data.put("metrics", metrics.snapshot());
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 设备列表,可按 deviceType(CONTROLLER / CONTROLLED)过滤。 */
|
||||
@GetMapping("/devices")
|
||||
public List<DeviceInfo> devices(@RequestParam(required = false) String type) {
|
||||
List<DeviceInfo> all = sessionManager.getAllDeviceDetails();
|
||||
if (type == null || type.isBlank()) {
|
||||
return all;
|
||||
}
|
||||
final String upper = type.toUpperCase();
|
||||
return all.stream()
|
||||
.filter(d -> upper.equals(d.deviceType()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/** 当前处于“待被控端确认”状态的连接请求。 */
|
||||
@GetMapping("/connections")
|
||||
public List<Map<String, Object>> connections() {
|
||||
return connectionRequestManager.getPendingRequests();
|
||||
}
|
||||
|
||||
/** 服务器连接治理配置项(去重窗口、确认超时、WebSocket 端点等)。 */
|
||||
@GetMapping("/config")
|
||||
public Map<String, Object> config() {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("dedupWindowMs", connectionRequestManager.getDedupWindowMs());
|
||||
data.put("requestTimeoutMs", connectionRequestManager.getRequestTimeoutMs());
|
||||
data.put("websocketEndpoint", "/ws/signal");
|
||||
data.put("serverPort", 8080);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 健康检查。 */
|
||||
@GetMapping("/health")
|
||||
public Map<String, Object> health() {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("status", "UP");
|
||||
data.put("currentTime", System.currentTimeMillis());
|
||||
data.put("onlineDevices", sessionManager.getOnlineCount());
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.ttstd.signaling.handler;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ttstd.signaling.manager.ConnectionRequestManager;
|
||||
import com.ttstd.signaling.manager.SessionManager;
|
||||
import com.ttstd.signaling.manager.SignalMetrics;
|
||||
import com.ttstd.signaling.model.DeviceType;
|
||||
import com.ttstd.signaling.model.SignalMessage;
|
||||
import org.slf4j.Logger;
|
||||
@@ -26,10 +27,14 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
|
||||
|
||||
private final SessionManager sessionManager;
|
||||
private final ConnectionRequestManager connectionRequestManager;
|
||||
private final SignalMetrics metrics;
|
||||
|
||||
public SignalWebSocketHandler(SessionManager sessionManager, ConnectionRequestManager connectionRequestManager) {
|
||||
public SignalWebSocketHandler(SessionManager sessionManager,
|
||||
ConnectionRequestManager connectionRequestManager,
|
||||
SignalMetrics metrics) {
|
||||
this.sessionManager = sessionManager;
|
||||
this.connectionRequestManager = connectionRequestManager;
|
||||
this.metrics = metrics;
|
||||
this.connectionRequestManager.setSender(this::sendToDevice);
|
||||
}
|
||||
|
||||
@@ -54,6 +59,7 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
|
||||
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
|
||||
String payload = message.getPayload();
|
||||
logger.debug("Received message: {}", payload);
|
||||
metrics.incMessage();
|
||||
|
||||
try {
|
||||
SignalMessage signalMessage = objectMapper.readValue(payload, SignalMessage.class);
|
||||
@@ -75,16 +81,19 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
|
||||
// 客户端心跳保活消息,无需处理,仅用于防止中间代理因空闲超时断开连接
|
||||
break;
|
||||
case "OFFER":
|
||||
metrics.incOffer();
|
||||
// 连接请求:统一经 ConnectionRequestManager 做校验/去重/待确认跟踪后再转发
|
||||
handleConnectionRequest(signalMessage);
|
||||
break;
|
||||
case "ANSWER":
|
||||
metrics.incAnswer();
|
||||
// 被控端已接受:清理待确认状态并转发 Answer 给主控端
|
||||
connectionRequestManager.completePendingRequest(
|
||||
signalMessage.getToDeviceId(), signalMessage.getFromDeviceId());
|
||||
forwardMessage(signalMessage, true);
|
||||
break;
|
||||
case "CONNECTION_REJECTED":
|
||||
metrics.incRejected();
|
||||
// 被控端已拒绝:清理待确认状态并转发拒绝通知给主控端
|
||||
connectionRequestManager.completePendingRequest(
|
||||
signalMessage.getToDeviceId(), signalMessage.getFromDeviceId());
|
||||
@@ -109,12 +118,14 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
|
||||
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
|
||||
logger.info("WebSocket connection closed: {} ({})", session.getId(), status);
|
||||
sessionManager.unregisterSession(session);
|
||||
metrics.recordSessionCount(sessionManager.getOnlineCount());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
|
||||
logger.error("Transport error on session {}: {}", session.getId(), exception.getMessage());
|
||||
sessionManager.unregisterSession(session);
|
||||
metrics.recordSessionCount(sessionManager.getOnlineCount());
|
||||
if (session.isOpen()) {
|
||||
session.close(CloseStatus.SERVER_ERROR);
|
||||
}
|
||||
@@ -132,6 +143,8 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
|
||||
try {
|
||||
DeviceType deviceType = DeviceType.valueOf(deviceTypeStr.toUpperCase());
|
||||
sessionManager.registerDevice(deviceId, deviceType, session);
|
||||
metrics.incRegistered();
|
||||
metrics.recordSessionCount(sessionManager.getOnlineCount());
|
||||
|
||||
// 回复注册成功
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
@@ -5,10 +5,14 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
@@ -64,6 +68,29 @@ public class ConnectionRequestManager {
|
||||
return from + "->" + to;
|
||||
}
|
||||
|
||||
public long getDedupWindowMs() {
|
||||
return DEDUP_WINDOW_MS;
|
||||
}
|
||||
|
||||
public long getRequestTimeoutMs() {
|
||||
return REQUEST_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
/** 返回当前处于“待被控端确认”状态的连接请求列表(供后台管理接口使用)。 */
|
||||
public List<Map<String, Object>> getPendingRequests() {
|
||||
long now = System.currentTimeMillis();
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (PendingRequest p : pendingRequests.values()) {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("fromDeviceId", p.fromDeviceId);
|
||||
m.put("toDeviceId", p.toDeviceId);
|
||||
m.put("createdAt", p.createdAt);
|
||||
m.put("elapsedMs", now - p.createdAt);
|
||||
result.add(m);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 OFFER 的合法性(发送方必须为 CONTROLLER,接收方必须为 CONTROLLED)。
|
||||
*
|
||||
@@ -143,11 +170,13 @@ public class ConnectionRequestManager {
|
||||
private static class PendingRequest {
|
||||
final String fromDeviceId;
|
||||
final String toDeviceId;
|
||||
java.util.concurrent.ScheduledFuture<?> timeoutTask;
|
||||
final long createdAt;
|
||||
ScheduledFuture<?> timeoutTask;
|
||||
|
||||
PendingRequest(String fromDeviceId, String toDeviceId) {
|
||||
this.fromDeviceId = fromDeviceId;
|
||||
this.toDeviceId = toDeviceId;
|
||||
this.createdAt = System.currentTimeMillis();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.ttstd.signaling.manager;
|
||||
|
||||
import com.ttstd.signaling.model.DeviceInfo;
|
||||
import com.ttstd.signaling.model.DeviceType;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -7,8 +8,10 @@ import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Component
|
||||
@@ -22,11 +25,14 @@ public class SessionManager {
|
||||
private final Map<String, DeviceType> deviceTypes = new ConcurrentHashMap<>();
|
||||
// session.getId() -> deviceId (用于断开连接时清理)
|
||||
private final Map<String, String> sessionToDevice = new ConcurrentHashMap<>();
|
||||
// deviceId -> 注册(上线)时间戳
|
||||
private final Map<String, Long> connectTime = new ConcurrentHashMap<>();
|
||||
|
||||
public void registerDevice(String deviceId, DeviceType deviceType, WebSocketSession session) {
|
||||
sessions.put(deviceId, session);
|
||||
deviceTypes.put(deviceId, deviceType);
|
||||
sessionToDevice.put(session.getId(), deviceId);
|
||||
connectTime.put(deviceId, System.currentTimeMillis());
|
||||
logger.info("Device registered: {} ({})", deviceId, deviceType);
|
||||
}
|
||||
|
||||
@@ -35,6 +41,7 @@ public class SessionManager {
|
||||
if (deviceId != null) {
|
||||
sessions.remove(deviceId);
|
||||
deviceTypes.remove(deviceId);
|
||||
connectTime.remove(deviceId);
|
||||
logger.info("Device unregistered: {}", deviceId);
|
||||
}
|
||||
}
|
||||
@@ -71,4 +78,32 @@ public class SessionManager {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public int getOnlineCount() {
|
||||
int count = 0;
|
||||
for (WebSocketSession session : sessions.values()) {
|
||||
if (session.isOpen()) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
public Long getConnectedAt(String deviceId) {
|
||||
return connectTime.get(deviceId);
|
||||
}
|
||||
|
||||
/** 返回全部已注册设备的明细(含当前是否在线)。 */
|
||||
public List<DeviceInfo> getAllDeviceDetails() {
|
||||
Set<String> allIds = new HashSet<>();
|
||||
allIds.addAll(sessions.keySet());
|
||||
allIds.addAll(deviceTypes.keySet());
|
||||
List<DeviceInfo> result = new ArrayList<>();
|
||||
for (String id : allIds) {
|
||||
DeviceType type = deviceTypes.get(id);
|
||||
boolean online = isDeviceOnline(id);
|
||||
result.add(new DeviceInfo(id, type == null ? null : type.name(), online, connectTime.get(id)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.ttstd.signaling.manager;
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
/**
|
||||
* 信令流量与连接指标采集器(进程内计数)。
|
||||
* 由 {@link com.ttstd.signaling.handler.SignalWebSocketHandler} 在关键事件处累加。
|
||||
*/
|
||||
@Component
|
||||
public class SignalMetrics {
|
||||
private final AtomicLong totalMessages = new AtomicLong(0);
|
||||
private final AtomicLong totalOffers = new AtomicLong(0);
|
||||
private final AtomicLong totalAnswers = new AtomicLong(0);
|
||||
private final AtomicLong totalRejected = new AtomicLong(0);
|
||||
private final AtomicLong totalRegistered = new AtomicLong(0);
|
||||
private final AtomicLong peakSessions = new AtomicLong(0);
|
||||
public void incMessage() {
|
||||
totalMessages.incrementAndGet();
|
||||
}
|
||||
public void incOffer() {
|
||||
totalOffers.incrementAndGet();
|
||||
}
|
||||
public void incAnswer() {
|
||||
totalAnswers.incrementAndGet();
|
||||
}
|
||||
public void incRejected() {
|
||||
totalRejected.incrementAndGet();
|
||||
}
|
||||
public void incRegistered() {
|
||||
totalRegistered.incrementAndGet();
|
||||
}
|
||||
/** 记录历史峰值在线会话数(无锁 CAS 更新)。 */
|
||||
public void recordSessionCount(long count) {
|
||||
long cur;
|
||||
while ((cur = peakSessions.get()) < count) {
|
||||
if (peakSessions.compareAndSet(cur, count)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
public Map<String, Object> snapshot() {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("totalMessages", totalMessages.get());
|
||||
map.put("totalOffers", totalOffers.get());
|
||||
map.put("totalAnswers", totalAnswers.get());
|
||||
map.put("totalRejected", totalRejected.get());
|
||||
map.put("totalRegistered", totalRegistered.get());
|
||||
map.put("peakSessions", peakSessions.get());
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ttstd.signaling.model;
|
||||
|
||||
/**
|
||||
* 设备管理视图对象:用于在后台管理接口中序列化设备信息。
|
||||
*/
|
||||
public record DeviceInfo(
|
||||
String deviceId,
|
||||
String deviceType,
|
||||
boolean online,
|
||||
Long connectedAt) {
|
||||
}
|
||||
@@ -5,6 +5,11 @@ spring:
|
||||
application:
|
||||
name: webrtc-signal-server
|
||||
|
||||
# 后台管理接口鉴权令牌;可通过环境变量 ADMIN_TOKEN 覆盖。
|
||||
# WebRTCSignalServerWeb 登录时使用的默认令牌即为此值。
|
||||
admin:
|
||||
token: ${ADMIN_TOKEN:webrtc-admin-token}
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.ttstd.signaling: DEBUG
|
||||
|
||||
Reference in New Issue
Block a user