项目介绍
校园活动直播平台是一个面向高校师生的在线直播系统,旨在为校园文化活动、学术讲座、社团活动等提供线上直播服务。该平台采用前后端分离架构,使用主流的Java技术栈开发。
技术架构
后端技术栈
- Spring Boot 2.7.x
- Spring Security
- MyBatis Plus
- Redis
- MySQL 8.0
- WebSocket
- JWT认证
前端技术栈
- Vue 3
- Element Plus
- Axios
- WebRTC
- Vue Router
- Pinia
直播相关技术
- SRS流媒体服务器
- RTMP协议
- HLS协议
- WebRTC
核心功能模块
1. 用户管理模块
- 用户注册与登录
- 角色权限管理(管理员、主播、观众)
- 个人信息管理
- JWT token认证
2. 直播管理模块
- 直播间创建与管理
- 直播状态控制
- 直播间信息设置
- 直播数据统计
3. 互动功能模块
- 实时弹幕系统
- 在线人数统计
- 直播间聊天
- 点赞功能
4. 活动管理模块
- 活动发布与管理
- 活动分类管理
- 活动审核流程
- 活动日程安排
5. 内容存储模块
- 直播回放功能
- 视频点播系统
- 文件存储管理
- 视频转码服务
数据库设计
主要数据表
-- 用户表
CREATE TABLE `user` (
`id` bigint NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL,
`password` varchar(100) NOT NULL,
`nickname` varchar(50),
`avatar` varchar(255),
`role` varchar(20),
`create_time` datetime,
PRIMARY KEY (`id`)
);
-- 直播间表
CREATE TABLE `live_room` (
`id` bigint NOT NULL AUTO_INCREMENT,
`title` varchar(100) NOT NULL,
`cover_img` varchar(255),
`user_id` bigint,
`status` tinyint,
`viewer_count` int,
`start_time` datetime,
`end_time` datetime,
PRIMARY KEY (`id`)
);
-- 活动表
CREATE TABLE `activity` (
`id` bigint NOT NULL AUTO_INCREMENT,
`title` varchar(100) NOT NULL,
`description` text,
`category` varchar(50),
`status` tinyint,
`start_time` datetime,
`end_time` datetime,
PRIMARY KEY (`id`)
);
关键技术实现
1. 直播流处理
@Service
public class LiveStreamService {
@Autowired
private SrsClient srsClient;
public String createLiveStream(Long roomId) {
// 生成推流地址
String pushUrl = generatePushUrl(roomId);
// 生成拉流地址
String pullUrl = generatePullUrl(roomId);
return new StreamInfo(pushUrl, pullUrl);
}
private String generatePushUrl(Long roomId) {
// 实现推流地址生成逻辑
return "rtmp://localhost:1935/live/" + roomId;
}
}
2. WebSocket实现弹幕系统
@ServerEndpoint("/websocket/{roomId}")
@Component
public class WebSocketServer {
private static Map<String, Set<Session>> roomSessions = new ConcurrentHashMap<>();
@OnMessage
public void onMessage(String message, Session session, @PathParam("roomId") String roomId) {
// 处理接收到的弹幕消息
broadcastMessage(roomId, message);
}
private void broadcastMessage(String roomId, String message) {
Set<Session> sessions = roomSessions.get(roomId);
if (sessions != null) {
sessions.forEach(session -> {
session.getAsyncRemote().sendText(message);
});
}
}
}
3. 直播间在线人数统计
@Service
public class ViewerCountService {
@Autowired
private StringRedisTemplate redisTemplate;
public void incrementViewerCount(Long roomId) {
String key = "live:room:" + roomId + ":viewers";
redisTemplate.opsForValue().increment(key);
}
public void decrementViewerCount(Long roomId) {
String key = "live:room:" + roomId + ":viewers";
redisTemplate.opsForValue().decrement(key);
}
public Long getViewerCount(Long roomId) {
String key = "live:room:" + roomId + ":viewers";
return Long.parseLong(redisTemplate.opsForValue().get(key));
}
}
项目部署
部署架构
- Nginx反向代理
- Docker容器化部署
- Redis集群
- MySQL主从复制
Docker部署示例
version: '3'
services:
app:
build: .
ports:
- "8080:8080"
depends_on:
- mysql
- redis
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: live_platform
redis:
image: redis:6.2
ports:
- "6379:6379"
srs:
image: ossrs/srs:4
ports:
- "1935:1935"
- "1985:1985"
- "8080:8080"
性能优化
-
Redis缓存优化
- 使用Redis缓存热点数据
- 实现分布式锁
- 设置合理的缓存过期策略
-
数据库优化
- 合理设计索引
- SQL语句优化
- 分库分表设计
-
直播流优化
- 多码率转码
- CDN加速
- 延迟优化
项目亮点
- 采用前后端分离架构,提高开发效率和系统可维护性
- 使用WebSocket实现实时弹幕和在线聊天功能
- 整合多种直播协议,支持不同场景需求
- 实现分布式部署,提高系统可用性
- 完善的监控和日志系统,便于问题排查
总结与展望
本项目实现了一个功能完整的校园活动直播平台,涵盖了用户管理、直播管理、互动功能等多个模块。通过使用主流的Java技术栈,不仅保证了系统的稳定性和可扩展性,也为后续功能扩展提供了良好的基础。
后续优化方向
- 引入AI技术,实现智能弹幕过滤
- 优化直播延迟,提升用户体验
- 增加更多互动功能
- 完善数据统计和分析功能
校园活动直播平台 - 用户管理模块详细设计
一、数据库设计
1. 用户表(user)
CREATE TABLE `user` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`username` varchar(50) NOT NULL COMMENT '用户名',
`password` varchar(100) NOT NULL COMMENT '密码',
`nickname` varchar(50) COMMENT '昵称',
`real_name` varchar(50) COMMENT '真实姓名',
`student_id` varchar(20) COMMENT '学号',
`avatar` varchar(255) COMMENT '头像URL',
`email` varchar(100) COMMENT '邮箱',
`phone` varchar(20) COMMENT '手机号',
`status` tinyint DEFAULT 1 COMMENT '状态(0:禁用,1:启用)',
`last_login_time` datetime COMMENT '最后登录时间',
`last_login_ip` varchar(50) COMMENT '最后登录IP',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`),
UNIQUE KEY `uk_email` (`email`),
UNIQUE KEY `uk_phone` (`phone`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
2. 角色表(role)
CREATE TABLE `role` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '角色ID',
`name` varchar(50) NOT NULL COMMENT '角色名称',
`code` varchar(50) NOT NULL COMMENT '角色编码',
`description` varchar(200) COMMENT '角色描述',
`status` tinyint DEFAULT 1 COMMENT '状态(0:禁用,1:启用)',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表';
3. 用户角色关联表(user_role)
CREATE TABLE `user_role` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL COMMENT '用户ID',
`role_id` bigint NOT NULL COMMENT '角色ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_role` (`user_id`,`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表';
4. 权限表(permission)
CREATE TABLE `permission` (
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL COMMENT '权限名称',
`code` varchar(50) NOT NULL COMMENT '权限编码',
`type` tinyint NOT NULL COMMENT '类型(1:菜单,2:按钮)',
`parent_id` bigint COMMENT '父权限ID',
`path` varchar(200) COMMENT '路由路径',
`icon` varchar(100) COMMENT '图标',
`sort` int DEFAULT 0 COMMENT '排序',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限表';
5. 角色权限关联表(role_permission)
CREATE TABLE `role_permission` (
`id` bigint NOT NULL AUTO_INCREMENT,
`role_id` bigint NOT NULL COMMENT '角色ID',
`permission_id` bigint NOT NULL COMMENT '权限ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_role_permission` (`role_id`,`permission_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色权限关联表';
二、核心功能实现
1. 用户注册
@Service
@Slf4j
public class UserService {
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private UserMapper userMapper;
@Autowired
private UserRoleMapper userRoleMapper;
@Transactional
public void register(UserRegisterDTO registerDTO) {
// 1. 参数校验
validateRegisterParams(registerDTO);
// 2. 检查用户名是否已存在
if (userMapper.selectByUsername(registerDTO.getUsername()) != null) {
throw new BusinessException("用户名已存在");
}
// 3. 创建用户
User user = new User();
user.setUsername(registerDTO.getUsername());
// 密码加密
user.setPassword(passwordEncoder.encode(registerDTO.getPassword()));
user.setEmail(registerDTO.getEmail());
user.setPhone(registerDTO.getPhone());
user.setNickname(registerDTO.getNickname());
userMapper.insert(user);
// 4. 分配默认角色(观众角色)
UserRole userRole = new UserRole();
userRole.setUserId(user.getId());
userRole.setRoleId(RoleEnum.VIEWER.getId());
userRoleMapper.insert(userRole);
// 5. 发送注册成功邮件
sendRegisterSuccessEmail(user);
}
private void validateRegisterParams(UserRegisterDTO registerDTO) {
// 用户名格式验证
if (!Pattern.matches("^[a-zA-Z0-9_]{4,16}$", registerDTO.getUsername())) {
throw new BusinessException("用户名格式不正确");
}
// 密码强度验证
if (!Pattern.matches("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,}$",
registerDTO.getPassword())) {
throw new BusinessException("密码强度不够");
}
// 邮箱格式验证
if (!Pattern.matches("^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$",
registerDTO.getEmail())) {
throw new BusinessException("邮箱格式不正确");
}
}
}
2. 用户登录与JWT认证
@Service
public class AuthService {
@Autowired
private UserService userService;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public LoginResponseDTO login(LoginRequestDTO loginRequest) {
// 1. 验证用户名密码
User user = userService.getByUsername(loginRequest.getUsername());
if (user == null || !passwordEncoder.matches(loginRequest.getPassword(), user.getPassword())) {
throw new BusinessException("用户名或密码错误");
}
// 2. 检查用户状态
if (user.getStatus() == 0) {
throw new BusinessException("账号已被禁用");
}
// 3. 生成Token
String token = jwtTokenProvider.generateToken(user);
// 4. 更新登录信息
updateLoginInfo(user, loginRequest.getLoginIp());
// 5. 获取用户权限
List<String> permissions = getUserPermissions(user.getId());
// 6. 缓存用户信息到Redis
cacheUserInfo(token, user, permissions);
return new LoginResponseDTO(token, user, permissions);
}
private void cacheUserInfo(String token, User user, List<String> permissions) {
String key = "user:token:" + token;
UserCacheDTO userCache = new UserCacheDTO(user, permissions);
// 设置24小时过期
redisTemplate.opsForValue().set(key, userCache, 24, TimeUnit.HOURS);
}
}
3. JWT Token工具类
@Component
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String jwtSecret;
@Value("${jwt.expiration}")
private long jwtExpiration;
public String generateToken(User user) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + jwtExpiration);
return Jwts.builder()
.setSubject(user.getId().toString())
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(SignatureAlgorithm.HS512, jwtSecret)
.compact();
}
public Long getUserIdFromToken(String token) {
Claims claims = Jwts.parser()
.setSigningKey(jwtSecret)
.parseClaimsJws(token)
.getBody();
return Long.parseLong(claims.getSubject());
}
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token);
return true;
} catch (SignatureException ex) {
log.error("Invalid JWT signature");
} catch (MalformedJwtException ex) {
log.error("Invalid JWT token");
} catch (ExpiredJwtException ex) {
log.error("Expired JWT token");
} catch (UnsupportedJwtException ex) {
log.error("Unsupported JWT token");
} catch (IllegalArgumentException ex) {
log.error("JWT claims string is empty");
}
return false;
}
}
4. Spring Security配置
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Autowired
private CustomUserDetailsService userDetailsService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors().and().csrf().disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/api/auth/**").permitAll()
.antMatchers("/api/public/**").permitAll()
.anyRequest().authenticated();
// 添加JWT过滤器
http.addFilterBefore(jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class);
}
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
5. 个人信息管理
@RestController
@RequestMapping("/api/user/profile")
public class UserProfileController {
@Autowired
private UserService userService;
@GetMapping
public ResponseEntity<UserProfileDTO> getUserProfile() {
Long userId = SecurityUtils.getCurrentUserId();
return ResponseEntity.ok(userService.getUserProfile(userId));
}
@PutMapping
public ResponseEntity<Void> updateProfile(@Valid @RequestBody UpdateProfileDTO profileDTO) {
Long userId = SecurityUtils.getCurrentUserId();
userService.updateProfile(userId, profileDTO);
return ResponseEntity.ok().build();
}
@PutMapping("/password")
public ResponseEntity<Void> changePassword(@Valid @RequestBody ChangePasswordDTO passwordDTO) {
Long userId = SecurityUtils.getCurrentUserId();
userService.changePassword(userId, passwordDTO);
return ResponseEntity.ok().build();
}
@PostMapping("/avatar")
public ResponseEntity<String> uploadAvatar(@RequestParam("file") MultipartFile file) {
Long userId = SecurityUtils.getCurrentUserId();
String avatarUrl = userService.uploadAvatar(userId, file);
return ResponseEntity.ok(avatarUrl);
}
}
三、前端实现
1. 登录页面
<template>
<div class="login-container">
<el-form
ref="loginForm"
:model="loginForm"
:rules="loginRules"
class="login-form"
>
<h3 class="title">校园活动直播平台</h3>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="用户名"
prefix-icon="el-icon-user"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="密码"
prefix-icon="el-icon-lock"
@keyup.enter.native="handleLogin"
/>
</el-form-item>
<el-form-item>
<el-button
:loading="loading"
type="primary"
style="width: 100%"
@click.native.prevent="handleLogin"
>
登录
</el-button>
</el-form-item>
<div class="tips">
<span>还没有账号?</span>
<router-link to="/register">立即注册</router-link>
</div>
</el-form>
</div>
</template>
<script>
import { login } from '@/api/auth'
import { setToken } from '@/utils/auth'
export default {
name: 'Login',
data() {
return {
loginForm: {
username: '',
password: ''
},
loginRules: {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' }
]
},
loading: false
}
},
methods: {
async handleLogin() {
try {
await this.$refs.loginForm.validate()
this.loading = true
const response = await login(this.loginForm)
setToken(response.data.token)
// 存储用户信息
this.$store.commit('SET_USER_INFO', response.data.user)
this.$store.commit('SET_PERMISSIONS', response.data.permissions)
// 跳转到首页
this.$router.push({ path: '/' })
} catch (error) {
this.$message.error(error.message)
} finally {
this.loading = false
}
}
}
}
</script>
2. 用户信息管理页面
<template>
<div class="profile-container">
<el-card class="profile-card">
<div slot="header">
<span>个人信息</span>
</div>
<el-form
ref="profileForm"
:model="profileForm"
:rules="profileRules"
label-width="100px"
>
<el-form-item label="头像">
<el-upload
class="avatar-uploader"
:action="uploadUrl"
:headers="headers"
:show-file-list="false"
:on-success="handleAvatarSuccess"
:before-upload="beforeAvatarUpload"
>
<img v-if="profileForm.avatar" :src="profileForm.avatar" class="avatar">
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
</el-upload>
</el-form-item>
<el-form-item label="昵称" prop="nickname">
<el-input v-model="profileForm.nickname" />
</el-form-item>
<el-form-item label="真实姓名" prop="realName">
<el-input v-model="profileForm.realName" />
</el-form-item>
<el-form-item label="学号" prop="studentId">
<el-input v-model="profileForm.studentId" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="profileForm.email" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="profileForm.phone" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="updateProfile">保存修改</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="password-card">
<div slot="header">
<span>修改密码</span>
</div>
<el-form
ref="passwordForm"
:model="passwordForm"
:rules="passwordRules"
label-width="100px"
>
<el-form-item label="原密码" prop="oldPassword">
<el-input
v-model="passwordForm.oldPassword"
type="password"
show-password
/>
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input
v-model="passwordForm.newPassword"
type="password"
show-password
/>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input
v-model="passwordForm.confirmPassword"
type="password"
show-password
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="changePassword">修改密码</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script>
import { getUserProfile, updateProfile, changePassword } from '@/api/user'
import { getToken } from '@/utils/auth'
export default {
name: 'UserProfile',
data() {
// 验证确认密码
const validateConfirmPassword = (rule, value, callback) => {
if (value !== this.passwordForm.newPassword) {
callback(new Error('两次输入的密码不一致'))
} else {
callback()
}
}
return {
profileForm: {
nickname: '',
realName: '',
studentId: '',
email: '',
phone: '',
avatar: ''
},
passwordForm: {
oldPassword: '',
newPassword: '',
confirmPassword: ''
},
profileRules: {
email: [
{ required: true, message: '请输入邮箱地址', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
]
},
passwordRules: {
oldPassword: [
{ required: true, message: '请输入原密码', trigger: 'blur' }
],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 8, message: '密码长度不能小于8位', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{ validator: validateConfirmPassword, trigger: 'blur' }
]
},
uploadUrl: process.env.VUE_APP_BASE_API + '/api/user/profile/avatar',
headers: {
Authorization: 'Bearer ' + getToken()
}
}
},
created() {
this.getProfile()
},
methods: {
async getProfile() {
try {
const { data } = await getUserProfile()
this.profileForm = data
} catch (error) {
this.$message.error('获取个人信息失败')
}
},
async updateProfile() {
try {
await this.$refs.profileForm.validate()
await updateProfile(this.profileForm)
this.$message.success('个人信息更新成功')
} catch (error) {
this.$message.error(error.message || '更新失败')
}
},
async changePassword() {
try {
await this.$refs.passwordForm.validate()
await changePassword(this.passwordForm)
this.$message.success('密码修改成功,请重新登录')
this.$store.dispatch('logout')
} catch (error) {
this.$message.error(error.message || '密码修改失败')
}
},
beforeAvatarUpload(file) {
const isJPG = file.type === 'image/jpeg'
const isPNG = file.type === 'image/png'
const isLt2M = file.size / 1024 / 1024 < 2
if (!isJPG && !isPNG) {
this.$message.error('上传头像图片只能是 JPG 或 PNG 格式!')
}
if (!isLt2M) {
this.$message.error('上传头像图片大小不能超过 2MB!')
}
return (isJPG || isPNG) && isLt2M
},
handleAvatarSuccess(response) {
this.profileForm.avatar = response.data
this.$message.success('头像上传成功')
}
}
}
</script>
四、接口文档
1. 用户注册
POST /api/auth/register
Request:
{
"username": "string",
"password": "string",
"email": "string",
"phone": "string",
"nickname": "string"
}
Response:
{
"code": 200,
"message": "注册成功"
}
2. 用户登录
POST /api/auth/login
Request:
{
"username": "string",
"password": "string"
}
Response:
{
"code": 200,
"data": {
"token": "string",
"user": {
"id": "number",
"username": "string",
"nickname": "string",
"avatar": "string",
"roles": ["string"]
},
"permissions": ["string"]
}
}
3. 获取用户信息
GET /api/user/profile
Response:
{
"code": 200,
"data": {
"id": "number",
"username": "string",
"nickname": "string",
"realName": "string",
"studentId": "string",
"avatar": "string",
"email": "string",
"phone": "string",
"roles": ["string"]
}
}
4. 更新用户信息
PUT /api/user/profile
Request:
{
"nickname": "string",
"realName": "string",
"studentId": "string",
"email": "string",
"phone": "string"
}
Response:
{
"code": 200,
"message": "更新成功"
}
5. 修改密码
PUT /api/user/profile/password
Request:
{
"oldPassword": "string",
"newPassword": "string"
}
Response:
{
"code": 200,
"message": "密码修改成功"
}
五、安全考虑
-
密码安全
- 使用BCrypt加密存储密码
- 密码强度要求:至少8位,包含大小写字母和数字
- 密码错误次数限制,防止暴力破解
-
JWT Token安全
- Token有效期设置(默认24小时)
- Token刷新机制
- Token黑名单机制
-
接口安全
- 登录接口限流
- 敏感操作需要验证码
- 关键接口需要二次验证
-
数据安全
- 敏感信息加密存储
- 用户信息脱敏展示
- 操作日志记录
六、性能优化
-
缓存优化
- 使用Redis缓存用户信息
- 缓存权限数据
- 合理设置缓存过期时间
-
数据库优化
- 用户表索引优化
- 分库分表预案
- SQL语句优化
-
接口优化
- 接口响应数据精简
- 批量操作接口
- 异步处理耗时操作
校园活动直播平台 - 直播管理与互动功能模块详细设计
一、数据库设计
1. 直播间表(live_room)
CREATE TABLE `live_room` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '直播间ID',
`title` varchar(100) NOT NULL COMMENT '直播标题',
`cover_img` varchar(255) COMMENT '封面图片',
`user_id` bigint NOT NULL COMMENT '主播ID',
`status` tinyint DEFAULT 0 COMMENT '状态(0:未开播,1:直播中,2:已结束)',
`room_type` tinyint DEFAULT 1 COMMENT '直播类型(1:活动直播,2:课程直播)',
`start_time` datetime COMMENT '开播时间',
`end_time` datetime COMMENT '结束时间',
`viewer_count` int DEFAULT 0 COMMENT '当前观看人数',
`like_count` int DEFAULT 0 COMMENT '点赞数',
`push_url` varchar(255) COMMENT '推流地址',
`pull_url` varchar(255) COMMENT '拉流地址',
`description` text COMMENT '直播间描述',
`notice` varchar(500) COMMENT '直播间公告',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='直播间表';
2. 直播数据统计表(live_statistics)
CREATE TABLE `live_statistics` (
`id` bigint NOT NULL AUTO_INCREMENT,
`room_id` bigint NOT NULL COMMENT '直播间ID',
`peak_viewer_count` int DEFAULT 0 COMMENT '最高在线人数',
`total_viewer_count` int DEFAULT 0 COMMENT '累计观看人数',
`total_like_count` int DEFAULT 0 COMMENT '累计点赞数',
`total_chat_count` int DEFAULT 0 COMMENT '聊天消息数',
`total_danmaku_count` int DEFAULT 0 COMMENT '弹幕数量',
`duration` int DEFAULT 0 COMMENT '直播时长(秒)',
`date` date NOT NULL COMMENT '统计日期',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_room_date` (`room_id`,`date`),
KEY `idx_date` (`date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='直播数据统计表';
3. 弹幕表(danmaku)
CREATE TABLE `danmaku` (
`id` bigint NOT NULL AUTO_INCREMENT,
`room_id` bigint NOT NULL COMMENT '直播间ID',
`user_id` bigint NOT NULL COMMENT '用户ID',
`content` varchar(200) NOT NULL COMMENT '弹幕内容',
`color` varchar(10) DEFAULT '#ffffff' COMMENT '弹幕颜色',
`type` tinyint DEFAULT 0 COMMENT '弹幕类型(0:滚动,1:顶部,2:底部)',
`time` int NOT NULL COMMENT '弹幕时间点(秒)',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_room_time` (`room_id`,`time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='弹幕表';
4. 聊天消息表(chat_message)
CREATE TABLE `chat_message` (
`id` bigint NOT NULL AUTO_INCREMENT,
`room_id` bigint NOT NULL COMMENT '直播间ID',
`user_id` bigint NOT NULL COMMENT '发送者ID',
`content` varchar(500) NOT NULL COMMENT '消息内容',
`type` tinyint DEFAULT 0 COMMENT '消息类型(0:普通消息,1:系统消息)',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_room_time` (`room_id`,`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='聊天消息表';
二、核心功能实现
1. 直播间管理Service
@Service
@Slf4j
public class LiveRoomService {
@Autowired
private LiveRoomMapper liveRoomMapper;
@Autowired
private SrsService srsService;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Transactional
public LiveRoom createLiveRoom(CreateLiveRoomDTO dto) {
// 1. 创建直播间基本信息
LiveRoom liveRoom = new LiveRoom();
BeanUtils.copyProperties(dto, liveRoom);
liveRoom.setUserId(SecurityUtils.getCurrentUserId());
liveRoom.setStatus(LiveRoomStatus.NOT_STARTED.getCode());
// 2. 生成推拉流地址
StreamInfo streamInfo = srsService.createStream(liveRoom.getId());
liveRoom.setPushUrl(streamInfo.getPushUrl());
liveRoom.setPullUrl(streamInfo.getPullUrl());
liveRoomMapper.insert(liveRoom);
// 3. 初始化Redis计数器
initializeCounters(liveRoom.getId());
return liveRoom;
}
public void startLive(Long roomId) {
LiveRoom room = checkRoomExists(roomId);
checkRoomOwner(room);
// 更新直播状态
room.setStatus(LiveRoomStatus.LIVING.getCode());
room.setStartTime(new Date());
liveRoomMapper.updateById(room);
// 发送开播事件
eventPublisher.publishEvent(new LiveStartEvent(room));
}
public void endLive(Long roomId) {
LiveRoom room = checkRoomExists(roomId);
checkRoomOwner(room);
// 更新直播状态
room.setStatus(LiveRoomStatus.ENDED.getCode());
room.setEndTime(new Date());
liveRoomMapper.updateById(room);
// 保存直播统计数据
saveLiveStatistics(room);
// 发送结束直播事件
eventPublisher.publishEvent(new LiveEndEvent(room));
}
private void initializeCounters(Long roomId) {
String viewerKey = String.format("live:room:%d:viewers", roomId);
String likeKey = String.format("live:room:%d:likes", roomId);
redisTemplate.opsForValue().set(viewerKey, 0);
redisTemplate.opsForValue().set(likeKey, 0);
}
private void saveLiveStatistics(LiveRoom room) {
String viewerKey = String.format("live:room:%d:viewers", room.getId());
String likeKey = String.format("live:room:%d:likes", room.getId());
LiveStatistics statistics = new LiveStatistics();
statistics.setRoomId(room.getId());
statistics.setDate(LocalDate.now());
statistics.setPeakViewerCount(getPeakViewerCount(room.getId()));
statistics.setTotalViewerCount(getTotalViewerCount(room.getId()));
statistics.setTotalLikeCount(getLikeCount(likeKey));
statistics.setDuration(calculateDuration(room));
liveStatisticsMapper.insert(statistics);
}
}
2. WebSocket消息处理
@Component
@ServerEndpoint("/ws/live/{roomId}")
@Slf4j
public class LiveWebSocketServer {
private static Map<Long, Set<Session>> roomSessions = new ConcurrentHashMap<>();
private static Map<String, LiveUser> sessionUsers = new ConcurrentHashMap<>();
@OnOpen
public void onOpen(Session session, @PathParam("roomId") Long roomId) {
// 1. 添加用户到直播间
addUserToRoom(session, roomId);
// 2. 增加观看人数
incrementViewerCount(roomId);
// 3. 广播用户进入消息
broadcastUserJoin(roomId, sessionUsers.get(session.getId()));
}
@OnMessage
public void onMessage(String message, Session session) {
LiveMessage liveMessage = JSON.parseObject(message, LiveMessage.class);
switch (liveMessage.getType()) {
case CHAT:
handleChatMessage(liveMessage, session);
break;
case DANMAKU:
handleDanmaku(liveMessage, session);
break;
case LIKE:
handleLike(liveMessage, session);
break;
default:
log.warn("Unknown message type: {}", liveMessage.getType());
}
}
@OnClose
public void onClose(Session session) {
LiveUser user = sessionUsers.get(session.getId());
if (user != null) {
// 1. 减少观看人数
decrementViewerCount(user.getRoomId());
// 2. 清理session
removeUserFromRoom(session, user.getRoomId());
// 3. 广播用户离开消息
broadcastUserLeave(user.getRoomId(), user);
}
}
private void handleChatMessage(LiveMessage message, Session session) {
LiveUser user = sessionUsers.get(session.getId());
ChatMessage chatMessage = new ChatMessage();
chatMessage.setRoomId(user.getRoomId());
chatMessage.setUserId(user.getUserId());
chatMessage.setContent(message.getContent());
// 保存聊天消息
chatMessageMapper.insert(chatMessage);
// 广播消息给直播间所有用户
broadcastToRoom(user.getRoomId(), new LiveMessage(
MessageType.CHAT,
user,
message.getContent()
));
}
private void handleDanmaku(LiveMessage message, Session session) {
LiveUser user = sessionUsers.get(session.getId());
Danmaku danmaku = new Danmaku();
danmaku.setRoomId(user.getRoomId());
danmaku.setUserId(user.getUserId());
danmaku.setContent(message.getContent());
danmaku.setColor(message.getColor());
danmaku.setType(message.getDanmakuType());
// 保存弹幕
danmakuMapper.insert(danmaku);
// 广播弹幕
broadcastToRoom(user.getRoomId(), message);
}
private void handleLike(LiveMessage message, Session session) {
LiveUser user = sessionUsers.get(session.getId());
// 增加点赞数
String likeKey = String.format("live:room:%d:likes", user.getRoomId());
redisTemplate.opsForValue().increment(likeKey);
// 广播点赞消息
broadcastToRoom(user.getRoomId(), message);
}
}
3. 直播数据统计Service
@Service
public class LiveStatisticsService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private LiveStatisticsMapper liveStatisticsMapper;
public LiveRoomStats getRoomStats(Long roomId) {
String viewerKey = String.format("live:room:%d:viewers", roomId);
String likeKey = String.format("live:room:%d:likes", roomId);
LiveRoomStats stats = new LiveRoomStats();
stats.setCurrentViewerCount(getCurrentViewerCount(viewerKey));
stats.setPeakViewerCount(getPeakViewerCount(roomId));
stats.setTotalViewerCount(getTotalViewerCount(roomId));
stats.setLikeCount(getLikeCount(likeKey));
return stats;
}
public List<DailyStats> getDailyStats(Long roomId, LocalDate startDate, LocalDate endDate) {
return liveStatisticsMapper.selectDailyStats(roomId, startDate, endDate);
}
private Integer getCurrentViewerCount(String viewerKey) {
Object count = redisTemplate.opsForValue().get(viewerKey);
return count == null ? 0 : (Integer) count;
}
private Integer getPeakViewerCount(Long roomId) {
String peakKey = String.format("live:room:%d:peak_viewers", roomId);
Object count = redisTemplate.opsForValue().get(peakKey);
return count == null ? 0 : (Integer) count;
}
}
三、前端实现
1. 直播间管理页面
<template>
<div class="live-room-container">
<!-- 直播间基本信息设置 -->
<el-card class="live-room-info">
<div slot="header">
<span>直播间信息</span>
<el-button
v-if="room.status === 0"
type="primary"
@click="startLive"
>开始直播</el-button>
<el-button
v-if="room.status === 1"
type="danger"
@click="endLive"
>结束直播</el-button>
</div>
<el-form ref="roomForm" :model="room" :rules="rules" label-width="100px">
<el-form-item label="直播标题" prop="title">
<el-input v-model="room.title" />
</el-form-item>
<el-form-item label="封面图片" prop="coverImg">
<el-upload
class="cover-uploader"
:action="uploadUrl"
:show-file-list="false"
:on-success="handleCoverSuccess"
>
<img v-if="room.coverImg" :src="room.coverImg" class="cover">
<i v-else class="el-icon-plus"></i>
</el-upload>
</el-form-item>
<el-form-item label="直播类型" prop="roomType">
<el-select v-model="room.roomType">
<el-option label="活动直播" :value="1" />
<el-option label="课程直播" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="直播公告" prop="notice">
<el-input
type="textarea"
v-model="room.notice"
:rows="3"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveRoomInfo">保存设置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 推流信息 -->
<el-card class="stream-info" v-if="room.status !== 0">
<div slot="header">
<span>推流信息</span>
</div>
<div class="stream-url">
<div class="url-item">
<span class="label">推流地址:</span>
<el-input v-model="room.pushUrl" readonly>
<el-button slot="append" @click="copyUrl(room.pushUrl)">复制</el-button>
</el-input>
</div>
<div class="url-item">
<span class="label">播放地址:</span>
<el-input v-model="room.pullUrl" readonly>
<el-button slot="append" @click="copyUrl(room.pullUrl)">复制</el-button>
</el-input>
</div>
</div>
</el-card>
<!-- 数据统计 -->
<el-card class="statistics">
<div slot="header">
<span>实时数据</span>
</div>
<el-row :gutter="20">
<el-col :span="6">
<div class="stat-item">
<div class="label">当前观看</div>
<div class="value">{{ stats.currentViewerCount }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-item">
<div class="label">最高观看</div>
<div class="value">{{ stats.peakViewerCount }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-item">
<div class="label">累计观看</div>
<div class="value">{{ stats.totalViewerCount }}</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-item">
<div class="label">点赞数</div>
<div class="value">{{ stats.likeCount }}</div>
</div>
</el-col>
</el-row>
<!-- 统计图表 -->
<div class="charts">
<div id="viewerChart" style="height: 300px"></div>
</div>
</el-card>
</div>
</template>
<script>
import * as echarts from 'echarts'
import { getRoomInfo, updateRoom, startLive, endLive } from '@/api/live'
import { getStats } from '@/api/statistics'
export default {
name: 'LiveRoom',
data() {
return {
room: {
id: this.$route.params.id,
title: '',
coverImg: '',
roomType: 1,
notice: '',
status: 0,
pushUrl: '',
pullUrl: ''
},
stats: {
currentViewerCount: 0,
peakViewerCount: 0,
totalViewerCount: 0,
likeCount: 0
},
rules: {
title: [
{ required: true, message: '请输入直播标题', trigger: 'blur' }
]
},
viewerChart: null,
statsTimer: null
}
},
created() {
this.loadRoomInfo()
this.initWebSocket()
},
mounted() {
this.initViewerChart()
this.startStatsTimer()
},
beforeDestroy() {
if (this.statsTimer) {
clearInterval(this.statsTimer)
}
if (this.websocket) {
this.websocket.close()
}
},
methods: {
async loadRoomInfo() {
const { data } = await getRoomInfo(this.room.id)
this.room = data
},
async saveRoomInfo() {
try {
await this.$refs.roomForm.validate()
await updateRoom(this.room)
this.$message.success('设置保存成功')
} catch (error) {
this.$message.error(error.message || '保存失败')
}
},
async startLive() {
try {
await startLive(this.room.id)
this.room.status = 1
this.$message.success('直播开始')
} catch (error) {
this.$message.error(error.message || '开播失败')
}
},
async endLive() {
try {
await endLive(this.room.id)
this.room.status = 2
this.$message.success('直播结束')
} catch (error) {
this.$message.error(error.message || '结束直播失败')
}
},
initWebSocket() {
const wsUrl = `${process.env.VUE_APP_WS_API}/ws/live/${this.room.id}`
this.websocket = new WebSocket(wsUrl)
this.websocket.onmessage = (e) => {
const data = JSON.parse(e.data)
this.handleWebSocketMessage(data)
}
},
handleWebSocketMessage(message) {
switch (message.type) {
case 'STATS_UPDATE':
this.updateStats(message.data)
break
case 'VIEWER_CHANGE':
this.updateViewerChart(message.data)
break
}
},
initViewerChart() {
this.viewerChart = echarts.init(document.getElementById('viewerChart'))
const option = {
title: {
text: '观看人数变化'
},
tooltip: {
trigger: 'axis'
},
xAxis: {
type: 'time',
splitLine: {
show: false
}
},
yAxis: {
type: 'value',
splitLine: {
show: true
}
},
series: [{
name: '观看人数',
type: 'line',
smooth: true,
data: []
}]
}
this.viewerChart.setOption(option)
},
startStatsTimer() {
this.statsTimer = setInterval(async () => {
const { data } = await getStats(this.room.id)
this.stats = data
}, 5000)
}
}
}
</script>
2. 直播观看页面
<template>
<div class="live-viewer-container">
<div class="main-content">
<!-- 视频播放器 -->
<div class="player-container">
<video-player
ref="videoPlayer"
:options="playerOptions"
@ready="onPlayerReady"
/>
</div>
<!-- 直播信息 -->
<div class="live-info">
<div class="title">{{ room.title }}</div>
<div class="stats">
<span class="viewer-count">
<i class="el-icon-user"></i>
{{ stats.currentViewerCount }}
</span>
<span class="like-count">
<i class="el-icon-star-on"></i>
{{ stats.likeCount }}
</span>
</div>
</div>
</div>
<!-- 互动区域 -->
<div class="interaction-area">
<!-- 弹幕显示区域 -->
<div class="danmaku-container" ref="danmakuContainer">
<div
v-for="danmaku in danmakus"
:key="danmaku.id"
class="danmaku"
:style="getDanmakuStyle(danmaku)"
>
{{ danmaku.content }}
</div>
</div>
<!-- 聊天区域 -->
<div class="chat-container">
<div class="chat-messages" ref="chatMessages">
<div
v-for="message in chatMessages"
:key="message.id"
:class="['message', message.type]"
>
<template v-if="message.type === 'SYSTEM'">
<div class="system-message">{{ message.content }}</div>
</template>
<template v-else>
<div class="user-avatar">
<img :src="message.user.avatar">
</div>
<div class="message-content">
<div class="user-name">{{ message.user.nickname }}</div>
<div class="content">{{ message.content }}</div>
</div>
</template>
</div>
</div>
<!-- 发送消息区域 -->
<div class="message-input">
<el-input
v-model="messageContent"
placeholder="发送消息"
@keyup.enter.native="sendMessage"
>
<template slot="append">
<el-button @click="sendMessage">发送</el-button>
</template>
</el-input>
<!-- 弹幕发送 -->
<div class="danmaku-input">
<el-switch
v-model="isDanmaku"
active-text="弹幕模式"
/>
<el-color-picker
v-if="isDanmaku"
v-model="danmakuColor"
size="small"
/>
</div>
</div>
</div>
<!-- 点赞按钮 -->
<div class="like-button" @click="sendLike">
<i class="el-icon-star-on"></i>
</div>
</div>
</div>
</template>
<script>
import { VideoPlayer } from 'vue-video-player'
import 'video.js/dist/video-js.css'
import { getRoomInfo } from '@/api/live'
export default {
name: 'LiveViewer',
components: {
VideoPlayer
},
data() {
return {
room: {},
stats: {
currentViewerCount: 0,
likeCount: 0
},
playerOptions: {
autoplay: true,
controls: true,
sources: [{
type: 'application/x-mpegURL',
src: ''
}]
},
websocket: null,
messageContent: '',
isDanmaku: false,
danmakuColor: '#ffffff',
chatMessages: [],
danmakus: [],
danmakuId: 0
}
},
async created() {
await this.loadRoomInfo()
this.initWebSocket()
},
methods: {
async loadRoomInfo() {
const { data } = await getRoomInfo(this.$route.params.id)
this.room = data
this.playerOptions.sources[0].src = data.pullUrl
},
initWebSocket() {
const wsUrl = `${process.env.VUE_APP_WS_API}/ws/live/${this.room.id}`
this.websocket = new WebSocket(wsUrl)
this.websocket.onmessage = (e) => {
const message = JSON.parse(e.data)
this.handleWebSocketMessage(message)
}
},
handleWebSocketMessage(message) {
switch (message.type) {
case 'CHAT':
this.addChatMessage(message)
break
case 'DANMAKU':
this.addDanmaku(message)
break
case 'LIKE':
this.updateLikeCount(message)
break
case 'STATS_UPDATE':
this.updateStats(message.data)
break
}
},
sendMessage() {
if (!this.messageContent.trim()) return
const message = {
type: this.isDanmaku ? 'DANMAKU' : 'CHAT',
content: this.messageContent,
color: this.isDanmaku ? this.danmakuColor : undefined
}
this.websocket.send(JSON.stringify(message))
this.messageContent = ''
},
addChatMessage(message) {
this.chatMessages.push(message)
this.$nextTick(() => {
const container = this.$refs.chatMessages
container.scrollTop = container.scrollHeight
})
},
addDanmaku(message) {
const danmaku = {
id: this.danmakuId++,
content: message.content,
color: message.color,
top: Math.random() * 400
}
this.danmakus.push(danmaku)
setTimeout(() => {
const index = this.danmakus.findIndex(d => d.id === danmaku.id)
if (index !== -1) {
this.danmakus.splice(index, 1)
}
}, 8000)
},
getDanmakuStyle(danmaku) {
return {
color: danmaku.color,
top: `${danmaku.top}px`,
animation: 'danmaku 8s linear'
}
},
sendLike() {
const message = {
type: 'LIKE'
}
this.websocket.send(JSON.stringify(message))
// 添加点赞动画
this.addLikeAnimation()
},
addLikeAnimation() {
const heart = document.createElement('div')
heart.className = 'heart-animation'
heart.style.left = Math.random() * 100 + '%'
this.$el.appendChild(heart)
setTimeout(() => {
this.$el.removeChild(heart)
}, 2000)
},
updateStats(stats) {
this.stats = stats
}
}
}
</script>
<style lang="scss" scoped>
.live-viewer-container {
display: flex;
height: 100%;
.main-content {
flex: 1;
padding: 20px;
.player-container {
width: 100%;
height: 0;
padding-bottom: 56.25%; // 16:9比例
position: relative;
background: #000;
.video-player {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
}
.live-info {
margin-top: 20px;
padding: 15px;
background: #fff;
border-radius: 4px;
.title {
font-size: 18px;
font-weight: bold;
}
.stats {
margin-top: 10px;
color: #666;
span {
margin-right: 20px;
i {
margin-right: 5px;
}
}
}
}
}
.interaction-area {
width: 300px;
border-left: 1px solid #eee;
display: flex;
flex-direction: column;
.danmaku-container {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 400px;
pointer-events: none;
overflow: hidden;
.danmaku {
position: absolute;
white-space: nowrap;
transform: translateX(100%);
}
}
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
.chat-messages {
flex: 1;
padding: 15px;
overflow-y: auto;
.message {
margin-bottom: 15px;
display: flex;
&.SYSTEM {
justify-content: center;
.system-message {
color: #909399;
font-size: 12px;
}
}
.user-avatar {
width: 40px;
height: 40px;
margin-right: 10px;
img {
width: 100%;
height: 100%;
border-radius: 50%;
}
}
.message-content {
flex: 1;
.user-name {
font-size: 12px;
color: #666;
margin-bottom: 5px;
}
.content {
padding: 8px 12px;
background: #f5f7fa;
border-radius: 4px;
word-break: break-all;
}
}
}
}
.message-input {
padding: 15px;
border-top: 1px solid #eee;
.danmaku-input {
margin-top: 10px;
display: flex;
align-items: center;
gap: 10px;
}
}
}
.like-button {
padding: 15px;
text-align: center;
font-size: 24px;
color: #ff6b6b;
cursor: pointer;
transition: transform 0.2s;
&:hover {
transform: scale(1.1);
}
&:active {
transform: scale(0.9);
}
}
}
}
@keyframes danmaku {
from {
transform: translateX(100%);
}
to {
transform: translateX(-100%);
}
}
.heart-animation {
position: absolute;
bottom: 60px;
font-size: 20px;
color: #ff6b6b;
animation: float 2s ease-in-out;
&::before {
content: '♥';
}
}
@keyframes float {
0% {
transform: translateY(0) scale(1);
opacity: 1;
}
100% {
transform: translateY(-100px) scale(2);
opacity: 0;
}
}
</style>
四、WebSocket消息类型定义
// 消息类型枚举
enum MessageType {
CHAT = 'CHAT', // 聊天消息
DANMAKU = 'DANMAKU', // 弹幕消息
LIKE = 'LIKE', // 点赞消息
STATS_UPDATE = 'STATS_UPDATE', // 统计数据更新
USER_JOIN = 'USER_JOIN', // 用户加入
USER_LEAVE = 'USER_LEAVE', // 用户离开
SYSTEM = 'SYSTEM' // 系统消息
}
// 基础消息接口
interface BaseMessage {
type: MessageType;
timestamp: number;
}
// 聊天消息
interface ChatMessage extends BaseMessage {
type: MessageType.CHAT;
user: {
id: number;
nickname: string;
avatar: string;
};
content: string;
}
// 弹幕消息
interface DanmakuMessage extends BaseMessage {
type: MessageType.DANMAKU;
user: {
id: number;
nickname: string;
};
content: string;
color: string;
type: number; // 0: 滚动, 1: 顶部, 2: 底部
}
// 统计数据更新消息
interface StatsUpdateMessage extends BaseMessage {
type: MessageType.STATS_UPDATE;
data: {
currentViewerCount: number;
peakViewerCount: number;
likeCount: number;
};
}
五、Redis数据结构设计
1. 直播间实时数据
# 当前观看人数
live:room:{roomId}:viewers -> int
# 观看记录(用于计算累计观看人数)
live:room:{roomId}:viewer_records -> Set<userId>
# 点赞数
live:room:{roomId}:likes -> int
# 在线用户列表
live:room:{roomId}:online_users -> Hash
- {sessionId} -> {userId, nickname, avatar}
# 弹幕消息队列(最近100条)
live:room:{roomId}:danmaku -> List<danmakuJson>
# 聊天消息队列(最近100条)
live:room:{roomId}:chat -> List<messageJson>
2. 数据统计缓存
# 直播间每分钟统计数据
live:stats:{roomId}:{date}:{hour}:{minute} -> Hash
- viewer_count: 观看人数
- like_count: 点赞数
- danmaku_count: 弹幕数
- chat_count: 聊天数
# 直播间每小时统计数据
live:stats:{roomId}:{date}:{hour} -> Hash
- peak_viewer_count: 最高观看人数
- total_viewer_count: 累计观看人数
- total_like_count: 累计点赞数
- avg_viewer_count: 平均观看人数
六、性能优化
1. WebSocket连接优化
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
@Bean
public WebSocketHandshakeInterceptor webSocketInterceptor() {
return new WebSocketHandshakeInterceptor();
}
}
@Component
public class WebSocketHandshakeInterceptor implements HandshakeInterceptor {
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Map<String, Object> attributes) {
// 1. 验证token
String token = getToken(request);
if (!tokenService.validateToken(token)) {
return false;
}
// 2. 限流检查
String ip = getIpAddress(request);
if (!rateLimiter.tryAcquire(ip)) {
return false;
}
// 3. 设置用户信息
Long userId = tokenService.getUserIdFromToken(token);
attributes.put("userId", userId);
return true;
}
}
2. 消息广播优化
@Component
public class MessageBroadcaster {
private static final int BATCH_SIZE = 100;
private static final int QUEUE_SIZE = 1000;
private final BlockingQueue<BroadcastMessage> messageQueue =
new LinkedBlockingQueue<>(QUEUE_SIZE);
private final ScheduledExecutorService scheduler =
Executors.newSingleThreadScheduledExecutor();
@PostConstruct
public void init() {
scheduler.scheduleAtFixedRate(this::processBatchMessages,
0, 100, TimeUnit.MILLISECONDS);
}
public void broadcast(Long roomId, Object message) {
messageQueue.offer(new BroadcastMessage(roomId, message));
}
private void processBatchMessages() {
List<BroadcastMessage> batch = new ArrayList<>(BATCH_SIZE);
messageQueue.drainTo(batch, BATCH_SIZE);
if (!batch.isEmpty()) {
Map<Long, List<Object>> roomMessages = batch.stream()
.collect(Collectors.groupingBy(
BroadcastMessage::getRoomId,
Collectors.mapping(
BroadcastMessage::getMessage,
Collectors.toList()
)
));
roomMessages.forEach(this::sendBatchToRoom);
}
}
}
3. 弹幕发送优化
@Component
public class DanmakuManager {
private static final int MAX_DANMAKU_PER_SECOND = 50;
private static final int MAX_USER_DANMAKU_PER_MINUTE = 20;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public boolean canSendDanmaku(Long roomId, Long userId) {
String roomKey = String.format("live:room:%d:danmaku_rate", roomId);
String userKey = String.format("live:user:%d:danmaku_count", userId);
// 检查房间弹幕频率
Long roomCount = redisTemplate.opsForValue()
.increment(roomKey, 1);
if (roomCount != null && roomCount > MAX_DANMAKU_PER_SECOND) {
return false;
}
// 检查用户发送限制
Long userCount = redisTemplate.opsForValue()
.increment(userKey, 1);
if (userCount != null && userCount > MAX_USER_DANMAKU_PER_MINUTE) {
return false;
}
// 设置过期时间
if (roomCount != null && roomCount == 1) {
redisTemplate.expire(roomKey, 1, TimeUnit.SECONDS);
}
if (userCount != null && userCount == 1) {
redisTemplate.expire(userKey, 1, TimeUnit.MINUTES);
}
return true;
}
}
4. 在线人数统计优化
@Component
public class ViewerCountManager {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public void updateViewerCount(Long roomId) {
String viewerKey = String.format("live:room:%d:viewers", roomId);
String recordKey = String.format("live:room:%d:viewer_records", roomId);
// 使用Redis Pipeline批量处理
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
StringRedisConnection stringRedisConn = (StringRedisConnection) connection;
// 更新当前观看人数
stringRedisConn.incr(viewerKey);
// 更新最高观看人数
String peakKey = String.format("live:room:%d:peak_viewers", roomId);
String currentCount = stringRedisConn.get(viewerKey);
if (currentCount != null) {
String peakCount = stringRedisConn.get(peakKey);
if (peakCount == null || Integer.parseInt(currentCount) > Integer.parseInt(peakCount)) {
stringRedisConn.set(peakKey, currentCount);
}
}
return null;
});
}
}
七、监控告警
1. 直播状态监控
@Component
@Slf4j
public class LiveMonitor {
@Autowired
private LiveRoomService liveRoomService;
@Autowired
private AlertService alertService;
@Scheduled(fixedRate = 30000) // 每30秒检查一次
public void monitorLiveStatus() {
List<LiveRoom> livingRooms = liveRoomService.getLivingRooms();
for (LiveRoom room : livingRooms) {
try {
// 检查推流状态
boolean isStreaming = checkStreamStatus(room.getPushUrl());
if (!isStreaming) {
// 记录异常
log.warn("直播间 {} 推流中断", room.getId());
// 发送告警
alertService.sendAlert(AlertType.STREAM_INTERRUPTED, room);
}
// 检查观看人数异常
checkViewerCount(room);
} catch (Exception e) {
log.error("监控直播间 {} 失败", room.getId(), e);
}
}
}
private void checkViewerCount(LiveRoom room) {
Integer currentCount = liveRoomService.getCurrentViewerCount(room.getId());
Integer previousCount = liveRoomService.getPreviousViewerCount(room.getId());
// 如果观看人数突然大幅下降(超过50%)
if (previousCount != null && previousCount > 100 &&
currentCount < previousCount * 0.5) {
alertService.sendAlert(AlertType.VIEWER_DROP, room);
}
}
}
2. 系统资源监控
@Component
@Slf4j
public class SystemMonitor {
@Value("${monitor.memory.threshold}")
private double memoryThreshold;
@Value("${monitor.cpu.threshold}")
private double cpuThreshold;
@Scheduled(fixedRate = 60000) // 每分钟检查一次
public void monitorSystemResources() {
// 检查内存使用
double memoryUsage = getMemoryUsage();
if (memoryUsage > memoryThreshold) {
log.warn("内存使用率过高: {}%", memoryUsage);
alertService.sendAlert(AlertType.HIGH_MEMORY_USAGE, memoryUsage);
}
// 检查CPU使用
double cpuUsage = getCpuUsage();
if (cpuUsage > cpuThreshold) {
log.warn("CPU使用率过高: {}%", cpuUsage);
alertService.sendAlert(AlertType.HIGH_CPU_USAGE, cpuUsage);
}
// 检查WebSocket连接数
int connectionCount = getWebSocketConnectionCount();
if (connectionCount > 10000) {
log.warn("WebSocket连接数过多: {}", connectionCount);
alertService.sendAlert(AlertType.HIGH_CONNECTION_COUNT, connectionCount);
}
}
}
校园活动直播平台 - 活动管理与内容存储模块详细设计
一、数据库设计
1. 活动表(activity)
CREATE TABLE `activity` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '活动ID',
`title` varchar(100) NOT NULL COMMENT '活动标题',
`category_id` bigint NOT NULL COMMENT '分类ID',
`cover_img` varchar(255) COMMENT '封面图片',
`description` text COMMENT '活动描述',
`location` varchar(200) COMMENT '活动地点',
`start_time` datetime NOT NULL COMMENT '开始时间',
`end_time` datetime NOT NULL COMMENT '结束时间',
`organizer_id` bigint NOT NULL COMMENT '主办方ID',
`status` tinyint DEFAULT 0 COMMENT '状态(0:待审核,1:已通过,2:已拒绝,3:已取消)',
`review_user_id` bigint COMMENT '审核人ID',
`review_time` datetime COMMENT '审核时间',
`review_comment` varchar(500) COMMENT '审核意见',
`participant_limit` int DEFAULT 0 COMMENT '参与人数限制',
`participant_count` int DEFAULT 0 COMMENT '当前参与人数',
`live_room_id` bigint COMMENT '关联直播间ID',
`playback_url` varchar(255) COMMENT '回放地址',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_category` (`category_id`),
KEY `idx_status` (`status`),
KEY `idx_start_time` (`start_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动表';
2. 活动分类表(activity_category)
CREATE TABLE `activity_category` (
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL COMMENT '分类名称',
`code` varchar(50) NOT NULL COMMENT '分类编码',
`parent_id` bigint COMMENT '父分类ID',
`icon` varchar(100) COMMENT '分类图标',
`sort` int DEFAULT 0 COMMENT '排序',
`status` tinyint DEFAULT 1 COMMENT '状态(0:禁用,1:启用)',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动分类表';
3. 活动参与记录表(activity_participant)
CREATE TABLE `activity_participant` (
`id` bigint NOT NULL AUTO_INCREMENT,
`activity_id` bigint NOT NULL COMMENT '活动ID',
`user_id` bigint NOT NULL COMMENT '用户ID',
`status` tinyint DEFAULT 1 COMMENT '状态(0:取消,1:参与)',
`join_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '参与时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_activity_user` (`activity_id`,`user_id`),
KEY `idx_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动参与记录表';
4. 视频存储表(video_storage)
CREATE TABLE `video_storage` (
`id` bigint NOT NULL AUTO_INCREMENT,
`title` varchar(100) NOT NULL COMMENT '视频标题',
`description` text COMMENT '视频描述',
`duration` int DEFAULT 0 COMMENT '视频时长(秒)',
`size` bigint DEFAULT 0 COMMENT '文件大小(字节)',
`original_url` varchar(255) COMMENT '原始文件地址',
`transcode_status` tinyint DEFAULT 0 COMMENT '转码状态(0:待转码,1:转码中,2:已完成,3:失败)',
`playback_url` varchar(255) COMMENT '播放地址',
`thumbnail_url` varchar(255) COMMENT '缩略图地址',
`activity_id` bigint COMMENT '关联活动ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_activity` (`activity_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='视频存储表';
5. 转码任务表(transcode_task)
CREATE TABLE `transcode_task` (
`id` bigint NOT NULL AUTO_INCREMENT,
`video_id` bigint NOT NULL COMMENT '视频ID',
`status` tinyint DEFAULT 0 COMMENT '状态(0:等待,1:处理中,2:完成,3:失败)',
`output_format` varchar(20) COMMENT '输出格式',
`resolution` varchar(20) COMMENT '分辨率',
`bitrate` int COMMENT '码率(Kbps)',
`progress` int DEFAULT 0 COMMENT '转码进度(0-100)',
`error_message` varchar(500) COMMENT '错误信息',
`start_time` datetime COMMENT '开始时间',
`end_time` datetime COMMENT '结束时间',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_video` (`video_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='转码任务表';
二、核心功能实现
1. 活动管理Service
@Service
@Slf4j
public class ActivityService {
@Autowired
private ActivityMapper activityMapper;
@Autowired
private LiveRoomService liveRoomService;
@Autowired
private StorageService storageService;
@Transactional
public Activity createActivity(CreateActivityDTO dto) {
// 1. 创建活动基本信息
Activity activity = new Activity();
BeanUtils.copyProperties(dto, activity);
activity.setStatus(ActivityStatus.PENDING.getCode());
activity.setOrganizerId(SecurityUtils.getCurrentUserId());
// 2. 上传封面图片
if (dto.getCoverFile() != null) {
String coverUrl = storageService.uploadImage(dto.getCoverFile());
activity.setCoverImg(coverUrl);
}
// 3. 创建关联的直播间
if (dto.getNeedLiveRoom()) {
LiveRoom liveRoom = liveRoomService.createLiveRoom(new CreateLiveRoomDTO(
dto.getTitle(),
dto.getStartTime(),
dto.getEndTime()
));
activity.setLiveRoomId(liveRoom.getId());
}
activityMapper.insert(activity);
// 4. 发送审核通知
notifyReviewers(activity);
return activity;
}
@Transactional
public void reviewActivity(ActivityReviewDTO dto) {
Activity activity = checkActivityExists(dto.getActivityId());
// 更新审核状态
activity.setStatus(dto.getApproved() ?
ActivityStatus.APPROVED.getCode() :
ActivityStatus.REJECTED.getCode());
activity.setReviewUserId(SecurityUtils.getCurrentUserId());
activity.setReviewTime(new Date());
activity.setReviewComment(dto.getComment());
activityMapper.updateById(activity);
// 发送审核结果通知
notifyOrganizer(activity);
}
public PageResult<ActivityVO> searchActivities(ActivityQueryDTO query) {
// 构建查询条件
LambdaQueryWrapper<Activity> wrapper = new LambdaQueryWrapper<Activity>()
.eq(query.getCategoryId() != null, Activity::getCategoryId, query.getCategoryId())
.eq(query.getStatus() != null, Activity::getStatus, query.getStatus())
.ge(query.getStartTime() != null, Activity::getStartTime, query.getStartTime())
.le(query.getEndTime() != null, Activity::getEndTime, query.getEndTime())
.orderByDesc(Activity::getCreateTime);
// 执行分页查询
Page<Activity> page = activityMapper.selectPage(
new Page<>(query.getPageNum(), query.getPageSize()),
wrapper
);
// 转换为VO对象
List<ActivityVO> records = page.getRecords().stream()
.map(this::convertToVO)
.collect(Collectors.toList());
return new PageResult<>(records, page.getTotal());
}
}
2. 视频存储Service
@Service
@Slf4j
public class VideoStorageService {
@Autowired
private VideoStorageMapper videoStorageMapper;
@Autowired
private TranscodeService transcodeService;
@Autowired
private MinioClient minioClient;
@Value("${minio.bucket.video}")
private String videoBucket;
@Transactional
public VideoStorage uploadVideo(MultipartFile file, VideoUploadDTO dto) {
// 1. 保存原始文件
String originalUrl = uploadToMinio(file);
// 2. 创建视频记录
VideoStorage video = new VideoStorage();
video.setTitle(dto.getTitle());
video.setDescription(dto.getDescription());
video.setOriginalUrl(originalUrl);
video.setSize(file.getSize());
video.setActivityId(dto.getActivityId());
video.setTranscodeStatus(TranscodeStatus.PENDING.getCode());
videoStorageMapper.insert(video);
// 3. 创建转码任务
transcodeService.createTranscodeTask(video.getId(), Arrays.asList(
new TranscodeConfig("720p", "1280x720", 1500),
new TranscodeConfig("480p", "854x480", 800),
new TranscodeConfig("360p", "640x360", 400)
));
return video;
}
private String uploadToMinio(MultipartFile file) {
try {
String fileName = generateFileName(file);
minioClient.putObject(
PutObjectArgs.builder()
.bucket(videoBucket)
.object(fileName)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build()
);
return String.format("/%s/%s", videoBucket, fileName);
} catch (Exception e) {
log.error("上传视频文件失败", e);
throw new BusinessException("上传视频文件失败");
}
}
public VideoPlayInfo getPlayInfo(Long videoId) {
VideoStorage video = checkVideoExists(videoId);
VideoPlayInfo playInfo = new VideoPlayInfo();
playInfo.setTitle(video.getTitle());
playInfo.setDuration(video.getDuration());
// 获取转码后的不同清晰度播放地址
List<TranscodeTask> tasks = transcodeService.getCompletedTasks(videoId);
Map<String, String> playUrls = tasks.stream()
.collect(Collectors.toMap(
TranscodeTask::getResolution,
TranscodeTask::getPlaybackUrl
));
playInfo.setPlayUrls(playUrls);
return playInfo;
}
}
3. 视频转码Service
@Service
@Slf4j
public class TranscodeService {
@Autowired
private TranscodeTaskMapper taskMapper;
@Autowired
private VideoStorageMapper videoMapper;
@Autowired
private FFmpegService ffmpegService;
@Value("${transcode.thread-pool.size}")
private int threadPoolSize;
private final ExecutorService executorService;
public TranscodeService() {
this.executorService = new ThreadPoolExecutor(
threadPoolSize,
threadPoolSize,
0L,
TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(),
new ThreadFactoryBuilder()
.setNameFormat("transcode-pool-%d")
.build()
);
}
@Transactional
public void createTranscodeTask(Long videoId, List<TranscodeConfig> configs) {
VideoStorage video = videoMapper.selectById(videoId);
if (video == null) {
throw new BusinessException("视频不存在");
}
// 创建转码任务
for (TranscodeConfig config : configs) {
TranscodeTask task = new TranscodeTask();
task.setVideoId(videoId);
task.setStatus(TranscodeStatus.WAITING.getCode());
task.setOutputFormat("mp4");
task.setResolution(config.getResolution());
task.setBitrate(config.getBitrate());
taskMapper.insert(task);
// 提交转码任务
executorService.submit(() -> transcodeVideo(task));
}
// 更新视频状态
video.setTranscodeStatus(TranscodeStatus.PROCESSING.getCode());
videoMapper.updateById(video);
}
private void transcodeVideo(TranscodeTask task) {
try {
// 1. 更新任务状态
task.setStatus(TranscodeStatus.PROCESSING.getCode());
task.setStartTime(new Date());
taskMapper.updateById(task);
// 2. 获取视频信息
VideoStorage video = videoMapper.selectById(task.getVideoId());
// 3. 执行转码
String outputPath = generateOutputPath(video, task);
ffmpegService.transcode(
video.getOriginalUrl(),
outputPath,
task.getResolution(),
task.getBitrate(),
progress -> updateProgress(task.getId(), progress)
);
// 4. 更新任务状态
task.setStatus(TranscodeStatus.COMPLETED.getCode());
task.setPlaybackUrl(outputPath);
task.setEndTime(new Date());
taskMapper.updateById(task);
// 5. 检查是否所有任务都已完成
checkAllTasksCompleted(task.getVideoId());
} catch (Exception e) {
log.error("视频转码失败", e);
task.setStatus(TranscodeStatus.FAILED.getCode());
task.setErrorMessage(e.getMessage());
taskMapper.updateById(task);
}
}
private void checkAllTasksCompleted(Long videoId) {
List<TranscodeTask> tasks = taskMapper.selectByVideoId(videoId);
boolean allCompleted = tasks.stream()
.allMatch(t -> t.getStatus().equals(TranscodeStatus.COMPLETED.getCode()));
if (allCompleted) {
VideoStorage video = videoMapper.selectById(videoId);
video.setTranscodeStatus(TranscodeStatus.COMPLETED.getCode());
videoMapper.updateById(video);
}
}
}
三、前端实现
1. 活动管理页面
<template>
<div class="activity-manage">
<!-- 搜索栏 -->
<div class="search-bar">
<el-form :inline="true" :model="queryForm">
<el-form-item label="活动分类">
<el-cascader
v-model="queryForm.categoryId"
:options="categories"
:props="categoryProps"
clearable
/>
</el-form-item>
<el-form-item label="活动状态">
<el-select v-model="queryForm.status" clearable>
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="queryForm.timeRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="resetQuery">重置</el-button>
<el-button type="primary" @click="showCreateDialog">新建活动</el-button>
</el-form-item>
</el-form>
</div>
<!-- 活动列表 -->
<el-table
v-loading="loading"
:data="activityList"
border
style="width: 100%"
>
<el-table-column prop="title" label="活动标题">
<template #default="{ row }">
<div class="activity-title">
<el-image
v-if="row.coverImg"
:src="row.coverImg"
:preview-src-list="[row.coverImg]"
class="cover-img"
/>
<span>{{ row.title }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="categoryName" label="分类" width="120" />
<el-table-column label="活动时间" width="300">
<template #default="{ row }">
{{ formatDateTime(row.startTime) }} 至 {{ formatDateTime(row.endTime) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.status === 1"
type="primary"
size="small"
@click="goLiveRoom(row)"
>进入直播间</el-button>
<el-button
v-if="row.status === 0"
type="primary"
size="small"
@click="handleReview(row)"
>审核</el-button>
<el-button
type="primary"
size="small"
@click="handleEdit(row)"
>编辑</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
:current-page="queryForm.pageNum"
:page-size="queryForm.pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
</div>
<!-- 创建/编辑活动对话框 -->
<el-dialog
:title="dialogTitle"
:visible.sync="dialogVisible"
width="600px"
>
<el-form
ref="activityForm"
:model="activityForm"
:rules="rules"
label-width="100px"
>
<el-form-item label="活动标题" prop="title">
<el-input v-model="activityForm.title" />
</el-form-item>
<el-form-item label="活动分类" prop="categoryId">
<el-cascader
v-model="activityForm.categoryId"
:options="categories"
:props="categoryProps"
/>
</el-form-item>
<el-form-item label="活动封面" prop="coverImg">
<el-upload
class="cover-uploader"
:action="uploadUrl"
:show-file-list="false"
:before-upload="beforeCoverUpload"
:on-success="handleCoverSuccess"
>
<img
v-if="activityForm.coverImg"
:src="activityForm.coverImg"
class="cover"
>
<i v-else class="el-icon-plus"></i>
</el-upload>
</el-form-item>
<el-form-item label="活动时间" prop="timeRange">
<el-date-picker
v-model="activityForm.timeRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
/>
</el-form-item>
<el-form-item label="活动地点" prop="location">
<el-input v-model="activityForm.location" />
</el-form-item>
<el-form-item label="参与人数" prop="participantLimit">
<el-input-number
v-model="activityForm.participantLimit"
:min="0"
:max="10000"
/>
</el-form-item>
<el-form-item label="活动描述" prop="description">
<el-input
type="textarea"
v-model="activityForm.description"
:rows="4"
/>
</el-form-item>
<el-form-item label="开启直播" prop="needLiveRoom">
<el-switch v-model="activityForm.needLiveRoom" />
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { createActivity, updateActivity, deleteActivity, getActivityList } from '@/api/activity'
import { getCategoryTree } from '@/api/category'
import { formatDateTime } from '@/utils/date'
export default {
name: 'ActivityManage',
data() {
return {
loading: false,
queryForm: {
categoryId: null,
status: null,
timeRange: null,
pageNum: 1,
pageSize: 10
},
activityList: [],
total: 0,
categories: [],
categoryProps: {
value: 'id',
label: 'name',
children: 'children'
},
dialogVisible: false,
dialogTitle: '新建活动',
activityForm: {
title: '',
categoryId: null,
coverImg: '',
timeRange: null,
location: '',
participantLimit: 0,
description: '',
needLiveRoom: false
},
rules: {
title: [
{ required: true, message: '请输入活动标题', trigger: 'blur' },
{ min: 2, max: 100, message: '长度在 2 到 100 个字符', trigger: 'blur' }
],
categoryId: [
{ required: true, message: '请选择活动分类', trigger: 'change' }
],
timeRange: [
{ required: true, message: '请选择活动时间', trigger: 'change' }
],
location: [
{ required: true, message: '请输入活动地点', trigger: 'blur' }
]
}
}
},
created() {
this.loadCategories()
this.loadActivityList()
},
methods: {
async loadCategories() {
try {
const { data } = await getCategoryTree()
this.categories = data
} catch (error) {
this.$message.error('加载分类失败')
}
},
async loadActivityList() {
try {
this.loading = true
const { data } = await getActivityList(this.queryForm)
this.activityList = data.records
this.total = data.total
} catch (error) {
this.$message.error('加载活动列表失败')
} finally {
this.loading = false
}
},
handleSearch() {
this.queryForm.pageNum = 1
this.loadActivityList()
},
resetQuery() {
this.queryForm = {
categoryId: null,
status: null,
timeRange: null,
pageNum: 1,
pageSize: 10
}
this.loadActivityList()
},
async handleSubmit() {
try {
await this.$refs.activityForm.validate()
const params = {
...this.activityForm,
startTime: this.activityForm.timeRange[0],
endTime: this.activityForm.timeRange[1]
}
if (this.activityForm.id) {
await updateActivity(params)
this.$message.success('活动更新成功')
} else {
await createActivity(params)
this.$message.success('活动创建成功')
}
this.dialogVisible = false
this.loadActivityList()
} catch (error) {
this.$message.error(error.message || '操作失败')
}
}
}
}
</script>
2. 视频播放页面
<template>
<div class="video-player-container">
<!-- 视频播放器 -->
<div class="player-wrapper">
<video-player
ref="videoPlayer"
:options="playerOptions"
@ready="onPlayerReady"
@timeupdate="onTimeUpdate"
/>
<!-- 清晰度切换 -->
<div class="resolution-switch">
<el-radio-group v-model="currentResolution" size="small">
<el-radio-button
v-for="(url, resolution) in playUrls"
:key="resolution"
:label="resolution"
>
{{ resolution }}
</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 视频信息 -->
<div class="video-info">
<h2 class="title">{{ videoInfo.title }}</h2>
<div class="meta">
<span class="views">{{ videoInfo.viewCount }}次观看</span>
<span class="duration">{{ formatDuration(videoInfo.duration) }}</span>
</div>
<div class="description">{{ videoInfo.description }}</div>
</div>
<!-- 相关推荐 -->
<div class="related-videos">
<h3>相关视频</h3>
<div class="video-list">
<div
v-for="video in relatedVideos"
:key="video.id"
class="video-item"
@click="playVideo(video)"
>
<el-image :src="video.thumbnailUrl" fit="cover" />
<div class="info">
<div class="title">{{ video.title }}</div>
<div class="duration">{{ formatDuration(video.duration) }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { VideoPlayer } from 'vue-video-player'
import 'video.js/dist/video-js.css'
import { getVideoPlayInfo, getRelatedVideos } from '@/api/video'
export default {
name: 'VideoPlayer',
components: {
VideoPlayer
},
data() {
return {
videoInfo: {},
playUrls: {},
currentResolution: '720p',
playerOptions: {
autoplay: false,
controls: true,
preload: 'auto',
fluid: true,
playbackRates: [0.5, 1.0, 1.5, 2.0],
sources: [{
type: 'video/mp4',
src: ''
}]
},
relatedVideos: []
}
},
watch: {
currentResolution(newVal) {
const currentTime = this.player.currentTime()
this.player.src({ type: 'video/mp4', src: this.playUrls[newVal] })
this.player.currentTime(currentTime)
}
},
computed: {
player() {
return this.$refs.videoPlayer.player
}
},
created() {
this.loadVideoInfo()
this.loadRelatedVideos()
},
methods: {
async loadVideoInfo() {
try {
const { data } = await getVideoPlayInfo(this.$route.params.id)
this.videoInfo = data
this.playUrls = data.playUrls
this.currentResolution = Object.keys(data.playUrls)[0]
this.playerOptions.sources[0].src = data.playUrls[this.currentResolution]
} catch (error) {
this.$message.error('加载视频信息失败')
}
},
async loadRelatedVideos() {
try {
const { data } = await getRelatedVideos(this.$route.params.id)
this.relatedVideos = data
} catch (error) {
this.$message.error('加载相关视频失败')
}
},
onPlayerReady() {
console.log('播放器就绪')
// 记录播放次数
this.recordView()
},
onTimeUpdate() {
// 记录播放进度
const currentTime = this.player.currentTime()
this.savePlayProgress(currentTime)
},
async recordView() {
try {
await recordVideoView(this.$route.params.id)
} catch (error) {
console.error('记录播放次数失败', error)
}
},
async savePlayProgress(progress) {
try {
await saveVideoProgress({
videoId: this.$route.params.id,
progress: Math.floor(progress)
})
} catch (error) {
console.error('保存播放进度失败', error)
}
},
playVideo(video) {
this.$router.push(`/video/${video.id}`)
},
formatDuration(seconds) {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
return [h, m, s]
.map(v => v.toString().padStart(2, '0'))
.filter((v, i) => i > 0 || v !== '00')
.join(':')
}
}
}
</script>
<style lang="scss" scoped>
.video-player-container {
padding: 20px;
.player-wrapper {
position: relative;
width: 100%;
background: #000;
.resolution-switch {
position: absolute;
bottom: 60px;
right: 20px;
z-index: 2;
}
}
.video-info {
margin-top: 20px;
padding: 20px;
background: #fff;
border-radius: 4px;
.title {
font-size: 20px;
margin: 0 0 10px;
}
.meta {
color: #666;
font-size: 14px;
span {
margin-right: 20px;
}
}
.description {
margin-top: 15px;
color: #333;
line-height: 1.6;
}
}
.related-videos {
margin-top: 20px;
h3 {
margin: 0 0 15px;
font-size: 18px;
}
.video-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 20px;
.video-item {
cursor: pointer;
transition: transform 0.2s;
&:hover {
transform: translateY(-5px);
}
.el-image {
width: 100%;
height: 135px;
border-radius: 4px;
}
.info {
padding: 10px 0;
.title {
font-size: 14px;
margin-bottom: 5px;
@include text-overflow;
}
.duration {
font-size: 12px;
color: #666;
}
}
}
}
}
}
</style>
3. 文件上传组件
<template>
<div class="upload-component">
<el-upload
ref="upload"
:action="uploadUrl"
:headers="headers"
:before-upload="beforeUpload"
:on-progress="handleProgress"
:on-success="handleSuccess"
:on-error="handleError"
:file-list="fileList"
:multiple="false"
:limit="1"
drag
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">
将文件拖到此处,或<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
支持mp4、mov格式视频,单个文件不超过2GB
</div>
</template>
</el-upload>
<!-- 上传进度 -->
<el-dialog
title="上传进度"
:visible.sync="progressVisible"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
width="400px"
>
<div class="progress-info">
<div class="file-name">{{ currentFile.name }}</div>
<el-progress
:percentage="uploadProgress"
:status="uploadStatus"
/>
<div class="upload-speed">
上传速度: {{ formatSpeed(uploadSpeed) }}
</div>
</div>
</el-dialog>
</div>
</template>
<script>
import { getUploadToken } from '@/api/upload'
export default {
name: 'FileUpload',
props: {
accept: {
type: String,
default: 'video/*'
},
maxSize: {
type: Number,
default: 2 * 1024 * 1024 * 1024 // 2GB
}
},
data() {
return {
uploadUrl: process.env.VUE_APP_UPLOAD_URL,
headers: {},
fileList: [],
progressVisible: false,
uploadProgress: 0,
uploadStatus: '',
uploadSpeed: 0,
currentFile: {},
uploadStartTime: 0
}
},
created() {
this.getUploadAuth()
},
methods: {
async getUploadAuth() {
try {
const { data } = await getUploadToken()
this.headers = {
'Authorization': `Bearer ${data.token}`
}
} catch (error) {
this.$message.error('获取上传授权失败')
}
},
beforeUpload(file) {
// 检查文件类型
const isVideo = file.type.startsWith('video/')
if (!isVideo) {
this.$message.error('只能上传视频文件')
return false
}
// 检查文件大小
if (file.size > this.maxSize) {
this.$message.error('文件大小不能超过2GB')
return false
}
this.currentFile = file
this.progressVisible = true
this.uploadStartTime = Date.now()
this.uploadStatus = ''
this.uploadProgress = 0
return true
},
handleProgress(event) {
const progress = Math.round(event.percent)
this.uploadProgress = progress
// 计算上传速度
const uploadedSize = event.loaded
const timeElapsed = (Date.now() - this.uploadStartTime) / 1000
this.uploadSpeed = uploadedSize / timeElapsed
},
handleSuccess(response) {
this.progressVisible = false
this.uploadStatus = 'success'
this.$message.success('上传成功')
this.$emit('upload-success', response.data)
},
handleError() {
this.progressVisible = false
this.uploadStatus = 'exception'
this.$message.error('上传失败')
},
formatSpeed(speed) {
if (speed < 1024) {
return speed.toFixed(2) + ' B/s'
} else if (speed < 1024 * 1024) {
return (speed / 1024).toFixed(2) + ' KB/s'
} else {
return (speed / 1024 / 1024).toFixed(2) + ' MB/s'
}
}
}
}
</script>
<style lang="scss" scoped>
.upload-component {
.progress-info {
text-align: center;
.file-name {
margin-bottom: 15px;
@include text-overflow;
}
.upload-speed {
margin-top: 10px;
color: #666;
font-size: 14px;
}
}
}
</style>
4. 转码进度监控组件
<template>
<div class="transcode-monitor">
<el-card v-for="task in tasks" :key="task.id" class="task-card">
<div class="task-header">
<span class="resolution">{{ task.resolution }}</span>
<el-tag :type="getStatusType(task.status)">
{{ getStatusText(task.status) }}
</el-tag>
</div>
<el-progress
:percentage="task.progress"
:status="getProgressStatus(task.status)"
/>
<div class="task-info">
<div class="item">
<span class="label">开始时间:</span>
<span>{{ formatDateTime(task.startTime) }}</span>
</div>
<div class="item">
<span class="label">预计剩余时间:</span>
<span>{{ formatRemainTime(task) }}</span>
</div>
<div class="item">
<span class="label">输出格式:</span>
<span>{{ task.outputFormat }}</span>
</div>
<div class="item">
<span class="label">码率:</span>
<span>{{ task.bitrate }}Kbps</span>
</div>
</div>
<div v-if="task.status === 3" class="error-message">
{{ task.errorMessage }}
</div>
</el-card>
</div>
</template>
<script>
import { getTranscodeTasks } from '@/api/transcode'
export default {
name: 'TranscodeMonitor',
props: {
videoId: {
type: Number,
required: true
}
},
data() {
return {
tasks: [],
timer: null
}
},
created() {
this.loadTasks()
this.startPolling()
},
beforeDestroy() {
this.stopPolling()
},
methods: {
async loadTasks() {
try {
const { data } = await getTranscodeTasks(this.videoId)
this.tasks = data
} catch (error) {
this.$message.error('加载转码任务失败')
}
},
startPolling() {
this.timer = setInterval(() => {
this.loadTasks()
}, 5000)
},
stopPolling() {
if (this.timer) {
clearInterval(this.timer)
this.timer = null
}
},
getStatusType(status) {
const types = {
0: 'info',
1: 'warning',
2: 'success',
3: 'danger'
}
return types[status]
},
getStatusText(status) {
const texts = {
0: '等待中',
1: '转码中',
2: '已完成',
3: '失败'
}
return texts[status]
},
getProgressStatus(status) {
if (status === 2) return 'success'
if (status === 3) return 'exception'
return ''
},
formatRemainTime(task) {
if (task.status !== 1) return '--'
const progress = task.progress || 0
if (progress === 0) return '计算中...'
const startTime = new Date(task.startTime).getTime()
const now = Date.now()
const elapsed = (now - startTime) / 1000
const total = elapsed / (progress / 100)
const remain = total - elapsed
return this.formatDuration(remain)
}
}
}
</script>
<style lang="scss" scoped>
.transcode-monitor {
.task-card {
margin-bottom: 15px;
.task-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
.resolution {
font-size: 16px;
font-weight: bold;
}
}
.task-info {
margin-top: 15px;
.item {
margin-bottom: 8px;
font-size: 14px;
.label {
color: #666;
margin-right: 10px;
}
}
}
.error-message {
margin-top: 10px;
padding: 10px;
background: #fff2f0;
border: 1px solid #ffccc7;
border-radius: 4px;
color: #ff4d4f;
}
}
}
</style>
这些组件实现了视频播放、文件上传和转码进度监控等功能。视频播放器支持多清晰度切换、播放进度记录等功能;文件上传组件支持大文件上传、进度显示和速度计算;转码监控组件实现了实时进度更新和剩余时间预估等功能。
所有组件都采用了模块化设计,可以方便地集成到其他页面中使用。同时也注重了用户体验,提供了友好的交互反馈和错误提示。