feat:
This commit is contained in:
parent
e4a035aa33
commit
b5bb4909e4
|
@ -0,0 +1,9 @@
|
|||
# 视频模块接口文档
|
||||
|
||||
## 基础信息
|
||||
- 基础路径: `/api/videos`
|
||||
- 请求头: 需要携带 `Authorization: Bearer {token}` (除了获取视频列表和视频详情)
|
||||
|
||||
## 接口列表
|
||||
|
||||
### 1. 上传视频
|
|
@ -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
40
pom.xml
|
@ -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>
|
||||
|
|
|
@ -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()
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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")));
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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; // 标签,逗号分隔
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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> {
|
||||
}
|
|
@ -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> {
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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: '/**'
|
|
@ -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='视频表';
|
|
@ -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='视频点赞记录表';
|
|
@ -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='用户行为记录表';
|
|
@ -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='视频标签权重表';
|
|
@ -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='用户兴趣表';
|
Loading…
Reference in New Issue