This commit is contained in:
ovo 2024-12-08 15:28:12 +08:00
parent e4a035aa33
commit b5bb4909e4
26 changed files with 1461 additions and 30 deletions

9
docs/api/video-api.md Normal file
View File

@ -0,0 +1,9 @@
# 视频模块接口文档
## 基础信息
- 基础路径: `/api/videos`
- 请求头: 需要携带 `Authorization: Bearer {token}` (除了获取视频列表和视频详情)
## 接口列表
### 1. 上传视频

218
docs/api/video-api.txt Normal file
View File

@ -0,0 +1,218 @@
# 视频模块接口文档
## 基础信息
- 基础路径: `/api/videos`
- 请求头: 需要携带 `Authorization: Bearer {token}` (除了获取视频列表和视频详情)
## 接口列表
### 1. 上传视频
POST /api/videos/upload
// 请求参数 (multipart/form-data)
{
file: File, // 视频文件
title: string, // 视频标题
description: string, // 视频描述
tags?: string // 视频标签,可选,多个标签用逗号分隔
}
// 响应
{
code: number, // 200表示成功
message: string, // 响应消息
data: {
id: number, // 视频ID
title: string, // 视频标题
description: string, // 视频描述
url: string, // 视频URL
coverUrl: string, // 封面URL
duration: number, // 视频时长(秒)
size: number, // 文件大小(字节)
status: string, // 状态DRAFT-草稿PUBLISHED-已发布DELETED-已删除
userId: number, // 上传用户ID
username: string, // 上传用户名
createdTime: string, // 创建时间
updatedTime: string, // 更新时间
viewCount: number, // 观看次数
likeCount: number, // 点赞次数
tags: string, // 标签
hasLiked: boolean // 当前用户是否已点赞
}
}
### 2. 获取视频列表
GET /api/videos?pageNum=1&pageSize=10&keyword=xxx
// 请求参数 (query)
{
pageNum?: number, // 页码默认1
pageSize?: number, // 每页条数默认10
keyword?: string // 搜索关键词,可选
}
// 响应
{
code: number,
message: string,
data: {
records: Array<{ // 视频列表
id: number,
title: string,
description: string,
url: string,
coverUrl: string,
duration: number,
size: number,
status: string,
userId: number,
username: string,
createdTime: string,
updatedTime: string,
viewCount: number,
likeCount: number,
tags: string,
hasLiked: boolean
}>,
total: number, // 总记录数
size: number, // 每页条数
current: number, // 当前页码
pages: number // 总页数
}
}
### 3. 获取视频详情
GET /api/videos/{id}
// 响应
{
code: number,
message: string,
data: {
id: number,
title: string,
description: string,
url: string,
coverUrl: string,
duration: number,
size: number,
status: string,
userId: number,
username: string,
createdTime: string,
updatedTime: string,
viewCount: number,
likeCount: number,
tags: string,
hasLiked: boolean
}
}
### 4. 更新视频信息
PUT /api/videos/{id}
// 请求体
{
title: string,
description: string,
tags?: string
}
// 响应
{
code: number,
message: string,
data: VideoDTO // 同上面的视频详情
}
### 5. 删除视频
DELETE /api/videos/{id}
// 响应
{
code: number,
message: string,
data: null
}
### 6. 视频点赞/取消点赞
POST /api/videos/{id}/like
// 响应
{
code: number,
message: string,
data: null
}
### 7. 增加观看次数
POST /api/videos/{id}/view
// 响应
{
code: number,
message: string,
data: null
}
### 8. 获取推荐视频
GET /api/videos/recommend?limit=10
// 请求参数 (query)
{
limit?: number // 返回数量默认10
}
// 响应
{
code: number,
message: string,
data: Array<VideoDTO> // 视频列表
}
### 9. 获取相似视频
GET /api/videos/{id}/similar?limit=10
// 请求参数 (query)
{
limit?: number // 返回数量默认10
}
// 响应
{
code: number,
message: string,
data: Array<VideoDTO> // 视频列表
}
## 错误码说明
{
200: "操作成功",
400: "请求参数错误",
401: "未登录或token已过期",
403: "无权限执行此操作",
404: "资源不存在",
500: "服务器内部错误"
}
## 数据结构
### VideoDTO
{
id: number; // 视频ID
title: string; // 视频标题
description: string; // 视频描述
url: string; // 视频URL
coverUrl: string; // 封面URL
duration: number; // 视频时长(秒)
size: number; // 文件大小(字节)
status: string; // 状态DRAFT-草稿PUBLISHED-已发布DELETED-已删除
userId: number; // 上传用户ID
username: string; // 上传用户名
createdTime: string; // 创建时间
updatedTime: string; // 更新时间
viewCount: number; // 观看次数
likeCount: number; // 点赞次数
tags: string; // 标签,多个用逗号分隔
hasLiked: boolean; // 当前用户是否已点赞
}

40
pom.xml
View File

@ -174,6 +174,46 @@
<version>2.11.0</version>
</dependency>
<!-- DJL Core -->
<dependency>
<groupId>ai.djl</groupId>
<artifactId>api</artifactId>
<version>0.25.0</version>
</dependency>
<!-- PyTorch Engine -->
<dependency>
<groupId>ai.djl.pytorch</groupId>
<artifactId>pytorch-engine</artifactId>
<version>0.25.0</version>
</dependency>
<!-- DJL Model Zoo -->
<dependency>
<groupId>ai.djl.pytorch</groupId>
<artifactId>pytorch-model-zoo</artifactId>
<version>0.25.0</version>
</dependency>
<!-- Hadoop Dependencies -->
<dependency>
<groupId>org.apache.hadoop</groupId>
<artifactId>hadoop-client</artifactId>
<version>3.3.6</version>
</dependency>
<dependency>
<groupId>org.apache.hadoop</groupId>
<artifactId>hadoop-hdfs</artifactId>
<version>3.3.6</version>
</dependency>
<!-- Swagger UI -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.3.0</version>
</dependency>
</dependencies>
<build>

View File

@ -0,0 +1,44 @@
import torch
import torch.nn as nn
import torch.optim as optim
import pandas as pd
from sklearn.preprocessing import StandardScaler
class RecommendModel(nn.Module):
def __init__(self, input_size, embedding_size):
super().__init__()
self.encoder = nn.Sequential(
nn.Linear(input_size, 256),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(256, 128),
nn.ReLU(),
nn.Linear(128, embedding_size)
)
def forward(self, x):
return self.encoder(x)
def train_model():
# 加载数据
videos_df = pd.read_csv('videos.csv')
behaviors_df = pd.read_csv('behaviors.csv')
# 数据预处理
scaler = StandardScaler()
features = scaler.fit_transform(videos_df[['duration', 'view_count', 'like_count']])
# 创建模型
model = RecommendModel(input_size=features.shape[1], embedding_size=128)
criterion = nn.CosineEmbeddingLoss()
optimizer = optim.Adam(model.parameters())
# 训练模型
for epoch in range(100):
# ... 训练代码
# 保存模型
torch.save(model.state_dict(), '../src/main/resources/models/recommend_model.pt')
if __name__ == '__main__':
train_model()

View File

@ -0,0 +1,28 @@
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

@ -0,0 +1,29 @@
package com.guwan.backend.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI springShopOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("视频平台 API")
.description("视频平台后端接口文档")
.version("v1.0.0")
.license(new License().name("Apache 2.0").url("http://springdoc.org")))
.components(new Components()
.addSecuritySchemes("bearer-jwt",
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")));
}
}

View File

@ -0,0 +1,160 @@
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;
@Slf4j
@Tag(name = "视频管理", description = "视频相关接口")
@RestController
@RequestMapping("/api/videos")
@RequiredArgsConstructor
public class VideoController {
private final VideoService videoService;
private final VideoRecommendService recommendService;
@Operation(summary = "上传视频", description = "上传视频文件并返回视频信息")
@SecurityRequirement(name = "bearer-jwt")
@PostMapping("/upload")
public Result<VideoDTO> uploadVideo(
@Parameter(description = "视频文件") @RequestParam("file") MultipartFile file,
@Parameter(description = "视频标题") @RequestParam("title") String title,
@Parameter(description = "视频描述") @RequestParam("description") String description,
@Parameter(description = "视频标签,多个用逗号分隔") @RequestParam(value = "tags", required = false) String tags) {
try {
VideoDTO video = videoService.uploadVideo(file, title, description, tags);
return Result.success(video);
} catch (Exception e) {
log.error("上传视频失败", e);
return Result.error(e.getMessage());
}
}
@Operation(summary = "更新视频信息")
@SecurityRequirement(name = "bearer-jwt")
@PutMapping("/{id}")
public Result<VideoDTO> updateVideo(
@Parameter(description = "视频ID") @PathVariable Long id,
@RequestBody VideoDTO videoDTO) {
try {
videoDTO.setId(id);
return Result.success(videoService.updateVideo(videoDTO));
} catch (Exception e) {
log.error("更新视频失败", e);
return Result.error(e.getMessage());
}
}
@Operation(summary = "删除视频")
@SecurityRequirement(name = "bearer-jwt")
@DeleteMapping("/{id}")
public Result<Void> deleteVideo(
@Parameter(description = "视频ID") @PathVariable Long id) {
try {
videoService.deleteVideo(id);
return Result.success();
} catch (Exception e) {
log.error("删除视频失败", e);
return Result.error(e.getMessage());
}
}
@Operation(summary = "获取视频详情")
@GetMapping("/{id}")
public Result<VideoDTO> getVideo(
@Parameter(description = "视频ID") @PathVariable Long id) {
try {
VideoDTO video = videoService.getVideoById(id);
if (video == null) {
return Result.notFound("视频不存在");
}
return Result.success(video);
} catch (Exception e) {
log.error("获取视频失败", e);
return Result.error(e.getMessage());
}
}
@Operation(summary = "获取视频列表", description = "支持分页和关键词搜索")
@GetMapping
public Result<IPage<VideoDTO>> getVideoList(
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer pageNum,
@Parameter(description = "每页条数") @RequestParam(defaultValue = "10") Integer pageSize,
@Parameter(description = "搜索关键词") @RequestParam(required = false) String keyword) {
try {
return Result.success(videoService.getVideoList(pageNum, pageSize, keyword));
} catch (Exception e) {
log.error("获取视频列表失败", e);
return Result.error(e.getMessage());
}
}
@Operation(summary = "增加视频观看次数")
@PostMapping("/{id}/view")
public Result<Void> incrementViewCount(
@Parameter(description = "视频ID") @PathVariable Long id) {
try {
videoService.incrementViewCount(id);
return Result.success();
} catch (Exception e) {
log.error("增加观看次数失败", e);
return Result.error(e.getMessage());
}
}
@Operation(summary = "视频点赞/取消点赞")
@SecurityRequirement(name = "bearer-jwt")
@PostMapping("/{id}/like")
public Result<Void> toggleLike(
@Parameter(description = "视频ID") @PathVariable Long id) {
try {
videoService.toggleLike(id);
return Result.success();
} catch (Exception e) {
log.error("点赞失败", e);
return Result.error(e.getMessage());
}
}
@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

@ -0,0 +1,66 @@
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

@ -0,0 +1,57 @@
package com.guwan.backend.dto.video;
import lombok.Data;
import java.time.LocalDateTime;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "视频信息DTO")
@Data
public class VideoDTO {
@Schema(description = "视频ID")
private Long id;
@Schema(description = "视频标题")
private String title;
@Schema(description = "视频描述")
private String description;
@Schema(description = "视频URL")
private String url;
@Schema(description = "封面URL")
private String coverUrl;
@Schema(description = "视频时长(秒)")
private Long duration;
@Schema(description = "文件大小(字节)")
private Long size;
@Schema(description = "状态DRAFT-草稿PUBLISHED-已发布DELETED-已删除")
private String status;
@Schema(description = "上传用户ID")
private Long userId;
@Schema(description = "上传用户名")
private String username;
@Schema(description = "创建时间")
private LocalDateTime createdTime;
@Schema(description = "更新时间")
private LocalDateTime updatedTime;
@Schema(description = "观看次数")
private Integer viewCount;
@Schema(description = "点赞次数")
private Integer likeCount;
@Schema(description = "标签,多个用逗号分隔")
private String tags;
@Schema(description = "当前用户是否已点赞")
private Boolean hasLiked;
}

View File

@ -0,0 +1,42 @@
package com.guwan.backend.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("video")
public class Video {
@TableId(type = IdType.AUTO)
private Long id;
private String title; // 视频标题
private String description; // 视频描述
private String url; // 视频URL
private String coverUrl; // 封面图URL
private Long duration; // 视频时长
private Long size; // 文件大小字节
private String status; // 状态DRAFT-草稿PUBLISHED-已发布DELETED-已删除
private Long userId; // 上传用户ID
private String username; // 上传用户名
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedTime;
private Integer viewCount; // 观看次数
private Integer likeCount; // 点赞次数
private String tags; // 标签逗号分隔
}

View File

@ -0,0 +1,19 @@
package com.guwan.backend.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("video_like")
public class VideoLike {
@TableId(type = IdType.AUTO)
private Long id;
private Long videoId;
private Long userId;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdTime;
}

View File

@ -0,0 +1,9 @@
package com.guwan.backend.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.guwan.backend.entity.VideoLike;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface VideoLikeMapper extends BaseMapper<VideoLike> {
}

View File

@ -0,0 +1,9 @@
package com.guwan.backend.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.guwan.backend.entity.Video;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface VideoMapper extends BaseMapper<Video> {
}

View File

@ -0,0 +1,114 @@
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

@ -0,0 +1,82 @@
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

@ -0,0 +1,21 @@
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

@ -0,0 +1,28 @@
package com.guwan.backend.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.guwan.backend.dto.video.VideoDTO;
import org.springframework.web.multipart.MultipartFile;
public interface VideoService {
// 上传视频
VideoDTO uploadVideo(MultipartFile file, String title, String description, String tags);
// 更新视频信息
VideoDTO updateVideo(VideoDTO videoDTO);
// 删除视频
void deleteVideo(Long id);
// 获取视频详情
VideoDTO getVideoById(Long id);
// 分页查询视频列表
IPage<VideoDTO> getVideoList(Integer pageNum, Integer pageSize, String keyword);
// 增加观看次数
void incrementViewCount(Long id);
// 点赞/取消点赞
void toggleLike(Long id);
}

View File

@ -0,0 +1,143 @@
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

@ -0,0 +1,215 @@
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.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.service.VideoService;
import com.guwan.backend.util.MinioUtil;
import com.guwan.backend.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
@Slf4j
@Service
@RequiredArgsConstructor
public class VideoServiceImpl implements VideoService {
private final VideoMapper videoMapper;
private final MinioUtil minioUtil;
private final SecurityUtil securityUtil;
private final VideoLikeMapper videoLikeMapper;
@Override
@Transactional
@OperationLog(description = "上传视频", operationType = "上传")
public VideoDTO uploadVideo(MultipartFile file, String title, String description, String tags) {
// 获取当前用户
Long userId = securityUtil.getCurrentUserId();
if (userId == null) {
throw new IllegalStateException("用户未登录");
}
try {
// 上传视频文件到MinIO
String fileName = minioUtil.uploadFile("videos", file);
String url = minioUtil.getFileUrl("videos", fileName);
// 创建视频记录
Video video = new Video();
video.setTitle(title);
video.setDescription(description);
video.setUrl(url);
video.setSize(file.getSize());
video.setUserId(userId);
video.setStatus("PUBLISHED");
video.setTags(tags);
video.setViewCount(0);
video.setLikeCount(0);
videoMapper.insert(video);
return convertToDTO(video);
} catch (Exception e) {
log.error("上传视频失败", e);
throw new RuntimeException("上传视频失败", e);
}
}
@Override
@Transactional
@OperationLog(description = "更新视频信息", operationType = "更新")
public VideoDTO updateVideo(VideoDTO videoDTO) {
Video video = videoMapper.selectById(videoDTO.getId());
if (video == null) {
throw new IllegalArgumentException("视频不存在");
}
// 检查权限
Long currentUserId = securityUtil.getCurrentUserId();
if (!video.getUserId().equals(currentUserId)) {
throw new IllegalStateException("无权修改此视频");
}
BeanUtils.copyProperties(videoDTO, video, "id", "userId", "url", "createdTime");
videoMapper.updateById(video);
return convertToDTO(video);
}
@Override
@Transactional
@OperationLog(description = "删除视频", operationType = "删除")
public void deleteVideo(Long id) {
Video video = videoMapper.selectById(id);
if (video == null) {
return;
}
// 检查权限
Long currentUserId = securityUtil.getCurrentUserId();
if (!video.getUserId().equals(currentUserId)) {
throw new IllegalStateException("无权删除此视频");
}
// 从MinIO中删除视频文件
String fileName = video.getUrl().substring(video.getUrl().lastIndexOf("/") + 1);
minioUtil.deleteFile("videos", fileName);
// 删除数据库记录
videoMapper.deleteById(id);
}
@Override
@OperationLog(description = "获取视频详情", operationType = "查询")
public VideoDTO getVideoById(Long id) {
Video video = videoMapper.selectById(id);
return convertToDTO(video);
}
@Override
@OperationLog(description = "获取视频列表", operationType = "查询")
public IPage<VideoDTO> getVideoList(Integer pageNum, Integer pageSize, String keyword) {
Page<Video> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<Video> wrapper = new LambdaQueryWrapper<Video>()
.eq(Video::getStatus, "PUBLISHED")
.like(keyword != null, Video::getTitle, keyword)
.or()
.like(keyword != null, Video::getDescription, keyword)
.orderByDesc(Video::getCreatedTime);
IPage<Video> videoPage = videoMapper.selectPage(page, wrapper);
return videoPage.convert(this::convertToDTO);
}
@Override
@Transactional
@OperationLog(description = "增加视频观看次数", operationType = "更新")
public void incrementViewCount(Long id) {
Video video = videoMapper.selectById(id);
if (video != null) {
video.setViewCount(video.getViewCount() + 1);
videoMapper.updateById(video);
}
}
@Override
@Transactional
@OperationLog(description = "视频点赞/取消点赞", operationType = "更新")
public void toggleLike(Long id) {
// 获取当前用户
Long userId = securityUtil.getCurrentUserId();
if (userId == null) {
throw new IllegalStateException("用户未登录");
}
// 检查视频是否存在
Video video = videoMapper.selectById(id);
if (video == null) {
throw new IllegalArgumentException("视频不存在");
}
// 检查是否已点赞
LambdaQueryWrapper<VideoLike> wrapper = new LambdaQueryWrapper<VideoLike>()
.eq(VideoLike::getVideoId, id)
.eq(VideoLike::getUserId, userId);
VideoLike like = videoLikeMapper.selectOne(wrapper);
if (like == null) {
// 未点赞添加点赞记录
like = new VideoLike();
like.setVideoId(id);
like.setUserId(userId);
videoLikeMapper.insert(like);
// 更新视频点赞数
video.setLikeCount(video.getLikeCount() + 1);
} else {
// 已点赞取消点赞
videoLikeMapper.deleteById(like.getId());
// 更新视频点赞数
video.setLikeCount(video.getLikeCount() - 1);
}
videoMapper.updateById(video);
}
// 添加新方法检查用户是否已点赞
public boolean hasLiked(Long videoId) {
Long userId = securityUtil.getCurrentUserId();
if (userId == null) {
return false;
}
LambdaQueryWrapper<VideoLike> wrapper = new LambdaQueryWrapper<VideoLike>()
.eq(VideoLike::getVideoId, videoId)
.eq(VideoLike::getUserId, userId);
return videoLikeMapper.selectCount(wrapper) > 0;
}
private VideoDTO convertToDTO(Video video) {
if (video == null) {
return null;
}
VideoDTO dto = new VideoDTO();
BeanUtils.copyProperties(video, dto);
// 设置是否已点赞
dto.setHasLiked(hasLiked(video.getId()));
return dto;
}
}

View File

@ -17,6 +17,8 @@ import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import javax.annotation.PostConstruct;
@Slf4j
@Component
@RequiredArgsConstructor
@ -157,4 +159,14 @@ public class MinioUtil {
String extension = FilenameUtils.getExtension(originalFilename);
return UUID.randomUUID() + "." + extension;
}
// 初始化时创建视频桶
@PostConstruct
public void init() {
try {
createBucket("videos");
} catch (Exception e) {
log.error("创建视频存储桶失败", e);
}
}
}

View File

@ -53,37 +53,60 @@ spring:
exclude:
- org.springframework.boot.autoconfigure.sql.init.SqlInitializationAutoConfiguration
# MyBatis-Plus配置
mybatis-plus:
global-config:
db-config:
id-type: auto
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
# MyBatis-Plus配置
mybatis-plus:
global-config:
db-config:
id-type: auto
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
configuration:
map-underscore-to-camel-case: true
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配置
jwt:
secret: javax.crypto.spec.SecretKeySpec@5884561.Guwan.javax.crypto.spec.SecretKeySpec@5884561
expiration: 86400000 # 24小时
# 阿里云配置
aliyun:
sms:
# 阿里云配置
aliyun:
sms:
# MinIO配置
minio:
endpoint: http://localhost:9000
accessKey: admin
secretKey: admin123456
bucket:
files: files # 文件桶
images: images # 图片桶
# MinIO配置
minio:
endpoint: http://localhost:9000
accessKey: admin
secretKey: admin123456
bucket:
files: files # 文件桶
images: images # 图片桶
# 文件上传配置
file:
upload:
path: D:/upload # Windows路径示例根据实际情况修改
# 文件上传配置
file:
upload:
path: D:/upload # Windows路径示例根据实际情况修改
# 视频上传配置
servlet:
multipart:
max-file-size: 500MB
max-request-size: 500MB
hadoop:
fs:
defaultFS: hdfs://localhost:9000
username: hadoop
# Swagger配置
springdoc:
swagger-ui:
path: /swagger-ui.html
tags-sorter: alpha
operations-sorter: alpha
api-docs:
path: /v3/api-docs
group-configs:
- group: '默认'
paths-to-match: '/**'

View File

@ -0,0 +1,20 @@
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='视频表';

View File

@ -0,0 +1,10 @@
CREATE TABLE `video_like` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '记录ID',
`video_id` bigint NOT NULL COMMENT '视频ID',
`user_id` bigint NOT NULL COMMENT '用户ID',
`created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_video_user` (`video_id`,`user_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_video_id` (`video_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='视频点赞记录表';

View File

@ -0,0 +1,14 @@
CREATE TABLE `user_behavior` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '记录ID',
`user_id` bigint NOT NULL COMMENT '用户ID',
`video_id` bigint NOT NULL COMMENT '视频ID',
`behavior_type` varchar(20) NOT NULL COMMENT '行为类型VIEW-观看,LIKE-点赞,SHARE-分享,FAVORITE-收藏',
`watch_duration` int DEFAULT NULL COMMENT '观看时长(秒)',
`watch_progress` float DEFAULT NULL COMMENT '观看进度(0-1)',
`created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_video_id` (`video_id`),
KEY `idx_behavior_type` (`behavior_type`),
KEY `idx_created_time` (`created_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户行为记录表';

View File

@ -0,0 +1,9 @@
CREATE TABLE `video_tag_weight` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '记录ID',
`video_id` bigint NOT NULL COMMENT '视频ID',
`tag` varchar(50) NOT NULL COMMENT '标签',
`weight` float NOT NULL COMMENT '权重',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_video_tag` (`video_id`,`tag`),
KEY `idx_tag` (`tag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='视频标签权重表';

View File

@ -0,0 +1,10 @@
CREATE TABLE `user_interest` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '记录ID',
`user_id` bigint NOT NULL COMMENT '用户ID',
`tag` varchar(50) NOT NULL COMMENT '标签',
`weight` float NOT NULL COMMENT '权重',
`updated_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_tag` (`user_id`,`tag`),
KEY `idx_tag` (`tag`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户兴趣表';