Spring Boot + 小程序实战:构建多平台视频点播系统
最近在追剧的小伙伴们可能都发现了,现在看剧的渠道越来越分散。想看的剧,A平台有独播,B平台有会员专享,有时候还得去特定的小程序才能看全。这不,最近一部叫《崔国摄政王独宠小王妃》的剧,就遇到了类似情况:有“猫爪追番”的朋友可以直接看,没有的就得去小程序。
这背后其实是一个典型的 多平台内容分发与用户鉴权 的技术场景。作为开发者,我们不仅要会“追剧”,更要懂背后的技术逻辑。今天,我们就来深入聊聊,如何从零开始,构建一个类似的小程序视频点播系统,涵盖从视频存储、用户鉴权、多平台分发到前端播放的全链路实战。
本文将从后端架构设计讲起,使用 Spring Boot 搭建服务,整合对象存储、JWT鉴权,再到小程序前端开发,实现一个完整的、可运行的视频点播Demo。无论你是想了解现代流媒体技术栈,还是手头有类似的项目需求,都能从中获得一套可直接复用的解决方案。
1. 核心概念与技术选型
在动手之前,我们先厘清几个关键概念和为什么选择这些技术。
1.1 视频点播系统核心组件 一个基础的视频点播(VOD)系统通常包含以下部分:
- 媒体存储服务 :用于存放视频原文件。要求高可靠、高可用、低成本,并支持快速分发。我们通常不把视频文件直接放在应用服务器上。
- 内容分发网络 :将视频内容缓存到离用户更近的节点,加速播放,减轻源站压力。
- 业务后端服务 :处理用户注册登录、视频元数据管理(标题、描述、封面图)、播放权限校验、播放地址生成等核心业务逻辑。
- 播放器客户端 :在小程序、Web或App中实际播放视频的组件。
1.2 本项目技术栈说明 为了快速实现并聚焦业务逻辑,我们做出如下选型:
- 后端 :Spring Boot 2.7.x。生态成熟,能快速集成安全、数据库等组件。
- 数据库 :MySQL 8.0。存储用户信息、视频元数据等。
- 对象存储 :使用 腾讯云对象存储 作为示例。你也可以替换为阿里云OSS、七牛云等,原理相通。对象存储替代了自建文件服务器,是云上存储静态资源的首选。
- 内容分发 :使用对象存储服务自带的CDN加速功能,或单独配置腾讯云CDN。
- 用户鉴权 :JSON Web Token。无状态,适合RESTful API,易于在小程序、Web等多端使用。
- 小程序前端 :使用微信小程序原生框架,结合腾讯云播放器插件。
为什么是“猫爪追番”和小程序? 这其实是一种 渠道与权限的差异化设计 。“猫爪追番”可能是一个独立的App或深度合作渠道,拥有自己的用户体系。而小程序作为更轻量、易传播的载体,面向更广泛的用户。后端需要一套统一的视频元数据管理和权限系统,但针对不同渠道,提供不同的鉴权接口和播放地址签名逻辑。本文我们将模拟实现小程序的接入流程。
2. 环境准备与项目初始化
2.1 基础环境要求 确保你的开发环境包含以下工具:
- JDK 8 或 11
- Maven 3.6+
- MySQL 8.0 (也可使用5.7)
- IDE:IntelliJ IDEA 或 Eclipse
- 微信开发者工具
- 腾讯云账号(用于对象存储和CDN,有免费额度)
2.2 创建Spring Boot项目 使用 Spring Initializr 或 IDE 创建新项目,主要依赖如下:
- Spring Web
- Spring Data JPA
- MySQL Driver
- Lombok (简化代码)
- Spring Security (用于安全框架,但我们会简化配置)
最终的
pom.xml
关键依赖部分如下:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- JWT 相关 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- 腾讯云 COS SDK -->
<dependency>
<groupId>com.qcloud</groupId>
<artifactId>cos_api</artifactId>
<version>5.6.89</version>
</dependency>
</dependencies>
2.3 数据库初始化
在MySQL中创建数据库,例如
vod_system
。表结构我们将在后面通过JPA实体自动生成,或手动执行以下SQL创建基础表:
CREATE TABLE `user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL COMMENT '用户名',
`password` varchar(100) NOT NULL COMMENT '加密后的密码',
`wx_openid` varchar(100) DEFAULT NULL COMMENT '微信小程序OpenID,用于小程序登录',
`channel` varchar(20) DEFAULT 'miniprogram' COMMENT '用户来源渠道,如 miniprogram, catpaw',
`vip_expire_time` datetime DEFAULT NULL COMMENT 'VIP到期时间',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`),
UNIQUE KEY `uk_wx_openid` (`wx_openid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
CREATE TABLE `video` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`title` varchar(200) NOT NULL COMMENT '视频标题,如《崔国摄政王独宠小王妃》',
`description` text COMMENT '视频描述',
`cover_url` varchar(500) DEFAULT NULL COMMENT '封面图URL',
`file_key` varchar(500) NOT NULL COMMENT '对象存储中的文件Key',
`duration` int(11) DEFAULT NULL COMMENT '视频时长,秒',
`status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:0-待审核,1-已上线,2-已下线',
`channel_access` varchar(50) DEFAULT 'all' COMMENT '可访问渠道:all, miniprogram, catpaw',
`need_vip` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否需要VIP',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='视频元数据表';
2.4 配置文件
配置
application.yml
,包含数据库、JWT密钥和腾讯云COS信息。
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/vod_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: yourpassword
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update # 首次启动用update,生产环境用validate或none
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.MySQL8Dialect
# JWT配置
jwt:
secret: yourSuperSecretKeyForJWT256BitsOrMore # 务必使用强密钥,且保密
expiration: 86400000 # token有效期,单位毫秒,这里是24小时
# 腾讯云COS配置
tencent:
cos:
secret-id: your-secret-id # 替换为你的SecretId
secret-key: your-secret-key # 替换为你的SecretKey
region: ap-shanghai # 存储桶地域
bucket-name: your-bucket-1250000000 # 存储桶名称
base-url: https://your-bucket.cos.ap-shanghai.myqcloud.com # 存储桶访问基础URL
cdn-url: https://your-cdn-domain.com # CDN加速域名,如果没有,可以先使用base-url
3. 后端核心模块实现
我们将后端拆分为几个核心模块:实体模型、工具类、业务逻辑层和控制层。
3.1 实体与Repository 对应数据库表,创建JPA实体类。
// 文件路径:src/main/java/com/example/vod/entity/User.java
package com.example.vod.entity;
import lombok.Data;
import javax.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "user")
@Data
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String password; // 存储BCrypt加密后的密码
private String wxOpenid;
private String channel = "miniprogram";
private LocalDateTime vipExpireTime;
private LocalDateTime createdAt;
}
// 文件路径:src/main/java/com/example/vod/entity/Video.java
package com.example.vod.entity;
import lombok.Data;
import javax.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "video")
@Data
public class Video {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@Column(columnDefinition = "TEXT")
private String description;
private String coverUrl;
private String fileKey; // 如 `videos/崔国摄政王独宠小王妃/ep01.mp4`
private Integer duration;
private Integer status = 0; // 0-待审核,1-已上线,2-已下线
private String channelAccess = "all"; // all, miniprogram, catpaw
private Boolean needVip = false;
private LocalDateTime createdAt;
}
创建对应的Repository接口:
// 文件路径:src/main/java/com/example/vod/repository/UserRepository.java
package com.example.vod.repository;
import com.example.vod.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
Optional<User> findByWxOpenid(String wxOpenid);
}
// 文件路径:src/main/java/com/example/vod/repository/VideoRepository.java
package com.example.vod.repository;
import com.example.vod.entity.Video;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface VideoRepository extends JpaRepository<Video, Long> {
List<Video> findByStatusAndChannelAccessContainingOrderByCreatedAtDesc(Integer status, String channelAccess);
}
3.2 工具类:JWT与COS操作 创建JWT工具类用于生成和解析Token。
// 文件路径:src/main/java/com/example/vod/util/JwtUtil.java
package com.example.vod.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
private Key key;
@PostConstruct
public void init() {
this.key = Keys.hmacShaKeyFor(secret.getBytes());
}
// 生成Token (这里以userId和channel为例)
public String generateToken(Long userId, String channel) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("channel", channel);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
// 从Token中解析Claims
public Claims getClaimsFromToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
// 获取用户ID
public Long getUserIdFromToken(String token) {
return getClaimsFromToken(token).get("userId", Long.class);
}
// 验证Token是否过期
public Boolean isTokenExpired(String token) {
Date expiration = getClaimsFromToken(token).getExpiration();
return expiration.before(new Date());
}
}
创建腾讯云COS操作工具类,用于生成预签名URL(让前端能临时访问私有存储的视频)。
// 文件路径:src/main/java/com/example/vod/util/CosUtil.java
package com.example.vod.util;
import com.qcloud.cos.COSClient;
import com.qcloud.cos.ClientConfig;
import com.qcloud.cos.auth.BasicCOSCredentials;
import com.qcloud.cos.auth.COSCredentials;
import com.qcloud.cos.http.HttpMethodName;
import com.qcloud.cos.region.Region;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.net.URL;
import java.util.Date;
@Component
@Slf4j
public class CosUtil {
@Value("${tencent.cos.secret-id}")
private String secretId;
@Value("${tencent.cos.secret-key}")
private String secretKey;
@Value("${tencent.cos.region}")
private String region;
@Value("${tencent.cos.bucket-name}")
private String bucketName;
private COSClient cosClient;
@PostConstruct
public void init() {
COSCredentials cred = new BasicCOSCredentials(secretId, secretKey);
Region regionConfig = new Region(region);
ClientConfig clientConfig = new ClientConfig(regionConfig);
cosClient = new COSClient(cred, clientConfig);
}
/**
* 生成预签名URL,用于临时授权访问私有读文件
* @param key 对象键,即文件在存储桶中的路径
* @param expirationInSeconds URL有效期(秒)
* @return 带签名的URL
*/
public String generatePresignedUrl(String key, long expirationInSeconds) {
try {
Date expirationDate = new Date(System.currentTimeMillis() + expirationInSeconds * 1000);
// 这里使用GET方法生成预签名URL,用于下载/播放
URL url = cosClient.generatePresignedUrl(bucketName, key, expirationDate, HttpMethodName.GET);
// 可以将返回的URL中的域名替换为CDN域名,加速访问
String cdnUrl = url.toString().replace(
String.format("%s.cos.%s.myqcloud.com", bucketName, region),
"your-cdn-domain.com" // 实际项目中应从配置读取
);
return cdnUrl;
} catch (Exception e) {
log.error("生成预签名URL失败, key: {}", key, e);
return null;
}
}
}
3.3 业务逻辑层:视频与权限服务 创建服务类,处理核心业务逻辑。
// 文件路径:src/main/java/com/example/vod/service/VideoService.java
package com.example.vod.service;
import com.example.vod.entity.Video;
import com.example.vod.repository.VideoRepository;
import com.example.vod.util.CosUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class VideoService {
private final VideoRepository videoRepository;
private final CosUtil cosUtil;
/**
* 获取视频列表(已上线状态)
* @param channel 请求渠道,如 miniprogram
* @return 视频列表,包含可公开访问的信息和临时播放地址
*/
public List<VideoDTO> getAvailableVideos(String channel) {
// 查询已上线,且允许该渠道访问的视频
List<Video> videos = videoRepository.findByStatusAndChannelAccessContainingOrderByCreatedAtDesc(1, channel);
return videos.stream().map(video -> {
VideoDTO dto = new VideoDTO();
dto.setId(video.getId());
dto.setTitle(video.getTitle());
dto.setDescription(video.getDescription());
dto.setCoverUrl(video.getCoverUrl());
dto.setDuration(video.getDuration());
dto.setNeedVip(video.getNeedVip());
// 为每个视频生成一个临时的、有过期时间的播放地址
// 假设播放地址有效期为2小时
String playUrl = cosUtil.generatePresignedUrl(video.getFileKey(), 7200);
dto.setPlayUrl(playUrl);
return dto;
}).collect(Collectors.toList());
}
/**
* 根据视频ID和用户信息,获取单个视频的播放详情(包含权限校验)
* @param videoId 视频ID
* @param userId 用户ID
* @param isVip 用户是否为VIP
* @param channel 用户渠道
* @return 视频详情,如果无权限则返回null或抛出异常
*/
public VideoDetailDTO getVideoDetail(Long videoId, Long userId, Boolean isVip, String channel) {
Video video = videoRepository.findById(videoId).orElseThrow(() -> new RuntimeException("视频不存在"));
// 状态校验
if (!video.getStatus().equals(1)) {
throw new RuntimeException("视频已下线或未审核");
}
// 渠道校验
if (!"all".equals(video.getChannelAccess()) && !video.getChannelAccess().contains(channel)) {
throw new RuntimeException("该视频不在您的渠道播放");
}
// VIP校验
if (video.getNeedVip() && !isVip) {
throw new RuntimeException("该视频需要VIP权限");
}
// 生成播放地址
String playUrl = cosUtil.generatePresignedUrl(video.getFileKey(), 7200);
VideoDetailDTO dto = new VideoDetailDTO();
dto.setId(video.getId());
dto.setTitle(video.getTitle());
dto.setPlayUrl(playUrl);
// ... 设置其他信息
return dto;
}
// 内部使用的DTO类,用于控制返回给前端的数据
@Data
public static class VideoDTO {
private Long id;
private String title;
private String description;
private String coverUrl;
private Integer duration;
private Boolean needVip;
private String playUrl; // 临时播放地址
}
@Data
public static class VideoDetailDTO {
private Long id;
private String title;
private String playUrl;
// 可以添加更多详情字段
}
}
3.4 控制层:提供RESTful API 创建控制器,对外提供接口。
// 文件路径:src/main/java/com/example/vod/controller/VideoController.java
package com.example.vod.controller;
import com.example.vod.service.VideoService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
@RestController
@RequestMapping("/api/video")
@RequiredArgsConstructor
public class VideoController {
private final VideoService videoService;
private final JwtUtil jwtUtil; // 需要注入JwtUtil
/**
* 获取视频列表
* 需要携带Token,从中解析用户渠道
*/
@GetMapping("/list")
public ApiResponse<List<VideoService.VideoDTO>> list(HttpServletRequest request) {
String token = request.getHeader("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
return ApiResponse.error(401, "未授权");
}
token = token.substring(7);
try {
String channel = (String) jwtUtil.getClaimsFromToken(token).get("channel");
List<VideoService.VideoDTO> videos = videoService.getAvailableVideos(channel);
return ApiResponse.success(videos);
} catch (Exception e) {
return ApiResponse.error(401, "Token无效或已过期");
}
}
/**
* 获取视频详情和播放地址
*/
@GetMapping("/detail/{id}")
public ApiResponse<VideoService.VideoDetailDTO> detail(@PathVariable Long id, HttpServletRequest request) {
String token = request.getHeader("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
return ApiResponse.error(401, "未授权");
}
token = token.substring(7);
try {
Long userId = jwtUtil.getUserIdFromToken(token);
String channel = (String) jwtUtil.getClaimsFromToken(token).get("channel");
// 这里需要从数据库查询用户VIP状态,为简化示例,假设通过其他服务获取
Boolean isVip = false; // 应替换为实际查询逻辑
VideoService.VideoDetailDTO detail = videoService.getVideoDetail(id, userId, isVip, channel);
return ApiResponse.success(detail);
} catch (RuntimeException e) {
return ApiResponse.error(403, e.getMessage());
} catch (Exception e) {
return ApiResponse.error(401, "Token无效或已过期");
}
}
}
// 简单的统一响应封装
@Data
class ApiResponse<T> {
private int code;
private String message;
private T data;
public static <T> ApiResponse<T> success(T data) {
ApiResponse<T> resp = new ApiResponse<>();
resp.setCode(200);
resp.setMessage("success");
resp.setData(data);
return resp;
}
public static <T> ApiResponse<T> error(int code, String message) {
ApiResponse<T> resp = new ApiResponse<>();
resp.setCode(code);
resp.setMessage(message);
return resp;
}
}
3.5 用户登录(模拟小程序登录)
小程序登录流程涉及微信服务器,这里我们模拟一个简化版登录接口,实际项目中需要调用微信
code2session
API。
// 文件路径:src/main/java/com/example/vod/controller/AuthController.java
package com.example.vod.controller;
import com.example.vod.entity.User;
import com.example.vod.repository.UserRepository;
import com.example.vod.util.JwtUtil;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.util.Optional;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final UserRepository userRepository;
private final JwtUtil jwtUtil;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
/**
* 模拟小程序登录
* 真实场景:前端通过wx.login获取code,传给后端,后端用code+appid+secret请求微信接口换取openid和session_key
*/
@PostMapping("/login/miniprogram")
public ApiResponse<LoginResp> loginByMiniProgram(@RequestBody LoginReq req) {
// 这里简化处理,假设req中包含了微信返回的openid
String openid = req.getOpenid();
if (openid == null) {
return ApiResponse.error(400, "无效的openid");
}
// 查找或创建用户
Optional<User> userOpt = userRepository.findByWxOpenid(openid);
User user;
if (userOpt.isPresent()) {
user = userOpt.get();
} else {
user = new User();
user.setWxOpenid(openid);
user.setUsername("wx_" + openid.substring(0, 8)); // 生成一个默认用户名
user.setPassword(passwordEncoder.encode("default_password")); // 设置一个默认密码,实际小程序登录可能不需要密码
user.setChannel("miniprogram");
user = userRepository.save(user);
}
// 生成JWT Token
String token = jwtUtil.generateToken(user.getId(), user.getChannel());
LoginResp resp = new LoginResp();
resp.setToken(token);
resp.setUserId(user.getId());
resp.setChannel(user.getChannel());
resp.setVip(user.getVipExpireTime() != null && user.getVipExpireTime().isAfter(LocalDateTime.now()));
return ApiResponse.success(resp);
}
@Data
static class LoginReq {
private String openid; // 模拟参数,实际应为code
// 实际还应包含encryptedData, iv 用于解密用户信息(如果需要)
}
@Data
static class LoginResp {
private String token;
private Long userId;
private String channel;
private Boolean isVip;
}
}
4. 小程序前端开发实战
后端API准备好后,我们开始搭建小程序前端。这里假设你已注册微信小程序并获取了AppID。
4.1 小程序项目结构
miniprogram/
├── pages/
│ ├── index/ // 首页,视频列表
│ │ ├── index.js
│ │ ├── index.json
│ │ ├── index.wxml
│ │ └── index.wxss
│ └── play/ // 视频播放页
│ ├── play.js
│ ├── play.json
│ ├── play.wxml
│ └── play.wxss
├── utils/
│ └── request.js // 封装网络请求
├── app.js
├── app.json
└── app.wxss
4.2 封装网络请求
在
utils/request.js
中封装带Token的请求。
// utils/request.js
const BASE_URL = 'https://your-backend-domain.com'; // 替换为你的后端地址
const request = (url, method, data, header = {}) => {
// 从本地存储获取Token
const token = wx.getStorageSync('token');
const defaultHeader = {
'Content-Type': 'application/json',
};
if (token) {
defaultHeader['Authorization'] = `Bearer ${token}`;
}
return new Promise((resolve, reject) => {
wx.request({
url: BASE_URL + url,
method: method,
data: data,
header: { ...defaultHeader, ...header },
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data);
} else {
reject(res.data);
}
},
fail: (err) => {
reject(err);
}
});
});
};
// 导出常用方法
export const get = (url, data) => request(url, 'GET', data);
export const post = (url, data) => request(url, 'POST', data);
export default request;
4.3 首页:视频列表 首页展示可播放的视频列表。
<!-- pages/index/index.wxml -->
<view class="container">
<view class="video-list">
<block wx:for="{{videoList}}" wx:key="id">
<view class="video-item" bindtap="goToPlay" data-id="{{item.id}}">
<image class="cover" src="{{item.coverUrl}}" mode="aspectFill"></image>
<view class="info">
<text class="title">{{item.title}}</text>
<text class="duration">{{formatDuration(item.duration)}}</text>
<text wx:if="{{item.needVip}}" class="vip-tag">VIP</text>
</view>
</view>
</block>
</view>
</view>
// pages/index/index.js
import { get } from '../../utils/request.js';
Page({
data: {
videoList: []
},
onLoad() {
this.fetchVideoList();
},
async fetchVideoList() {
wx.showLoading({ title: '加载中...' });
try {
const res = await get('/api/video/list');
if (res.code === 200) {
this.setData({ videoList: res.data });
} else {
wx.showToast({ title: res.message, icon: 'none' });
}
} catch (err) {
wx.showToast({ title: '网络错误', icon: 'none' });
console.error(err);
} finally {
wx.hideLoading();
}
},
formatDuration(seconds) {
if (!seconds) return '00:00';
const min = Math.floor(seconds / 60);
const sec = seconds % 60;
return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
},
goToPlay(e) {
const videoId = e.currentTarget.dataset.id;
wx.navigateTo({
url: `/pages/play/play?id=${videoId}`
});
}
});
4.4 播放页:集成腾讯云播放器
播放页需要引入腾讯云播放器插件。首先在
app.json
中声明插件。
// app.json
{
"pages": [
"pages/index/index",
"pages/play/play"
],
"plugins": {
"player": {
"version": "1.2.0",
"provider": "wx2b03c6e691cd7370"
}
},
"window": {
"backgroundTextStyle": "light",
"navigationBarBackgroundColor": "#fff",
"navigationBarTitleText": "视频点播",
"navigationBarTextStyle": "black"
}
}
然后,在播放页使用该插件。
<!-- pages/play/play.wxml -->
<view class="container" wx:if="{{videoInfo}}">
<player
id="player"
src="{{videoInfo.playUrl}}"
autoplay="{{true}}"
bindplay="onPlay"
binderror="onError"
></player>
<view class="video-info">
<text class="title">{{videoInfo.title}}</text>
</view>
</view>
<view wx:else class="loading">加载中...</view>
// pages/play/play.js
import { get } from '../../utils/request.js';
Page({
data: {
videoId: null,
videoInfo: null
},
onLoad(options) {
this.setData({ videoId: options.id });
this.fetchVideoDetail();
},
async fetchVideoDetail() {
wx.showLoading({ title: '获取播放地址...' });
try {
const res = await get(`/api/video/detail/${this.data.videoId}`);
if (res.code === 200) {
this.setData({ videoInfo: res.data });
// 播放器组件会自动加载src并播放
} else {
wx.showToast({ title: res.message, icon: 'none' });
setTimeout(() => wx.navigateBack(), 1500);
}
} catch (err) {
wx.showToast({ title: '获取失败', icon: 'none' });
console.error(err);
} finally {
wx.hideLoading();
}
},
onPlay(e) {
console.log('开始播放', e);
},
onError(e) {
console.error('播放器错误', e);
wx.showToast({ title: '播放失败,请重试', icon: 'none' });
}
});
/* pages/play/play.wxss */
.container {
display: flex;
flex-direction: column;
height: 100vh;
}
player {
width: 100%;
height: 400rpx; /* 根据实际情况调整 */
}
.video-info {
padding: 30rpx;
}
.title {
font-size: 36rpx;
font-weight: bold;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-size: 32rpx;
color: #999;
}
5. 部署与运行验证
5.1 后端服务启动
-
确保MySQL服务运行,数据库
vod_system已创建。 -
修改
application.yml中的数据库密码、腾讯云密钥和存储桶信息。 -
在IDE中运行Spring Boot主类,或使用命令
mvn spring-boot:run。 -
观察控制台,确保无报错,并看到类似
Tomcat started on port(s): 8080的日志。
5.2 小程序配置与运行
- 在微信公众平台注册小程序,获取AppID。
- 在微信开发者工具中导入项目,填入AppID。
-
修改
utils/request.js中的BASE_URL为你的后端服务地址(如果是本地调试,需开启微信开发者工具的“不校验合法域名”选项,或配置服务器域名)。 -
在
app.js的onLaunch中模拟登录,获取Token并存储。
// app.js 模拟登录
import { post } from './utils/request.js';
App({
onLaunch() {
// 模拟获取openid,实际应从wx.login获取code,然后调用自己的后端接口
const mockOpenid = 'mock_openid_' + Date.now();
this.login(mockOpenid);
},
async login(openid) {
try {
const res = await post('/api/auth/login/miniprogram', { openid });
if (res.code === 200) {
wx.setStorageSync('token', res.data.token);
wx.setStorageSync('userInfo', { userId: res.data.userId, isVip: res.data.isVip });
console.log('登录成功');
}
} catch (err) {
console.error('登录失败', err);
}
}
});
- 编译运行小程序,即可在首页看到视频列表,点击进入播放页观看。
5.3 上传测试视频到COS
- 登录腾讯云COS控制台,进入你的存储桶。
-
创建一个文件夹,例如
videos/。 -
上传一个测试视频文件,例如
test.mp4。 -
记录下该文件的“对象键”,如
videos/test.mp4。 -
在MySQL的
video表中插入一条测试数据:
INSERT INTO `video` (`title`, `description`, `file_key`, `status`, `channel_access`, `need_vip`)
VALUES ('测试视频', '这是一个测试视频描述', 'videos/test.mp4', 1, 'miniprogram', 0);
- 刷新小程序,应该能看到这条测试视频,并可以正常播放。
6. 常见问题与排查思路
在实际开发和部署中,你可能会遇到以下问题:
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
小程序请求后端接口报
403
或
404
|
1. 后端服务未启动或端口不对。
2. 小程序未配置服务器域名。 3. 本地开发未关闭域名校验。 |
1. 检查后端服务日志,确认是否启动成功。
2. 登录微信公众平台,在“开发管理”-“开发设置”-“服务器域名”中配置
request
合法域名。
3. 微信开发者工具详情页,勾选“不校验合法域名、web-view域名、TLS版本”。 |
| 播放器黑屏/加载失败 |
1. 预签名URL生成失败或过期。
2. COS文件权限为私有读,但未正确生成签名。 3. CDN域名未配置或未生效。 4. 视频格式小程序播放器不支持。 |
1. 检查后端
CosUtil
日志,确认密钥、地域、存储桶名正确,且
generatePresignedUrl
成功。
2. 在COS控制台确认文件是否为“私有读”。 3. 检查生成的URL是否能直接在浏览器中下载(临时)。 4. 确保视频为H.264编码的MP4格式,这是小程序播放器的通用支持格式。 |
| 获取视频列表为空 |
1. 数据库
video
表无数据或
status
不为1。
2.
channel_access
字段不包含当前渠道(如
miniprogram
)。
3. JWT Token解析失败,导致渠道获取错误。 |
1. 检查数据库数据,确保有
status=1
且
channel_access
包含
miniprogram
的记录。
2. 在
VideoController
的
list
方法中打印解析出的
channel
,确认是否正确。
3. 检查Token的生成和解析密钥是否一致。 |
| Token无效或过期 |
1. 前后端
jwt.secret
不一致。
2. Token过期时间设置太短。 3. 前端未正确存储或发送Token。 |
1. 确保后端
application.yml
中的
jwt.secret
一致且保密。
2. 适当调整
jwt.expiration
。
3. 使用浏览器开发者工具或微信开发者工具的Network面板,检查请求头中
Authorization
字段是否正确携带
Bearer <token>
。
|
| 上传视频到COS失败 |
1. 腾讯云SecretId/SecretKey错误或权限不足。
2. 存储桶地域 (
region
) 填写错误。
3. 网络问题。 |
1. 在腾讯云“访问管理”中检查密钥对,并确保该密钥对拥有对应存储桶的读写权限。
2. 地域代码需精确,如
ap-shanghai
。
3. 可在后端写一个简单的测试接口,尝试上传一个小文件,根据错误信息排查。 |
7. 最佳实践与工程建议
将Demo升级为生产可用的系统,还需要考虑以下方面:
7.1 安全与权限深度优化
- Token刷新机制 :实现Refresh Token机制,避免用户频繁重新登录。
- 权限细粒度控制 :除了渠道和VIP,可增加基于用户角色、购买记录、观看次数等更复杂的权限模型。
- 防盗链与签名 :预签名URL是基础防盗链。可进一步结合CDN的防盗链配置(如Referer黑名单、时间戳签名)。
- 接口限流与防刷 :对获取播放地址等核心接口进行限流,防止恶意刷取。
7.2 性能与用户体验
- 视频处理 :上传视频后,应使用云函数或转码服务(如腾讯云MPS)生成多种清晰度(标清、高清、超清)和封面图,并写入元数据。
- 播放器优化 :根据网络状况自适应切换清晰度。在小程序端做好加载、重试、断点续播的UI提示。
- 列表分页与缓存 :视频列表接口实现分页。小程序端可对列表数据进行本地缓存,提升二次打开速度。
- CDN预热与刷新 :对于热门剧集,可提前将文件预热至CDN边缘节点。视频更新后,及时刷新CDN缓存。
7.3 架构扩展性
- 微服务拆分 :当业务增长,可将用户服务、视频元数据服务、播放签权服务、支付服务等拆分为独立微服务。
- 消息队列解耦 :视频上传、转码、审核等耗时操作,通过消息队列(如RabbitMQ、Kafka)异步处理,提升主流程响应速度。
- 配置中心 :将JWT密钥、COS配置、CDN域名等敏感和易变配置移至配置中心(如Apollo、Nacos),实现动态刷新。
- 监控与日志 :接入APM工具监控接口性能,使用ELK等收集业务日志和播放错误日志,便于快速定位问题。
7.4 数据管理与运营
- 后台管理系统 :开发一个管理后台,用于上传视频、编辑元数据、审核内容、管理用户和VIP权益。
- 数据统计 :记录用户播放行为(播放、暂停、完成、时长),用于内容推荐和运营分析。
-
多渠道管理
:在数据库设计上预留扩展性,方便未来接入更多渠道(如H5、App、电视端),通过统一的
channel字段进行路由和权限控制。
通过以上步骤,我们不仅实现了一个“能看剧”的小程序,更构建了一个具备生产级潜力的视频点播系统核心框架。从接到“新剧上映,有猫爪追番的朋友去,没有的,在小程序可以收看”这样的需求开始,到技术方案选型、前后端编码、部署调试,再到深入思考安全、性能与扩展性,这正是一个全栈开发者需要掌握的完整闭环。
火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。
更多推荐
所有评论(0)