Bootstrap

Java全栈项目 - 校园活动直播平台

项目介绍

校园活动直播平台是一个面向高校师生的在线直播系统,旨在为校园文化活动、学术讲座、社团活动等提供线上直播服务。该平台采用前后端分离架构,使用主流的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"

性能优化

  1. Redis缓存优化

    • 使用Redis缓存热点数据
    • 实现分布式锁
    • 设置合理的缓存过期策略
  2. 数据库优化

    • 合理设计索引
    • SQL语句优化
    • 分库分表设计
  3. 直播流优化

    • 多码率转码
    • CDN加速
    • 延迟优化

项目亮点

  1. 采用前后端分离架构,提高开发效率和系统可维护性
  2. 使用WebSocket实现实时弹幕和在线聊天功能
  3. 整合多种直播协议,支持不同场景需求
  4. 实现分布式部署,提高系统可用性
  5. 完善的监控和日志系统,便于问题排查

总结与展望

本项目实现了一个功能完整的校园活动直播平台,涵盖了用户管理、直播管理、互动功能等多个模块。通过使用主流的Java技术栈,不仅保证了系统的稳定性和可扩展性,也为后续功能扩展提供了良好的基础。

后续优化方向

  1. 引入AI技术,实现智能弹幕过滤
  2. 优化直播延迟,提升用户体验
  3. 增加更多互动功能
  4. 完善数据统计和分析功能

校园活动直播平台 - 用户管理模块详细设计

一、数据库设计

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": "密码修改成功"
}

五、安全考虑

  1. 密码安全

    • 使用BCrypt加密存储密码
    • 密码强度要求:至少8位,包含大小写字母和数字
    • 密码错误次数限制,防止暴力破解
  2. JWT Token安全

    • Token有效期设置(默认24小时)
    • Token刷新机制
    • Token黑名单机制
  3. 接口安全

    • 登录接口限流
    • 敏感操作需要验证码
    • 关键接口需要二次验证
  4. 数据安全

    • 敏感信息加密存储
    • 用户信息脱敏展示
    • 操作日志记录

六、性能优化

  1. 缓存优化

    • 使用Redis缓存用户信息
    • 缓存权限数据
    • 合理设置缓存过期时间
  2. 数据库优化

    • 用户表索引优化
    • 分库分表预案
    • SQL语句优化
  3. 接口优化

    • 接口响应数据精简
    • 批量操作接口
    • 异步处理耗时操作

校园活动直播平台 - 直播管理与互动功能模块详细设计

一、数据库设计

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>

这些组件实现了视频播放、文件上传和转码进度监控等功能。视频播放器支持多清晰度切换、播放进度记录等功能;文件上传组件支持大文件上传、进度显示和速度计算;转码监控组件实现了实时进度更新和剩余时间预估等功能。

所有组件都采用了模块化设计,可以方便地集成到其他页面中使用。同时也注重了用户体验,提供了友好的交互反馈和错误提示。

;