feat(live): embed room api and SRS hooks into crmeb-front

This commit is contained in:
xiao12feng@outlook.com 2025-12-21 18:07:19 +08:00
parent 9cf2beef40
commit d6577ad99b
12 changed files with 418 additions and 0 deletions

View File

@ -0,0 +1,54 @@
package com.zbkj.common.model.live;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.util.Date;
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("eb_live_room")
@ApiModel(value = "LiveRoom对象", description = "直播房间")
public class LiveRoom implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty(value = "主键ID")
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@ApiModelProperty(value = "房主用户ID")
private Integer uid;
@ApiModelProperty(value = "直播间标题")
private String title;
@ApiModelProperty(value = "主播名称")
@TableField("streamer_name")
private String streamerName;
@ApiModelProperty(value = "流密钥(推流 streamKey)")
@TableField("stream_key")
private String streamKey;
@ApiModelProperty(value = "是否直播中(1=直播中,0=未开播)")
@TableField("is_live")
private Integer isLive;
@ApiModelProperty(value = "创建时间")
@TableField("create_time")
private Date createTime;
@ApiModelProperty(value = "开始直播时间")
@TableField("started_at")
private Date startedAt;
}

View File

@ -57,6 +57,8 @@ public class WebConfig implements WebMvcConfigurer {
//前端用户登录token //前端用户登录token
registry.addInterceptor(frontTokenInterceptor()). registry.addInterceptor(frontTokenInterceptor()).
addPathPatterns("/api/front/**"). addPathPatterns("/api/front/**").
excludePathPatterns("/api/front/live/public/**").
excludePathPatterns("/api/front/live/srs/**").
excludePathPatterns("/api/front/index"). excludePathPatterns("/api/front/index").
excludePathPatterns("/api/front/qrcode/**"). excludePathPatterns("/api/front/qrcode/**").
excludePathPatterns("/api/front/login/mobile"). excludePathPatterns("/api/front/login/mobile").

View File

@ -0,0 +1,109 @@
package com.zbkj.front.controller;
import com.zbkj.common.model.live.LiveRoom;
import com.zbkj.common.result.CommonResult;
import com.zbkj.common.token.FrontTokenComponent;
import com.zbkj.front.request.live.CreateLiveRoomRequest;
import com.zbkj.front.response.live.LiveRoomResponse;
import com.zbkj.front.response.live.StreamUrlsResponse;
import com.zbkj.service.service.LiveRoomService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("api/front/live")
@Api(tags = "直播 -- 房间")
public class LiveRoomController {
@Autowired
private LiveRoomService liveRoomService;
@Autowired
private FrontTokenComponent frontTokenComponent;
@Value("${LIVE_PUBLIC_SRS_HOST:}")
private String publicHost;
@Value("${LIVE_PUBLIC_SRS_RTMP_PORT:}")
private String publicRtmpPort;
@Value("${LIVE_PUBLIC_SRS_HTTP_PORT:}")
private String publicHttpPort;
@ApiOperation(value = "公开:直播间列表")
@GetMapping("/public/rooms")
public CommonResult<List<LiveRoomResponse>> publicRooms(HttpServletRequest request) {
String requestHost = request.getServerName();
return CommonResult.success(liveRoomService.getAll().stream()
.map(r -> toResponse(r, requestHost))
.collect(Collectors.toList()));
}
@ApiOperation(value = "公开:直播间详情")
@GetMapping("/public/rooms/{id}")
public CommonResult<LiveRoomResponse> publicRoom(@PathVariable Integer id, HttpServletRequest request) {
LiveRoom room = liveRoomService.getById(id);
if (room == null) return CommonResult.failed("房间不存在");
return CommonResult.success(toResponse(room, request.getServerName()));
}
@ApiOperation(value = "创建直播间(登录)")
@PostMapping("/rooms")
public CommonResult<LiveRoomResponse> create(@RequestBody @Validated CreateLiveRoomRequest req, HttpServletRequest request) {
Integer uid = frontTokenComponent.getUserId();
if (uid == null) return CommonResult.failed("未登录");
LiveRoom room = liveRoomService.createRoom(uid, req.getTitle(), req.getStreamerName());
return CommonResult.success(toResponse(room, request.getServerName()));
}
@ApiOperation(value = "删除直播间(登录)")
@DeleteMapping("/rooms/{id}")
public CommonResult<String> delete(@PathVariable Integer id) {
Integer uid = frontTokenComponent.getUserId();
if (uid == null) return CommonResult.failed("未登录");
LiveRoom room = liveRoomService.getById(id);
if (room == null) return CommonResult.failed("房间不存在");
if (!uid.equals(room.getUid())) return CommonResult.failed("无权限");
liveRoomService.removeById(id);
return CommonResult.success();
}
private LiveRoomResponse toResponse(LiveRoom room, String requestHost) {
LiveRoomResponse resp = new LiveRoomResponse();
BeanUtils.copyProperties(room, resp);
resp.setIsLive(room.getIsLive() != null && room.getIsLive() == 1);
resp.setViewerCount(0);
String host = (publicHost != null && !publicHost.trim().isEmpty()) ? publicHost.trim() : requestHost;
int rtmpPort = parsePort(publicRtmpPort, 25002);
int httpPort = parsePort(publicHttpPort, 25003);
StreamUrlsResponse urls = new StreamUrlsResponse();
urls.setRtmp(String.format("rtmp://%s:%d/live/%s", host, rtmpPort, room.getStreamKey()));
urls.setFlv(String.format("http://%s:%d/live/%s.flv", host, httpPort, room.getStreamKey()));
urls.setHls(String.format("http://%s:%d/live/%s.m3u8", host, httpPort, room.getStreamKey()));
resp.setStreamUrls(urls);
return resp;
}
private int parsePort(String s, int def) {
try {
if (s == null) return def;
String v = s.trim();
if (v.isEmpty()) return def;
return Integer.parseInt(v);
} catch (Exception e) {
return def;
}
}
}

View File

@ -0,0 +1,86 @@
package com.zbkj.front.controller;
import com.zbkj.service.service.LiveRoomService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.util.MultiValueMap;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("api/front/live/srs")
@Api(tags = "直播 -- SRS 回调")
public class SrsCallbackController {
@Autowired
private LiveRoomService liveRoomService;
@ApiOperation(value = "SRS 推流开始回调")
@PostMapping(value = "/on_publish", consumes = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> onPublishJson(@RequestBody(required = false) Map<String, Object> body) {
String stream = body == null ? null : String.valueOf(body.get("stream"));
if (stream != null && !stream.trim().isEmpty()) {
liveRoomService.setLiveStatus(stream.trim(), true);
}
Map<String, Object> res = new HashMap<>();
res.put("code", 0);
return res;
}
@ApiOperation(value = "SRS 推流开始回调")
@PostMapping(value = "/on_publish", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public Map<String, Object> onPublishForm(@RequestParam MultiValueMap<String, String> form) {
String stream = form == null ? null : form.getFirst("stream");
if (stream != null && !stream.trim().isEmpty()) {
liveRoomService.setLiveStatus(stream.trim(), true);
}
Map<String, Object> res = new HashMap<>();
res.put("code", 0);
return res;
}
@ApiOperation(value = "SRS 推流结束回调")
@PostMapping(value = "/on_unpublish", consumes = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> onUnpublishJson(@RequestBody(required = false) Map<String, Object> body) {
String stream = body == null ? null : String.valueOf(body.get("stream"));
if (stream != null && !stream.trim().isEmpty()) {
liveRoomService.setLiveStatus(stream.trim(), false);
}
Map<String, Object> res = new HashMap<>();
res.put("code", 0);
return res;
}
@ApiOperation(value = "SRS 推流结束回调")
@PostMapping(value = "/on_unpublish", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public Map<String, Object> onUnpublishForm(@RequestParam MultiValueMap<String, String> form) {
String stream = form == null ? null : form.getFirst("stream");
if (stream != null && !stream.trim().isEmpty()) {
liveRoomService.setLiveStatus(stream.trim(), false);
}
Map<String, Object> res = new HashMap<>();
res.put("code", 0);
return res;
}
@ApiOperation(value = "SRS 观看回调")
@PostMapping("/on_play")
public Map<String, Object> onPlay(@RequestBody(required = false) Map<String, Object> body) {
Map<String, Object> res = new HashMap<>();
res.put("code", 0);
return res;
}
@ApiOperation(value = "SRS 停止观看回调")
@PostMapping("/on_stop")
public Map<String, Object> onStop(@RequestBody(required = false) Map<String, Object> body) {
Map<String, Object> res = new HashMap<>();
res.put("code", 0);
return res;
}
}

View File

@ -0,0 +1,20 @@
package com.zbkj.front.request.live;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
@ApiModel(value = "CreateLiveRoomRequest", description = "创建直播间请求")
public class CreateLiveRoomRequest {
@ApiModelProperty(value = "直播间标题")
@NotBlank
private String title;
@ApiModelProperty(value = "主播名称")
@NotBlank
private String streamerName;
}

View File

@ -0,0 +1,31 @@
package com.zbkj.front.response.live;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@Data
@ApiModel(value = "LiveRoomResponse", description = "直播间返回")
public class LiveRoomResponse {
@ApiModelProperty(value = "房间ID")
private Integer id;
@ApiModelProperty(value = "标题")
private String title;
@ApiModelProperty(value = "主播名")
private String streamerName;
@ApiModelProperty(value = "streamKey")
private String streamKey;
@ApiModelProperty(value = "是否直播中")
private Boolean isLive;
@ApiModelProperty(value = "观看人数(预留)")
private Integer viewerCount;
@ApiModelProperty(value = "推流/播放地址")
private StreamUrlsResponse streamUrls;
}

View File

@ -0,0 +1,19 @@
package com.zbkj.front.response.live;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@Data
@ApiModel(value = "StreamUrlsResponse", description = "推流/播放地址")
public class StreamUrlsResponse {
@ApiModelProperty(value = "RTMP 推流地址")
private String rtmp;
@ApiModelProperty(value = "HTTP-FLV 播放地址")
private String flv;
@ApiModelProperty(value = "HLS 播放地址")
private String hls;
}

View File

@ -0,0 +1,7 @@
package com.zbkj.service.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.zbkj.common.model.live.LiveRoom;
public interface LiveRoomDao extends BaseMapper<LiveRoom> {
}

View File

@ -0,0 +1,15 @@
package com.zbkj.service.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.zbkj.common.model.live.LiveRoom;
import java.util.List;
public interface LiveRoomService extends IService<LiveRoom> {
List<LiveRoom> getAll();
LiveRoom createRoom(Integer uid, String title, String streamerName);
boolean setLiveStatus(String streamKey, boolean isLive);
}

View File

@ -0,0 +1,52 @@
package com.zbkj.service.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.zbkj.common.model.live.LiveRoom;
import com.zbkj.service.dao.LiveRoomDao;
import com.zbkj.service.service.LiveRoomService;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.Date;
import java.util.List;
import java.util.UUID;
@Service
public class LiveRoomServiceImpl extends ServiceImpl<LiveRoomDao, LiveRoom> implements LiveRoomService {
@Resource
private LiveRoomDao dao;
@Override
public List<LiveRoom> getAll() {
LambdaQueryWrapper<LiveRoom> qw = new LambdaQueryWrapper<>();
qw.orderByDesc(LiveRoom::getCreateTime);
return dao.selectList(qw);
}
@Override
public LiveRoom createRoom(Integer uid, String title, String streamerName) {
String streamKey = UUID.randomUUID().toString().replace("-", "");
LiveRoom room = new LiveRoom();
room.setUid(uid);
room.setTitle(title);
room.setStreamerName(streamerName);
room.setStreamKey(streamKey);
room.setIsLive(0);
room.setCreateTime(new Date());
room.setStartedAt(null);
dao.insert(room);
return room;
}
@Override
public boolean setLiveStatus(String streamKey, boolean isLive) {
LambdaUpdateWrapper<LiveRoom> uw = new LambdaUpdateWrapper<>();
uw.eq(LiveRoom::getStreamKey, streamKey);
uw.set(LiveRoom::getIsLive, isLive ? 1 : 0);
uw.set(LiveRoom::getStartedAt, isLive ? new Date() : null);
return dao.update(null, uw) > 0;
}
}

View File

@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS `eb_live_room` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`uid` int(11) NOT NULL,
`title` varchar(255) NOT NULL,
`streamer_name` varchar(255) NOT NULL,
`stream_key` varchar(64) NOT NULL,
`is_live` tinyint(1) NOT NULL DEFAULT 0,
`create_time` datetime DEFAULT NULL,
`started_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_stream_key` (`stream_key`),
KEY `idx_uid` (`uid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@ -79,4 +79,14 @@ vhost __defaultVhost__ {
# 减少正常包超时 # 减少正常包超时
normal_timeout 5000; normal_timeout 5000;
} }
# HTTP Hooks: 推流/播放事件回调到 Java 后端
# Windows Docker Desktop 下建议用 host.docker.internal 访问宿主机
http_hooks {
enabled on;
on_publish http://host.docker.internal:8081/api/front/live/srs/on_publish;
on_unpublish http://host.docker.internal:8081/api/front/live/srs/on_unpublish;
on_play http://host.docker.internal:8081/api/front/live/srs/on_play;
on_stop http://host.docker.internal:8081/api/front/live/srs/on_stop;
}
} }