最近在追剧的小伙伴们可能都发现了,现在看剧的渠道越来越分散。想看的剧,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 后端服务启动

  1. 确保MySQL服务运行,数据库 vod_system 已创建。
  2. 修改 application.yml 中的数据库密码、腾讯云密钥和存储桶信息。
  3. 在IDE中运行Spring Boot主类,或使用命令 mvn spring-boot:run
  4. 观察控制台,确保无报错,并看到类似 Tomcat started on port(s): 8080 的日志。

5.2 小程序配置与运行

  1. 在微信公众平台注册小程序,获取AppID。
  2. 在微信开发者工具中导入项目,填入AppID。
  3. 修改 utils/request.js 中的 BASE_URL 为你的后端服务地址(如果是本地调试,需开启微信开发者工具的“不校验合法域名”选项,或配置服务器域名)。
  4. 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);
    }
  }
});
  1. 编译运行小程序,即可在首页看到视频列表,点击进入播放页观看。

5.3 上传测试视频到COS

  1. 登录腾讯云COS控制台,进入你的存储桶。
  2. 创建一个文件夹,例如 videos/
  3. 上传一个测试视频文件,例如 test.mp4
  4. 记录下该文件的“对象键”,如 videos/test.mp4
  5. 在MySQL的 video 表中插入一条测试数据:
INSERT INTO `video` (`title`, `description`, `file_key`, `status`, `channel_access`, `need_vip`)
VALUES ('测试视频', '这是一个测试视频描述', 'videos/test.mp4', 1, 'miniprogram', 0);
  1. 刷新小程序,应该能看到这条测试视频,并可以正常播放。

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 字段进行路由和权限控制。

通过以上步骤,我们不仅实现了一个“能看剧”的小程序,更构建了一个具备生产级潜力的视频点播系统核心框架。从接到“新剧上映,有猫爪追番的朋友去,没有的,在小程序可以收看”这样的需求开始,到技术方案选型、前后端编码、部署调试,再到深入思考安全、性能与扩展性,这正是一个全栈开发者需要掌握的完整闭环。

Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐