Gin 实现相册模块的 CRUD 接口 (下)

前篇的是 相册管理 比较简单, 几乎没有任何的技能点, 然后本篇就涉及到本地图片的上传, 解析, 校验,

再上传 阿里云OSS 等, 最后用数据库来记录本地图片和远程图片的联系, 算是比较通用和实用的功能吧.

项目目录

我是在 mac 终端下, 用命令行来模拟一下 tree 的功能, 体现目录结构.

find . -path './.git' -prune -o -print | sed -e 's;[^/]*/;|____;g;s;____|; |;g'
.
|____go.mod
|____internal
| |____middleware
| | |____auth.go
| |____db
| | |____db.go
|____go.sum
|____api
| |____routers.go
| |____handlers
| | |____role
| | | |____views.go
| | |____auth
| | | |____views.go
| | |____user
| | | |____menu.go
| | | |____views.go
| | |____image
| | | |____views.go
| | | |____image.go
| | |____notice
| | | |____views.go
| | |____rule
| | | |____views.go
|____.vscode
| |____extensions.json
|____tmp
| |____runner-build
|____main.go
|____pkg
| |____utils
| | |____response.go
| | |____tools.go
| | |____imageOss.go
| |____jwtt
| | |____jwt.go
cj@mini gin-shop-admin % 

先不干, 突出一本篇的知识点再说.

OSS 统一封装

再写接口之前, 先突出下 OSS 管理这部分, 就首先还是要自己先买一个 阿里云OSS 服务, 即对象存储, 存图片, 文件啥的都行, 也很便宜, 搞一搞. 然后就是看它提供的 SDK 各主流语言都有, 或者像我这种直接 AI 帮我搞就行, 测一下能用就ok.

放在工具函数模块了, 即 pkg/utils/ossImage.go 中了

package utils

import (
	"io"
	"path/filepath"
	"sync"

	"github.com/aliyun/aliyun-oss-go-sdk/oss"
)

// 全局变量声明
var (
	client  *oss.Client
	bucket  *oss.Bucket
	once    sync.Once
	errInit error

	endpoint        string = "https://oss-cn-shenzhen.aliyuncs.com"
	projectDir      string = "shop-admin"
	bucketName      string = "yyy" // 人工创建的
	accessKeyId     string = "申请即可"
	accessKeySecret string = "也是申请哈"
)

// 初始化 oss 客户端 和 bucket (懒加载)
func initOSS() {
	client, errInit = oss.New(endpoint, accessKeyId, accessKeySecret)
	if errInit != nil {
		return
	}
	bucket, errInit = client.Bucket(bucketName)
}

// 封装上传图片的方法

// 场景 01: 前端传的是本地文件路径, 后端来读取文件上传
func UploadImageFromFile(localFilePath, objectPath string) error {
	// 上传是时才加载一下
	once.Do(initOSS)
	if errInit != nil {
		return errInit
	}

	// 获取文件名 "/abc/d/cj.png" => cj.png
	fileName := filepath.Base(localFilePath)
	objectName := filepath.Join(objectPath, fileName)

	return bucket.PutObjectFromFile(objectName, localFilePath)

}

// 场景 02: 前端传的是一个二进制流, 表单传递等
func UploadImageFromReader(reader io.Reader, objectPath string) (string, error) {
	// 上传时才加载一下
	once.Do(initOSS)
	if errInit != nil {
		return "", errInit
	}

	// 拼接上项目路径
	objectPath = projectDir + "/" + objectPath
	// 使用 PutObject 上传
	err := bucket.PutObject(objectPath, reader)
  
	// 拼接文件完整的 oss 路径用来存储
	fileOssPath := "https://" + bucketName + "." + endpoint[8:] + "/" + objectPath
	return fileOssPath, err
}

func DeleteImageFromOss(ossFilePath string) error {
	once.Do(initOSS)
	if errInit != nil {
		return errInit
	}

	// 删除 oss 文件, 根据oss中, 从 bucket 之后的路径
	err := bucket.DeleteObject(ossFilePath)
	return err
}

我当前用的基本就是, 从本地上传图片, 即后端来处理; 或者从前端传二级制文件数据流 过来, 后端处理, 还有删除图片这几个方法就可以了. 代码就基本按 AI 写的, 没啥说的.

数据库表

本篇部分主要涉及 image

-- 图片信息
drop table if exists image;

create table image(
	id int auto_increment primary key comment '自增id'
	, url varchar(255) default null comment '图片oss链接'
	, name varchar(50) default null comment '图片名称'
	, path varchar(255) default null comment '图片oss短路径'
	, image_class_id int default null comment '相册id'
	, create_time datetime default current_timestamp
	, update_time datetime default current_timestamp on update current_timestamp
);

模块路由

api/routers.go
package api

import (
	"github.com/gin-gonic/gin"
	"youge.com/api/handlers/image"
	"youge.com/internal/middleware"
)

// 统一注册入口
func RegisterAllRouters(r *gin.Engine) {
	// 登录认证模块

	// 用户管理模块

	// 公告模块

	// 菜单权限管理模块

	// 角色管理模块

	// 图库管理模块
	imageGroup := r.Group("api/image")
	// ruleGroup.Use(middleware.JWT())
	{
		// 图库相关
		imageGroup.GET("/image_class", image.GetImageClass)
		imageGroup.POST("/image_class", image.CreateImageClass)
		imageGroup.PUT("/image_class/:id", image.UpdateImageClass)
		imageGroup.DELETE("/image_class/:id/delete", image.DeleteImageClass)

		// 图片相关
		imageGroup.POST("/image/upload", image.UploadImage)
		imageGroup.GET("/image_class/:class_id/image/:page", image.GetImagesByclass)
		imageGroup.PUT("/image/:id/update", image.UpdateImageName)
		imageGroup.DELETE("/image/:id/delete", image.DeleteImage)

}

图片的对应的接口逻辑实现都放在 /api/handlers/image/image.go 里面, 也是不做分层啦.

POST 新增图片接口

前端: http://localhost:8000/api/image/image/upload

前端 FormData, 包含 image_class_id 和一个 二进制流数据

后端: /api/handlers/image/image.go => UploadImage

package image

import (
	"path/filepath"
	"strings"

	"github.com/gin-gonic/gin"
	"youge.com/internal/db"
	"youge.com/pkg/utils"
)

// 02: 图片信息表
type Image struct {
	Id           int    `db:"id" json:"id"`
	Url          string `db:"url" json:"url"`
	Name         string `db:"name" json:"name"`
	Path         string `db:"path" json:"path"`
	ImageClassId int    `db:"image_class_id" json:"image_class_id"`
}

// 接口: 上传图片到某个相册中
func UploadImage(c *gin.Context) {
	// 前端通过 FormData 传递二进制图片数据过来
	// 获取相册 class_id
	//classId := c.PostForm("class_id")
	var req struct {
		ClassId int `form:"class_id" validate:"required,min=1"`
	}

	if err := utils.BindAndValidate(c, &req); err != nil {
		utils.BadRequest(c, "获取相册失败")
		return
	}

	// 先获取文件的一些元信息
	file, err := c.FormFile("file")
	if err != nil {
		utils.BadRequest(c, "获取文件失败")
		return
	}

	// 限制上传照片最大为 10M
	if file.Size >= (10 * 1024 * 1024) {
		utils.BadRequest(c, "图片大小不能超过10M")
		return
	}

	// 分离出文件名和后缀, cjj.png => cjj; png
	fileName := filepath.Base(file.Filename)
	index := strings.LastIndex(fileName, ".")
	if index == -1 {
		return
	}

	fileNameShort := fileName[:index]                  // cjj
	extensition := fileName[index+1:]                  // png
	ossFileName := utils.GetUUID() + "." + extensition // sdfsdfsfs.png

	// 读取文件流, 上传 oss
	ioReader, err := file.Open()
	if err != nil {
		utils.BadRequest(c, "图片数据读取错误")
		return
	}
	defer ioReader.Close()

	// 上传到 OSS
	fileOssPath, err2 := utils.UploadImageFromReader(ioReader, ossFileName)
	if err2 != nil {
		utils.BadRequest(c, "上传 oss 失败")
		return
	}

	// 硬编码 ossFileName 带上 bucketName, 因为是自己用就不搞匹配了
	ossFileName = "shop-admin/" + ossFileName

	// 最后记录信息到数据库中
	mysql := `
	insert into image(url, name, path, image_class_id)
	values (?, ?, ?, ?);
	`
	if _, err := db.Exec(mysql, fileOssPath, fileNameShort, ossFileName, req.ClassId); err != nil {
		utils.BadRequest(c, "存数据库失败")
		return
	}

	utils.Success(c, gin.H{
		"msg":      "上传成功",
		"class_id": req.ClassId,
	})
}

这个逻辑还是有点长的, 设计文件读取和处理, 还有 oss 上传, 后续可以将文件处理部分单独抽离出来, 不然这样的代码后续很难调试, 然后也不够完善, 比如文件格式验证等都没有做 (因为我前端做了, 后端就偷懒了哈哈哈)

额这里就先不搞了, 下篇优化吧.

GET 查询图片某个相册接口

前端: http://localhost:8000/api/image/image_class/1/image/1?page=1&limit=10

后端: /api/handlers/image/image.go => GetImagesByclass

// 接口: 获取某个相册下的所有图片
func GetImagesByclass(c *gin.Context) {
	// 统一验证 uri, query, form, json 等
	var req struct {
		ClassId int `uri:"class_id" validate:"required,min=1"`
		Page    int `uri:"page" validate:"required,min=1"`
		Limit   int `form:"limit" validate:"omitempty,min=1,max=1000"`
	}

	if err := utils.BindAndValidate(c, &req); err != nil {
		utils.BadRequest(c, "请求参数错误")
		return
	}

	// 设置默认值, ?page=1&limit=10
	req.Page, req.Limit = utils.SetDefaultPagination(req.Page, req.Limit)

	// 分页查询数据
	offset := (req.Page - 1) * req.Limit
	mysql := `
	select 
		id 
		, url 
		, name
		, path
		, image_class_id
	from image 
	where image_class_id = ?
	limit ?, ?;
	`
	var imageList []Image
	if err := db.Query(&imageList, mysql, req.ClassId, offset, req.Limit); err != nil {
		utils.BadRequest(c, "获取数据失败")
		return
	}

	// 查询总条数
	var total int
	// 这个sql 肯定不会错, 除非表不在, 不校验了
	sql2 := "select count(*) as total from image where image_class_id = ?"
	db.Get(&total, sql2, req.ClassId)

	// 返回结果
	utils.Success(c, gin.H{
		"list":       imageList,
		"totalCount": total,
		"limit":      req.Limit,
	})
}

修改图片名称接口

前端: http://localhost:8000/api/image/image/3/update

前端请求体 json :

{
    "name": "去自由",
}

后端: /api/handlers/image/image.go => UpdateImageName

// 接口: 修改某张图片的短名称
func UpdateImageName(c *gin.Context) {
	// 校验路径参数和请求体参数
	var req struct {
		Id   int    `uri:"id" validate:"required,min=1"`
		Name string `json:"name" validate:"required"`
	}
	if err := utils.BindAndValidate(c, &req); err != nil {
		utils.BadRequest(c, "获取图片失败")
		return
	}

	// 更新图片名称, 根据id
	sql := `update image set name = ? where id = ?;`
	if _, err := db.Exec(sql, req.Name, req.Id); err != nil {
		utils.BadRequest(c, "修改图片名称失败")
		return
	}

	utils.Success(c, gin.H{
		"msg": "修改成功",
	})
}

就改一个字段而已, 没啥

DELETE 删除图片接口

前端: http://localhost:8000/api/image/image/6/delete

后端: /api/handlers/image/image.go => DeleteImage

// 接口: 删除某张图片
func DeleteImage(c *gin.Context) {
	var req struct {
		Id int `uri:"id" validate:"required,min=1"`
	}
	if err := utils.BindAndValidate(c, &req); err != nil {
		utils.BadRequest(c, "获取图片失败")
		return
	}

	// 因为要涉及到 oss 的删除, 所有得先查询到图片的 oss 中 bucket 路径
	var imageOssPath string
	sql_01 := `select path from image where id = ?;`
	if err := db.Get(&imageOssPath, sql_01, req.Id); err != nil {
		// 没有查询到也算错哈
		utils.BadRequest(c, "获取图片失败")
		return
	}
	// 先删表关系
	sql_02 := "delete from image where id = ?"
	if _, err := db.Exec(sql_02, req.Id); err != nil {
		utils.BadRequest(c, "删除图片失败")
		return
	}

	// 再删除远程的 oss
	if err := utils.DeleteImageFromOss(imageOssPath); err != nil {
		utils.BadRequest(c, "删除图片 oss 失败")
		return
	}

	utils.Success(c, gin.H{
		"msg": "删除成功",
	})
}

这里的删除其实还是有点问题的, 就如果本地数据删了, 然后远程删除失败了, 那就没法回滚了, 应该要用事务处理的, 但这个项目毕竟是自己搞着玩就算了吧, 后面用 AI 来搞一下即可.

posted @ 2025-05-21 22:59  致于数据科学家的小陈  阅读(17)  评论(0)    收藏  举报