⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 风象南 「风象南」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

一、引言

随着互联网技术的发展,直播已成为一种主流的内容传播形式。

其中,连麦功能作为直播互动的重要手段,能够有效提升用户参与感和观看体验。

本文将介绍如何使用SpringBoot和WebSocket技术构建一个直播连麦系统,实现主播与观众之间的实时音视频交流和文字聊天功能。

为了方便DEMO的运行,本系统基于纯内存操作实现核心业务逻辑,不依赖外部数据库或者缓存组件。

二、技术设计

2.1 技术栈

后端:SpringBoot 3.4.5、WebSocket、STOMP子协议、JWT
前端: HTML5、CSS3、JavaScript、WebRTC、SockJS

2.2 整体架构

后端:基于SpringBoot实现,负责用户认证、直播间管理、WebSocket消息处理等
前端:负责用户界面交互、WebRTC音视频传输、WebSocket连接管理等
通信协议:WebSocket + STOMP实现实时消息传递,WebRTC实现P2P音视频传输

2.3 核心功能模块

用户认证模块:处理用户登录和简单认证
直播间管理模块:创建、查询和管理直播间
WebSocket通信模块:处理实时消息传递
连麦管理模块:处理连麦请求、状态管理和信令交换
聊天消息模块:处理直播间内的文字聊天

2.3 数据流程

  • 用户通过HTTP接口登录获取Token
  • 主播创建直播间,系统分配直播间ID
  • 观众通过直播间ID加入直播间
  • 观众发送连麦请求,主播确认后建立WebRTC连接
  • 所有用户通过WebSocket发送和接收聊天消息

三、后端实现

3.1 项目基础配置

Maven依赖

<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>

<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>

应用配置

server:
port: 8080

spring:
application:
name: livestream-system

jwt:
secret: livestreamSecretKey123456789012345678901234567890
expiration: 86400000

3.2 WebSocket配置

package com.example.livestream.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

@Autowired
private WebSocketAuthInterceptor webSocketAuthInterceptor;

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS();
}

/*@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 使用/topic和/queue前缀的目的地会路由到消息代理
registry.enableSimpleBroker("/topic", "/queue", "/user");

// 用户目标前缀
registry.setUserDestinationPrefix("/user");

// 应用前缀
registry.setApplicationDestinationPrefixes("/app");
}*/

@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 使用/topic和/queue前缀的目的地会路由到消息代理
registry.enableSimpleBroker("/topic", "/queue");

// 用户目标前缀
registry.setUserDestinationPrefix("/user");

// 应用前缀
registry.setApplicationDestinationPrefixes("/app");
}

@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(webSocketAuthInterceptor);
}
}

3.3 WebSocket认证拦截器

package com.example.livestream.config;

import com.example.livestream.service.UserService;
import com.example.livestream.util.JwtTokenUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.stereotype.Component;

@Component
@Slf4j
public class WebSocketAuthInterceptor implements ChannelInterceptor {

@Autowired
private JwtTokenUtil jwtTokenUtil;

@Autowired
private UserService userService;

@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
String token = accessor.getFirstNativeHeader("Authorization");

if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);

try {
String username = jwtTokenUtil.getUsernameFromToken(token);
if (username != null && jwtTokenUtil.validateToken(token)) {
accessor.setUser(() -> username);
log.info("User authenticated: {}", username);
}
} catch (Exception e) {
log.error("Invalid JWT token: {}", e.getMessage());
}
}
}

return message;
}
}

3.4 JWT工具类

package com.example.livestream.util;

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

@Component
public class JwtTokenUtil {

@Value("${jwt.secret}")
private String secret;

@Value("${jwt.expiration}")
private long expiration;

private Key getSigningKey() {
byte[] keyBytes = secret.getBytes();
return Keys.hmacShaKeyFor(keyBytes);
}

public String generateToken(String username) {
Map<String, Object> claims = new HashMap<>();
return createToken(claims, username);
}

private String createToken(Map<String, Object> claims, String subject) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);

return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}

public String getUsernameFromToken(String token) {
return getClaimFromToken(token, Claims::getSubject);
}

public String getUsername(){
// 获取HttpServletRequest对象
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
return (String)request.getAttribute("username");
}

public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
final Claims claims = getAllClaimsFromToken(token);
return claimsResolver.apply(claims);
}

private Claims getAllClaimsFromToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}

public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(getSigningKey()).build().parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
}

3.5 模型类设计

用户模型

package com.example.livestream.model;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
private String username;
private String password;
private String nickname;
private String avatar;
private UserRole role; // BROADCASTER or AUDIENCE

public enum UserRole {
BROADCASTER,
AUDIENCE
}
}

直播间模型

package com.example.livestream.model;

import lombok.Data;

import java.time.LocalDateTime;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;

@Data
public class LiveRoom {
private String roomId;
private String title;
private String broadcaster;
private LocalDateTime createdTime;
private boolean active;

// 连麦用户列表
private Set<String> activeMicUsers = new CopyOnWriteArraySet<>();

// 观众列表
private Set<String> audiences = new CopyOnWriteArraySet<>();

// 连麦申请列表
private Map<String, MicRequest> micRequests = new ConcurrentHashMap<>();

@Data
public static class MicRequest {
private String username;
private LocalDateTime requestTime;
private MicRequestStatus status;

public enum MicRequestStatus {
PENDING,
ACCEPTED,
REJECTED
}
}
}

消息模型

package com.example.livestream.model;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessage {
private String roomId;
private String sender;
private String content;
private LocalDateTime timestamp;
private MessageType type;

public enum MessageType {
CHAT,
JOIN,
LEAVE,
MIC_REQUEST,
MIC_RESPONSE,
SIGNAL
}
}

3.6 服务层实现

用户服务

package com.example.livestream.service;

import com.example.livestream.model.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Service
@Slf4j
public class UserService {

// 内存中存储用户信息
private final Map<String, User> users = new ConcurrentHashMap<>();

public UserService() {
// 初始化一些测试用户
users.put("zb1", new User("zb1", "zb1", "主播一号",
"avatar1.jpg", User.UserRole.BROADCASTER));
users.put("zb2", new User("zb2", "zb2", "主播二号",
"avatar2.jpg", User.UserRole.BROADCASTER));
users.put("gz1", new User("gz1", "gz1", "观众一号",
"user1.jpg", User.UserRole.AUDIENCE));
users.put("gz2", new User("gz2", "gz2", "观众二号",
"user2.jpg", User.UserRole.AUDIENCE));
}

public User getUserByUsername(String username) {
return users.get(username);
}

public boolean validateUser(String username, String password) {
User user = users.get(username);
return user != null && user.getPassword().equals(password);
}

public User registerUser(User user) {
if (users.containsKey(user.getUsername())) {
return null;
}
users.put(user.getUsername(), user);
return user;
}
}

直播间服务

package com.example.livestream.service;

import com.example.livestream.model.LiveRoom;
import com.example.livestream.model.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

@Service
@Slf4j
public class LiveRoomService {

@Autowired
private SimpMessagingTemplate messagingTemplate;

@Autowired
private UserService userService;

// 内存中存储直播间信息
private final Map<String, LiveRoom> liveRooms = new ConcurrentHashMap<>();

public LiveRoom createLiveRoom(String title, String username) {
User user = userService.getUserByUsername(username);
if (user == null || user.getRole() != User.UserRole.BROADCASTER) {
return null;
}

String roomId = UUID.randomUUID().toString().substring(0, 8);
LiveRoom liveRoom = new LiveRoom();
liveRoom.setRoomId(roomId);
liveRoom.setTitle(title);
liveRoom.setBroadcaster(username);
liveRoom.setCreatedTime(LocalDateTime.now());
liveRoom.setActive(true);

liveRooms.put(roomId, liveRoom);
log.info("Live room created: {} by {}", roomId, username);
return liveRoom;
}

public LiveRoom getLiveRoom(String roomId) {
return liveRooms.get(roomId);
}

public List<LiveRoom> getAllActiveLiveRooms() {
List<LiveRoom> activeRooms = new ArrayList<>();
for (LiveRoom room : liveRooms.values()) {
if (room.isActive()) {
activeRooms.add(room);
}
}
return activeRooms;
}

public boolean joinLiveRoom(String roomId, String username) {
LiveRoom room = liveRooms.get(roomId);
if (room == null || !room.isActive()) {
return false;
}

room.getAudiences().add(username);
log.info("User {} joined room {}", username, roomId);
return true;
}

public boolean leaveLiveRoom(String roomId, String username) {
LiveRoom room = liveRooms.get(roomId);
if (room == null) {
return false;
}

room.getAudiences().remove(username);
room.getActiveMicUsers().remove(username);
room.getMicRequests().remove(username);
log.info("User {} left room {}", username, roomId);
return true;
}

public boolean closeLiveRoom(String roomId, String username) {
LiveRoom room = liveRooms.get(roomId);
if (room == null || !room.getBroadcaster().equals(username)) {
return false;
}

room.setActive(false);
log.info("Room {} closed by {}", roomId, username);
return true;
}

public boolean requestMic(String roomId, String username) {
LiveRoom room = liveRooms.get(roomId);
if (room == null || !room.isActive() || !room.getAudiences().contains(username)) {
return false;
}

LiveRoom.MicRequest request = new LiveRoom.MicRequest();
request.setUsername(username);
request.setRequestTime(LocalDateTime.now());
request.setStatus(LiveRoom.MicRequest.MicRequestStatus.PENDING);

room.getMicRequests().put(username, request);

// 通知主播有新的连麦请求
messagingTemplate.convertAndSendToUser(
room.getBroadcaster(),
"/queue/mic-requests",
request);

log.info("Mic request from {} in room {}", username, roomId);
return true;
}

public boolean handleMicRequest(String roomId, String requestUsername,
boolean accept, String broadcasterUsername) {
LiveRoom room = liveRooms.get(roomId);
if (room == null || !room.isActive() ||
!room.getBroadcaster().equals(broadcasterUsername)) {
return false;
}

LiveRoom.MicRequest request = room.getMicRequests().get(requestUsername);
if (request == null) {
return false;
}

if (accept) {
request.setStatus(LiveRoom.MicRequest.MicRequestStatus.ACCEPTED);
room.getActiveMicUsers().add(requestUsername);
} else {
request.setStatus(LiveRoom.MicRequest.MicRequestStatus.REJECTED);
}

// 通知申请者结果
messagingTemplate.convertAndSendToUser(
requestUsername,
"/queue/mic-response",
request);

log.info("Mic request from {} was {} by broadcaster in room {}",
requestUsername, accept ? "accepted" : "rejected", roomId);
return true;
}

public boolean endMic(String roomId, String username, String initiator) {
LiveRoom room = liveRooms.get(roomId);
if (room == null || !room.isActive()) {
return false;
}

// 只有主播或用户自己可以结束连麦
if (!room.getBroadcaster().equals(initiator) && !username.equals(initiator)) {
return false;
}

room.getActiveMicUsers().remove(username);
room.getMicRequests().remove(username);

// 通知相关用户连麦已结束
messagingTemplate.convertAndSendToUser(
username,
"/queue/mic-ended",
roomId);

if (!username.equals(room.getBroadcaster())) {
messagingTemplate.convertAndSendToUser(
room.getBroadcaster(),
"/queue/mic-ended",
username);
}

log.info("Mic ended for {} in room {}, initiated by {}",
username, roomId, initiator);
return true;
}
}

聊天服务

package com.example.livestream.service;

import com.example.livestream.model.ChatMessage;
import com.example.livestream.model.LiveRoom;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Service
@Slf4j
public class ChatService {

@Autowired
private SimpMessagingTemplate messagingTemplate;

@Autowired
private LiveRoomService liveRoomService;

// 内存中存储每个直播间的最近消息
private final Map<String, List<ChatMessage>> roomMessages = new ConcurrentHashMap<>();
private final int MAX_MESSAGES_PER_ROOM = 100;

public void sendMessage(ChatMessage message) {
LiveRoom room = liveRoomService.getLiveRoom(message.getRoomId());
if (room == null || !room.isActive()) {
return;
}

message.setTimestamp(LocalDateTime.now());

// 将消息广播到直播间
messagingTemplate.convertAndSend("/topic/room/" + message.getRoomId(), message);

// 存储消息
roomMessages.computeIfAbsent(message.getRoomId(), k -> new ArrayList<>()).add(message);

// 限制消息数量
List<ChatMessage> messages = roomMessages.get(message.getRoomId());
if (messages.size() > MAX_MESSAGES_PER_ROOM) {
messages.remove(0);
}

log.info("Message sent in room {}: {}", message.getRoomId(), message.getContent());
}

public List<ChatMessage> getRecentMessages(String roomId) {
return roomMessages.getOrDefault(roomId, new ArrayList<>());
}

public void sendJoinMessage(String roomId, String username) {
ChatMessage message = new ChatMessage();
message.setRoomId(roomId);
message.setSender("System");
message.setContent(username + " 加入了直播间");
message.setTimestamp(LocalDateTime.now());
message.setType(ChatMessage.MessageType.JOIN);

messagingTemplate.convertAndSend("/topic/room/" + roomId, message);
}

public void sendLeaveMessage(String roomId, String username) {
ChatMessage message = new ChatMessage();
message.setRoomId(roomId);
message.setSender("System");
message.setContent(username + " 离开了直播间");
message.setTimestamp(LocalDateTime.now());
message.setType(ChatMessage.MessageType.LEAVE);

messagingTemplate.convertAndSend("/topic/room/" + roomId, message);
}

public void sendSignalingMessage(String roomId, String sender, String receiver, String content) {
ChatMessage message = new ChatMessage();
message.setRoomId(roomId);
message.setSender(sender);
message.setContent(content);
message.setTimestamp(LocalDateTime.now());
message.setType(ChatMessage.MessageType.SIGNAL);

// 发送给特定用户
messagingTemplate.convertAndSendToUser(
receiver,
"/queue/signal",
message);

log.info("Signal sent from {} to {} in room {}", sender, receiver, roomId);
}
}

3.7 控制器实现

认证控制器

package com.example.livestream.controller;

import com.example.livestream.model.User;
import com.example.livestream.service.UserService;
import com.example.livestream.util.JwtTokenUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/api/auth")
@Slf4j
public class AuthController {

@Autowired
private UserService userService;

@Autowired
private JwtTokenUtil jwtTokenUtil;

@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody Map<String, String> loginRequest) {
String username = loginRequest.get("username");
String password = loginRequest.get("password");

if (userService.validateUser(username, password)) {
User user = userService.getUserByUsername(username);
String token = jwtTokenUtil.generateToken(username);

Map<String, Object> response = new HashMap<>();
response.put("token", token);
response.put("username", username);
response.put("nickname", user.getNickname());
response.put("role", user.getRole());
response.put("avatar", user.getAvatar());

log.info("User logged in: {}", username);
return ResponseEntity.ok(response);
} else {
return ResponseEntity.badRequest().body("Invalid username or password");
}
}

@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody User user) {
User registeredUser = userService.registerUser(user);
if (registeredUser != null) {
log.info("User registered: {}", user.getUsername());
return ResponseEntity.ok("User registered successfully");
} else {
return ResponseEntity.badRequest().body("Username already exists");
}
}
}

直播间控制器

package com.example.livestream.controller;

import com.example.livestream.model.LiveRoom;
import com.example.livestream.service.LiveRoomService;
import com.example.livestream.util.JwtTokenUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/rooms")
@Slf4j
public class LiveRoomController {

@Autowired
private LiveRoomService liveRoomService;

@Autowired
private JwtTokenUtil jwtTokenUtil;

@PostMapping
public ResponseEntity<?> createRoom(@RequestBody Map<String, String> roomRequest) {
String title = roomRequest.get("title");
LiveRoom room = liveRoomService.createLiveRoom(title, jwtTokenUtil.getUsername());

if (room != null) {
return ResponseEntity.ok(room);
} else {
return ResponseEntity.badRequest().body("Failed to create room");
}
}

@PostMapping("/{roomId}/mic-response")
public ResponseEntity<?> handleMicResponse(
@PathVariable String roomId,
@RequestParam String username,
@RequestParam boolean accept) {

boolean result = liveRoomService.handleMicRequest(roomId, username, accept, jwtTokenUtil.getUsername());
if (result) {
return ResponseEntity.ok().build();
} else {
return ResponseEntity.badRequest().body("Failed to handle mic request");
}
}

@GetMapping
public ResponseEntity<List<LiveRoom>> getAllRooms() {
return ResponseEntity.ok(liveRoomService.getAllActiveLiveRooms());
}

@GetMapping("/{roomId}")
public ResponseEntity<?> getRoomInfo(@PathVariable String roomId) {
LiveRoom room = liveRoomService.getLiveRoom(roomId);
if (room != null) {
return ResponseEntity.ok(room);
} else {
return ResponseEntity.notFound().build();
}
}

@PostMapping("/{roomId}/join")
public ResponseEntity<?> joinRoom(@PathVariable String roomId) {
boolean joined = liveRoomService.joinLiveRoom(roomId, jwtTokenUtil.getUsername());
if (joined) {
return ResponseEntity.ok().build();
} else {
return ResponseEntity.badRequest().body("Failed to join room");
}
}

@PostMapping("/{roomId}/leave")
public ResponseEntity<?> leaveRoom(@PathVariable String roomId) {
boolean left = liveRoomService.leaveLiveRoom(roomId, jwtTokenUtil.getUsername());
if (left) {
return ResponseEntity.ok().build();
} else {
return ResponseEntity.badRequest().body("Failed to leave room");
}
}

@PostMapping("/{roomId}/close")
public ResponseEntity<?> closeRoom(@PathVariable String roomId) {
boolean closed = liveRoomService.closeLiveRoom(roomId, jwtTokenUtil.getUsername());
if (closed) {
return ResponseEntity.ok().build();
} else {
return ResponseEntity.badRequest().body("Failed to close room");
}
}

@GetMapping("/{roomId}/mic-status")
public ResponseEntity<?> getMicStatus(
@PathVariable String roomId,
@RequestParam String username) {

if (!jwtTokenUtil.getUsername().equals(username)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Unauthorized");
}

LiveRoom room = liveRoomService.getLiveRoom(roomId);
if (room == null) {
return ResponseEntity.notFound().build();
}

LiveRoom.MicRequest request = room.getMicRequests().get(username);
Map<String, Object> result = new HashMap<>();

if (request == null) {
// 没有找到请求,可能是已经被处理并移除
if (room.getActiveMicUsers().contains(username)) {
result.put("status", "ACCEPTED");
} else {
result.put("status", "NOT_FOUND");
}
} else {
result.put("status", request.getStatus().toString());
}

return ResponseEntity.ok(result);
}
}

WebSocket消息控制器

package com.example.livestream.controller;

import com.example.livestream.model.ChatMessage;
import com.example.livestream.model.LiveRoom;
import com.example.livestream.service.ChatService;
import com.example.livestream.service.LiveRoomService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.annotation.SubscribeMapping;
import org.springframework.stereotype.Controller;

import java.security.Principal;
import java.util.List;
import java.util.Map;

@Controller
@Slf4j
public class WebSocketController {

@Autowired
private ChatService chatService;

@Autowired
private LiveRoomService liveRoomService;

@Autowired
private SimpMessagingTemplate messagingTemplate;

@MessageMapping("/heartbeat")
public void processHeartbeat(String message, Principal principal) {
if (principal != null) {
log.debug("Received heartbeat from {}: {}", principal.getName(), message);
}
}

@MessageMapping("/chat.send/{roomId}")
public void sendMessage(@DestinationVariable String roomId,
@Payload ChatMessage chatMessage,
Principal principal) {
if (principal == null) return;

chatMessage.setRoomId(roomId);
chatMessage.setSender(principal.getName());
chatMessage.setType(ChatMessage.MessageType.CHAT);

chatService.sendMessage(chatMessage);
}

@SubscribeMapping("/room/{roomId}")
public List<ChatMessage> subscribeRoom(@DestinationVariable String roomId,
Principal principal,
SimpMessageHeaderAccessor headerAccessor) {
if (principal == null) return null;

String username = principal.getName();
liveRoomService.joinLiveRoom(roomId, username);
chatService.sendJoinMessage(roomId, username);

return chatService.getRecentMessages(roomId);
}

@MessageMapping("/mic.request/{roomId}")
public void requestMic(@DestinationVariable String roomId, Principal principal, SimpMessageHeaderAccessor headerAccessor) {
if (principal == null) {
log.error("Cannot request mic: user not authenticated");
return;
}

String username = principal.getName();
log.info("Received mic request from {} for room {}", username, roomId);

// 输出当前会话的所有信息,帮助调试
log.info("Session ID: {}", headerAccessor.getSessionId());
log.info("User: {}", headerAccessor.getUser());
log.info("Headers: {}", headerAccessor.toMap());

boolean success = liveRoomService.requestMic(roomId, username);
log.info("Mic request processing result: {}", success ? "SUCCESS" : "FAILED");

// 如果处理失败,直接通知请求者
if (!success) {
messagingTemplate.convertAndSendToUser(
username,
"/queue/mic-response",
"Mic request failed");
}
}

@MessageMapping("/mic.response/{roomId}")
public void handleMicRequest(@DestinationVariable String roomId,
@Payload Map<String, Object> payload,
Principal principal) {
if (principal == null) return;

String requestUsername = (String) payload.get("username");
boolean accept = (boolean) payload.get("accept");

liveRoomService.handleMicRequest(roomId, requestUsername, accept, principal.getName());
}

@MessageMapping("/mic.end/{roomId}")
public void endMic(@DestinationVariable String roomId,
@Payload String username,
Principal principal) {
if (principal == null) return;

liveRoomService.endMic(roomId, username, principal.getName());
}

@MessageMapping("/signal/{roomId}")
public void handleSignal(@DestinationVariable String roomId,
@Payload Map<String, String> payload,
Principal principal) {
if (principal == null) return;

String sender = principal.getName();
String receiver = payload.get("receiver");
String content = payload.get("content");

chatService.sendSignalingMessage(roomId, sender, receiver, content);
}
}

四、前端实现

4.1 HTML结构

登录页面 (login.html)

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>直播连麦系统 - 登录</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div class="login-container">
<div class="login-box">
<h1>直播连麦系统</h1>
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" placeholder="请输入用户名">
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" placeholder="请输入密码">
</div>
<button id="login-btn">登录</button>
<p class="register-link">没有账号?<a href="register.html">注册</a></p>
</div>
</div>
<script src="js/auth.js"></script>
</body>
</html>

直播大厅 (index.html)

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>直播连麦系统 - 大厅</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<header>
<div class="logo">直播连麦系统</div>
<div class="user-info">
<span id="user-nickname"></span>
<img id="user-avatar" src="" alt="头像">
<button id="logout-btn">退出</button>
</div>
</header>
<main>
<div class="create-room-box" id="broadcaster-controls">
<h2>创建直播间</h2>
<div class="form-group">
<label for="room-title">直播间标题</label>
<input type="text" id="room-title" placeholder="请输入直播间标题">
</div>
<button id="create-room-btn">创建直播间</button>
</div>
<div class="room-list">
<h2>直播间列表</h2>
<div id="rooms-container"></div>
</div>
</main>
<script src="js/auth.js"></script>
<script src="js/index.js"></script>
</body>
</html>

直播间页面 (room.html)

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>直播间</title>
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/room.css">
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
</head>
<body>
<div class="room-container">
<header class="room-header">
<div class="room-info">
<h1 id="room-title">加载中...</h1>
<div class="broadcaster-info">
<span>主播:</span>
<span id="broadcaster-name"></span>
</div>
</div>
<div class="room-controls">
<button id="back-to-lobby">返回大厅</button>
</div>
</header>
<main class="room-main">
<div class="video-section">
<div class="main-video">
<video id="broadcaster-video" autoplay playsinline></video>
<div class="video-label" id="broadcaster-label"></div>
</div>
<div class="audience-videos" id="audience-videos-container"></div>
</div>
<div class="interaction-section">
<div class="chat-box">
<div class="chat-messages" id="chat-messages"></div>
<div class="chat-input">
<input type="text" id="chat-input" placeholder="发送消息...">
<button id="send-message-btn">发送</button>
</div>
</div>
<div class="control-panel">
<div id="audience-controls">
<button id="request-mic-btn">申请连麦</button>
<div id="mic-status"></div>
</div>
<div id="broadcaster-controls" style="display: none;">
<div class="mic-requests">
<h3>连麦申请</h3>
<ul id="mic-requests-list"></ul>
</div>
<div class="active-mics">
<h3>当前连麦</h3>
<ul id="active-mics-list"></ul>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="js/auth.js"></script>
<script src="js/webrtc.js"></script>
<script src="js/room.js"></script>
</body>
</html>

4.2 JavaScript实现

认证相关 (auth.js)

const API_URL = 'http://localhost:8080/api';

// 检查是否已登录
function checkAuth() {
const token = localStorage.getItem('token');
if (!token) {
// 未登录,跳转到登录页
if (window.location.pathname !== '/login.html' &&
window.location.pathname !== '/register.html') {
window.location.href = 'login.html';
}
return false;
}
return true;
}

// 获取当前用户信息
function getCurrentUser() {
return {
username: localStorage.getItem('username'),
nickname: localStorage.getItem('nickname'),
role: localStorage.getItem('role'),
avatar: localStorage.getItem('avatar')
};
}

// 登录处理
async function login(username, password) {
try {
const response = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText);
}

const data = await response.json();

// 存储认证信息
localStorage.setItem('token', data.token);
localStorage.setItem('username', data.username);
localStorage.setItem('nickname', data.nickname);
localStorage.setItem('role', data.role);
localStorage.setItem('avatar', data.avatar);

return true;
} catch (error) {
console.error('Login failed:', error);
return false;
}
}

// 注册处理
async function register(userData) {
try {
const response = await fetch(`${API_URL}/auth/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(userData)
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText);
}

return true;
} catch (error) {
console.error('Registration failed:', error);
return false;
}
}

// 退出登录
function logout() {
localStorage.removeItem('token');
localStorage.removeItem('username');
localStorage.removeItem('nickname');
localStorage.removeItem('role');
localStorage.removeItem('avatar');
window.location.href = 'login.html';
}

// 获取授权头信息
function getAuthHeader() {
const token = localStorage.getItem('token');
return {
'Authorization': `Bearer ${token}`
};
}

// 页面加载时检查认证状态
document.addEventListener('DOMContentLoaded', function() {
// 登录页面处理
if (window.location.pathname.includes('login.html')) {
const loginForm = document.getElementById('login-btn');
if (loginForm) {
loginForm.addEventListener('click', async function() {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;

if (!username || !password) {
alert('请输入用户名和密码');
return;
}

const success = await login(username, password);
if (success) {
window.location.href = 'index.html';
} else {
alert('登录失败,请检查用户名和密码');
}
});
}
return;
}

// 其他页面需要检查认证
if (!checkAuth()) {
return;
}

// 显示用户信息
const userNickname = document.getElementById('user-nickname');
const userAvatar = document.getElementById('user-avatar');
const logoutBtn = document.getElementById('logout-btn');

if (userNickname) {
userNickname.textContent = getCurrentUser().nickname;
}

if (userAvatar) {
userAvatar.src = getCurrentUser().avatar || 'img/default-avatar.png';
}

if (logoutBtn) {
logoutBtn.addEventListener('click', logout);
}

// 根据用户角色显示/隐藏主播控制面板
const broadcasterControls = document.getElementById('broadcaster-controls');
if (broadcasterControls && getCurrentUser().role !== 'BROADCASTER') {
broadcasterControls.style.display = 'none';
}
});

直播大厅页面脚本 (index.js)

document.addEventListener('DOMContentLoaded', function() {
if (!checkAuth()) return;

loadRooms();
setupCreateRoom();
});

// 加载直播间列表
async function loadRooms() {
try {
const response = await fetch(`${API_URL}/rooms`, {
headers: getAuthHeader()
});

if (!response.ok) {
throw new Error('Failed to load rooms');
}

const rooms = await response.json();
displayRooms(rooms);
} catch (error) {
console.error('Error loading rooms:', error);
alert('加载直播间列表失败');
}
}

// 显示直播间列表
function displayRooms(rooms) {
const container = document.getElementById('rooms-container');
container.innerHTML = '';

if (rooms.length === 0) {
container.innerHTML = '<p class="no-rooms">当前没有直播间,创建一个吧!</p>';
return;
}

rooms.forEach(room => {
const roomElement = document.createElement('div');
roomElement.className = 'room-card';
roomElement.innerHTML = `
<h3>${room.title}</h3>
<div class="room-info">
<p>主播: ${room.broadcaster}</p>
<p>观众: ${room.audiences.length}人</p>
<p>连麦: ${room.activeMicUsers.length}人</p>
</div>
<button class="join-room-btn" data-room-id="${room.roomId}">进入直播间</button>
`;
container.appendChild(roomElement);
});

// 添加进入直播间的点击事件
document.querySelectorAll('.join-room-btn').forEach(button => {
button.addEventListener('click', function() {
const roomId = this.getAttribute('data-room-id');
window.location.href = `room.html?id=${roomId}`;
});
});
}

// 设置创建直播间功能
function setupCreateRoom() {
const createBtn = document.getElementById('create-room-btn');
if (!createBtn) return;

createBtn.addEventListener('click', async function() {
const title = document.getElementById('room-title').value;

if (!title) {
alert('请输入直播间标题');
return;
}

try {
const response = await fetch(`${API_URL}/rooms`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeader()
},
body: JSON.stringify({ title })
});

if (!response.ok) {
throw new Error('Failed to create room');
}

const room = await response.json();
window.location.href = `room.html?id=${room.roomId}`;
} catch (error) {
console.error('Error creating room:', error);
alert('创建直播间失败');
}
});
}

WebRTC实现 (webrtc.js)

class WebRTCClient {
constructor(username, roomId, isBroadcaster) {
this.username = username;
this.roomId = roomId;
this.isBroadcaster = isBroadcaster;
this.localStream = null;
this.peerConnections = {};
this.stompClient = null;
this.mediaConstraints = {
audio: true,
video: true
};

// ICE服务器配置
this.iceServers = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
};
}

// 初始化WebSocket连接
async connectWebSocket() {
return new Promise((resolve, reject) => {
const socket = new SockJS('/ws');

// 增加STOMP客户端调试
const client = Stomp.over(socket);
client.debug = function(str) {
console.log('STOMP Debug: ' + str);
};
this.stompClient = client;

const headers = {
'Authorization': `Bearer ${localStorage.getItem('token')}`
};

this.stompClient.connect(headers, frame => {
console.log('Connected to WebSocket:', frame);

// 订阅信令消息
this.stompClient.subscribe(`/user/queue/signal`, message => {
console.log('Received signal message:', message.body);
const signalData = JSON.parse(message.body);
this.handleSignalingData(signalData);
});

// 订阅连麦响应
this.stompClient.subscribe(`/user/queue/mic-response`, message => {
console.log('Received mic response:', message.body);
try {
const response = JSON.parse(message.body);
this.handleMicResponse(response);
} catch (e) {
console.log('Non-JSON mic response:', message.body);
}
});

// 订阅连麦结束通知
this.stompClient.subscribe(`/user/queue/mic-ended`, message => {
console.log('Received mic ended notification:', message.body);
const data = message.body;
this.handleMicEnded(data);
});

// 如果是主播,订阅连麦请求
if (this.isBroadcaster) {
console.log('Broadcaster subscribing to mic-requests');

// 方法1: 标准订阅
this.stompClient.subscribe(`/user/queue/mic-requests`, message => {
console.log('Received mic request (standard):', message.body);
try {
const request = JSON.parse(message.body);
this.handleMicRequest(request);
} catch (e) {
console.error('Error parsing mic request:', e);
}
});

// 方法2: 尝试直接订阅完整路径
const username = localStorage.getItem('username');
this.stompClient.subscribe(`/user/${username}/queue/mic-requests`, message => {
console.log('Received mic request (direct):', message.body);
try {
const request = JSON.parse(message.body);
this.handleMicRequest(request);
} catch (e) {
console.error('Error parsing mic request:', e);
}
});
}

resolve();
}, error => {
console.error('WebSocket connection error:', error);
reject(error);
});
});
}

// 初始化本地媒体流
async initLocalStream() {
try {
this.localStream = await navigator.mediaDevices.getUserMedia(this.mediaConstraints);
return this.localStream;
} catch (error) {
console.error('Error accessing media devices:', error);
throw error;
}
}

// 创建一个新的RTCPeerConnection
createPeerConnection(remoteUsername) {
const pc = new RTCPeerConnection(this.iceServers);

// 添加本地流到连接
this.localStream.getTracks().forEach(track => {
pc.addTrack(track, this.localStream);
});

// 监听ICE候选
pc.onicecandidate = event => {
if (event.candidate) {
this.sendSignalingData(remoteUsername, {
type: 'ice-candidate',
candidate: event.candidate
});
}
};

// 监听远程流
pc.ontrack = event => {
const remoteStream = event.streams[0];
this.onRemoteStreamAdded(remoteUsername, remoteStream);
};

this.peerConnections[remoteUsername] = pc;
return pc;
}

// 发起连接(创建offer)
async createOffer(remoteUsername) {
try {
const pc = this.createPeerConnection(remoteUsername);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);

this.sendSignalingData(remoteUsername, {
type: 'offer',
sdp: pc.localDescription
});
} catch (error) {
console.error('Error creating offer:', error);
}
}

// 响应offer(创建answer)
async handleOffer(offer, remoteUsername) {
try {
const pc = this.createPeerConnection(remoteUsername);
await pc.setRemoteDescription(new RTCSessionDescription(offer));

const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);

this.sendSignalingData(remoteUsername, {
type: 'answer',
sdp: pc.localDescription
});
} catch (error) {
console.error('Error handling offer:', error);
}
}

// 处理answer
async handleAnswer(answer, remoteUsername) {
try {
const pc = this.peerConnections[remoteUsername];
if (pc) {
await pc.setRemoteDescription(new RTCSessionDescription(answer));
}
} catch (error) {
console.error('Error handling answer:', error);
}
}

// 处理ICE候选
async handleIceCandidate(candidate, remoteUsername) {
try {
const pc = this.peerConnections[remoteUsername];
if (pc) {
await pc.addIceCandidate(new RTCIceCandidate(candidate));
}
} catch (error) {
console.error('Error handling ICE candidate:', error);
}
}

// 发送信令数据
sendSignalingData(receiver, data) {
if (!this.stompClient) {
console.error('WebSocket not connected');
return;
}

this.stompClient.send(`/app/signal/${this.roomId}`, {}, JSON.stringify({
receiver: receiver,
content: JSON.stringify(data)
}));
}

// 处理收到的信令数据
handleSignalingData(signalData) {
const sender = signalData.sender;
const content = JSON.parse(signalData.content);

switch (content.type) {
case 'offer':
this.handleOffer(content.sdp, sender);
break;
case 'answer':
this.handleAnswer(content.sdp, sender);
break;
case 'ice-candidate':
this.handleIceCandidate(content.candidate, sender);
break;
default:
console.warn('Unknown signal type:', content.type);
}
}

// 申请连麦
requestMic() {
if (!this.stompClient || !this.stompClient.connected) {
console.error('WebSocket not connected');
alert('WebSocket连接未建立,无法申请连麦');
return false;
}

console.log(`Sending mic request for room ${this.roomId} by user ${this.username}`);

try {
// 添加更多错误处理
this.stompClient.send(`/app/mic.request/${this.roomId}`, {}, JSON.stringify({
// 添加更多信息以便调试
timestamp: new Date().getTime(),
username: this.username
}));
console.log('Mic request sent successfully');
return true;
} catch (error) {
console.error('Error sending mic request:', error);
alert('发送连麦请求失败: ' + error.message);
return false;
}
}

// 处理连麦响应
async handleMicResponse(response) {
console.log('处理连麦响应:', response);

// 兼容字符串和对象两种响应格式
let status;
if (typeof response === 'string') {
console.log('收到字符串格式的响应:', response);
// 尝试解析字符串响应
if (response.includes('ACCEPTED')) {
status = 'ACCEPTED';
} else if (response.includes('REJECTED')) {
status = 'REJECTED';
} else {
console.warn('无法识别的响应内容:', response);
return;
}
} else {
// 对象格式响应
status = response.status;
}

console.log('连麦响应状态:', status);

if (status === 'ACCEPTED') {
try {
console.log('连麦请求已接受,初始化媒体流');
// 初始化本地媒体流(如果还没有)
if (!this.localStream) {
await this.initLocalStream();
this.onLocalStreamCreated(this.localStream);
}

// 等待主播发起WebRTC连接
console.log('等待主播发起WebRTC连接');
this.onMicRequestAccepted();
} catch (error) {
console.error('初始化媒体流失败:', error);
alert('连麦初始化失败: ' + error.message);
}
} else if (status === 'REJECTED') {
console.log('连麦请求被拒绝');
this.onMicRequestRejected();
} else {
console.warn('未知的连麦响应状态:', status);
}
}

// 添加新的回调方法
onMicRequestAccepted() {
console.log('连麦请求已接受,等待连接建立');
// 这个方法会由使用者实现
}

// 处理连麦请求(主播)
async handleMicRequest(request) {
// 通知UI显示请求
this.onMicRequestReceived(request);
}

// 响应连麦请求(主播)
async respondToMicRequest(username, accept) {
if (!this.stompClient) {
console.error('WebSocket not connected');
return;
}

this.stompClient.send(`/app/mic.response/${this.roomId}`, {}, JSON.stringify({
username: username,
accept: accept
}));

if (accept) {
// 初始化本地媒体流(如果还没有)
if (!this.localStream) {
await this.initLocalStream();
this.onLocalStreamCreated(this.localStream);
}

// 主播发起WebRTC连接
await this.createOffer(username);
}
}

// 结束连麦
endMic(username) {
if (!this.stompClient) {
console.error('WebSocket not connected');
return;
}

this.stompClient.send(`/app/mic.end/${this.roomId}`, {}, username);
this.closePeerConnection(username);
}

// 处理连麦结束
handleMicEnded(data) {
if (this.isBroadcaster) {
// 主播收到用户结束连麦的通知
this.closePeerConnection(data);
this.onMicEnded(data);
} else {
// 用户收到主播结束连麦的通知
this.closePeerConnection(this.roomId);
this.onMicEnded();
}
}

// 关闭对等连接
closePeerConnection(username) {
const pc = this.peerConnections[username];
if (pc) {
pc.close();
delete this.peerConnections[username];
}
}

// 关闭所有连接
close() {
// 关闭所有对等连接
Object.keys(this.peerConnections).forEach(username => {
this.closePeerConnection(username);
});

// 关闭本地媒体流
if (this.localStream) {
this.localStream.getTracks().forEach(track => track.stop());
}

// 断开WebSocket连接
if (this.stompClient && this.stompClient.connected) {
this.stompClient.disconnect();
}
}

// 以下方法需要在使用时实现
onLocalStreamCreated(stream) {
// 显示本地视频流
console.log('Local stream created, implement this method');
}

onRemoteStreamAdded(username, stream) {
// 显示远程视频流
console.log('Remote stream added for', username, 'implement this method');
}

onMicRequestReceived(request) {
// 显示连麦请求
console.log('Mic request received from', request.username, 'implement this method');
}

onMicRequestRejected() {
// 连麦请求被拒绝
console.log('Mic request rejected, implement this method');
}

onMicEnded(username) {
// 连麦结束
console.log('Mic ended for', username, 'implement this method');
}
}

直播间页面脚本 (room.js)

document.addEventListener('DOMContentLoaded', function() {
if (!checkAuth()) return;

const urlParams = new URLSearchParams(window.location.search);
const roomId = urlParams.get('id');

if (!roomId) {
alert('直播间ID不能为空');
window.location.href = 'index.html';
return;
}

const currentUser = getCurrentUser();
const isBroadcaster = currentUser.role === 'BROADCASTER';

// 初始化WebRTC客户端
const webrtcClient = new WebRTCClient(currentUser.username, roomId, isBroadcaster);

// 明确设置为全局变量
window.webrtcClient = webrtcClient;

// 设置事件处理函数
setupEventHandlers(webrtcClient);

// 加载直播间信息
loadRoomInfo(roomId);

// 连接WebSocket
initializeWebSocketAndMedia(webrtcClient, isBroadcaster);
});


// 设置事件处理函数
function setupEventHandlers(webrtcClient) {
// 返回大厅按钮
document.getElementById('back-to-lobby').addEventListener('click', function() {
webrtcClient.close();
window.location.href = 'index.html';
});

// 发送消息按钮
document.getElementById('send-message-btn').addEventListener('click', sendChatMessage);

// 聊天输入框回车发送
document.getElementById('chat-input').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
sendChatMessage();
}
});

// 申请连麦按钮(观众)
const requestMicBtn = document.getElementById('request-mic-btn');
if (requestMicBtn) {
requestMicBtn.addEventListener('click', function() {
console.log('申请连麦按钮被点击');

try {
const success = webrtcClient.requestMic();

if (success) {
this.disabled = true;
this.textContent = '已申请连麦,等待主播接受...';
document.getElementById('mic-status').textContent = '连麦申请中...';
console.log('连麦申请已发送');
} else {
console.error('连麦申请发送失败');
}
} catch (error) {
console.error('申请连麦时发生错误:', error);
alert('申请连麦失败: ' + error.message);
}
});
}

// 重写WebRTC回调函数
webrtcClient.onLocalStreamCreated = function(stream) {
const videoElement = document.getElementById('local-video') ||
document.getElementById('broadcaster-video');
videoElement.srcObject = stream;
};

webrtcClient.onRemoteStreamAdded = function(username, stream) {
let videoElement;

if (webrtcClient.isBroadcaster) {
// 主播看到观众视频
const audienceContainer = document.getElementById('audience-videos-container');
const audienceVideoDiv = document.createElement('div');
audienceVideoDiv.className = 'audience-video-container';
audienceVideoDiv.id = `audience-${username}`;

audienceVideoDiv.innerHTML = `
<video class="audience-video" id="video-${username}" autoplay playsinline></video>
<div class="video-label">${username}</div>
<button class="end-mic-btn" data-username="${username}">结束连麦</button>
`;

audienceContainer.appendChild(audienceVideoDiv);
videoElement = document.getElementById(`video-${username}`);

// 添加结束连麦按钮事件
audienceVideoDiv.querySelector('.end-mic-btn').addEventListener('click', function() {
const username = this.getAttribute('data-username');
webrtcClient.endMic(username);
});
} else {
// 观众看到主播视频
videoElement = document.getElementById('broadcaster-video');
}

if (videoElement) {
videoElement.srcObject = stream;
}
};

webrtcClient.onMicRequestReceived = function(request) {
console.log('处理连麦请求:', request);

const requestsList = document.getElementById('mic-requests-list');
if (!requestsList) {
console.error('找不到连麦请求列表元素');
return;
}

// 检查是否已存在相同的请求
const existingRequest = document.getElementById(`request-${request.username}`);
if (existingRequest) {
console.log('已存在相同用户的连麦请求,不重复添加');
return;
}

const requestItem = document.createElement('li');
requestItem.className = 'mic-request-item';
requestItem.id = `request-${request.username}`;
requestItem.innerHTML = `
<span>${request.username} 申请连麦</span>
<div class="request-actions">
<button class="accept-btn" data-username="${request.username}">接受</button>
<button class="reject-btn" data-username="${request.username}">拒绝</button>
</div>
`;

requestsList.appendChild(requestItem);
console.log('连麦请求UI已添加');

// 添加按钮事件
const acceptBtn = requestItem.querySelector('.accept-btn');
if (acceptBtn) {
acceptBtn.addEventListener('click', function() {
const username = this.getAttribute('data-username');
console.log(`接受用户 ${username} 的连麦请求`);

// 同时使用WebSocket和HTTP API发送响应
webrtcClient.respondToMicRequest(username, true);

// 备用HTTP请求
const urlParams = new URLSearchParams(window.location.search);
const roomId = urlParams.get('id');
fetch(`${API_URL}/rooms/${roomId}/mic-response?username=${username}&accept=true`, {
method: 'POST',
headers: getAuthHeader()
})
.then(response => {
if (!response.ok) {
throw new Error('HTTP API连麦响应失败');
}
console.log('通过HTTP API发送连麦接受成功');
})
.catch(error => {
console.error('发送连麦响应失败:', error);
});

requestItem.remove();

// 添加到活跃连麦列表
const activeList = document.getElementById('active-mics-list');
if (activeList) {
const activeItem = document.createElement('li');
activeItem.className = 'active-mic-item';
activeItem.id = `active-${username}`;
activeItem.innerHTML = `
<span>${username}</span>
<button class="end-mic-btn" data-username="${username}">结束连麦</button>
`;
activeList.appendChild(activeItem);

// 添加结束连麦按钮事件
const endMicBtn = activeItem.querySelector('.end-mic-btn');
if (endMicBtn) {
endMicBtn.addEventListener('click', function() {
const username = this.getAttribute('data-username');
console.log(`结束与用户 ${username} 的连麦`);
webrtcClient.endMic(username);
});
}
}
});
}

const rejectBtn = requestItem.querySelector('.reject-btn');
if (rejectBtn) {
rejectBtn.addEventListener('click', function() {
const username = this.getAttribute('data-username');
console.log(`拒绝用户 ${username} 的连麦请求`);

// 同时使用WebSocket和HTTP API发送响应
webrtcClient.respondToMicRequest(username, false);

// 备用HTTP请求
const urlParams = new URLSearchParams(window.location.search);
const roomId = urlParams.get('id');
fetch(`${API_URL}/rooms/${roomId}/mic-response?username=${username}&accept=false`, {
method: 'POST',
headers: getAuthHeader()
})
.then(response => {
if (!response.ok) {
throw new Error('HTTP API连麦响应失败');
}
console.log('通过HTTP API发送连麦拒绝成功');
})
.catch(error => {
console.error('发送连麦响应失败:', error);
});

requestItem.remove();
});
}
};

webrtcClient.onMicEnded = function(username) {
if (webrtcClient.isBroadcaster && username) {
// 主播结束观众连麦
const audienceVideo = document.getElementById(`audience-${username}`);
if (audienceVideo) {
audienceVideo.remove();
}

const activeMicItem = document.getElementById(`active-${username}`);
if (activeMicItem) {
activeMicItem.remove();
}
} else {
// 观众的连麦被结束
const requestMicBtn = document.getElementById('request-mic-btn');
if (requestMicBtn) {
requestMicBtn.disabled = false;
requestMicBtn.textContent = '申请连麦';
}
document.getElementById('mic-status').textContent = '连麦已结束';

// 停止本地视频
const localVideo = document.getElementById('local-video');
if (localVideo && localVideo.srcObject) {
localVideo.srcObject.getTracks().forEach(track => track.stop());
localVideo.srcObject = null;
}
}
};

webrtcClient.onMicRequestAccepted = function() {
const requestMicBtn = document.getElementById('request-mic-btn');
if (requestMicBtn) {
requestMicBtn.disabled = true;
requestMicBtn.textContent = '连麦已接受,正在建立连接...';
}
document.getElementById('mic-status').textContent = '连麦已接受,等待连接建立...';

// 可以添加一个本地视频预览
const videoSection = document.querySelector('.video-section');
if (!document.getElementById('local-video-container')) {
const localVideoContainer = document.createElement('div');
localVideoContainer.className = 'local-video-container';
localVideoContainer.id = 'local-video-container';
localVideoContainer.innerHTML = `
<video id="local-video" autoplay playsinline muted></video>
<div class="video-label">我(观众)</div>
<button id="end-local-mic-btn">结束连麦</button>
`;
videoSection.appendChild(localVideoContainer);

// 设置本地视频流
if (webrtcClient.localStream) {
document.getElementById('local-video').srcObject = webrtcClient.localStream;
}

// 添加结束连麦按钮事件
document.getElementById('end-local-mic-btn').addEventListener('click', function() {
webrtcClient.endMic(getCurrentUser().username);
});
}
};

webrtcClient.onMicRequestRejected = function() {
const requestMicBtn = document.getElementById('request-mic-btn');
if (requestMicBtn) {
requestMicBtn.disabled = false;
requestMicBtn.textContent = '申请连麦';
}
document.getElementById('mic-status').textContent = '连麦申请被拒绝';

// 提醒用户
alert('主播拒绝了您的连麦申请');
};

}

// 加载直播间信息
async function loadRoomInfo(roomId) {
try {
const response = await fetch(`${API_URL}/rooms/${roomId}`, {
headers: getAuthHeader()
});

if (!response.ok) {
throw new Error('Failed to load room info');
}

const room = await response.json();

// 更新页面信息
document.getElementById('room-title').textContent = room.title;
document.getElementById('broadcaster-name').textContent = room.broadcaster;

// 显示主播控制面板或观众控制面板
const currentUser = getCurrentUser();
const isBroadcaster = currentUser.username === room.broadcaster;

if (isBroadcaster) {
document.getElementById('broadcaster-controls').style.display = 'block';
document.getElementById('audience-controls').style.display = 'none';
document.getElementById('broadcaster-label').textContent = '我(主播)';
} else {
document.getElementById('broadcaster-controls').style.display = 'none';
document.getElementById('audience-controls').style.display = 'block';
document.getElementById('broadcaster-label').textContent = room.broadcaster;
}

} catch (error) {
console.error('Error loading room info:', error);
alert('加载直播间信息失败');
window.location.href = 'index.html';
}
}

// 初始化WebSocket和媒体流
async function initializeWebSocketAndMedia(webrtcClient, isBroadcaster) {
try {
// 连接WebSocket
await webrtcClient.connectWebSocket();

// 订阅直播间消息
subscribeToRoomMessages(webrtcClient.stompClient, webrtcClient.roomId);

// 如果是主播,初始化本地媒体流
if (isBroadcaster) {
const localStream = await webrtcClient.initLocalStream();
webrtcClient.onLocalStreamCreated(localStream);
}

} catch (error) {
console.error('Error initializing:', error);
alert('初始化连接失败');
}
}

// 订阅直播间消息
function subscribeToRoomMessages(stompClient, roomId) {
stompClient.subscribe(`/topic/room/${roomId}`, function(message) {
const chatMessage = JSON.parse(message.body);
displayChatMessage(chatMessage);
});
}

// 发送聊天消息
function sendChatMessage() {
const inputElement = document.getElementById('chat-input');
const content = inputElement.value.trim();

if (!content) return;

const urlParams = new URLSearchParams(window.location.search);
const roomId = urlParams.get('id');

// 添加检查确保webrtcClient存在
if (window.webrtcClient && window.webrtcClient.stompClient && window.webrtcClient.stompClient.connected) {
window.webrtcClient.stompClient.send(`/app/chat.send/${roomId}`, {}, JSON.stringify({
content: content,
type: 'CHAT'
}));

inputElement.value = '';
} else {
console.error('WebSocket连接未建立');
alert('消息发送失败,WebSocket连接未建立');
}
}

// 显示聊天消息
function displayChatMessage(message) {
const messagesContainer = document.getElementById('chat-messages');
const messageElement = document.createElement('div');
messageElement.className = 'chat-message';

switch (message.type) {
case 'CHAT':
messageElement.innerHTML = `
<span class="message-sender">${message.sender}:</span>
<span class="message-content">${message.content}</span>
<span class="message-time">${formatTime(message.timestamp)}</span>
`;
break;
case 'JOIN':
case 'LEAVE':
messageElement.className += ' system-message';
messageElement.innerHTML = `
<span class="message-content">${message.content}</span>
<span class="message-time">${formatTime(message.timestamp)}</span>
`;
break;
}

messagesContainer.appendChild(messageElement);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}

function setupMicResponsePolling(roomId, username) {
// 只有观众且有正在等待的连麦请求时才需要轮询
const isBroadcaster = getCurrentUser().role === 'BROADCASTER';
if (isBroadcaster) {
return;
}

let isPolling = false;
const requestMicBtn = document.getElementById('request-mic-btn');

// 监听按钮状态变化
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'attributes' && mutation.attributeName === 'disabled') {
// 按钮被禁用,表示已发送连麦请求
if (requestMicBtn.disabled && !isPolling) {
startPolling();
} else if (!requestMicBtn.disabled && isPolling) {
stopPolling();
}
}
});
});

observer.observe(requestMicBtn, { attributes: true });

let pollingInterval;

function startPolling() {
console.log('开始轮询连麦响应');
isPolling = true;

pollingInterval = setInterval(async function() {
try {
const response = await fetch(`${API_URL}/rooms/${roomId}/mic-status?username=${username}`, {
headers: getAuthHeader()
});

if (!response.ok) {
throw new Error('获取连麦状态失败');
}

const data = await response.json();
console.log('轮询到的连麦状态:', data);

if (data.status === 'ACCEPTED') {
console.log('连麦请求已被接受');
// 手动触发处理
webrtcClient.handleMicResponse({ status: 'ACCEPTED' });
stopPolling();
} else if (data.status === 'REJECTED') {
console.log('连麦请求已被拒绝');
// 手动触发处理
webrtcClient.handleMicResponse({ status: 'REJECTED' });
stopPolling();
}
// PENDING状态继续轮询

} catch (error) {
console.error('轮询连麦状态失败:', error);
}
}, 2000); // 每2秒轮询一次
}

function stopPolling() {
if (pollingInterval) {
clearInterval(pollingInterval);
isPolling = false;
console.log('停止轮询连麦响应');
}
}

// 页面卸载时清除轮询
window.addEventListener('beforeunload', stopPolling);
}

// 格式化时间
function formatTime(timestamp) {
if (!timestamp) return '';

const date = new Date(timestamp);
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}

4.3 CSS样式

基础样式 (style.css)

/* style.css */

* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

body {
font-family: Arial, sans-serif;
background-color: #f5f5f5;
color: #333;
line-height: 1.6;
}

/* 登录和注册页面样式 */
.login-container, .register-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #f0f2f5;
}

.login-box, .register-box {
width: 400px;
padding: 30px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

.login-box h1, .register-box h1 {
text-align: center;
margin-bottom: 20px;
color: #1890ff;
}

.form-group {
margin-bottom: 15px;
}

.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}

.form-group input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
}

button {
width: 100%;
padding: 12px;
background-color: #1890ff;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s;
}

button:hover {
background-color: #40a9ff;
}

button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}

.register-link, .login-link {
text-align: center;
margin-top: 20px;
}

.register-link a, .login-link a {
color: #1890ff;
text-decoration: none;
}

/* 头部导航栏 */
header {
background-color: #1890ff;
color: white;
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}

.logo {
font-size: 20px;
font-weight: bold;
}

.user-info {
display: flex;
align-items: center;
}

.user-info span {
margin-right: 10px;
}

.user-info img {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 10px;
}

/* 大厅页面样式 */
main {
max-width: 1200px;
margin: 20px auto;
padding: 0 20px;
}

.create-room-box {
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
}

.create-room-box h2 {
margin-bottom: 15px;
color: #1890ff;
}

.room-list {
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}

.room-list h2 {
margin-bottom: 15px;
color: #1890ff;
}

.room-card {
border: 1px solid #eee;
padding: 15px;
border-radius: 4px;
margin-bottom: 15px;
transition: all 0.3s;
}

.room-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.room-card h3 {
margin-bottom: 10px;
color: #333;
}

.room-info {
margin-bottom: 15px;
color: #666;
}

.room-card .join-room-btn {
width: auto;
padding: 8px 15px;
}

.no-rooms {
text-align: center;
color: #999;
padding: 20px;
}

直播间页面样式 (room.css)

/* room.css */

.room-container {
display: flex;
flex-direction: column;
height: 100vh;
}

.room-header {
background-color: #1890ff;
color: white;
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}

.room-info h1 {
margin: 0;
font-size: 24px;
}

.broadcaster-info {
font-size: 14px;
margin-top: 5px;
}

.room-controls button {
width: auto;
padding: 8px 15px;
background-color: white;
color: #1890ff;
}

.room-main {
display: flex;
flex: 1;
overflow: hidden;
}

.video-section {
flex: 2;
display: flex;
flex-direction: column;
background-color: #000;
position: relative;
}

.main-video {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
position: relative;
}

.main-video video {
width: 100%;
height: 100%;
object-fit: contain;
}

.video-label {
position: absolute;
bottom: 10px;
left: 10px;
background-color: rgba(0, 0, 0, 0.6);
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 14px;
}

.audience-videos {
height: 150px;
display: flex;
padding: 10px;
gap: 10px;
background-color: #1a1a1a;
overflow-x: auto;
}

.audience-video-container {
width: 200px;
height: 100%;
position: relative;
background-color: #333;
border-radius: 4px;
overflow: hidden;
}

.audience-video {
width: 100%;
height: 100%;
object-fit: cover;
}

.end-mic-btn {
position: absolute;
transform: translateY( 5px;
right: 5px;
width: auto;
padding: 3px 8px;
font-size: 12px;
background-color: #ff4d4f;
}

.interaction-section {
flex: 1;
display: flex;
flex-direction: column;
background-color: white;
border-left: 1px solid #eee;
}

.chat-box {
flex: 2;
display: flex;
flex-direction: column;
border-bottom: 1px solid #eee;
}

.chat-messages {
flex: 1;
padding: 15px;
overflow-y: auto;
}

.chat-message {
margin-bottom: 10px;
padding: 8px 10px;
background-color: #f9f9f9;
border-radius: 4px;
word-break: break-word;
}

.message-sender {
font-weight: bold;
color: #1890ff;
margin-right: 5px;
}

.message-time {
font-size: 12px;
color: #999;
margin-left: 5px;
}

.system)-message {
background-color: #f0f0f0;
color: #888;
text-align: center;
font-style: italic;
}

.chat-input {
display: flex;
padding: 10px;
border-top: 1px solid #eee;
}

.chat-input input {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
margin-right: 10px;
}

.chat-input button {
width: auto;
}

.control-panel {
flex: 1;
padding: 15px;
overflow-y: auto;
}

#audience-controls {
text-align: center;
}

#mic-status {
margin-top: 10px;
font-size: 14px;
color: #666;
}

.mic-requests, .active-mics {
margin-bottom: 20px;
}

.mic-requests h3, .active-mics h3 {
font-size: 16px;
margin-bottom: 10px;
color: #1890ff;
}

.mic-request-item, .active-mic-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background-color: #f9f9f9;
border-radius: 4px;
margin-bottom: 10px;
}

.request-actions {
display: flex;
gap: 5px;
}

.accept-btn, .reject-btn {
width: auto;
padding: 5px 10px;
font-size: 12px;
}

.accept-btn {
background-color: #52c41a;
}

.reject-btn {
background-color: #ff4d4f;
}

/* 响应式布局 */
@media (max-width: 768px) {
.room-main {
flex-direction: column;
}

.audience-videos {
height: 100px;
}

.audience-video-container {
width: 120px;
}

.interaction-section {
border-left: none;
border-top: 1px solid #eee;
}
}

五、功能演示流程

5.1 登录主播账号

打开一个浏览器窗口,访问http://localhost:8080/login.html 输入账号 zb1, 密码 zb1登录系统创建直播间

5.2 登录观众账号

新打开另一个浏览器窗口(注意不是多个TAB,是独立的浏览器窗口),输入账号 gz1, 密码 gz1登录系统,进入直播间,申请连麦

5.3 接受或拒绝连麦

主播收到连麦申请,可以选择接受或者拒绝连麦

此处需要注意,同一台电脑的device只能在一个窗口使用,也就是没法在两个窗口同时看到画面内容

文章目录
  1. 1. 一、引言
  2. 2. 二、技术设计
    1. 2.0.1. 2.1 技术栈
    2. 2.0.2. 2.2 整体架构
    3. 2.0.3. 2.3 核心功能模块
    4. 2.0.4. 2.3 数据流程
  • 3. 三、后端实现
    1. 3.0.1. 3.1 项目基础配置
      1. 3.0.1.1. Maven依赖
      2. 3.0.1.2. 应用配置
    2. 3.0.2. 3.2 WebSocket配置
    3. 3.0.3. 3.3 WebSocket认证拦截器
    4. 3.0.4. 3.4 JWT工具类
    5. 3.0.5. 3.5 模型类设计
      1. 3.0.5.1. 用户模型
      2. 3.0.5.2. 直播间模型
      3. 3.0.5.3. 消息模型
    6. 3.0.6. 3.6 服务层实现
      1. 3.0.6.1. 用户服务
      2. 3.0.6.2. 直播间服务
      3. 3.0.6.3. 聊天服务
    7. 3.0.7. 3.7 控制器实现
      1. 3.0.7.1. 认证控制器
      2. 3.0.7.2. 直播间控制器
      3. 3.0.7.3. WebSocket消息控制器
  • 4. 四、前端实现
    1. 4.0.1. 4.1 HTML结构
      1. 4.0.1.1. 登录页面 (login.html)
      2. 4.0.1.2. 直播大厅 (index.html)
      3. 4.0.1.3. 直播间页面 (room.html)
    2. 4.0.2. 4.2 JavaScript实现
      1. 4.0.2.1. 认证相关 (auth.js)
      2. 4.0.2.2. 直播大厅页面脚本 (index.js)
      3. 4.0.2.3. WebRTC实现 (webrtc.js)
      4. 4.0.2.4. 直播间页面脚本 (room.js)
    3. 4.0.3. 4.3 CSS样式
      1. 4.0.3.1. 基础样式 (style.css)
      2. 4.0.3.2. 直播间页面样式 (room.css)
  • 5. 五、功能演示流程
    1. 5.0.1. 5.1 登录主播账号
    2. 5.0.2. 5.2 登录观众账号
    3. 5.0.3. 5.3 接受或拒绝连麦