检测计划统计功能

This commit is contained in:
caozehui
2026-05-28 13:27:15 +08:00
parent 49ca27d994
commit 7f21049d0f
25 changed files with 1067 additions and 38 deletions

View File

@@ -0,0 +1,86 @@
package com.njcn.gather.system.resource.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.njcn.common.pojo.annotation.OperateInfo;
import com.njcn.common.pojo.constant.OperateType;
import com.njcn.common.pojo.enums.common.LogEnum;
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
import com.njcn.common.pojo.response.HttpResult;
import com.njcn.common.utils.LogUtil;
import com.njcn.gather.system.resource.pojo.param.ResourceManageParam;
import com.njcn.gather.system.resource.pojo.vo.PlayVO;
import com.njcn.gather.system.resource.pojo.vo.ResourceManageVO;
import com.njcn.gather.system.resource.service.IResourceManageService;
import com.njcn.web.controller.BaseController;
import com.njcn.web.utils.HttpResultUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;
/**
* 资源管理
*/
@Slf4j
@Api(tags = "资源管理")
@RestController
@RequestMapping("/resourceManage")
@RequiredArgsConstructor
public class ResourceManageController extends BaseController {
private final IResourceManageService resourceManageService;
@OperateInfo(info = LogEnum.SYSTEM_COMMON)
@PostMapping("/list")
@ApiOperation("分页查询资源列表")
@ApiImplicitParam(name = "queryParam", value = "查询参数", required = true)
public HttpResult<Page<ResourceManageVO>> list(@RequestBody @Validated ResourceManageParam.QueryParam queryParam) {
String methodDescribe = getMethodDescribe("list");
LogUtil.njcnDebug(log, "{},查询参数为:{}", methodDescribe, queryParam);
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS, resourceManageService.list(queryParam), methodDescribe);
}
@OperateInfo(info = LogEnum.SYSTEM_COMMON, operateType = OperateType.ADD)
@PostMapping("/add")
@ApiOperation("新增视频资源")
@ApiImplicitParam(name = "resourceManageParam", value = "资源参数", required = true)
public HttpResult<Boolean> add(ResourceManageParam resourceManageParam) {
String methodDescribe = getMethodDescribe("add");
LogUtil.njcnDebug(log, "{},新增资源参数为:{}", methodDescribe, resourceManageParam);
boolean result = resourceManageService.add(resourceManageParam);
if (result) {
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS, true, methodDescribe);
}
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.FAIL, false, methodDescribe);
}
@OperateInfo(info = LogEnum.SYSTEM_COMMON)
@GetMapping("/play")
@ApiOperation("获取视频播放地址")
@ApiImplicitParam(name = "id", value = "资源id", required = true)
public HttpResult<PlayVO> play(@RequestParam("id") String id) {
String methodDescribe = getMethodDescribe("play");
LogUtil.njcnDebug(log, "{}资源id为{}", methodDescribe, id);
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS, resourceManageService.play(id), methodDescribe);
}
@GetMapping("/stream")
@ApiOperation("播放视频流")
public void stream(@RequestParam("id") String id,
@RequestParam("token") String token,
@RequestHeader(value = "Range", required = false) String rangeHeader,
HttpServletResponse response) {
resourceManageService.stream(id, token, rangeHeader, response);
}
}

View File

@@ -0,0 +1,10 @@
package com.njcn.gather.system.resource.mapper;
import com.github.yulichang.base.MPJBaseMapper;
import com.njcn.gather.system.resource.pojo.po.ResourceManage;
/**
* 资源管理 mapper
*/
public interface ResourceManageMapper extends MPJBaseMapper<ResourceManage> {
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.njcn.gather.system.resource.mapper.ResourceManageMapper">
</mapper>

View File

@@ -0,0 +1,31 @@
package com.njcn.gather.system.resource.pojo.enums;
import lombok.Getter;
/**
* 资源管理业务响应
*/
@Getter
public enum ResourceManageResponseEnum {
NAME_NOT_BLANK("A013001", "资源名称不能为空"),
REMARK_NOT_BLANK("A013002", "备注不能为空"),
FILE_NOT_NULL("A013003", "上传的视频文件不能为空"),
FILE_SUFFIX_ERROR("A013004", "仅支持上传 MP4 文件"),
FILE_SIZE_ERROR("A013005", "文件大小不能超过 250MB"),
FILE_UPLOAD_FAILED("A013006", "文件上传失败"),
RESOURCE_NOT_EXIST("A013007", "资源不存在"),
RESOURCE_FILE_NOT_EXIST("A013008", "资源文件不存在"),
PLAY_TOKEN_INVALID("A013009", "播放授权无效"),
PLAY_TOKEN_EXPIRED("A013010", "播放授权已过期"),
RANGE_INVALID("A013011", "视频请求范围非法"),
DISK_SPACE_NOT_ENOUGH("A013012", "磁盘空间不足");
private final String code;
private final String message;
ResourceManageResponseEnum(String code, String message) {
this.code = code;
this.message = message;
}
}

View File

@@ -0,0 +1,33 @@
package com.njcn.gather.system.resource.pojo.param;
import com.njcn.web.pojo.param.BaseParam;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.web.multipart.MultipartFile;
/**
* 资源管理参数
*/
@Data
public class ResourceManageParam {
@ApiModelProperty(value = "资源名称", required = true)
private String name;
@ApiModelProperty(value = "备注", required = true)
private String remark;
@ApiModelProperty(value = "视频文件", required = true)
private MultipartFile file;
@Data
@EqualsAndHashCode(callSuper = true)
public static class QueryParam extends BaseParam {
@ApiModelProperty("资源名称")
private String name;
@ApiModelProperty("原始文件名")
private String fileName;
}
}

View File

@@ -0,0 +1,32 @@
package com.njcn.gather.system.resource.pojo.po;
import com.baomidou.mybatisplus.annotation.TableName;
import com.njcn.db.mybatisplus.bo.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
/**
* 资源管理
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_resource_manage")
public class ResourceManage extends BaseEntity implements Serializable {
private static final long serialVersionUID = 684206384930125506L;
private String id;
private String name;
private String fileName;
private Long fileSize;
private String relativePath;
private String remark;
private Integer state;
}

View File

@@ -0,0 +1,15 @@
package com.njcn.gather.system.resource.pojo.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 播放授权信息
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PlayVO {
private String url;
}

View File

@@ -0,0 +1,33 @@
package com.njcn.gather.system.resource.pojo.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 资源管理列表数据
*/
@Data
public class ResourceManageVO {
private String id;
private String name;
private String fileName;
private Long fileSize;
private String relativePath;
private String remark;
private Integer state;
private String createBy;
private LocalDateTime createTime;
private String updateBy;
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,24 @@
package com.njcn.gather.system.resource.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.njcn.gather.system.resource.pojo.param.ResourceManageParam;
import com.njcn.gather.system.resource.pojo.po.ResourceManage;
import com.njcn.gather.system.resource.pojo.vo.PlayVO;
import com.njcn.gather.system.resource.pojo.vo.ResourceManageVO;
import javax.servlet.http.HttpServletResponse;
/**
* 资源管理服务
*/
public interface IResourceManageService extends IService<ResourceManage> {
Page<ResourceManageVO> list(ResourceManageParam.QueryParam queryParam);
boolean add(ResourceManageParam resourceManageParam);
PlayVO play(String id);
void stream(String id, String token, String rangeHeader, HttpServletResponse response);
}

View File

@@ -0,0 +1,359 @@
package com.njcn.gather.system.resource.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.njcn.common.pojo.enums.common.DataStateEnum;
import com.njcn.common.pojo.exception.BusinessException;
import com.njcn.gather.system.resource.mapper.ResourceManageMapper;
import com.njcn.gather.system.resource.pojo.enums.ResourceManageResponseEnum;
import com.njcn.gather.system.resource.pojo.param.ResourceManageParam;
import com.njcn.gather.system.resource.pojo.po.ResourceManage;
import com.njcn.gather.system.resource.pojo.vo.PlayVO;
import com.njcn.gather.system.resource.pojo.vo.ResourceManageVO;
import com.njcn.gather.system.resource.service.IResourceManageService;
import com.njcn.web.factory.PageFactory;
import com.njcn.web.utils.RequestUtil;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLEncoder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class ResourceManageServiceImpl extends ServiceImpl<ResourceManageMapper, ResourceManage> implements IResourceManageService {
private static final long MAX_FILE_SIZE = 250L * 1024L * 1024L;
private static final long PLAY_TOKEN_TTL_MILLIS = 30L * 60L * 1000L;
private static final String MP4_SUFFIX = ".mp4";
private static final String RELATIVE_VIDEO_ROOT = "resources/videos";
private static final int BUFFER_SIZE = 8192;
private static final ConcurrentHashMap<String, PlayToken> PLAY_TOKEN_CACHE = new ConcurrentHashMap<>();
@Value("${resource.videoDir:D:/data/resources/videos}")
private String videoDir;
@Override
public Page<ResourceManageVO> list(ResourceManageParam.QueryParam queryParam) {
QueryWrapper<ResourceManage> wrapper = new QueryWrapper<>();
wrapper.like(StrUtil.isNotBlank(queryParam.getName()), "Name", queryParam.getName())
.like(StrUtil.isNotBlank(queryParam.getFileName()), "File_Name", queryParam.getFileName())
.eq("State", DataStateEnum.ENABLE.getCode())
.orderByDesc("Create_Time");
Page<ResourceManage> page = this.page(
new Page<>(PageFactory.getPageNum(queryParam), PageFactory.getPageSize(queryParam)),
wrapper
);
List<ResourceManageVO> records = page.getRecords().stream().map(resource -> {
ResourceManageVO vo = new ResourceManageVO();
BeanUtil.copyProperties(resource, vo);
return vo;
}).collect(Collectors.toList());
Page<ResourceManageVO> result = new Page<>(PageFactory.getPageNum(queryParam), PageFactory.getPageSize(queryParam));
result.setTotal(page.getTotal());
result.setPages(page.getPages());
result.setCurrent(page.getCurrent());
result.setSize(page.getSize());
result.setRecords(records);
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean add(ResourceManageParam resourceManageParam) {
validateAddParam(resourceManageParam);
MultipartFile file = resourceManageParam.getFile();
String originalFilename = file.getOriginalFilename();
String dateDir = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String savedFileName = UUID.randomUUID().toString().replace("-", "") + MP4_SUFFIX;
Path rootPath = Paths.get(videoDir).toAbsolutePath().normalize();
Path targetDir = rootPath.resolve(dateDir).normalize();
Path targetFile = targetDir.resolve(savedFileName).normalize();
ensurePathInRoot(rootPath, targetFile);
String relativePath = RELATIVE_VIDEO_ROOT + "/" + dateDir + "/" + savedFileName;
try {
Files.createDirectories(targetDir);
long usableSpace = targetDir.toFile().getUsableSpace();
if (usableSpace > 0 && usableSpace < file.getSize()) {
throw new BusinessException(ResourceManageResponseEnum.DISK_SPACE_NOT_ENOUGH);
}
file.transferTo(targetFile.toFile());
} catch (BusinessException e) {
throw e;
} catch (IOException e) {
throw new BusinessException(ResourceManageResponseEnum.FILE_UPLOAD_FAILED);
}
ResourceManage resourceManage = new ResourceManage();
resourceManage.setId(UUID.randomUUID().toString().replace("-", ""));
resourceManage.setName(resourceManageParam.getName().trim());
resourceManage.setRemark(resourceManageParam.getRemark().trim());
resourceManage.setFileName(originalFilename);
resourceManage.setFileSize(file.getSize());
resourceManage.setRelativePath(relativePath);
resourceManage.setState(DataStateEnum.ENABLE.getCode());
try {
boolean saved = this.save(resourceManage);
if (!saved) {
deleteQuietly(targetFile);
}
return saved;
} catch (RuntimeException e) {
deleteQuietly(targetFile);
throw e;
}
}
@Override
public PlayVO play(String id) {
ResourceManage resourceManage = getEnabledResource(id);
Path filePath = resolveResourcePath(resourceManage);
if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) {
throw new BusinessException(ResourceManageResponseEnum.RESOURCE_FILE_NOT_EXIST);
}
String token = UUID.randomUUID().toString().replace("-", "");
PlayToken playToken = new PlayToken();
playToken.setResourceId(id);
playToken.setUserId(RequestUtil.getUserId());
playToken.setExpireTime(System.currentTimeMillis() + PLAY_TOKEN_TTL_MILLIS);
PLAY_TOKEN_CACHE.put(token, playToken);
clearExpiredTokens();
return new PlayVO("/resourceManage/stream?id=" + id + "&token=" + token);
}
@Override
public void stream(String id, String token, String rangeHeader, HttpServletResponse response) {
if (!validatePlayToken(id, token)) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
return;
}
ResourceManage resourceManage = getEnabledResource(id);
Path filePath = resolveResourcePath(resourceManage);
if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
try {
long fileLength = Files.size(filePath);
Range range = parseRange(rangeHeader, fileLength);
String encodedFileName = URLEncoder.encode(resourceManage.getFileName(), "UTF-8")
.replaceAll("\\+", "%20");
response.setContentType("video/mp4");
response.setHeader("Accept-Ranges", "bytes");
response.setHeader("Content-Disposition", "inline; filename*=UTF-8''" + encodedFileName);
response.setHeader("Content-Length", String.valueOf(range.getLength()));
if (range.isPartial()) {
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
response.setHeader("Content-Range", "bytes " + range.getStart() + "-" + range.getEnd() + "/" + fileLength);
} else {
response.setStatus(HttpServletResponse.SC_OK);
}
writeRange(filePath, range, response);
} catch (IllegalArgumentException e) {
response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
} catch (IOException e) {
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}
}
private void validateAddParam(ResourceManageParam param) {
if (param == null || StrUtil.isBlank(param.getName())) {
throw new BusinessException(ResourceManageResponseEnum.NAME_NOT_BLANK);
}
if (StrUtil.isBlank(param.getRemark())) {
throw new BusinessException(ResourceManageResponseEnum.REMARK_NOT_BLANK);
}
MultipartFile file = param.getFile();
if (file == null || file.isEmpty()) {
throw new BusinessException(ResourceManageResponseEnum.FILE_NOT_NULL);
}
String originalFilename = file.getOriginalFilename();
if (StrUtil.isBlank(originalFilename) || !originalFilename.toLowerCase().endsWith(MP4_SUFFIX)) {
throw new BusinessException(ResourceManageResponseEnum.FILE_SUFFIX_ERROR);
}
String contentType = file.getContentType();
if (StrUtil.isNotBlank(contentType) && !"video/mp4".equalsIgnoreCase(contentType)
&& !"application/octet-stream".equalsIgnoreCase(contentType)) {
throw new BusinessException(ResourceManageResponseEnum.FILE_SUFFIX_ERROR);
}
if (file.getSize() > MAX_FILE_SIZE) {
throw new BusinessException(ResourceManageResponseEnum.FILE_SIZE_ERROR);
}
}
private ResourceManage getEnabledResource(String id) {
ResourceManage resourceManage = this.lambdaQuery()
.eq(ResourceManage::getId, id)
.eq(ResourceManage::getState, DataStateEnum.ENABLE.getCode())
.one();
if (resourceManage == null) {
throw new BusinessException(ResourceManageResponseEnum.RESOURCE_NOT_EXIST);
}
return resourceManage;
}
private Path resolveResourcePath(ResourceManage resourceManage) {
Path rootPath = Paths.get(videoDir).toAbsolutePath().normalize();
String relativePath = resourceManage.getRelativePath().replace("\\", "/");
String prefix = RELATIVE_VIDEO_ROOT + "/";
if (!relativePath.startsWith(prefix)) {
throw new BusinessException(ResourceManageResponseEnum.RESOURCE_FILE_NOT_EXIST);
}
String relativeToVideoRoot = relativePath.substring(prefix.length());
Path filePath = rootPath.resolve(relativeToVideoRoot).normalize();
ensurePathInRoot(rootPath, filePath);
return filePath;
}
private void ensurePathInRoot(Path rootPath, Path targetPath) {
if (!targetPath.startsWith(rootPath)) {
throw new BusinessException(ResourceManageResponseEnum.RESOURCE_FILE_NOT_EXIST);
}
}
private boolean validatePlayToken(String id, String token) {
if (StrUtil.isBlank(id) || StrUtil.isBlank(token)) {
return false;
}
PlayToken playToken = PLAY_TOKEN_CACHE.get(token);
if (playToken == null || !id.equals(playToken.getResourceId())) {
return false;
}
if (playToken.getExpireTime() < System.currentTimeMillis()) {
PLAY_TOKEN_CACHE.remove(token);
return false;
}
return true;
}
private void clearExpiredTokens() {
long now = System.currentTimeMillis();
PLAY_TOKEN_CACHE.entrySet().removeIf(entry -> entry.getValue().getExpireTime() < now);
}
private Range parseRange(String rangeHeader, long fileLength) {
if (StrUtil.isBlank(rangeHeader)) {
return new Range(0, fileLength - 1, false);
}
if (!rangeHeader.startsWith("bytes=")) {
throw new IllegalArgumentException("Invalid range");
}
String rangeValue = rangeHeader.substring("bytes=".length());
String[] parts = rangeValue.split("-", -1);
if (parts.length != 2) {
throw new IllegalArgumentException("Invalid range");
}
long start;
long end;
if (StrUtil.isBlank(parts[0])) {
long suffixLength = Long.parseLong(parts[1]);
if (suffixLength <= 0) {
throw new IllegalArgumentException("Invalid range");
}
start = Math.max(fileLength - suffixLength, 0);
end = fileLength - 1;
} else {
start = Long.parseLong(parts[0]);
end = StrUtil.isBlank(parts[1]) ? fileLength - 1 : Long.parseLong(parts[1]);
}
if (start < 0 || end < start || start >= fileLength) {
throw new IllegalArgumentException("Invalid range");
}
end = Math.min(end, fileLength - 1);
return new Range(start, end, true);
}
private void writeRange(Path filePath, Range range, HttpServletResponse response) throws IOException {
try (InputStream inputStream = Files.newInputStream(filePath);
ServletOutputStream outputStream = response.getOutputStream()) {
long skipped = inputStream.skip(range.getStart());
while (skipped < range.getStart()) {
long current = inputStream.skip(range.getStart() - skipped);
if (current <= 0) {
break;
}
skipped += current;
}
byte[] buffer = new byte[BUFFER_SIZE];
long bytesRemaining = range.getLength();
while (bytesRemaining > 0) {
int readLength = (int) Math.min(buffer.length, bytesRemaining);
int read = inputStream.read(buffer, 0, readLength);
if (read == -1) {
break;
}
outputStream.write(buffer, 0, read);
bytesRemaining -= read;
}
outputStream.flush();
}
}
private void deleteQuietly(Path path) {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
log.warn("删除资源文件失败: {}", path, e);
}
}
@Data
private static class PlayToken {
private String resourceId;
private String userId;
private long expireTime;
}
@Data
private static class Range {
private final long start;
private final long end;
private final boolean partial;
private long getLength() {
return end - start + 1;
}
}
}