文章目录

  • Softhub软件下载站实战开发(十):实现图片视频上传下载接口 🖼️🎥
    • 系统架构图
    • 核心功能设计 🛠️
      • 1. 文件上传流程
      • 2. 关键技术实现
        • 2.1 雪花算法
        • 2.2 文件校验机制 ✅
        • 2.3 文件去重机制 🔍
        • 2.4 视频封面提取 🎞️
        • 2.5 文件存储策略 📂
        • 2.6 视频上传示例
      • 3. 文件查看实现 ⬇️

Softhub软件下载站实战开发(十):实现图片视频上传下载接口 🖼️🎥

在上一篇文章中,我们实现了软件配置面板,实现了ai配置信息的存储,为后续富文本编辑器的ai功能提供了基础,本文致力于解决在富文本编辑器中图片和视频的上传查看功能。

系统架构图

上传文件
下载文件
读取
客户端
API接口
文件处理层
存储服务
MinIO存储
数据库
MySQL

核心功能设计 🛠️

1. 文件上传流程

客户端 服务端 MinIO 数据库 上传文件请求 验证文件类型和大小 计算文件MD5 检查文件是否已存在 返回已存在记录 直接返回文件URL 上传文件到MinIO 返回成功 保存文件元信息 返回成功 返回文件URL alt [文件已存在] [文件不存在] 客户端 服务端 MinIO 数据库

2. 关键技术实现

2.1 雪花算法

关键数据不能采取自增id方案,采用md5也会有碰撞和页分裂的问题,这里采用雪花算法来解决这一问题

安装

go get -u "github.com/bwmarrin/snowflake"

初始化

var node *snowflake.Nodefunc init() {var err errornode, err = snowflake.NewNode(1)
}

使用

id := node.Generate().Int64()
2.2 文件校验机制 ✅
// 检查文件类型
fileType := strings.ToLower(filepath.Ext(req.File.Filename))
allowedTypes := []string{".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
isAllowed := false
for _, t := range allowedTypes {if t == fileType {isAllowed = truebreak}
}
if !isAllowed {return fmt.Errorf("不支持的文件类型:%s", fileType)
}// 检查文件大小
if req.File.Size > 10*1024*1024 { // 10MBreturn fmt.Errorf("文件大小不能超过10MB")
}
2.3 文件去重机制 🔍

通过计算文件MD5值实现文件去重:

// 计算文件MD5
fileBytes, _ := io.ReadAll(file)
md5 := gmd5.MustEncryptBytes(fileBytes)// 检查是否已存在
var existFile *model.DsImageInfo
err = dao.DsImage.Ctx(ctx).Where(dao.DsImage.Columns().Md5, md5).Scan(&existFile)
if existFile != nil {// 直接返回已有文件信息return existFile, nil
}
2.4 视频封面提取 🎞️

需要ffmpeg添加到环境变量中

使用FFmpeg提取视频首帧作为封面:

cmd := exec.Command("ffmpeg","-y",                 // 覆盖输出文件"-loglevel", "error", // 只输出错误信息"-i", tempVideoPath,  // 输入文件"-vframes", "1",      // 只提取一帧"-an",                // 不处理音频"-vf", "scale='-1:min(720,ih)'", // 限制最大高度为720"-c:v", "mjpeg",      // 使用mjpeg编码器"-f", "image2",       // 输出格式"-q:v", "2",          // 高质量输出tempFramePath)        // 输出文件
2.5 文件存储策略 📂

采用分层目录结构存储文件:

pic/2024/05/07/abc123def456.pic
video/2024/05/07/xyz789uvw012.video

代码实现:

now := gtime.Now()
year := now.Year()
month := int(now.Month())
day := now.Day()
objectName := fmt.Sprintf("pic/%d/%02d/%02d/%s.pic", year, month, day, md5)
2.6 视频上传示例
func (s *sDsIUpload) VideoUpload(ctx context.Context, req *api.DsVideoUploadReq) (res *api.DsVideoUploadRes, err error) {res = &api.DsVideoUploadRes{}err = g.Try(ctx, func(ctx context.Context) {// 检查文件类型fileType := strings.ToLower(filepath.Ext(req.File.Filename))allowedTypes := []string{".mp4", ".avi", ".mov", ".mkv"}isAllowed := falsefor _, t := range allowedTypes {if t == fileType {isAllowed = truebreak}}if !isAllowed {liberr.ErrIsNil(ctx, fmt.Errorf("不支持的文件类型:%s", fileType))}// 检查文件大小(如限制20MB)if req.File.Size > 20*1024*1024 {liberr.ErrIsNil(ctx, fmt.Errorf("文件大小不能超过20MB"))}// 计算MD5file, err := req.File.Open()liberr.ErrIsNil(ctx, err, "打开文件失败")defer file.Close()fileBytes, err := io.ReadAll(file)liberr.ErrIsNil(ctx, err, "读取文件失败")md5 := gmd5.MustEncryptBytes(fileBytes)// 检查是否已存在var existVideo *model.DsVideoInfoerr = dao.DsVideo.Ctx(ctx).Where(dao.DsVideo.Columns().Md5, md5).Scan(&existVideo)liberr.ErrIsNil(ctx, err, "查询视频信息失败")if existVideo != nil {res.Id = existVideo.Idres.Url = fmt.Sprintf("/api/v1/admin/ds/dsVideo/view?id=%d", existVideo.Id)// 获取首帧图片URLimageInfo, err := s.GetImageInfo(ctx, &api.DsImageInfoReq{Id: existVideo.PosterId})if err == nil && imageInfo != nil {res.Poster = fmt.Sprintf("/api/v1/admin/ds/dsImage/view?id=%d", imageInfo.Id)}return}// 创建临时目录tempDir := filepath.Join(os.TempDir(), "upload", md5)if _, err := os.Stat(tempDir); os.IsNotExist(err) {err = os.MkdirAll(tempDir, 0755)liberr.ErrIsNil(ctx, err, "创建临时目录失败")}// 生成临时文件路径tempVideoPath := filepath.Join(tempDir, fmt.Sprintf("video%s", fileType))tempFramePath := filepath.Join(tempDir, "frame.jpg")g.Log().Debugf(ctx, "临时视频文件路径: %s", tempVideoPath)g.Log().Debugf(ctx, "临时帧图片路径: %s", tempFramePath)// 保存视频到临时文件file.Seek(0, 0)tempFile, err := os.OpenFile(tempVideoPath, os.O_WRONLY|os.O_CREATE, 0644)liberr.ErrIsNil(ctx, err, "创建临时文件失败")_, err = io.Copy(tempFile, file)tempFile.Close()liberr.ErrIsNil(ctx, err, "保存临时文件失败")// 确保临时文件存在且可读if _, err := os.Stat(tempVideoPath); err != nil {liberr.ErrIsNil(ctx, fmt.Errorf("临时视频文件不存在或无法访问: %v", err))}// 使用ffmpeg提取首帧cmd := exec.Command("ffmpeg","-y",                 // 覆盖输出文件"-loglevel", "error", // 只输出错误信息"-i", tempVideoPath, // 输入文件"-vframes", "1", // 只提取一帧"-an",                           // 不处理音频"-vf", "scale='-1:min(720,ih)'", // 限制最大高度为720,保持宽高比"-c:v", "mjpeg", // 使用 mjpeg 编码器"-f", "image2", // 输出格式"-q:v", "2", // 高质量输出tempFramePath) // 输出文件output, err := cmd.CombinedOutput()if err != nil {// 清理临时文件os.RemoveAll(tempDir)liberr.ErrIsNil(ctx, fmt.Errorf("提取视频首帧失败: %v, 输出: %s", err, string(output)))}// 获取MinIO客户端drive := storage.MinioDrive{}client, err := drive.GetClient()liberr.ErrIsNil(ctx, err, "获取MinIO客户端失败")// 生成存储路径now := gtime.Now()year := now.Year()month := int(now.Month())day := now.Day()frameObjectName := fmt.Sprintf("pic/%d/%02d/%02d/%s.jpg", year, month, day, md5)// 读取首帧图片frameFile, err := os.Open(tempFramePath)liberr.ErrIsNil(ctx, err, "打开首帧图片失败")defer frameFile.Close()// 获取首帧图片信息frameInfo, err := frameFile.Stat()liberr.ErrIsNil(ctx, err, "获取首帧图片信息失败")// 检查是否已存在相同MD5的图片var existingImage *model.DsImageInfoerr = dao.DsImage.Ctx(ctx).Where(dao.DsImage.Columns().Md5, md5).Scan(&existingImage)liberr.ErrIsNil(ctx, err, "查询图片信息失败")var imageId int64if existingImage != nil {// 使用已存在的图片记录imageId = existingImage.Id} else {// 获取图片尺寸frameFile.Seek(0, 0)img, _, err := image.DecodeConfig(frameFile)if err != nil {g.Log().Warningf(ctx, "获取图片尺寸失败: %v", err)}// 重新定位到文件开始位置用于上传frameFile.Seek(0, 0)// 上传首帧图片到MinIO_, err = client.PutObject(ctx, config.MINIO_BUCKET, frameObjectName, frameFile, frameInfo.Size(), minio.PutObjectOptions{ContentType: "image/jpeg",})liberr.ErrIsNil(ctx, err, "上传首帧图片失败")// 保存首帧图片信息imageInfo := &model.DsImageInfo{Id:        node.Generate().Int64(),Md5:       md5,Name:      fmt.Sprintf("%s_frame.jpg", req.File.Filename),Path:      frameObjectName,Size:      frameInfo.Size(),MimeType:  "image/jpeg",Width:     img.Width,Height:    img.Height,CreatedBy: 0,CreatedAt: gtime.Now(),UpdatedBy: 0,UpdatedAt: gtime.Now(),}// 保存首帧图片信息到数据库_, err = dao.DsImage.Ctx(ctx).Insert(imageInfo)liberr.ErrIsNil(ctx, err, "保存首帧图片信息失败")imageId = imageInfo.Id}// 获取视频元数据cmd = exec.Command("ffprobe","-v", "quiet","-print_format", "json","-show_format","-show_streams",tempVideoPath)output, err = cmd.Output()liberr.ErrIsNil(ctx, err, "获取视频信息失败")var probeData struct {Streams []struct {Width    int    `json:"width"`Height   int    `json:"height"`Duration string `json:"duration"`} `json:"streams"`}err = json.Unmarshal(output, &probeData)liberr.ErrIsNil(ctx, err, "解析视频信息失败")width := 0height := 0duration := 0if len(probeData.Streams) > 0 {width = probeData.Streams[0].Widthheight = probeData.Streams[0].Heightif d, err := strconv.ParseFloat(probeData.Streams[0].Duration, 64); err == nil {duration = int(d)}}// 保存视频文件到MinIOvideoObjectName := fmt.Sprintf("video/%d/%02d/%02d/%s.video", year, month, day, md5)file.Seek(0, 0)err = drive.UploadWithPath(ctx, req.File, videoObjectName)liberr.ErrIsNil(ctx, err, "保存文件失败")// 保存视频信息videoInfo := &model.DsVideoInfo{Id:        node.Generate().Int64(),PosterId:  imageId,Md5:       md5,Name:      req.File.Filename,Path:      videoObjectName,Size:      req.File.Size,MimeType:  req.File.Header.Get("Content-Type"),Duration:  duration,Width:     width,Height:    height,CreatedBy: 0,CreatedAt: gtime.Now(),UpdatedBy: 0,UpdatedAt: gtime.Now(),}_, err = dao.DsVideo.Ctx(ctx).Insert(videoInfo)liberr.ErrIsNil(ctx, err, "保存视频信息失败")// 清理临时目录os.RemoveAll(tempDir)res.Id = videoInfo.Idres.Url = fmt.Sprintf("/api/v1/admin/ds/dsVideo/view?id=%d", videoInfo.Id)res.Poster = fmt.Sprintf("/api/v1/admin/ds/dsImage/view?id=%d", imageId)})return
}

3. 文件查看实现 ⬇️

获取文件信息:返回JSON格式的元数据,前端根据返回的路径进行接口请求

以视频为例

// GetVideoInfo 获取视频信息
func (c *dsUploadController) GetVideoInfo(ctx context.Context, req *api.DsVideoInfoReq) (res *api.DsVideoInfoRes, err error) {// 查询视频信息videoInfo, err := service.DsUpload().GetVideoInfo(ctx, req)if err != nil {return nil, err}// 直接从 MinIO 读取视频内容drive := storage.MinioDrive{}client, err := drive.GetClient()if err != nil {return nil, err}obj, err := client.GetObject(ctx, config.MINIO_BUCKET, videoInfo.Path, minio.GetObjectOptions{})if err != nil {return nil, err}defer obj.Close()// 设置响应头writer := g.RequestFromCtx(ctx).Response.ResponseWriterwriter.Header().Set("Content-Type", videoInfo.MimeType)writer.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", videoInfo.Name))// 写入视频流_, err = io.Copy(writer, obj)return nil, err // 不返回JSON
}// ViewVideo 返回视频二进制流
func (c *dsUploadController) ViewVideo(ctx context.Context, req *api.DsVideoViewReq) (res *api.DsVideoViewRes, err error) {// 查询视频信息videoInfo, err := service.DsUpload().GetVideoInfo(ctx, &api.DsVideoInfoReq{Id: req.Id})if err != nil {return nil, err}// 直接从 MinIO 读取视频内容drive := storage.MinioDrive{}client, err := drive.GetClient()if err != nil {return nil, err}obj, err := client.GetObject(ctx, config.MINIO_BUCKET, videoInfo.Path, minio.GetObjectOptions{})if err != nil {return nil, err}defer obj.Close()// 设置响应头writer := g.RequestFromCtx(ctx).Response.ResponseWriterwriter.Header().Set("Content-Type", videoInfo.MimeType)writer.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", videoInfo.Name))// 写入视频流_, err = io.Copy(writer, obj)return nil, err // 不返回JSON
}

softhub系列往期文章

  1. Softhub软件下载站实战开发(一):项目总览
  2. Softhub软件下载站实战开发(二):项目基础框架搭建
  3. Softhub软件下载站实战开发(三):平台管理模块实战
  4. Softhub软件下载站实战开发(四):代码生成器设计与实现
  5. Softhub软件下载站实战开发(五):分类模块实现
  6. Softhub软件下载站实战开发(六):软件配置面板实现
  7. Softhub软件下载站实战开发(七):集成MinIO实现文件存储功能
  8. Softhub软件下载站实战开发(八):编写软件后台管理
  9. Softhub软件下载站实战开发(九):编写软件配置管理界面

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.pswp.cn/web/87147.shtml
繁体地址,请注明出处:http://hk.pswp.cn/web/87147.shtml
英文地址,请注明出处:http://en.pswp.cn/web/87147.shtml

如若内容造成侵权/违法违规/事实不符,请联系英文站点网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

[JS逆向] 喜马拉雅登录案例 -- 补环境

博客配套代码发布于github:喜马拉雅登录 (欢迎顺手Star一下⭐) 相关知识点:webpack 补环境 相关爬虫专栏:JS逆向爬虫实战 爬虫知识点合集 爬虫实战案例 逆向知识点合集 此案例目标为逆向成功对应的参数&#xff0c…

大语言模型推理系统综述

摘要 近年来,随着 ChatGPT 等服务推动大语言模型(LLM)的快速普及,一批专门面向 LLM 推理的系统相继涌现,如 vLLM、SGLang、Mooncake 和 DeepFlow。这些系统设计工作的核心动因是 LLM 请求处理过程中所特有的自回归特性…

用Firecrawl轻松获取网站数据,提升AI应用的效率!

🔥 Firecrawl:助力AI应用的强大工具! 在数字化信息爆炸的时代,如何高效地从海量网页中提取有用数据变得尤其重要。Firecrawl的问世,为我们揭开了一种便捷的方法来应对这一挑战。它不仅能够将整个网站的数据转化为适用…

【王阳明代数讲义】谷歌编程智能体Gemini CLI 使用指南、架构详解与核心框架分析

Gemini CLI 使用指南、架构详解与核心框架分析 Gemini CLI 使用指南、架构详解与核心框架分析Gemini CLI 使用指南Gemini CLI 架构详解Gemini CLI 核心框架总结 Gemini CLI 使用指南、架构详解与核心框架分析 Gemini CLI 使用指南 1. 安装与配置 环境要求: Node.…

camera调试:安卓添加xml注册

对接安卓的平台时,需要注册对应的camera设备,供安卓标准api进行操作,rk的平台需要在HAL层配置camera3_profiles.xml文件,适配驱动的信息,进行注册camera设备。该xml对应的内容很多,很多CTS测试问题都是该文…

使用 Ansys Discovery 为初学者准备几何结构

介绍 设计几何体通常会包含一些特征,使其无法直接导入我们的仿真工具,例如 Ansys Mechanical、LS-DYNA、Fluent 等。有些干扰或错位虽然适合制造,但在我们的仿真工具中却会造成问题。有时,一些小特征(例如孔或圆角&am…

推客系统全栈开发指南:从架构设计到商业化落地

一、推客系统概述 推客系统(TuiKe System)是一种结合社交网络与内容分发的创新型平台,旨在通过用户间的相互推荐机制实现内容的高效传播。这类系统通常包含用户关系管理、内容发布、智能推荐、数据分析等核心模块,广泛应用于电商…

大数据开发实战:如何做企业级的数据服务产品

1.背景 数据服务通常以解决方案的形式进行组织,面向一个应用场景的所有数据需求或数据内容可以通过一个解决方案进行封装,统一对外服务。一个数据需求或数据接口以一个数据服务实例的形式存在于解决方案之下。 下游消费方可以通过统一API进行数据消费&…

基于IndexTTS的零样本语音合成

IndexTTS 项目采用模块化设计,将 BPE 文本编码、GPT 单元预测、dVAE 语音特征抽取和 BigVGAN 音频生成串联为完整的语音合成流程。系统通过统一的配置文件和模型目录规范,实现高效的文本到语音转换,支持命令行与 Web 界面双模式操作&#xff…

基于go-zero的短链生成系统

go-zero框架 gozero(又称go-zero)是一款由知名开发者kevwan设计的Golang微服务框架,专注于高性能、低延迟和易用性。其核心目标是简化分布式系统的开发,提供开箱即用的工具链,涵盖API网关、RPC服务、缓存管理、数据库…

Linux-修改线上MariaDB服务端口号

准备工作(很重要!!!): 提前做好Linux服务器快照 提前做好数据库数据备份 1. 修改配置文件 首先,我们需要找到MariaDB的配置文件。通常情况下,这个文件位于以下位置:…

Spring Cloud 微服务(负载均衡策略深度解析)

📌 摘要 在微服务架构中,负载均衡是实现高可用、高性能服务调用的关键机制之一。Spring Cloud 提供了基于客户端的负载均衡组件 Ribbon,结合 Feign 和 OpenFeign,实现了服务间的智能路由与流量分配。 本文将深入讲解 Spring Clo…

HTML/CSS基础

1.html:超文本标记语言。它是一种标识性的语言,非编程语言,不能使用逻辑运算。通过标签将网络上的文本格式进行统一,使用分散网络资源链接为一个逻辑整体,属于标记语言。 超文本:就是指页面内可以包含图片&#xff0…

C# 事件驱动编程的核心:深度解析发布者_订阅者模式

适用场景:GUI交互、消息队列、微服务通信等需要解耦事件生产与消费的系统 🧩 模式核心组件解析 发布者(Publisher) 作用:定义事件并管理订阅者列表关键行为: 提供和-运算符注册/注销订阅者通过Invoke()方…

华为云Flexus+DeepSeek征文 | 从零开始搭建Dify-LLM应用开发平台:华为云全流程单机部署实战教程

华为云FlexusDeepSeek征文 | 从零开始搭建Dify-LLM应用开发平台:华为云全流程单机部署实战教程 前言一、华为云Dify-LLM平台介绍1. Dify-LLM解决方案简介2. Dify-LLM解决方案地址3. Dify-LLM单机架构介绍4. 预估成本说明 二、华为云Maas平台介绍1. 华为云ModelArts …

oracle集合三嵌套表(Nested Table)学习

嵌套表 嵌套表(Nested Table)是Oracle中的一种集合数据类型,它允许在表中存储多值属性,类似于在表中嵌套另一个表。 嵌套表具有以下特点: 是Oracle对象关系特性的一部分 可以看作是一维数组,没有最大元素数量限制 存储在单独…

Python学习之——单例模式

Python学习之——单例模式 参考1 利用__metaclass__实现单例super的用法class Singleton(type)元类 2 重载__new__方法实现单例模式3 利用装饰器实现单例考虑一个类如果继承一个单例类的问题 参考 python之metaclasssingleton(一) python之metaclasssin…

【Linux】U-boot常用命令总结

U-Boot 是嵌入式系统中常用的引导加载程序(bootloader),它提供了一套命令行接口,用于调试、加载操作系统镜像以及进行硬件测试等操作。 1、变量操作命令 这些命令用于管理 U-Boot 的环境变量。 命令功能说明setenv name value设…

【Linux】不小心又创建了一个root权限账户,怎么将它删除?!

一.前言 今天在学习linux提权的时候,把新建的一个普通账户权限提升成了root, 当我练习完提权,想要把这个账户删掉的时候。 发现… 好家伙,这个根本删不掉 随后试了各种各样的方法,都不行,后来突然想到是否…

数据结构:数组(Array)

目录 什么是数组(Array)? 🔍为什么数组的下标要从 0 开始? 一、内存地址与偏移量的关系:从 0 开始是最自然的映射 二、指针的起点就是第 0 个元素的地址 三、历史原因:BCPL → B → C → …