SpringBoot 集成阿里云直播 + 点播全实战:推流、拉流、转码、回放一站式落地

0 阅读14分钟

一、核心概念与底层逻辑

1.1 阿里云直播核心原理

阿里云直播(ApsaraVideo Live)是基于领先的内容接入与分发网络和大规模分布式实时转码技术打造的音视频直播平台,核心工作链路为:

  1. 主播端通过推流协议(RTMP/WebRTC)将音视频流推送到阿里云边缘CDN节点
  2. 边缘节点将流同步到直播中心集群,完成转码、录制、审核、水印等处理
  3. 处理后的流同步到全球边缘CDN节点,观众端通过拉流协议(RTMP/HTTP-FLV/HLS/WebRTC)从就近节点拉取流播放
  4. 业务服务端通过OpenAPI完成流管理、配置下发、事件监听等全流程控制

1.2 阿里云点播核心原理

阿里云点播(ApsaraVideo VOD)是集音视频采集、编辑、上传、自动化转码处理、媒体资源管理、分发加速、视频播放于一体的一站式音视频点播解决方案,核心工作链路为:

  1. 业务服务端获取上传凭证,下发给客户端
  2. 客户端通过凭证直接将音视频文件上传到点播绑定的OSS存储
  3. 上传完成后自动触发转码、截图、审核、水印等媒体处理任务
  4. 处理完成后生成多清晰度、多格式的播放文件,同步到CDN节点
  5. 业务服务端获取带鉴权的播放地址,下发给观众端播放
  6. 业务服务端获取带鉴权的播放地址,下发给观众端播放

1.3 直播与点播的核心差异与联动场景

维度阿里云直播阿里云点播
核心场景实时音视频互动、直播带货、在线教育直播视频回放、课程点播、短视频分发、录播内容
数据形态实时流式传输,无完整文件离线音视频文件,预先生成完整内容
延迟要求低延迟(WebRTC可做到300ms内)无强延迟要求,追求播放稳定性
核心能力实时转码、流状态管控、实时录制、连麦互动媒体文件处理、多清晰度转码、内容审核、版权保护

最核心的联动场景为直播录制自动转点播回放:直播过程中实时录制流内容生成视频文件,录制完成后自动同步到点播平台,完成转码、审核后生成回放视频,实现直播内容的二次分发。

二、前置准备工作

  1. 阿里云账号开通服务:登录阿里云控制台,开通「视频直播」和「视频点播」服务,完成实名认证

  2. 域名准备与配置

    • 直播需准备2个已备案域名,分别作为推流域名和拉流域名,在直播控制台完成域名添加
    • 按控制台提示完成域名CNAME解析,将域名解析到阿里云分配的CNAME地址
    • 开启域名的URL鉴权功能,获取鉴权密钥,防止盗链
  3. RAM权限配置

    • 创建RAM用户,生成AccessKey ID和AccessKey Secret
    • 为RAM用户授予最小权限:AliyunLiveFullAccess(直播全权限)、AliyunVODFullAccess(点播全权限),生产环境可按需细化权限粒度
  4. 模板配置

    • 直播控制台配置录制模板、转码模板、水印模板,获取模板ID
    • 点播控制台配置转码模板组、工作流、审核模板,获取对应ID

三、项目环境搭建

3.1 Maven依赖配置

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.5</version>
        <relativePath/>
    </parent>
    <groupId>com.jam</groupId>
    <artifactId>live-vod-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>live-vod-demo</name>
    <properties>
        <java.version>17</java.version>
        <aliyun.live.version>4.1.0</aliyun.live.version>
        <aliyun.vod.version>3.1.0</aliyun.vod.version>
        <aliyun.tea.version>0.3.3</aliyun.tea.version>
        <mybatis-plus.version>3.5.7</mybatis-plus.version>
        <springdoc.version>2.5.0</springdoc.version>
        <fastjson2.version>2.0.52</fastjson2.version>
        <guava.version>33.1.0-jre</guava.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>${springdoc.version}</version>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>${mybatis-plus.version}</version>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.32</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>com.alibaba.fastjson2</groupId>
            <artifactId>fastjson2</artifactId>
            <version>${fastjson2.version}</version>
        </dependency>
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>${guava.version}</version>
        </dependency>
        <dependency>
            <groupId>com.aliyun</groupId>
            <artifactId>live20161101</artifactId>
            <version>${aliyun.live.version}</version>
        </dependency>
        <dependency>
            <groupId>com.aliyun</groupId>
            <artifactId>vod20170321</artifactId>
            <version>${aliyun.vod.version}</version>
        </dependency>
        <dependency>
            <groupId>com.aliyun</groupId>
            <artifactId>tea-openapi</artifactId>
            <version>${aliyun.tea.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

3.2 应用配置文件

server:
  port: 8080
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/live_vod_demo?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
    username: root
    password: your_mysql_password
  jackson:
    default-property-inclusion: non_null
    time-zone: Asia/Shanghai
mybatis-plus:
  mapper-locations: classpath*:/mapper/**/*.xml
  configuration:
    map-underscore-to-camel-casetrue
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      id-type: auto
      logic-delete-field: deleted
      logic-delete-value: 1
      logic-not-delete-value: 0
springdoc:
  swagger-ui:
    path: /swagger-ui.html
    tags-sorter: alpha
    operations-sorter: alpha
  api-docs:
    path: /v3/api-docs
  packages-to-scan: com.jam.demo.controller
aliyun:
  access-key-id: your_aliyun_access_key_id
  access-key-secret: your_aliyun_access_key_secret
  live:
    region-id: cn-shanghai
    push-domain: your-push-domain.com
    pull-domain: your-pull-domain.com
    app-name: live
    auth-key: your_live_auth_key
    callback-auth-key: your_live_callback_auth_key
    record-template-id: your_record_template_id
    transcode-template-ids: your_transcode_template_ids
  vod:
    region-id: cn-shanghai
    access-key-id: your_aliyun_access_key_id
    access-key-secret: your_aliyun_access_key_secret
    play-auth-key: your_vod_play_auth_key
    template-group-id: your_transcode_template_group_id
    workflow-id: your_workflow_id

3.3 数据库表设计

CREATE TABLE `live_stream` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `stream_name` varchar(128NOT NULL COMMENT '直播流名称',
  `app_name` varchar(64NOT NULL DEFAULT 'live' COMMENT '应用名称',
  `push_url` varchar(512NOT NULL COMMENT '推流地址',
  `rtmp_pull_url` varchar(512) DEFAULT NULL COMMENT 'RTMP拉流地址',
  `flv_pull_url` varchar(512) DEFAULT NULL COMMENT 'FLV拉流地址',
  `hls_pull_url` varchar(512) DEFAULT NULL COMMENT 'HLS拉流地址',
  `stream_status` tinyint NOT NULL DEFAULT '0' COMMENT '流状态 0-未推流 1-直播中 2-已断流 3-已禁用',
  `record_template_id` varchar(64) DEFAULT NULL COMMENT '录制模板ID',
  `transcode_template_ids` varchar(256) DEFAULT NULL COMMENT '转码模板ID列表,逗号分隔',
  `vod_video_id` varchar(64) DEFAULT NULL COMMENT '关联的点播视频ID',
  `start_time` datetime DEFAULT NULL COMMENT '直播开始时间',
  `end_time` datetime DEFAULT NULL COMMENT '直播结束时间',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_stream_name` (`stream_name`),
  KEY `idx_stream_status` (`stream_status`),
  KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='直播流信息表';

CREATE TABLE `vod_video` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `video_id` varchar(64NOT NULL COMMENT '阿里云点播视频ID',
  `title` varchar(256NOT NULL COMMENT '视频标题',
  `file_name` varchar(256) DEFAULT NULL COMMENT '源文件名称',
  `file_size` bigint DEFAULT NULL COMMENT '源文件大小,单位字节',
  `duration` decimal(10,2) DEFAULT NULL COMMENT '视频时长,单位秒',
  `cover_url` varchar(512) DEFAULT NULL COMMENT '封面地址',
  `transcode_status` tinyint NOT NULL DEFAULT '0' COMMENT '转码状态 0-未转码 1-转码中 2-转码成功 3-转码失败',
  `audit_status` tinyint NOT NULL DEFAULT '0' COMMENT '审核状态 0-未审核 1-审核中 2-审核通过 3-审核拒绝',
  `play_url` varchar(512) DEFAULT NULL COMMENT '默认播放地址',
  `stream_name` varchar(128) DEFAULT NULL COMMENT '关联的直播流名称',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_video_id` (`video_id`),
  KEY `idx_stream_name` (`stream_name`),
  KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='点播视频信息表';

CREATE TABLE `live_vod_callback` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `request_id` varchar(128NOT NULL COMMENT '回调请求ID,幂等键',
  `callback_type` varchar(64NOT NULL COMMENT '回调类型',
  `callback_content` text NOT NULL COMMENT '回调内容',
  `request_ip` varchar(64) DEFAULT NULL COMMENT '请求IP',
  `handle_status` tinyint NOT NULL DEFAULT '0' COMMENT '处理状态 0-待处理 1-处理成功 2-处理失败',
  `error_msg` varchar(512) DEFAULT NULL COMMENT '错误信息',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_request_id` (`request_id`),
  KEY `idx_callback_type` (`callback_type`),
  KEY `idx_handle_status` (`handle_status`),
  KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='直播点播回调事件记录表';

3.4 核心配置类

package com.jam.demo.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * 阿里云客户端配置类
 * @author ken
 */
@Configuration
public class AliyunClientConfig {

    @Value("${aliyun.access-key-id}")
    private String accessKeyId;

    @Value("${aliyun.access-key-secret}")
    private String accessKeySecret;

    @Value("${aliyun.live.region-id}")
    private String liveRegionId;

    @Value("${aliyun.vod.region-id}")
    private String vodRegionId;

    @Bean
    public com.aliyun.live20161101.Client liveClient() throws Exception {
        com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config()
                .setAccessKeyId(accessKeyId)
                .setAccessKeySecret(accessKeySecret)
                .setRegionId(liveRegionId)
                .setEndpoint("live." + liveRegionId + ".aliyuncs.com");
        return new com.aliyun.live20161101.Client(config);
    }

    @Bean
    public com.aliyun.vod20170321.Client vodClient() throws Exception {
        com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config()
                .setAccessKeyId(accessKeyId)
                .setAccessKeySecret(accessKeySecret)
                .setRegionId(vodRegionId)
                .setEndpoint("vod." + vodRegionId + ".aliyuncs.com");
        return new com.aliyun.vod20170321.Client(config);
    }
}

3.5 核心工具类

package com.jam.demo.util;

import org.springframework.util.DigestUtils;
import org.springframework.util.StringUtils;

/**
 * 阿里云直播鉴权工具类
 * @author ken
 */
public class LiveAuthUtil {

    /**
     * 生成带鉴权的RTMP推流地址
     * @param domain 推流域名
     * @param appName 应用名称
     * @param streamName 流名称
     * @param authKey 鉴权密钥
     * @param expireSecond 地址有效期,单位秒
     * @return 带鉴权的推流地址
     */
    public static String generatePushUrl(String domain, String appName, String streamName, String authKey, long expireSecond) {
        String uri = "/" + appName + "/" + streamName;
        long expireTime = System.currentTimeMillis() / 1000 + expireSecond;
        String randomStr = "0";
        String uuid = "0";
        String signStr = uri + "-" + expireTime + "-" + randomStr + "-" + uuid + "-" + authKey;
        String sign = DigestUtils.md5Hex(signStr);
        String authParam = expireTime + "-" + randomStr + "-" + uuid + "-" + sign;
        return String.format("rtmp://%s%s?auth_key=%s", domain, uri, authParam);
    }

    /**
     * 生成带鉴权的RTMP拉流地址
     */
    public static String generateRtmpPullUrl(String domain, String appName, String streamName, String authKey, long expireSecond) {
        String uri = "/" + appName + "/" + streamName;
        long expireTime = System.currentTimeMillis() / 1000 + expireSecond;
        String randomStr = "0";
        String uuid = "0";
        String signStr = uri + "-" + expireTime + "-" + randomStr + "-" + uuid + "-" + authKey;
        String sign = DigestUtils.md5Hex(signStr);
        String authParam = expireTime + "-" + randomStr + "-" + uuid + "-" + sign;
        return String.format("rtmp://%s%s?auth_key=%s", domain, uri, authParam);
    }

    /**
     * 生成带鉴权的HTTP-FLV拉流地址
     */
    public static String generateFlvPullUrl(String domain, String appName, String streamName, String authKey, long expireSecond) {
        String uri = "/" + appName + "/" + streamName + ".flv";
        long expireTime = System.currentTimeMillis() / 1000 + expireSecond;
        String randomStr = "0";
        String uuid = "0";
        String signStr = uri + "-" + expireTime + "-" + randomStr + "-" + uuid + "-" + authKey;
        String sign = DigestUtils.md5Hex(signStr);
        String authParam = expireTime + "-" + randomStr + "-" + uuid + "-" + sign;
        return String.format("https://%s%s?auth_key=%s", domain, uri, authParam);
    }

    /**
     * 生成带鉴权的HLS拉流地址
     */
    public static String generateHlsPullUrl(String domain, String appName, String streamName, String authKey, long expireSecond) {
        String uri = "/" + appName + "/" + streamName + ".m3u8";
        long expireTime = System.currentTimeMillis() / 1000 + expireSecond;
        String randomStr = "0";
        String uuid = "0";
        String signStr = uri + "-" + expireTime + "-" + randomStr + "-" + uuid + "-" + authKey;
        String sign = DigestUtils.md5Hex(signStr);
        String authParam = expireTime + "-" + randomStr + "-" + uuid + "-" + sign;
        return String.format("https://%s%s?auth_key=%s", domain, uri, authParam);
    }

    /**
     * 验证直播回调签名
     * @param requestBody 回调请求体
     * @param authorization 请求头中的Authorization字段
     * @param callbackAuthKey 回调鉴权密钥
     * @return 验签结果
     */
    public static boolean verifyLiveCallbackSign(String requestBody, String authorization, String callbackAuthKey) {
        if (!StringUtils.hasText(requestBody) || !StringUtils.hasText(authorization) || !StringUtils.hasText(callbackAuthKey)) {
            return false;
        }
        String sign = DigestUtils.md5Hex(requestBody + callbackAuthKey);
        return sign.equalsIgnoreCase(authorization);
    }

    /**
     * 生成点播播放鉴权地址
     * @param playDomain 播放域名
     * @param fileUri 视频文件URI
     * @param playAuthKey 播放鉴权密钥
     * @param expireSecond 有效期,单位秒
     * @return 带鉴权的播放地址
     */
    public static String generateVodPlayUrl(String playDomain, String fileUri, String playAuthKey, long expireSecond) {
        long expireTime = System.currentTimeMillis() / 1000 + expireSecond;
        String randomStr = "0";
        String uuid = "0";
        String signStr = fileUri + "-" + expireTime + "-" + randomStr + "-" + uuid + "-" + playAuthKey;
        String sign = DigestUtils.md5Hex(signStr);
        String authParam = expireTime + "-" + randomStr + "-" + uuid + "-" + sign;
        return String.format("https://%s%s?auth_key=%s", playDomain, fileUri, authParam);
    }
}

四、直播模块全实战

4.1 实体层与Mapper层

package com.jam.demo.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.time.LocalDateTime;

/**
 * 直播流实体类
 * @author ken
 */
@Data
@TableName("live_stream")
@Schema(description = "直播流信息")
public class LiveStream {
    @TableId(type = IdType.AUTO)
    @Schema(description = "主键ID")
    private Long id;

    @Schema(description = "直播流名称")
    private String streamName;

    @Schema(description = "应用名称")
    private String appName;

    @Schema(description = "推流地址")
    private String pushUrl;

    @Schema(description = "RTMP拉流地址")
    private String rtmpPullUrl;

    @Schema(description = "FLV拉流地址")
    private String flvPullUrl;

    @Schema(description = "HLS拉流地址")
    private String hlsPullUrl;

    @Schema(description = "流状态 0-未推流 1-直播中 2-已断流 3-已禁用")
    private Integer streamStatus;

    @Schema(description = "录制模板ID")
    private String recordTemplateId;

    @Schema(description = "转码模板ID列表,逗号分隔")
    private String transcodeTemplateIds;

    @Schema(description = "关联的点播视频ID")
    private String vodVideoId;

    @Schema(description = "直播开始时间")
    private LocalDateTime startTime;

    @Schema(description = "直播结束时间")
    private LocalDateTime endTime;

    @Schema(description = "创建时间")
    private LocalDateTime createTime;

    @Schema(description = "更新时间")
    private LocalDateTime updateTime;
}
package com.jam.demo.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jam.demo.entity.LiveStream;
import org.apache.ibatis.annotations.Mapper;

/**
 * 直播流Mapper接口
 * @author ken
 */
@Mapper
public interface LiveStreamMapper extends BaseMapper<LiveStream> {
}

4.2 服务层实现

package com.jam.demo.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.jam.demo.entity.LiveStream;
import com.jam.demo.vo.LiveStreamCreateReq;
import com.jam.demo.vo.LiveStreamInfoVo;

/**
 * 直播服务接口
 * @author ken
 */
public interface LiveService extends IService<LiveStream> {

    LiveStreamInfoVo createLiveStream(LiveStreamCreateReq req);

    LiveStreamInfoVo getLiveStreamInfo(String streamName);

    void forbidLiveStream(String streamName);

    void resumeLiveStream(String streamName);

    void updateLiveStreamStatus(String streamName, Integer status);
}
package com.jam.demo.service.impl;

import com.aliyun.live20161101.Client;
import com.aliyun.live20161101.models.AddLiveStreamRecordRequest;
import com.aliyun.live20161101.models.AddLiveStreamTranscodeRequest;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.google.common.base.Splitter;
import com.jam.demo.entity.LiveStream;
import com.jam.demo.mapper.LiveStreamMapper;
import com.jam.demo.service.LiveService;
import com.jam.demo.util.LiveAuthUtil;
import com.jam.demo.vo.LiveStreamCreateReq;
import com.jam.demo.vo.LiveStreamInfoVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;

import jakarta.annotation.Resource;
import java.time.LocalDateTime;
import java.util.List;

/**
 * 直播服务实现类
 * @author ken
 */
@Slf4j
@Service
public class LiveServiceImpl extends ServiceImpl<LiveStreamMapper, LiveStream> implements LiveService {

    @Value("${aliyun.live.push-domain}")
    private String pushDomain;

    @Value("${aliyun.live.pull-domain}")
    private String pullDomain;

    @Value("${aliyun.live.app-name}")
    private String appName;

    @Value("${aliyun.live.auth-key}")
    private String authKey;

    @Value("${aliyun.live.record-template-id}")
    private String defaultRecordTemplateId;

    @Value("${aliyun.live.transcode-template-ids}")
    private String defaultTranscodeTemplateIds;

    @Resource
    private Client liveClient;

    @Override
    public LiveStreamInfoVo createLiveStream(LiveStreamCreateReq req) {
        String streamName = req.getStreamName();
        if (!StringUtils.hasText(streamName)) {
            throw new IllegalArgumentException("直播流名称不能为空");
        }
        LambdaQueryWrapper<LiveStream> queryWrapper = new LambdaQueryWrapper<LiveStream>()
                .eq(LiveStream::getStreamName, streamName);
        LiveStream existStream = this.getOne(queryWrapper);
        if (!ObjectUtils.isEmpty(existStream)) {
            throw new IllegalArgumentException("该直播流名称已存在");
        }
        long expireSecond = req.getExpireSecond() == null ? 7 * 24 * 3600L : req.getExpireSecond();
        String pushUrl = LiveAuthUtil.generatePushUrl(pushDomain, appName, streamName, authKey, expireSecond);
        String rtmpPullUrl = LiveAuthUtil.generateRtmpPullUrl(pullDomain, appName, streamName, authKey, expireSecond);
        String flvPullUrl = LiveAuthUtil.generateFlvPullUrl(pullDomain, appName, streamName, authKey, expireSecond);
        String hlsPullUrl = LiveAuthUtil.generateHlsPullUrl(pullDomain, appName, streamName, authKey, expireSecond);
        String recordTemplateId = StringUtils.hasText(req.getRecordTemplateId()) ? req.getRecordTemplateId() : defaultRecordTemplateId;
        String transcodeTemplateIds = StringUtils.hasText(req.getTranscodeTemplateIds()) ? req.getTranscodeTemplateIds() : defaultTranscodeTemplateIds;
        LiveStream liveStream = new LiveStream();
        liveStream.setStreamName(streamName);
        liveStream.setAppName(appName);
        liveStream.setPushUrl(pushUrl);
        liveStream.setRtmpPullUrl(rtmpPullUrl);
        liveStream.setFlvPullUrl(flvPullUrl);
        liveStream.setHlsPullUrl(hlsPullUrl);
        liveStream.setStreamStatus(0);
        liveStream.setRecordTemplateId(recordTemplateId);
        liveStream.setTranscodeTemplateIds(transcodeTemplateIds);
        this.save(liveStream);
        try {
            if (StringUtils.hasText(recordTemplateId)) {
                AddLiveStreamRecordRequest recordRequest = new AddLiveStreamRecordRequest()
                        .setDomainName(pushDomain)
                        .setAppName(appName)
                        .setStreamName(streamName)
                        .setOssBucket(req.getOssBucket())
                        .setOssEndpoint(req.getOssEndpoint())
                        .setRecordTemplateId(recordTemplateId);
                liveClient.addLiveStreamRecord(recordRequest);
            }
            if (StringUtils.hasText(transcodeTemplateIds)) {
                List<String> templateIdList = Splitter.on(",").omitEmptyStrings().trimResults().splitToList(transcodeTemplateIds);
                for (String templateId : templateIdList) {
                    AddLiveStreamTranscodeRequest transcodeRequest = new AddLiveStreamTranscodeRequest()
                            .setDomain(pullDomain)
                            .setApp(appName)
                            .setStream(streamName)
                            .setTemplate(templateId);
                    liveClient.addLiveStreamTranscode(transcodeRequest);
                }
            }
        } catch (Exception e) {
            log.error("配置直播录制/转码模板失败,streamName:{}", streamName, e);
            throw new RuntimeException("直播流配置失败", e);
        }
        LiveStreamInfoVo vo = new LiveStreamInfoVo();
        BeanUtils.copyProperties(liveStream, vo);
        return vo;
    }

    @Override
    public LiveStreamInfoVo getLiveStreamInfo(String streamName) {
        if (!StringUtils.hasText(streamName)) {
            throw new IllegalArgumentException("直播流名称不能为空");
        }
        LambdaQueryWrapper<LiveStream> queryWrapper = new LambdaQueryWrapper<LiveStream>()
                .eq(LiveStream::getStreamName, streamName);
        LiveStream liveStream = this.getOne(queryWrapper);
        if (ObjectUtils.isEmpty(liveStream)) {
            throw new IllegalArgumentException("直播流不存在");
        }
        LiveStreamInfoVo vo = new LiveStreamInfoVo();
        BeanUtils.copyProperties(liveStream, vo);
        return vo;
    }

    @Override
    public void forbidLiveStream(String streamName) {
        if (!StringUtils.hasText(streamName)) {
            throw new IllegalArgumentException("直播流名称不能为空");
        }
        LambdaQueryWrapper<LiveStream> queryWrapper = new LambdaQueryWrapper<LiveStream>()
                .eq(LiveStream::getStreamName, streamName);
        LiveStream liveStream = this.getOne(queryWrapper);
        if (ObjectUtils.isEmpty(liveStream)) {
            throw new IllegalArgumentException("直播流不存在");
        }
        try {
            com.aliyun.live20161101.models.ForbidLiveStreamRequest request = new com.aliyun.live20161101.models.ForbidLiveStreamRequest()
                    .setDomainName(pushDomain)
                    .setAppName(appName)
                    .setStreamName(streamName);
            liveClient.forbidLiveStream(request);
        } catch (Exception e) {
            log.error("禁用直播流失败,streamName:{}", streamName, e);
            throw new RuntimeException("禁用直播流失败", e);
        }
        liveStream.setStreamStatus(3);
        this.updateById(liveStream);
    }

    @Override
    public void resumeLiveStream(String streamName) {
        if (!StringUtils.hasText(streamName)) {
            throw new IllegalArgumentException("直播流名称不能为空");
        }
        LambdaQueryWrapper<LiveStream> queryWrapper = new LambdaQueryWrapper<LiveStream>()
                .eq(LiveStream::getStreamName, streamName);
        LiveStream liveStream = this.getOne(queryWrapper);
        if (ObjectUtils.isEmpty(liveStream)) {
            throw new IllegalArgumentException("直播流不存在");
        }
        try {
            com.aliyun.live20161101.models.ResumeLiveStreamRequest request = new com.aliyun.live20161101.models.ResumeLiveStreamRequest()
                    .setDomainName(pushDomain)
                    .setAppName(appName)
                    .setStreamName(streamName);
            liveClient.resumeLiveStream(request);
        } catch (Exception e) {
            log.error("恢复直播流失败,streamName:{}", streamName, e);
            throw new RuntimeException("恢复直播流失败", e);
        }
        liveStream.setStreamStatus(0);
        this.updateById(liveStream);
    }

    @Override
    public void updateLiveStreamStatus(String streamName, Integer status) {
        if (!StringUtils.hasText(streamName) || ObjectUtils.isEmpty(status)) {
            throw new IllegalArgumentException("参数不能为空");
        }
        LambdaQueryWrapper<LiveStream> queryWrapper = new LambdaQueryWrapper<LiveStream>()
                .eq(LiveStream::getStreamName, streamName);
        LiveStream liveStream = this.getOne(queryWrapper);
        if (ObjectUtils.isEmpty(liveStream)) {
            return;
        }
        liveStream.setStreamStatus(status);
        if (status == 1) {
            liveStream.setStartTime(LocalDateTime.now());
        } else if (status == 2) {
            liveStream.setEndTime(LocalDateTime.now());
        }
        this.updateById(liveStream);
    }
}

4.3 视图对象定义

package com.jam.demo.vo;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

/**
 * 直播流创建请求对象
 * @author ken
 */
@Data
@Schema(description = "直播流创建请求")
public class LiveStreamCreateReq {
    @Schema(description = "直播流名称", requiredMode = Schema.RequiredMode.REQUIRED)
    private String streamName;

    @Schema(description = "地址有效期,单位秒,默认7天")
    private Long expireSecond;

    @Schema(description = "录制模板ID,默认使用配置的模板")
    private String recordTemplateId;

    @Schema(description = "转码模板ID列表,逗号分隔,默认使用配置的模板")
    private String transcodeTemplateIds;

    @Schema(description = "录制文件存储OSS Bucket")
    private String ossBucket;

    @Schema(description = "OSS Endpoint")
    private String ossEndpoint;
}
package com.jam.demo.vo;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.time.LocalDateTime;

/**
 * 直播流信息响应对象
 * @author ken
 */
@Data
@Schema(description = "直播流信息响应")
public class LiveStreamInfoVo {
    @Schema(description = "主键ID")
    private Long id;

    @Schema(description = "直播流名称")
    private String streamName;

    @Schema(description = "应用名称")
    private String appName;

    @Schema(description = "推流地址")
    private String pushUrl;

    @Schema(description = "RTMP拉流地址")
    private String rtmpPullUrl;

    @Schema(description = "FLV拉流地址")
    private String flvPullUrl;

    @Schema(description = "HLS拉流地址")
    private String hlsPullUrl;

    @Schema(description = "流状态 0-未推流 1-直播中 2-已断流 3-已禁用")
    private Integer streamStatus;

    @Schema(description = "录制模板ID")
    private String recordTemplateId;

    @Schema(description = "转码模板ID列表,逗号分隔")
    private String transcodeTemplateIds;

    @Schema(description = "关联的点播视频ID")
    private String vodVideoId;

    @Schema(description = "直播开始时间")
    private LocalDateTime startTime;

    @Schema(description = "直播结束时间")
    private LocalDateTime endTime;

    @Schema(description = "创建时间")
    private LocalDateTime createTime;
}

4.4 控制层实现

package com.jam.demo.controller;

import com.jam.demo.service.LiveService;
import com.jam.demo.vo.LiveStreamCreateReq;
import com.jam.demo.vo.LiveStreamInfoVo;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

/**
 * 直播管理控制器
 * @author ken
 */
@RestController
@RequestMapping("/api/live")
@RequiredArgsConstructor
@Tag(name = "直播管理接口", description = "阿里云直播相关操作接口")
public class LiveController {

    private final LiveService liveService;

    @PostMapping("/stream")
    @Operation(summary = "创建直播流", description = "创建直播流并生成带鉴权的推流、拉流地址,自动配置录制和转码模板")
    public ResponseEntity<LiveStreamInfoVocreateLiveStream(@RequestBody LiveStreamCreateReq req) {
        LiveStreamInfoVo vo = liveService.createLiveStream(req);
        return ResponseEntity.ok(vo);
    }

    @GetMapping("/stream/{streamName}")
    @Operation(summary = "获取直播流信息", description = "根据流名称查询直播流详情与播放地址")
    public ResponseEntity<LiveStreamInfoVogetLiveStreamInfo(
            @Parameter(description = "直播流名称", required = true)
            @PathVariable String streamName) {
        LiveStreamInfoVo vo = liveService.getLiveStreamInfo(streamName);
        return ResponseEntity.ok(vo);
    }

    @PostMapping("/stream/{streamName}/forbid")
    @Operation(summary = "禁用直播流", description = "禁用指定直播流,禁止推流")
    public ResponseEntity<VoidforbidLiveStream(
            @Parameter(description = "直播流名称", required = true)
            @PathVariable String streamName) {
        liveService.forbidLiveStream(streamName);
        return ResponseEntity.ok().build();
    }

    @PostMapping("/stream/{streamName}/resume")
    @Operation(summary = "恢复直播流", description = "恢复已禁用的直播流,允许推流")
    public ResponseEntity<VoidresumeLiveStream(
            @Parameter(description = "直播流名称", required = true)
            @PathVariable String streamName) {
        liveService.resumeLiveStream(streamName);
        return ResponseEntity.ok().build();
    }
}

五、点播模块全实战

5.1 实体层与Mapper层

package com.jam.demo.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.math.BigDecimal;
import java.time.LocalDateTime;

/**
 * 点播视频实体类
 * @author ken
 */
@Data
@TableName("vod_video")
@Schema(description = "点播视频信息")
public class VodVideo {
    @TableId(type = IdType.AUTO)
    @Schema(description = "主键ID")
    private Long id;

    @Schema(description = "阿里云点播视频ID")
    private String videoId;

    @Schema(description = "视频标题")
    private String title;

    @Schema(description = "源文件名称")
    private String fileName;

    @Schema(description = "源文件大小,单位字节")
    private Long fileSize;

    @Schema(description = "视频时长,单位秒")
    private BigDecimal duration;

    @Schema(description = "封面地址")
    private String coverUrl;

    @Schema(description = "转码状态 0-未转码 1-转码中 2-转码成功 3-转码失败")
    private Integer transcodeStatus;

    @Schema(description = "审核状态 0-未审核 1-审核中 2-审核通过 3-审核拒绝")
    private Integer auditStatus;

    @Schema(description = "默认播放地址")
    private String playUrl;

    @Schema(description = "关联的直播流名称")
    private String streamName;

    @Schema(description = "创建时间")
    private LocalDateTime createTime;

    @Schema(description = "更新时间")
    private LocalDateTime updateTime;
}
package com.jam.demo.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jam.demo.entity.VodVideo;
import org.apache.ibatis.annotations.Mapper;

/**
 * 点播视频Mapper接口
 * @author ken
 */
@Mapper
public interface VodVideoMapper extends BaseMapper<VodVideo> {
}

5.2 服务层实现

package com.jam.demo.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.jam.demo.entity.VodVideo;
import com.jam.demo.vo.UploadAuthVo;
import com.jam.demo.vo.VideoPlayInfoVo;

/**
 * 点播服务接口
 * @author ken
 */
public interface VodService extends IService<VodVideo> {

    UploadAuthVo getUploadAuth(String title, String fileName);

    VideoPlayInfoVo getVideoPlayInfo(String videoId);

    void syncVideoInfo(String videoId);

    void updateVideoTranscodeStatus(String videoId, Integer status);

    void updateVideoAuditStatus(String videoId, Integer status);

    void createVideoByLiveRecord(String videoId, String title, String streamName, String fileUrl);
}
package com.jam.demo.service.impl;

import com.aliyun.vod20170321.Client;
import com.aliyun.vod20170321.models.CreateUploadVideoRequest;
import com.aliyun.vod20170321.models.CreateUploadVideoResponse;
import com.aliyun.vod20170321.models.GetPlayInfoRequest;
import com.aliyun.vod20170321.models.GetPlayInfoResponse;
import com.aliyun.vod20170321.models.GetVideoInfoRequest;
import com.aliyun.vod20170321.models.GetVideoInfoResponse;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.jam.demo.entity.VodVideo;
import com.jam.demo.mapper.VodVideoMapper;
import com.jam.demo.service.VodService;
import com.jam.demo.util.LiveAuthUtil;
import com.jam.demo.vo.UploadAuthVo;
import com.jam.demo.vo.VideoPlayInfoVo;
import com.jam.demo.vo.VideoPlayInfoVo.PlayInfo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;

import jakarta.annotation.Resource;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

/**
 * 点播服务实现类
 * @author ken
 */
@Slf4j
@Service
public class VodServiceImpl extends ServiceImpl<VodVideoMapper, VodVideo> implements VodService {

    @Value("${aliyun.vod.play-auth-key}")
    private String playAuthKey;

    @Value("${aliyun.vod.template-group-id}")
    private String templateGroupId;

    @Value("${aliyun.vod.workflow-id}")
    private String workflowId;

    @Resource
    private Client vodClient;

    @Override
    public UploadAuthVo getUploadAuth(String title, String fileName) {
        if (!StringUtils.hasText(title)) {
            throw new IllegalArgumentException("视频标题不能为空");
        }
        try {
            CreateUploadVideoRequest request = new CreateUploadVideoRequest()
                    .setTitle(title)
                    .setFileName(fileName)
                    .setTemplateGroupId(templateGroupId)
                    .setWorkflowId(workflowId);
            CreateUploadVideoResponse response = vodClient.createUploadVideo(request);
            UploadAuthVo vo = new UploadAuthVo();
            vo.setVideoId(response.getBody().getVideoId());
            vo.setUploadAddress(response.getBody().getUploadAddress());
            vo.setUploadAuth(response.getBody().getUploadAuth());
            VodVideo vodVideo = new VodVideo();
            vodVideo.setVideoId(vo.getVideoId());
            vodVideo.setTitle(title);
            vodVideo.setFileName(fileName);
            vodVideo.setTranscodeStatus(0);
            vodVideo.setAuditStatus(0);
            this.save(vodVideo);
            return vo;
        } catch (Exception e) {
            log.error("获取上传凭证失败,title:{}", title, e);
            throw new RuntimeException("获取上传凭证失败", e);
        }
    }

    @Override
    public VideoPlayInfoVo getVideoPlayInfo(String videoId) {
        if (!StringUtils.hasText(videoId)) {
            throw new IllegalArgumentException("视频ID不能为空");
        }
        LambdaQueryWrapper<VodVideo> queryWrapper = new LambdaQueryWrapper<VodVideo>()
                .eq(VodVideo::getVideoId, videoId);
        VodVideo vodVideo = this.getOne(queryWrapper);
        if (ObjectUtils.isEmpty(vodVideo)) {
            throw new IllegalArgumentException("视频不存在");
        }
        if (vodVideo.getAuditStatus() == 3) {
            throw new RuntimeException("视频审核未通过,无法播放");
        }
        try {
            GetPlayInfoRequest request = new GetPlayInfoRequest().setVideoId(videoId);
            GetPlayInfoResponse response = vodClient.getPlayInfo(request);
            List<GetPlayInfoResponse.GetPlayInfoResponseBodyPlayInfoPlayInfoList> playInfoList = response.getBody().getPlayInfoList().getPlayInfo();
            VideoPlayInfoVo vo = new VideoPlayInfoVo();
            vo.setVideoId(videoId);
            vo.setTitle(vodVideo.getTitle());
            vo.setDuration(vodVideo.getDuration());
            vo.setCoverUrl(vodVideo.getCoverUrl());
            List<PlayInfo> playInfoVoList = new ArrayList<>();
            for (GetPlayInfoResponse.GetPlayInfoResponseBodyPlayInfoPlayInfoList playInfo : playInfoList) {
                PlayInfo playInfoVo = new PlayInfo();
                BeanUtils.copyProperties(playInfo, playInfoVo);
                if (StringUtils.hasText(playAuthKey) && StringUtils.hasText(playInfo.getPlayURL())) {
                    java.net.URL url = new java.net.URL(playInfo.getPlayURL());
                    String playUrl = LiveAuthUtil.generateVodPlayUrl(url.getHost(), url.getPath(), playAuthKey, 3600L);
                    playInfoVo.setPlayURL(playUrl);
                }
                playInfoVoList.add(playInfoVo);
            }
            vo.setPlayInfoList(playInfoVoList);
            return vo;
        } catch (Exception e) {
            log.error("获取视频播放信息失败,videoId:{}", videoId, e);
            throw new RuntimeException("获取视频播放信息失败", e);
        }
    }

    @Override
    public void syncVideoInfo(String videoId) {
        if (!StringUtils.hasText(videoId)) {
            throw new IllegalArgumentException("视频ID不能为空");
        }
        try {
            GetVideoInfoRequest request = new GetVideoInfoRequest().setVideoId(videoId);
            GetVideoInfoResponse response = vodClient.getVideoInfo(request);
            GetVideoInfoResponse.GetVideoInfoResponseBodyVideo video = response.getBody().getVideo();
            LambdaQueryWrapper<VodVideo> queryWrapper = new LambdaQueryWrapper<VodVideo>()
                    .eq(VodVideo::getVideoId, videoId);
            VodVideo vodVideo = this.getOne(queryWrapper);
            if (ObjectUtils.isEmpty(vodVideo)) {
                vodVideo = new VodVideo();
                vodVideo.setVideoId(videoId);
                vodVideo.setTitle(video.getTitle());
                vodVideo.setFileName(video.getFileName());
            }
            vodVideo.setFileSize(video.getSize());
            vodVideo.setDuration(new BigDecimal(video.getDuration()));
            vodVideo.setCoverUrl(video.getCoverURL());
            vodVideo.setTranscodeStatus(video.getStatus().equals("Normal") ? 2 : 1);
            this.saveOrUpdate(vodVideo);
        } catch (Exception e) {
            log.error("同步视频信息失败,videoId:{}", videoId, e);
            throw new RuntimeException("同步视频信息失败", e);
        }
    }

    @Override
    public void updateVideoTranscodeStatus(String videoId, Integer status) {
        if (!StringUtils.hasText(videoId) || ObjectUtils.isEmpty(status)) {
            throw new IllegalArgumentException("参数不能为空");
        }
        LambdaQueryWrapper<VodVideo> queryWrapper = new LambdaQueryWrapper<VodVideo>()
                .eq(VodVideo::getVideoId, videoId);
        VodVideo vodVideo = this.getOne(queryWrapper);
        if (ObjectUtils.isEmpty(vodVideo)) {
            return;
        }
        vodVideo.setTranscodeStatus(status);
        this.updateById(vodVideo);
    }

    @Override
    public void updateVideoAuditStatus(String videoId, Integer status) {
        if (!StringUtils.hasText(videoId) || ObjectUtils.isEmpty(status)) {
            throw new IllegalArgumentException("参数不能为空");
        }
        LambdaQueryWrapper<VodVideo> queryWrapper = new LambdaQueryWrapper<VodVideo>()
                .eq(VodVideo::getVideoId, videoId);
        VodVideo vodVideo = this.getOne(queryWrapper);
        if (ObjectUtils.isEmpty(vodVideo)) {
            return;
        }
        vodVideo.setAuditStatus(status);
        this.updateById(vodVideo);
    }

    @Override
    public void createVideoByLiveRecord(String videoId, String title, String streamName, String fileUrl) {
        if (!StringUtils.hasText(videoId) || !StringUtils.hasText(title) || !StringUtils.hasText(fileUrl)) {
            throw new IllegalArgumentException("参数不能为空");
        }
        try {
            com.aliyun.vod20170321.models.RegisterMediaRequest request = new com.aliyun.vod20170321.models.RegisterMediaRequest()
                    .setTitle(title)
                    .setFileURLs(fileUrl)
                    .setTemplateGroupId(templateGroupId)
                    .setWorkflowId(workflowId);
            com.aliyun.vod20170321.models.RegisterMediaResponse response = vodClient.registerMedia(request);
            String registeredVideoId = response.getBody().getMediaList().get(0).getVideoId();
            VodVideo vodVideo = new VodVideo();
            vodVideo.setVideoId(registeredVideoId);
            vodVideo.setTitle(title);
            vodVideo.setStreamName(streamName);
            vodVideo.setTranscodeStatus(1);
            vodVideo.setAuditStatus(0);
            this.save(vodVideo);
            com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<LiveStream> updateWrapper = new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<LiveStream>()
                    .eq(com.jam.demo.entity.LiveStream::getStreamName, streamName)
                    .set(com.jam.demo.entity.LiveStream::getVodVideoId, registeredVideoId);
            com.jam.demo.mapper.LiveStreamMapper liveStreamMapper = applicationContext.getBean(com.jam.demo.mapper.LiveStreamMapper.class);
            liveStreamMapper.update(null, updateWrapper);
        } catch (Exception e) {
            log.error("注册直播录制视频到点播失败,streamName:{}", streamName, e);
            throw new RuntimeException("注册视频到点播失败", e);
        }
    }

    @Resource
    private org.springframework.context.ApplicationContext applicationContext;
}

5.3 视图对象定义

package com.jam.demo.vo;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

/**
 * 上传凭证响应对象
 * @author ken
 */
@Data
@Schema(description = "视频上传凭证响应")
public class UploadAuthVo {
    @Schema(description = "视频ID")
    private String videoId;

    @Schema(description = "上传地址")
    private String uploadAddress;

    @Schema(description = "上传凭证")
    private String uploadAuth;
}
package com.jam.demo.vo;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.math.BigDecimal;
import java.util.List;

/**
 * 视频播放信息响应对象
 * @author ken
 */
@Data
@Schema(description = "视频播放信息响应")
public class VideoPlayInfoVo {
    @Schema(description = "视频ID")
    private String videoId;

    @Schema(description = "视频标题")
    private String title;

    @Schema(description = "视频时长,单位秒")
    private BigDecimal duration;

    @Schema(description = "封面地址")
    private String coverUrl;

    @Schema(description = "播放地址列表")
    private List<PlayInfo> playInfoList;

    @Data
    @Schema(description = "播放地址详情")
    public static class PlayInfo {
        @Schema(description = "清晰度")
        private String definition;

        @Schema(description = "视频格式")
        private String format;

        @Schema(description = "码率,单位bps")
        private Long bitrate;

        @Schema(description = "宽度,单位像素")
        private Integer width;

        @Schema(description = "高度,单位像素")
        private Integer height;

        @Schema(description = "播放地址")
        private String playURL;
    }
}

5.4 控制层实现

package com.jam.demo.controller;

import com.jam.demo.service.VodService;
import com.jam.demo.vo.UploadAuthVo;
import com.jam.demo.vo.VideoPlayInfoVo;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

/**
 * 点播管理控制器
 * @author ken
 */
@RestController
@RequestMapping("/api/vod")
@RequiredArgsConstructor
@Tag(name = "点播管理接口", description = "阿里云点播相关操作接口")
public class VodController {

    private final VodService vodService;

    @GetMapping("/upload-auth")
    @Operation(summary = "获取视频上传凭证", description = "获取OSS直传的上传地址和凭证,客户端可直接上传到阿里云")
    public ResponseEntity<UploadAuthVogetUploadAuth(
            @Parameter(description = "视频标题", required = true)
            @RequestParam String title,
            @Parameter(description = "视频文件名", required = true)
            @RequestParam String fileName) {
        UploadAuthVo vo = vodService.getUploadAuth(title, fileName);
        return ResponseEntity.ok(vo);
    }

    @GetMapping("/video/{videoId}/play-info")
    @Operation(summary = "获取视频播放信息", description = "根据视频ID获取带鉴权的多清晰度播放地址")
    public ResponseEntity<VideoPlayInfoVogetVideoPlayInfo(
            @Parameter(description = "视频ID", required = true)
            @PathVariable String videoId) {
        VideoPlayInfoVo vo = vodService.getVideoPlayInfo(videoId);
        return ResponseEntity.ok(vo);
    }

    @PostMapping("/video/{videoId}/sync")
    @Operation(summary = "同步视频信息", description = "从阿里云点播同步视频元信息到本地数据库")
    public ResponseEntity<VoidsyncVideoInfo(
            @Parameter(description = "视频ID", required = true)
            @PathVariable String videoId) {
        vodService.syncVideoInfo(videoId);
        return ResponseEntity.ok().build();
    }
}

六、回调事件处理模块

6.1 回调实体与Mapper层

package com.jam.demo.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.time.LocalDateTime;

/**
 * 回调事件实体类
 * @author ken
 */
@Data
@TableName("live_vod_callback")
@Schema(description = "直播点播回调事件记录")
public class LiveVodCallback {
    @TableId(type = IdType.AUTO)
    @Schema(description = "主键ID")
    private Long id;

    @Schema(description = "回调请求ID,幂等键")
    private String requestId;

    @Schema(description = "回调类型")
    private String callbackType;

    @Schema(description = "回调内容")
    private String callbackContent;

    @Schema(description = "请求IP")
    private String requestIp;

    @Schema(description = "处理状态 0-待处理 1-处理成功 2-处理失败")
    private Integer handleStatus;

    @Schema(description = "错误信息")
    private String errorMsg;

    @Schema(description = "创建时间")
    private LocalDateTime createTime;

    @Schema(description = "更新时间")
    private LocalDateTime updateTime;
}
package com.jam.demo.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jam.demo.entity.LiveVodCallback;
import org.apache.ibatis.annotations.Mapper;

/**
 * 回调事件Mapper接口
 * @author ken
 */
@Mapper
public interface LiveVodCallbackMapper extends BaseMapper<LiveVodCallback> {
}

6.2 回调服务层实现

package com.jam.demo.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.jam.demo.entity.LiveVodCallback;

/**
 * 回调处理服务接口
 * @author ken
 */
public interface CallbackService extends IService<LiveVodCallback> {

    void handleLiveStreamPublishCallback(String requestBody, String requestId, String requestIp);

    void handleLiveStreamUnpublishCallback(String requestBody, String requestId, String requestIp);

    void handleLiveRecordCompleteCallback(String requestBody, String requestId, String requestIp);

    void handleVodTranscodeCompleteCallback(String requestBody, String requestId, String requestIp);

    void handleVodAuditCompleteCallback(String requestBody, String requestId, String requestIp);
}
package com.jam.demo.service.impl;

import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.jam.demo.entity.LiveVodCallback;
import com.jam.demo.mapper.LiveVodCallbackMapper;
import com.jam.demo.service.CallbackService;
import com.jam.demo.service.LiveService;
import com.jam.demo.service.VodService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.util.StringUtils;

/**
 * 回调处理服务实现类
 * @author ken
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class CallbackServiceImpl extends ServiceImpl<LiveVodCallbackMapper, LiveVodCallback> implements CallbackService {

    private final LiveService liveService;
    private final VodService vodService;
    private final PlatformTransactionManager transactionManager;

    private void saveCallbackRecord(String requestId, String callbackType, String requestBody, String requestIp, Integer handleStatus, String errorMsg) {
        LiveVodCallback callback = new LiveVodCallback();
        callback.setRequestId(requestId);
        callback.setCallbackType(callbackType);
        callback.setCallbackContent(requestBody);
        callback.setRequestIp(requestIp);
        callback.setHandleStatus(handleStatus);
        callback.setErrorMsg(errorMsg);
        this.save(callback);
    }

    private boolean checkIdempotent(String requestId) {
        if (!StringUtils.hasText(requestId)) {
            return false;
        }
        LambdaQueryWrapper<LiveVodCallback> queryWrapper = new LambdaQueryWrapper<LiveVodCallback>()
                .eq(LiveVodCallback::getRequestId, requestId);
        long count = this.count(queryWrapper);
        return count == 0;
    }

    @Override
    public void handleLiveStreamPublishCallback(String requestBody, String requestId, String requestIp) {
        if (!checkIdempotent(requestId)) {
            log.info("推流回调重复请求,requestId:{}", requestId);
            return;
        }
        TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            JSONObject jsonObject = JSON.parseObject(requestBody);
            String streamName = jsonObject.getString("streamId");
            liveService.updateLiveStreamStatus(streamName, 1);
            saveCallbackRecord(requestId, "LIVE_PUBLISH", requestBody, requestIp, 1, null);
            transactionManager.commit(status);
        } catch (Exception e) {
            transactionManager.rollback(status);
            log.error("处理推流回调失败,requestId:{}", requestId, e);
            saveCallbackRecord(requestId, "LIVE_PUBLISH", requestBody, requestIp, 2, e.getMessage());
            throw new RuntimeException("处理推流回调失败", e);
        }
    }

    @Override
    public void handleLiveStreamUnpublishCallback(String requestBody, String requestId, String requestIp) {
        if (!checkIdempotent(requestId)) {
            log.info("断流回调重复请求,requestId:{}", requestId);
            return;
        }
        TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            JSONObject jsonObject = JSON.parseObject(requestBody);
            String streamName = jsonObject.getString("streamId");
            liveService.updateLiveStreamStatus(streamName, 2);
            saveCallbackRecord(requestId, "LIVE_UNPUBLISH", requestBody, requestIp, 1, null);
            transactionManager.commit(status);
        } catch (Exception e) {
            transactionManager.rollback(status);
            log.error("处理断流回调失败,requestId:{}", requestId, e);
            saveCallbackRecord(requestId, "LIVE_UNPUBLISH", requestBody, requestIp, 2, e.getMessage());
            throw new RuntimeException("处理断流回调失败", e);
        }
    }

    @Override
    public void handleLiveRecordCompleteCallback(String requestBody, String requestId, String requestIp) {
        if (!checkIdempotent(requestId)) {
            log.info("录制完成回调重复请求,requestId:{}", requestId);
            return;
        }
        TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            JSONObject jsonObject = JSON.parseObject(requestBody);
            String streamName = jsonObject.getString("streamId");
            String fileUrl = jsonObject.getString("fileUrl");
            String title = "直播回放-" + streamName + "-" + System.currentTimeMillis();
            vodService.createVideoByLiveRecord(null, title, streamName, fileUrl);
            saveCallbackRecord(requestId, "LIVE_RECORD_COMPLETE", requestBody, requestIp, 1, null);
            transactionManager.commit(status);
        } catch (Exception e) {
            transactionManager.rollback(status);
            log.error("处理录制完成回调失败,requestId:{}", requestId, e);
            saveCallbackRecord(requestId, "LIVE_RECORD_COMPLETE", requestBody, requestIp, 2, e.getMessage());
            throw new RuntimeException("处理录制完成回调失败", e);
        }
    }

    @Override
    public void handleVodTranscodeCompleteCallback(String requestBody, String requestId, String requestIp) {
        if (!checkIdempotent(requestId)) {
            log.info("转码完成回调重复请求,requestId:{}", requestId);
            return;
        }
        TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            JSONObject jsonObject = JSON.parseObject(requestBody);
            String videoId = jsonObject.getString("VideoId");
            String statusStr = jsonObject.getString("Status");
            Integer transcodeStatus = "success".equalsIgnoreCase(statusStr) ? 2 : 3;
            vodService.updateVideoTranscodeStatus(videoId, transcodeStatus);
            if (transcodeStatus == 2) {
                vodService.syncVideoInfo(videoId);
            }
            saveCallbackRecord(requestId, "VOD_TRANSCODE_COMPLETE", requestBody, requestIp, 1, null);
            transactionManager.commit(status);
        } catch (Exception e) {
            transactionManager.rollback(status);
            log.error("处理转码完成回调失败,requestId:{}", requestId, e);
            saveCallbackRecord(requestId, "VOD_TRANSCODE_COMPLETE", requestBody, requestIp, 2, e.getMessage());
            throw new RuntimeException("处理转码完成回调失败", e);
        }
    }

    @Override
    public void handleVodAuditCompleteCallback(String requestBody, String requestId, String requestIp) {
        if (!checkIdempotent(requestId)) {
            log.info("审核完成回调重复请求,requestId:{}", requestId);
            return;
        }
        TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            JSONObject jsonObject = JSON.parseObject(requestBody);
            String videoId = jsonObject.getString("VideoId");
            String result = jsonObject.getString("Result");
            Integer auditStatus = "Pass".equalsIgnoreCase(result) ? 2 : 3;
            vodService.updateVideoAuditStatus(videoId, auditStatus);
            saveCallbackRecord(requestId, "VOD_AUDIT_COMPLETE", requestBody, requestIp, 1, null);
            transactionManager.commit(status);
        } catch (Exception e) {
            transactionManager.rollback(status);
            log.error("处理审核完成回调失败,requestId:{}", requestId, e);
            saveCallbackRecord(requestId, "VOD_AUDIT_COMPLETE", requestBody, requestIp, 2, e.getMessage());
            throw new RuntimeException("处理审核完成回调失败", e);
        }
    }
}

6.3 回调控制器实现

package com.jam.demo.controller;

import com.jam.demo.service.CallbackService;
import com.jam.demo.util.LiveAuthUtil;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StreamUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.nio.charset.StandardCharsets;

/**
 * 事件回调控制器
 * @author ken
 */
@Slf4j
@RestController
@RequestMapping("/api/callback")
@RequiredArgsConstructor
@Hidden
public class CallbackController {

    private final CallbackService callbackService;

    @Value("${aliyun.live.callback-auth-key}")
    private String callbackAuthKey;

    private String getRequestBody(HttpServletRequest request) throws Exception {
        return StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
    }

    private String getRequestId(HttpServletRequest request) {
        String requestId = request.getHeader("EventId");
        if (!org.springframework.util.StringUtils.hasText(requestId)) {
            requestId = request.getHeader("RequestId");
        }
        if (!org.springframework.util.StringUtils.hasText(requestId)) {
            requestId = String.valueOf(System.currentTimeMillis());
        }
        return requestId;
    }

    @PostMapping("/live/publish")
    public ResponseEntity<String> handleLivePublish(HttpServletRequest request) {
        try {
            String requestBody = getRequestBody(request);
            String authorization = request.getHeader("Authorization");
            boolean verifyResult = LiveAuthUtil.verifyLiveCallbackSign(requestBody, authorization, callbackAuthKey);
            if (!verifyResult) {
                log.warn("推流回调验签失败,requestBody:{}", requestBody);
                return ResponseEntity.status(403).body("验签失败");
            }
            String requestId = getRequestId(request);
            String requestIp = request.getRemoteAddr();
            callbackService.handleLiveStreamPublishCallback(requestBody, requestId, requestIp);
            return ResponseEntity.ok("success");
        } catch (Exception e) {
            log.error("处理推流回调异常", e);
            return ResponseEntity.status(500).body("处理失败");
        }
    }

    @PostMapping("/live/unpublish")
    public ResponseEntity<String> handleLiveUnpublish(HttpServletRequest request) {
        try {
            String requestBody = getRequestBody(request);
            String authorization = request.getHeader("Authorization");
            boolean verifyResult = LiveAuthUtil.verifyLiveCallbackSign(requestBody, authorization, callbackAuthKey);
            if (!verifyResult) {
                log.warn("断流回调验签失败,requestBody:{}", requestBody);
                return ResponseEntity.status(403).body("验签失败");
            }
            String requestId = getRequestId(request);
            String requestIp = request.getRemoteAddr();
            callbackService.handleLiveStreamUnpublishCallback(requestBody, requestId, requestIp);
            return ResponseEntity.ok("success");
        } catch (Exception e) {
            log.error("处理断流回调异常", e);
            return ResponseEntity.status(500).body("处理失败");
        }
    }

    @PostMapping("/live/record-complete")
    public ResponseEntity<String> handleLiveRecordComplete(HttpServletRequest request) {
        try {
            String requestBody = getRequestBody(request);
            String authorization = request.getHeader("Authorization");
            boolean verifyResult = LiveAuthUtil.verifyLiveCallbackSign(requestBody, authorization, callbackAuthKey);
            if (!verifyResult) {
                log.warn("录制完成回调验签失败,requestBody:{}", requestBody);
                return ResponseEntity.status(403).body("验签失败");
            }
            String requestId = getRequestId(request);
            String requestIp = request.getRemoteAddr();
            callbackService.handleLiveRecordCompleteCallback(requestBody, requestId, requestIp);
            return ResponseEntity.ok("success");
        } catch (Exception e) {
            log.error("处理录制完成回调异常", e);
            return ResponseEntity.status(500).body("处理失败");
        }
    }

    @PostMapping("/vod/transcode-complete")
    public ResponseEntity<String> handleVodTranscodeComplete(HttpServletRequest request) {
        try {
            String requestBody = getRequestBody(request);
            String requestId = getRequestId(request);
            String requestIp = request.getRemoteAddr();
            callbackService.handleVodTranscodeCompleteCallback(requestBody, requestId, requestIp);
            return ResponseEntity.ok("success");
        } catch (Exception e) {
            log.error("处理转码完成回调异常", e);
            return ResponseEntity.status(500).body("处理失败");
        }
    }

    @PostMapping("/vod/audit-complete")
    public ResponseEntity<String> handleVodAuditComplete(HttpServletRequest request) {
        try {
            String requestBody = getRequestBody(request);
            String requestId = getRequestId(request);
            String requestIp = request.getRemoteAddr();
            callbackService.handleVodAuditCompleteCallback(requestBody, requestId, requestIp);
            return ResponseEntity.ok("success");
        } catch (Exception e) {
            log.error("处理审核完成回调异常", e);
            return ResponseEntity.status(500).body("处理失败");
        }
    }
}

七、全局统一响应与异常处理

7.1 统一响应体

package com.jam.demo.common;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 全局统一响应体
 * @author ken
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "全局统一响应体")
public class Result<T> {

    @Schema(description = "响应码,200为成功")
    private Integer code;

    @Schema(description = "响应消息")
    private String msg;

    @Schema(description = "响应数据")
    private T data;

    public static <T> Result<T> success(T data) {
        return new Result<>(200"操作成功", data);
    }

    public static <T> Result<T> success() {
        return new Result<>(200"操作成功"null);
    }

    public static <T> Result<T> fail(Integer code, String msg) {
        return new Result<>(code, msg, null);
    }

    public static <T> Result<T> fail(String msg) {
        return new Result<>(500, msg, null);
    }
}

7.2 全局异常处理器

package com.jam.demo.common;

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
 * 全局异常处理器
 * @author ken
 */
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<Result<Void>> handleIllegalArgumentException(IllegalArgumentException e) {
        log.warn("参数校验异常:{}", e.getMessage());
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Result.fail(400, e.getMessage()));
    }

    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<Result<Void>> handleRuntimeException(RuntimeException e) {
        log.error("业务运行异常", e);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Result.fail(e.getMessage()));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<Result<Void>> handleException(Exception e) {
        log.error("系统异常", e);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Result.fail("系统内部错误,请稍后重试"));
    }
}

八、核心联动场景实战

8.1 直播录制自动转点播回放全流程

该场景是直播业务最核心的联动需求,实现主播直播结束后,自动将直播录制文件同步到点播平台,生成可长期分发的回放视频,完整执行流程如下:

  1. 创建直播流时绑定录制模板,开启直播录制功能
  2. 主播推流成功后,阿里云直播服务实时录制直播内容
  3. 主播断流后,录制任务完成,阿里云回调业务服务端的录制完成接口
  4. 业务服务端接收到回调后,将录制文件注册到点播平台
  5. 点播平台自动执行转码、审核、截图等工作流任务
  6. 任务完成后回调业务服务端,更新视频状态,生成带鉴权的播放地址
  7. 前端通过视频ID获取播放地址,实现回放功能

8.2 启动类配置

package com.jam.demo;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * 项目启动类
 * @author ken
 */
@SpringBootApplication
@MapperScan("com.jam.demo.mapper")
public class LiveVodDemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(LiveVodDemoApplication.class, args);
    }
}

九、常见问题与解决方案

9.1 推流失败相关问题

  1. 推流地址鉴权失败

    • 原因:鉴权密钥错误、地址过期、推流域名与密钥不匹配
    • 解决方案:核对控制台配置的鉴权密钥,检查地址有效期,确认推流域名绑定的鉴权密钥与代码中一致
  2. 推流连接超时

    • 原因:域名CNAME解析未生效、本地网络限制RTMP协议、推流域名配置错误
    • 解决方案:通过nslookup命令检查域名解析是否指向阿里云CNAME地址,检查本地防火墙是否放行1935端口,确认域名已在直播控制台完成添加

9.2 播放失败相关问题

  1. 拉流地址403错误

    • 原因:URL鉴权失败、流不存在、域名未备案
    • 解决方案:核对鉴权密钥与地址生成逻辑,确认流处于直播中状态,确认域名已完成ICP备案
  2. HLS流播放卡顿

    • 原因:切片时长设置过长、转码模板配置不合理、CDN节点调度异常
    • 解决方案:将HLS切片时长设置为5-10秒,优化转码码率与分辨率匹配,通过阿里云控制台刷新CDN缓存

9.3 回调相关问题

  1. 回调请求未触发

    • 原因:回调地址公网不可访问、端口未放行、域名配置错误、回调模板未绑定域名
    • 解决方案:通过公网环境测试回调接口是否可正常访问,确认服务器防火墙放行对应端口,在直播/点播控制台正确配置回调地址,确认回调模板已绑定到对应域名
  2. 回调验签失败

    • 原因:回调鉴权密钥错误、请求体读取不完整、签名算法不匹配
    • 解决方案:核对控制台配置的回调鉴权密钥,确保完整读取请求体内容,严格按照阿里云官方文档的签名算法实现验签逻辑

9.4 点播相关问题

  1. 上传凭证获取失败

    • 原因:RAM用户权限不足、AccessKey错误、区域配置不匹配
    • 解决方案:为RAM用户授予VOD相关权限,核对AccessKey ID与Secret,确认代码中配置的区域与点播服务开通区域一致
  2. 转码任务失败

    • 原因:源文件格式不支持、转码模板配置错误、OSS权限不足
    • 解决方案:检查源文件是否为标准音视频格式,核对转码模板的分辨率、码率等参数配置,确认点播服务有权限访问绑定的OSS存储空间

十、总结

本文完整实现了SpringBoot项目与阿里云直播、点播服务的深度集成,从核心原理拆解、环境搭建、代码实现,到回调处理、异常管控、问题排查,形成了一套完整可落地的音视频业务解决方案。