feat(视频):

视频初步
This commit is contained in:
Guwan 2024-12-08 15:43:59 +08:00
parent b5bb4909e4
commit 641a2e8d96
17 changed files with 53 additions and 632 deletions

View File

@ -1,28 +0,0 @@
package com.guwan.backend.config;
import org.apache.hadoop.fs.FileSystem;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class HadoopConfig {
@Value("${hadoop.fs.defaultFS}")
private String fsDefaultFS;
@Value("${hadoop.username}")
private String username;
@Bean
public FileSystem fileSystem() throws Exception {
org.apache.hadoop.conf.Configuration conf = new org.apache.hadoop.conf.Configuration();
conf.set("fs.defaultFS", fsDefaultFS);
// 设置副本数
conf.set("dfs.replication", "3");
// 设置块大小适合大文件存储
conf.set("dfs.blocksize", "128m");
return FileSystem.get(conf);
}
}

View File

@ -26,6 +26,7 @@ public class SecurityConstants {
public static final List<String> STATIC_RESOURCES = List.of(
"/static/**", // 静态资源目录
"/public/**", // 公共资源目录
"/error" // 错误页面
"/error", // 错误页面
"/swagger-ui.html"
);
}

View File

@ -1,22 +1,17 @@
package com.guwan.backend.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.guwan.backend.annotation.OperationLog;
import com.guwan.backend.common.Result;
import com.guwan.backend.dto.video.VideoDTO;
import com.guwan.backend.service.VideoService;
import com.guwan.backend.service.VideoRecommendService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@Slf4j
@Tag(name = "视频管理", description = "视频相关接口")
@ -26,7 +21,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
public class VideoController {
private final VideoService videoService;
private final VideoRecommendService recommendService;
@Operation(summary = "上传视频", description = "上传视频文件并返回视频信息")
@SecurityRequirement(name = "bearer-jwt")
@ -131,30 +126,7 @@ public class VideoController {
}
}
@Operation(summary = "获取推荐视频")
@SecurityRequirement(name = "bearer-jwt")
@GetMapping("/recommend")
public Result<List<VideoDTO>> getRecommendVideos(
@Parameter(description = "返回数量") @RequestParam(defaultValue = "10") Integer limit) {
try {
Long userId = securityUtil.getCurrentUserId();
return Result.success(recommendService.getRecommendVideos(userId, limit));
} catch (Exception e) {
log.error("获取推荐视频失败", e);
return Result.error(e.getMessage());
}
}
@Operation(summary = "获取相似视频")
@GetMapping("/{id}/similar")
public Result<List<VideoDTO>> getSimilarVideos(
@Parameter(description = "视频ID") @PathVariable Long id,
@Parameter(description = "返回数量") @RequestParam(defaultValue = "10") Integer limit) {
try {
return Result.success(recommendService.getSimilarVideos(id, limit));
} catch (Exception e) {
log.error("获取相似视频失败", e);
return Result.error(e.getMessage());
}
}
}

View File

@ -1,66 +0,0 @@
package com.guwan.backend.controller;
import com.guwan.backend.entity.Video;
import com.guwan.backend.service.HdfsStorageService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.ResourceRegion;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.io.InputStream;
@Slf4j
@RestController
@RequestMapping("/api/stream")
@RequiredArgsConstructor
public class VideoStreamController {
private final HdfsStorageService hdfsStorageService;
private static final long CHUNK_SIZE = 1024 * 1024; // 1MB chunks
@GetMapping("/video/{id}")
public ResponseEntity<ResourceRegion> streamVideo(
@PathVariable Long id,
@RequestHeader(value = "Range", required = false) String rangeHeader) {
try {
// 获取视频路径
Video video = videoMapper.selectById(id);
if (video == null) {
return ResponseEntity.notFound().build();
}
// 获取视频流
InputStream videoStream = hdfsStorageService.getVideoStream(video.getUrl());
InputStreamResource resource = new InputStreamResource(videoStream);
// 获取视频元数据
VideoMetadata metadata = hdfsStorageService.getVideoMetadata(video.getUrl());
long contentLength = metadata.getSize();
// 处理Range请求
HttpRange range = rangeHeader == null ? null : HttpRange.parseRanges(rangeHeader).get(0);
ResourceRegion region;
if (range != null) {
long start = range.getRangeStart(contentLength);
long end = range.getRangeEnd(contentLength);
long rangeLength = Math.min(CHUNK_SIZE, end - start + 1);
region = new ResourceRegion(resource, start, rangeLength);
} else {
long rangeLength = Math.min(CHUNK_SIZE, contentLength);
region = new ResourceRegion(resource, 0, rangeLength);
}
return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(region);
} catch (IOException e) {
log.error("视频流处理失败", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}

View File

@ -1,30 +0,0 @@
package com.guwan.backend.entity;
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 lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("sms_log")
public class SmsLog {
@TableId(type = IdType.AUTO)
private Long id;
private String phone;
private String type;
private String content;
private Integer status;
@TableField("error_msg")
private String errorMsg;
@TableField("created_time")
private LocalDateTime createdTime;
}

View File

@ -1,114 +0,0 @@
package com.guwan.backend.service;
import ai.djl.ndarray.NDArray;
import ai.djl.ndarray.NDList;
import ai.djl.ndarray.NDManager;
import ai.djl.training.util.ProgressBar;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class DeepRecommendService {
private static final int EMBEDDING_SIZE = 128;
private static final String MODEL_PATH = "models/recommend_model.pt";
private final NDManager manager;
private final Model model;
public DeepRecommendService() {
// 初始化DJL环境
this.manager = NDManager.newBaseManager();
this.model = Model.newInstance("recommend");
loadModel();
}
/**
* 获取视频的嵌入向量
*/
public float[] getVideoEmbedding(Video video) {
try {
// 准备输入特征
NDArray features = prepareVideoFeatures(video);
// 获取模型输出
NDArray embedding = model.forward(new NDList(features)).singletonOrThrow();
// 转换为Java数组
return embedding.toFloatArray();
} catch (Exception e) {
log.error("获取视频嵌入向量失败", e);
return new float[EMBEDDING_SIZE];
}
}
/**
* 获取用户的嵌入向量
*/
public float[] getUserEmbedding(List<UserBehavior> behaviors) {
try {
// 准备输入特征
NDArray features = prepareUserFeatures(behaviors);
// 获取模型输出
NDArray embedding = model.forward(new NDList(features)).singletonOrThrow();
// 转换为Java数组
return embedding.toFloatArray();
} catch (Exception e) {
log.error("获取用户嵌入向量失败", e);
return new float[EMBEDDING_SIZE];
}
}
/**
* 计算相似度分数
*/
public double calculateSimilarityScore(float[] embedding1, float[] embedding2) {
double dotProduct = 0.0;
double norm1 = 0.0;
double norm2 = 0.0;
for (int i = 0; i < embedding1.length; i++) {
dotProduct += embedding1[i] * embedding2[i];
norm1 += embedding1[i] * embedding1[i];
norm2 += embedding2[i] * embedding2[i];
}
return dotProduct / (Math.sqrt(norm1) * Math.sqrt(norm2));
}
private void loadModel() {
try {
// 加载预训练模型
Path modelPath = Paths.get(MODEL_PATH);
model.load(modelPath);
} catch (Exception e) {
log.error("加载推荐模型失败", e);
}
}
private NDArray prepareVideoFeatures(Video video) {
// 将视频特征转换为模型输入格式
float[] features = new float[]{
video.getDuration(),
video.getViewCount(),
video.getLikeCount(),
// ... 其他特征
};
return manager.create(features);
}
private NDArray prepareUserFeatures(List<UserBehavior> behaviors) {
// 将用户行为特征转换为模型输入格式
float[] features = new float[]{
// 统计特征
behaviors.size(),
calculateAverageWatchDuration(behaviors),
calculateLikeRatio(behaviors),
// ... 其他特征
};
return manager.create(features);
}
}

View File

@ -1,5 +0,0 @@
package com.guwan.backend.service;
public interface FileService {
String uploadBase64Image(String base64Image, String folder);
}

View File

@ -1,82 +0,0 @@
package com.guwan.backend.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.hadoop.fs.FSDataInputStream;
import org.apache.hadoop.fs.FSDataOutputStream;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
@Slf4j
@Service
@RequiredArgsConstructor
public class HdfsStorageService {
private final FileSystem fileSystem;
private static final String VIDEO_PATH = "/videos";
private static final int BUFFER_SIZE = 4096;
/**
* 上传视频到HDFS
*/
public String uploadVideo(MultipartFile file, String fileName) throws IOException {
String hdfsPath = VIDEO_PATH + "/" + fileName;
Path path = new Path(hdfsPath);
try (FSDataOutputStream out = fileSystem.create(path);
BufferedInputStream in = new BufferedInputStream(file.getInputStream())) {
byte[] buffer = new byte[BUFFER_SIZE];
int length;
while ((length = in.read(buffer)) > 0) {
out.write(buffer, 0, length);
}
return hdfsPath;
}
}
/**
* 从HDFS读取视频流
*/
public InputStream getVideoStream(String path) throws IOException {
Path hdfsPath = new Path(path);
if (!fileSystem.exists(hdfsPath)) {
throw new IOException("Video not found: " + path);
}
return fileSystem.open(hdfsPath);
}
/**
* 删除HDFS中的视频
*/
public void deleteVideo(String path) throws IOException {
Path hdfsPath = new Path(path);
if (fileSystem.exists(hdfsPath)) {
fileSystem.delete(hdfsPath, false);
}
}
/**
* 获取视频元数据
*/
public VideoMetadata getVideoMetadata(String path) throws IOException {
Path hdfsPath = new Path(path);
if (!fileSystem.exists(hdfsPath)) {
throw new IOException("Video not found: " + path);
}
return VideoMetadata.builder()
.size(fileSystem.getFileStatus(hdfsPath).getLen())
.blockSize(fileSystem.getFileStatus(hdfsPath).getBlockSize())
.replication(fileSystem.getFileStatus(hdfsPath).getReplication())
.modificationTime(fileSystem.getFileStatus(hdfsPath).getModificationTime())
.build();
}
}

View File

@ -1,8 +0,0 @@
package com.guwan.backend.service;
public interface VerificationService {
void sendEmailCode(String email);
void sendSmsCode(String phone);
boolean verifyEmailCode(String email, String code);
boolean verifySmsCode(String phone, String code);
}

View File

@ -1,21 +0,0 @@
package com.guwan.backend.service;
import com.guwan.backend.dto.video.VideoDTO;
import java.util.List;
public interface VideoRecommendService {
// 获取推荐视频列表
List<VideoDTO> getRecommendVideos(Long userId, int limit);
// 记录用户行为
void recordUserBehavior(Long userId, Long videoId, String behaviorType, Integer watchDuration, Float watchProgress);
// 更新用户兴趣
void updateUserInterests(Long userId);
// 计算视频相似度
double calculateVideoSimilarity(Long videoId1, Long videoId2);
// 获取相似视频
List<VideoDTO> getSimilarVideos(Long videoId, int limit);
}

View File

@ -1,18 +0,0 @@
package com.guwan.backend.service.impl;
import com.guwan.backend.service.FileService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@Primary
public class DummyFileServiceImpl implements FileService {
@Override
public String uploadBase64Image(String base64Image, String folder) {
log.info("模拟上传图片到文件夹: {}", folder);
return "dummy/image/path.jpg";
}
}

View File

@ -1,46 +0,0 @@
package com.guwan.backend.service.impl;
import com.guwan.backend.service.FileService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.FileOutputStream;
import java.util.Base64;
import java.util.UUID;
@Slf4j
//@Service // 注释掉这个注解
public class FileServiceImpl implements FileService {
@Value("${file.upload.path}")
private String uploadPath;
@Override
public String uploadBase64Image(String base64Image, String folder) {
try {
// 解码Base64图片
String[] parts = base64Image.split(",");
byte[] imageBytes = Base64.getDecoder().decode(parts[1]);
// 生成文件名
String fileName = UUID.randomUUID().toString() + ".jpg";
String folderPath = uploadPath + "/" + folder;
String filePath = folderPath + "/" + fileName;
// 创建目录
new File(folderPath).mkdirs();
// 写入文件
try (FileOutputStream fos = new FileOutputStream(filePath)) {
fos.write(imageBytes);
}
return folder + "/" + fileName;
} catch (Exception e) {
log.error("上传图片失败", e);
throw new RuntimeException("上传图片失败", e);
}
}
}

View File

@ -1,143 +0,0 @@
package com.guwan.backend.service.impl;
import com.guwan.backend.service.VideoRecommendService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class VideoRecommendServiceImpl implements VideoRecommendService {
private final UserBehaviorMapper behaviorMapper;
private final VideoTagWeightMapper tagWeightMapper;
private final UserInterestMapper interestMapper;
private final VideoMapper videoMapper;
private final DeepRecommendService deepRecommendService;
@Override
public List<VideoDTO> getRecommendVideos(Long userId, int limit) {
// 1. 获取用户行为数据
List<UserBehavior> behaviors = getRecentBehaviors(userId);
// 2. 获取用户嵌入向量
float[] userEmbedding = deepRecommendService.getUserEmbedding(behaviors);
// 3. 获取候选视频
List<Video> candidates = getCandidateVideos(userId);
// 4. 计算每个视频的推荐分数
Map<Long, Double> scores = new HashMap<>();
for (Video video : candidates) {
// 获取视频嵌入向量
float[] videoEmbedding = deepRecommendService.getVideoEmbedding(video);
// 计算相似度分数
double similarityScore = deepRecommendService.calculateSimilarityScore(
userEmbedding, videoEmbedding);
// 结合其他特征计算最终分数
double finalScore = calculateFinalScore(similarityScore, video);
scores.put(video.getId(), finalScore);
}
// 5. 排序并返回推荐结果
return candidates.stream()
.sorted((v1, v2) -> Double.compare(scores.get(v2.getId()), scores.get(v1.getId())))
.limit(limit)
.map(this::convertToDTO)
.collect(Collectors.toList());
}
@Override
@Transactional
public void recordUserBehavior(Long userId, Long videoId, String behaviorType,
Integer watchDuration, Float watchProgress) {
UserBehavior behavior = new UserBehavior();
behavior.setUserId(userId);
behavior.setVideoId(videoId);
behavior.setBehaviorType(behaviorType);
behavior.setWatchDuration(watchDuration);
behavior.setWatchProgress(watchProgress);
behaviorMapper.insert(behavior);
// 异步更新用户兴趣
updateUserInterests(userId);
}
@Override
@Transactional
public void updateUserInterests(Long userId) {
// 1. 获取用户近期行为
List<UserBehavior> behaviors = getRecentBehaviors(userId);
// 2. 计算每个标签的权重
Map<String, Double> tagWeights = calculateTagWeights(behaviors);
// 3. 更新用户兴趣表
updateUserInterestTable(userId, tagWeights);
}
@Override
public double calculateVideoSimilarity(Long videoId1, Long videoId2) {
// 基于标签的余弦相似度计算
List<VideoTagWeight> tags1 = tagWeightMapper.selectByVideoId(videoId1);
List<VideoTagWeight> tags2 = tagWeightMapper.selectByVideoId(videoId2);
return calculateCosineSimilarity(tags1, tags2);
}
@Override
public List<VideoDTO> getSimilarVideos(Long videoId, int limit) {
// 1. 获取视频标签
List<VideoTagWeight> sourceTags = tagWeightMapper.selectByVideoId(videoId);
// 2. 获取候选视频
List<Video> candidates = getCandidateVideos(null);
// 3. 计算相似度并排序
Map<Long, Double> similarities = new HashMap<>();
for (Video candidate : candidates) {
if (!candidate.getId().equals(videoId)) {
double similarity = calculateVideoSimilarity(videoId, candidate.getId());
similarities.put(candidate.getId(), similarity);
}
}
return candidates.stream()
.filter(v -> !v.getId().equals(videoId))
.sorted((v1, v2) -> Double.compare(similarities.get(v2.getId()), similarities.get(v1.getId())))
.limit(limit)
.map(this::convertToDTO)
.collect(Collectors.toList());
}
private double calculateFinalScore(double similarityScore, Video video) {
// 结合多个因素计算最终分数
double timeDecay = calculateTimeDecay(video.getCreatedTime());
double popularityScore = calculatePopularityScore(video);
return similarityScore * 0.6 + // 相似度权重
timeDecay * 0.2 + // 时间衰减权重
popularityScore * 0.2; // 热度权重
}
private double calculateTimeDecay(LocalDateTime createdTime) {
long days = ChronoUnit.DAYS.between(createdTime, LocalDateTime.now());
return 1.0 / (1.0 + Math.log1p(days));
}
private double calculatePopularityScore(Video video) {
return Math.log1p(video.getViewCount()) + Math.log1p(video.getLikeCount() * 2);
}
private double calculateCosineSimilarity(List<VideoTagWeight> tags1, List<VideoTagWeight> tags2) {
// 实现余弦相似度计算
return 0.0;
}
}

View File

@ -3,11 +3,12 @@ package com.guwan.backend.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.guwan.backend.annotation.OperationLog;
import com.guwan.backend.dto.video.VideoDTO;
import com.guwan.backend.entity.Video;
import com.guwan.backend.entity.VideoLike;
import com.guwan.backend.mapper.VideoMapper;
import com.guwan.backend.mapper.VideoLikeMapper;
import com.guwan.backend.mapper.VideoMapper;
import com.guwan.backend.service.VideoService;
import com.guwan.backend.util.MinioUtil;
import com.guwan.backend.util.SecurityUtil;

View File

@ -4,6 +4,7 @@ import io.minio.*;
import io.minio.http.Method;
import io.minio.messages.DeleteError;
import io.minio.messages.DeleteObject;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
@ -17,7 +18,7 @@ import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import javax.annotation.PostConstruct;
@Slf4j
@Component

View File

@ -4,7 +4,12 @@ server:
spring:
application:
name: backend
# 视频上传配置
servlet:
multipart:
max-file-size: 500MB
max-request-size: 500MB
# 数据库配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
@ -66,16 +71,16 @@ spring:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
# JWT配置
jwt:
secret: javax.crypto.spec.SecretKeySpec@5884561.Guwan.javax.crypto.spec.SecretKeySpec@5884561
expiration: 86400000 # 24小时
jwt:
secret: javax.crypto.spec.SecretKeySpec@5884561.Guwan.javax.crypto.spec.SecretKeySpec@5884561
expiration: 86400000 # 24小时
# 阿里云配置
aliyun:
sms:
aliyun:
sms:
# MinIO配置
minio:
minio:
endpoint: http://localhost:9000
accessKey: admin
secretKey: admin123456
@ -84,20 +89,13 @@ spring:
images: images # 图片桶
# 文件上传配置
file:
file:
upload:
path: D:/upload # Windows路径示例根据实际情况修改
path: D:/upload # Windows路径示例根据实际情况修改
# 视频上传配置
servlet:
multipart:
max-file-size: 500MB
max-request-size: 500MB
hadoop:
fs:
defaultFS: hdfs://localhost:9000
username: hadoop
# Swagger配置
springdoc:

View File

@ -18,20 +18,6 @@ CREATE TABLE IF NOT EXISTS `user` (
UNIQUE KEY `uk_phone` (`phone`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户表';
-- 短信日志表
CREATE TABLE IF NOT EXISTS `sms_log` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '记录ID',
`phone` varchar(20) NOT NULL COMMENT '手机号',
`type` varchar(20) NOT NULL COMMENT '短信类型',
`content` varchar(500) NOT NULL COMMENT '短信内容',
`status` tinyint NOT NULL COMMENT '发送状态0-失败1-成功',
`error_msg` varchar(255) DEFAULT NULL COMMENT '错误信息',
`created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_phone` (`phone`),
KEY `idx_created_time` (`created_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='短信发送日志表';
CREATE TABLE IF NOT EXISTS `sys_log` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '日志ID',
@ -50,4 +36,27 @@ CREATE TABLE IF NOT EXISTS `sys_log` (
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='系统操作日志';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='系统操作日志';
CREATE TABLE `video` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '视频ID',
`title` varchar(100) NOT NULL COMMENT '视频标题',
`description` text COMMENT '视频描述',
`url` varchar(255) NOT NULL COMMENT '视频URL',
`cover_url` varchar(255) DEFAULT NULL COMMENT '封面图URL',
`duration` bigint DEFAULT NULL COMMENT '视频时长(秒)',
`size` bigint NOT NULL COMMENT '文件大小(字节)',
`status` varchar(20) NOT NULL COMMENT '状态DRAFT-草稿PUBLISHED-已发布DELETED-已删除',
`user_id` bigint NOT NULL COMMENT '上传用户ID',
`username` varchar(50) DEFAULT NULL COMMENT '上传用户名',
`created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`view_count` int NOT NULL DEFAULT '0' COMMENT '观看次数',
`like_count` int NOT NULL DEFAULT '0' COMMENT '点赞次数',
`tags` varchar(255) DEFAULT NULL COMMENT '标签,逗号分隔',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_created_time` (`created_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='视频表';