在上一篇我们集成了MinIO文件存储功能后,本文将深入讲解如何构建软件管理后台的核心功能模块,实现软件及其资源的高效管理。
type DsSoftwareInfo struct {
Id uint `orm:"id" json:"id" description:"id"`
CategoryId uint `orm:"category_id" json:"categoryId" description:"分类id"`
SoftwareName string `orm:"software_name" json:"softwareName" description:"软件名称"`
OfficialWebsite string `orm:"official_website" json:"officialWebsite" description:"官网地址"`
Icon string `orm:"icon" json:"icon" description:"图标"`
Detail string `orm:"detail" json:"detail" description:"软件详情"`
Remark string `orm:"remark" json:"remark" description:"备注"`
CreatedBy uint `orm:"created_by" json:"createdBy" description:"创建人"`
UpdatedBy uint `orm:"updated_by" json:"updatedBy" description:"更新人"`
CreatedAt *gtime.Time `orm:"created_at" json:"createdAt" description:"创建时间"`
UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt" description:"更新时间"`
PlatformIds []uint `json:"platformIds" description:"平台ID列表"`
}
func (s sDsSoftware) Add(ctx context.Context, req *api.DsSoftwareAddReq) (err error) {
err = g.Try(ctx, func(ctx context.Context) {
var software *model.DsSoftwareInfo
dao.DsSoftware.Ctx(ctx).Where(fmt.Sprintf("%s=?", dao.DsSoftware.Columns().SoftwareName), req.SoftwareName).Scan(&software)
if software != nil {
liberr.ErrIsNil(ctx, fmt.Errorf("软件名称%s已经存在", req.SoftwareName))
}
// 查询分类是否存在
var categoryInfo *model.DsCategoryInfo
dao.DsCategory.Ctx(ctx).Where(fmt.Sprintf("%s=?", dao.DsCategory.Columns().Id), req.CategoryId).Scan(&categoryInfo)
if categoryInfo == nil {
liberr.ErrIsNil(ctx, fmt.Errorf("分类id%d不存在", req.CategoryId))
}
// 开启事务
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// 添加软件
result, err := dao.DsSoftware.Ctx(ctx).Insert(do.DsSoftware{
CategoryId: req.CategoryId, // 分类id
SoftwareName: req.SoftwareName, // 软件名称
OfficialWebsite: req.OfficialWebsite, // 官网地址
Icon: req.Icon, // 图标
Detail: req.Detail, // 软件详情
Remark: req.Remark, // 备注
CreatedBy: SystemS.Context().GetUserId(ctx),
UpdatedBy: SystemS.Context().GetUserId(ctx),
})
liberr.ErrIsNil(ctx, err, "新增软件表失败")
// 获取新插入的软件ID
softwareId, err := result.LastInsertId()
liberr.ErrIsNil(ctx, err, "获取软件ID失败")
// 添加软件平台关系
for _, platformId := range req.PlatformIds {
// 查询平台是否存在
var platformInfo *model.DsPlatformInfo
dao.DsPlatform.Ctx(ctx).Where(fmt.Sprintf("%s=?", dao.DsPlatform.Columns().Id), platformId).Scan(&platformInfo)
if platformInfo == nil {
liberr.ErrIsNil(ctx, fmt.Errorf("平台id%d不存在", platformId))
}
// 添加平台关系
_, err = dao.DsSoftwarePlatform.Ctx(ctx).Insert(do.DsSoftwarePlatform{
SoftwareId: uint(softwareId), // 软件id
PlatformId: int(platformId), // 平台id
})
liberr.ErrIsNil(ctx, err, "新增软件平台关系失败")
}
return nil
})
liberr.ErrIsNil(ctx, err, "新增软件失败")
})
return
}
由于是面向个人,循环调用还能接受。实际业务中应该放到redis中查询,等功能全部完全这些细节都应该需要优化。
func (s sDsSoftware) List(ctx context.Context, req *api.DsSoftwareListReq) (total interface{}, dsSoftwareList []*model.DsSoftwareInfo, err error) {
err = g.Try(ctx, func(ctx context.Context) {
m := dao.DsSoftware.Ctx(ctx)
if req.CategoryId != 0 {
m = m.Where(dao.DsSoftware.Columns().CategoryId+" = ", req.CategoryId)
}
if req.SoftwareName != "" {
// like
m = m.Where(dao.DsSoftware.Columns().SoftwareName+" like ?", "%"+req.SoftwareName+"%")
}
total, err = m.Count()
liberr.ErrIsNil(ctx, err, "获取软件列表失败")
orderBy := req.OrderBy
if orderBy == "" {
orderBy = "created_at desc"
}
err = m.Page(req.PageNum, req.PageSize).Order(orderBy).Scan(&dsSoftwareList)
liberr.ErrIsNil(ctx, err, "获取软件表列表失败")
// 获取每个软件的平台ID列表
for _, software := range dsSoftwareList {
g.Log().Debug(ctx, "开始获取软件平台关系,软件ID:", software.Id)
// 构建查询
query := dao.DsSoftwarePlatform.Ctx(ctx).
Where(dao.DsSoftwarePlatform.Columns().SoftwareId, software.Id)
var platformRelations []*model.DsSoftwarePlatformInfo
err = query.Scan(&platformRelations)
if err != nil {
g.Log().Error(ctx, "获取软件平台关系失败:", err)
continue
}
g.Log().Debug(ctx, "获取到的平台关系:", gjson.MustEncodeString(platformRelations))
// 从关系中提取平台ID
platformIds := make([]uint, 0, len(platformRelations))
for _, relation := range platformRelations {
g.Log().Debug(ctx, "处理平台关系:", gjson.MustEncodeString(relation))
platformIds = append(platformIds, uint(relation.PlatformId))
}
g.Log().Debug(ctx, "最终的平台ID列表:", platformIds)
software.PlatformIds = platformIds
}
})
return
}
type DsSoftwareResourceInfo struct {
Id uint `orm:"id" json:"id"`
SoftwareId uint `orm:"software_id" json:"softwareId"`
ResourceName string `orm:"resource_name" json:"resourceName"`
OriginName string `orm:"origin_name" json:"originName"`
ResourceUrl string `orm:"resource_url" json:"resourceUrl"`
Size string `orm:"size" json:"size"`
Md5 string `orm:"md5" json:"md5"`
Version string `orm:"version" json:"version"`
DownloadCount uint `orm:"download_count" json:"downloadCount"`
Default uint `orm:"default" json:"default"`
Remark string `orm:"remark" json:"remark"`
}
1.初始化分片上传
func (s *sDsSoftwareResource) InitChunkUpload(ctx context.Context, req *api.ChunkInitReq) (res *api.ChunkInitRes, err error) {
err = g.Try(ctx, func(ctx context.Context) {
// 检查软件是否存在
software, err := dao.DsSoftware.Ctx(ctx).Where(dao.DsSoftware.Columns().Id, req.SoftwareId).One()
liberr.ErrIsNil(ctx, err, "获取软件信息失败")
if software == nil {
liberr.ErrIsNil(ctx, fmt.Errorf("软件不存在"))
}
// 生成上传ID
uploadId := gconv.String(gtime.TimestampNano())
// 创建临时目录
tempDir := gfile.Join(gfile.Temp(), "upload", fmt.Sprintf("%d_%s", req.SoftwareId, uploadId))
if err := gfile.Mkdir(tempDir); err != nil {
liberr.ErrIsNil(ctx, err, "创建临时目录失败")
}
res = &api.ChunkInitRes{
UploadId: uploadId,
}
})
return
}
2.上传分片
func (s *sDsSoftwareResource) UploadChunk(ctx context.Context, req *api.ChunkUploadReq) (res *api.ChunkUploadRes, err error) {
err = g.Try(ctx, func(ctx context.Context) {
// 验证上传会话
tempDir := gfile.Join(gfile.Temp(), "upload", fmt.Sprintf("%d_%s", req.SoftwareId, req.UploadId))
if !gfile.Exists(tempDir) {
liberr.ErrIsNil(ctx, fmt.Errorf("无效的上传会话"))
}
// 保存分片
chunkFile := gfile.Join(tempDir, fmt.Sprintf("chunk_%d", req.ChunkIndex))
file, err := req.File.Open()
if err != nil {
liberr.ErrIsNil(ctx, err, "打开分片文件失败")
}
defer file.Close()
// 读取文件内容
fileBytes, err := io.ReadAll(file)
if err != nil {
liberr.ErrIsNil(ctx, err, "读取分片文件失败")
}
// 保存分片
if err := gfile.PutBytes(chunkFile, fileBytes); err != nil {
liberr.ErrIsNil(ctx, err, "保存分片失败")
}
res = &api.ChunkUploadRes{
Success: true,
}
})
return
}
3.合并分片
// 合并分片
func (s *sDsSoftwareResource) MergeChunks(ctx context.Context, req *api.ChunkMergeReq) (res *api.ChunkMergeRes, err error) {
err = g.Try(ctx, func(ctx context.Context) {
// 验证上传会话
tempDir := gfile.Join(gfile.Temp(), "upload", fmt.Sprintf("%d_%s", req.SoftwareId, req.UploadId))
g.Log().Debug(ctx, "临时目录路径:", tempDir)
if !gfile.Exists(tempDir) {
liberr.ErrIsNil(ctx, fmt.Errorf("无效的上传会话"))
}
// 获取分片文件列表
chunkFiles, err := gfile.ScanDir(tempDir, "chunk_*")
liberr.ErrIsNil(ctx, err, "获取分片文件列表失败")
g.Log().Debug(ctx, "分片文件列表:", chunkFiles)
// 合并分片
mergedFile := gfile.Join(tempDir, "merged")
g.Log().Debug(ctx, "合并文件路径:", mergedFile)
outFile, err := gfile.OpenFile(mergedFile, os.O_CREATE|os.O_WRONLY, 0644)
liberr.ErrIsNil(ctx, err, "创建合并文件失败")
for _, chunkFile := range chunkFiles {
chunkData := gfile.GetBytes(chunkFile)
if _, err := outFile.Write(chunkData); err != nil {
liberr.ErrIsNil(ctx, err, "写入合并文件失败")
}
}
// 关闭写入文件句柄
outFile.Close()
// 计算MD5
md5, err := gmd5.EncryptFile(mergedFile)
liberr.ErrIsNil(ctx, err, "计算MD5失败")
// 上传到MinIO
drive := storage.MinioDrive{}
// 构建存储路径:software/year/month/day/md5.softhub
now := gtime.Now()
objectName := fmt.Sprintf("software/%d/%02d/%02d/%s.softhub",
now.Year(),
now.Month(),
now.Day(),
md5)
g.Log().Debug(ctx, "MinIO对象名称:", objectName)
// 获取文件信息
fileInfo, err := os.Stat(mergedFile)
liberr.ErrIsNil(ctx, err, "获取文件信息失败")
g.Log().Debug(ctx, "文件信息:", map[string]interface{}{
"name": fileInfo.Name(),
"size": fileInfo.Size(),
"mode": fileInfo.Mode(),
})
// 打开文件用于上传
file, err := os.Open(mergedFile)
liberr.ErrIsNil(ctx, err, "打开文件失败")
// 直接使用MinIO客户端上传
client, err := drive.GetClient()
liberr.ErrIsNil(ctx, err, "获取MinIO客户端失败")
opts := minio.PutObjectOptions{
ContentType: "application/octet-stream",
}
_, err = client.PutObject(ctx, config.MINIO_BUCKET, objectName, file, fileInfo.Size(), opts)
liberr.ErrIsNil(ctx, err, "上传到MinIO失败")
// 保存资源信息
_, err = dao.DsSoftwareResource.Ctx(ctx).Insert(do.DsSoftwareResource{
SoftwareId: req.SoftwareId, // 软件id
ResourceName: req.ResourceName, // 软件名称
OriginName: req.ResourceName, // 原始名称
Version: req.Version, // 版本
Md5: md5, // md5
DownloadCount: 0, // 下载次数
Size: gconv.String(fileInfo.Size()), // 大小
ResourceUrl: objectName, // 资源路径
Default: false, // 是否默认
Remark: req.Remark, // 备注
CreatedBy: SystemS.Context().GetUserId(ctx),
UpdatedBy: SystemS.Context().GetUserId(ctx),
})
liberr.ErrIsNil(ctx, err, "保存资源信息失败")
// 获取新创建的资源ID
var resource *model.DsSoftwareResourceInfo
err = dao.DsSoftwareResource.Ctx(ctx).Where(dao.DsSoftwareResource.Columns().ResourceName, req.ResourceName).Scan(&resource)
liberr.ErrIsNil(ctx, err, "获取资源信息失败")
res = &api.ChunkMergeRes{
ResourceId: resource.Id,
FileUrl: fmt.Sprintf("/minio/software/%s", objectName),
}
outFile.Close()
file.Close()
if err := os.RemoveAll(tempDir); err != nil {
g.Log().Error(ctx, "强制删除临时目录失败:", tempDir, err)
}
})
return
}
func (c *dsSoftwareResourceController) Download(ctx context.Context, req *api.DsSoftwareResourceDownloadReq) (res *api.DsSoftwareResourceDownloadRes, err error) {
res = new(api.DsSoftwareResourceDownloadRes)
info, err := service.DsSoftwareResource().GetById(ctx, req.Id)
if err != nil {
return
}
resourceUrl := info.ResourceUrl
fileName := info.ResourceName
drive := storage.MinioDrive{}
client, _ := drive.GetClient()
// 获取对象
obj, err := client.GetObject(ctx, config.MINIO_BUCKET, resourceUrl, minio.GetObjectOptions{})
if err != nil {
liberr.ErrIsNil(ctx, err, err.Error())
}
defer obj.Close()
// count+1
_, err = service.DsSoftwareResource().AddCount(ctx, req.Id)
writer := g.RequestFromCtx(ctx).Response.ResponseWriter
writer.Header().Set("access-control-expose-headers", "Content-Disposition")
writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename="+fileName))
// 流式传输对象到响应
_, err = io.Copy(writer, obj)
return
}
通过本篇文章,我们实现了:
✅ 完整的软件生命周期管理
✅ 多平台关联支持
✅ 大文件分片上传技术
✅ 资源下载功能
softhub系列往期文章