feat: 增加网页后台管理

This commit is contained in:
2026-07-21 17:46:00 +08:00
parent 1edb87b5f2
commit ef786b1529
42 changed files with 5222 additions and 2 deletions

View File

@@ -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\":\"未授权:无效的管理员令牌\"}");
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
/** 设备列表,可按 deviceTypeCONTROLLER / 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;
}
}

View File

@@ -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<>();

View File

@@ -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();
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,11 @@
package com.ttstd.signaling.model;
/**
* 设备管理视图对象:用于在后台管理接口中序列化设备信息。
*/
public record DeviceInfo(
String deviceId,
String deviceType,
boolean online,
Long connectedAt) {
}

View File

@@ -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