学习视频:【项目实战】基于 go-zero 实现网盘系统

文档:

基于 go-zero 实现网盘系统

Apifox 生成的在线接口文档,仅供参考:

https://www.apifox.cn/apidoc/shared-74bcbbbe-b20f-4ed6-afa8-02752683a8c9/api-20729960

go-zero 简单使用

Goctl 安装,参考:Goctl 安装 | go-zero

使用 go-zero 官方的工具生成代码:

# 创建 API 服务
goctl api new core

运行生成的代码:

# 启动服务
go run core.go -f etc/core-api.yaml

core/internal/logic/corelogic.go 中修改业务逻辑

访问:http://localhost:8888/from/you

go-zero 业务开发流程总结

  • core.api 中声明接口格式(包括路径、请求参数结构、响应数据结构)
  • 通过以下指令生成接口相关代码:
goctl api go -api core.api -dir . -style go_zero
  • 在生成的代码中 xxx_logic.go 中的 TODO 处补全接口逻辑
  • 通过以下指令启动服务:
go run core.go -f etc/core-api.yaml

生成的单体 api 服务目录:

|____go.mod
|____go.sum
|____greet.api // api接口与类型定义
|____etc // 网关层配置文件
| |____greet-api.yaml
|____internal
| |____config // 配置-对应etc下配置文件
| | |____config.go
| |____handler // 视图函数层, 路由与处理器
| | |____routes.go
| | |____greethandler.go
| |____logic // 逻辑处理
| | |____greetlogic.go
| |____svc // 依赖资源, 封装 rpc 对象的地方
| | |____servicecontext.go
| |____types // 中间类型
| | |____types.go
|____greet.go // main.go 入口

数据库分析

用户信息:存储用户基本信息,用于登录

CREATE TABLE `user_basic` (
	`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
	`identity` varchar(36) DEFAULT NULL,

	`name` varchar(60) DEFAULT NULL,
	`password` varchar(32) DEFAULT NULL,
	`email` varchar(100) DEFAULT NULL,

	`created_at` datetime DEFAULT NULL,
	`updated_at` datetime DEFAULT NULL,
	`deleted_at` datetime DEFAULT NULL,
	PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8;

公共文件存储池:存储文件信息

CREATE TABLE `repository_pool` (
	`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
	`identity` varchar(36) DEFAULT NULL,

	`hash` varchar(32) DEFAULT NULL COMMENT '文件的唯一标识',
	`name` varchar(255) DEFAULT NULL COMMENT '文件名称',
	`ext` varchar(30) DEFAULT NULL COMMENT '文件扩展名',
	`size` int(11) DEFAULT NULL COMMENT '文件大小',
	`path` varchar(255) DEFAULT NULL COMMENT '文件路径',

	`created_at` datetime DEFAULT NULL,
	`updated_at` datetime DEFAULT NULL,
	`deleted_at` datetime DEFAULT NULL,
	PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;

用户存储池:对公共文件存储池中文件信息的引用

CREATE TABLE `user_repository` (
	`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
	`identity` varchar(36) DEFAULT NULL,

	`parent_id` int(11) DEFAULT NULL COMMENT '父级文件层级, 0-【文件夹】',
	`user_identity` varchar(36) DEFAULT NULL COMMENT '对应用户的唯一标识',
	`repository_identity` varchar(36) DEFAULT NULL COMMENT '公共池中文件的唯一标识',
	`ext` varchar(255) DEFAULT NULL COMMENT '文件或文件夹类型',
	`name` varchar(255) DEFAULT NULL COMMENT '用户定义的文件名',

	`created_at` datetime DEFAULT NULL,
	`updated_at` datetime DEFAULT NULL,
	`deleted_at` datetime DEFAULT NULL,
	PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8;

文件分享

CREATE TABLE `share_basic` (
	`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
	`identity` varchar(36) DEFAULT NULL,

	`user_identity` varchar(36) DEFAULT NULL COMMENT '对应用户的唯一标识',
	`repository_identity` varchar(36) DEFAULT NULL COMMENT '公共池中文件的唯一标识',
	`user_repository_identity` varchar(36) DEFAULT NULL COMMENT '用户池子中的唯一标识',
	`expired_time` int(11) DEFAULT NULL COMMENT '失效时间,单位秒,【0-永不失效】',
	`click_num` int(11) DEFAULT '0' COMMENT '点击次数',

	`created_at` datetime DEFAULT NULL,
	`updated_at` datetime DEFAULT NULL,
	`deleted_at` datetime DEFAULT NULL,
	PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;

用户模块

密码登录
@handler  UserLogin
post /user/login(LoginRequest) returns(LoginReply)

type LoginRequest {
	Name     string `json:"name"`
	Password string `json:"password"`
}

type LoginReply {
	Token        string `json:"token"`
	RefreshToken string `json:"refresh_token"`
}

业务逻辑:

  • 根据用户名、密码(MD5 加密过)去数据库中查询用户
    • 查询不到,则返回错误
    • 查询到数据,则生成 token 返回
func (l *UserLoginLogic) UserLogin(req *types.LoginRequest) (resp *types.LoginReply, err error) {
	// 1. 从数据库中查询当前用户
	user := new(models.UserBasic)
	has, err := l.svcCtx.Engine.Where("name = ? AND password = ?", req.Name, helper.Md5(req.Password)).Get(user)
	if err != nil {
		return
	}
	// 用户不存在
	if !has {
		err = errors.New("用户名或密码错误")
		return
	}
	// 2. 生成 token
	token, err := helper.GenerateToken(user.Id, user.Identity, user.Name, define.TokenExpire)
	if err != nil {
		return
	}
	// 3. 生成用于刷新 token 的 token
	refreshToken, err := helper.GenerateToken(user.Id, user.Identity, user.Name, define.RefreshTokenExpire)
	if err != nil {
		return
	}
	resp = new(types.LoginReply)
	resp.Token = token
	resp.RefreshToken = refreshToken

	return
}
用户详情
@handler UserDetail
get /user/detail(UserDetailRequest) returns(UserDetailReply)


type UserDetailRequest {
	Identity string `json:"identity"`
}

type UserDetailReply {
	Name  string `json:"name"`
	Email string `json:"email"`
}

业务逻辑:

  • 根据 identity 去数据库中查询用户信息
func (l *UserDetailLogic) UserDetail(req *types.UserDetailRequest) (resp *types.UserDetailReply, err error) {
	ub := new(models.UserBasic)
	has, err := l.svcCtx.Engine.Where("identity = ?", req.Identity).Get(ub)
	if err != nil {
		return
	}
	// 用户不存在
	if !has {
		return nil, errors.New("user not found")
	}

	resp = new(types.UserDetailReply)
	resp.Name = ub.Name
	resp.Email = ub.Email

	return
}
鉴权中间件

在 service 上面添加 @server(middleware: Auth) 则可以让下面的接口都走鉴权中间件

@server(
	middleware: Auth
)
service core-api {
	...
}

goctl 会自动生成中间件的基础代码,补充其中的业务逻辑即可。

中间件鉴权逻辑

  • 获取 Header 中的 Authorization 字段
  • 为空则返回未授权
  • 不为空则进行解析,从中解析出 UserClaim 对象
    • 通过 r.Header.Set("xxx", "xxx") 设置到 r.Header
    • 后续逻辑中可以通过 r.Header.Get("") 取出其中的值
type UserClaim struct {
	Id       int
	Identity string
	Name     string
	jwt.StandardClaims
}
func (m *AuthMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		// 获取 Header 中的 Authorization
		auth := r.Header.Get("Authorization")
		// 为空则返回未授权
		if auth == "" {
			w.WriteHeader(http.StatusUnauthorized)
			w.Write([]byte("Unauthorized"))
			return
		}
		// 解析, 解析失败返回错误
		uc, err := helper.AnalyzeToken(auth)
		if err != nil {
			w.WriteHeader(http.StatusUnauthorized)
			w.Write([]byte(err.Error()))
			return
		}
	
		r.Header.Set("UserId", string(rune(uc.Id)))
		r.Header.Set("UserIdentity", uc.Identity)
		r.Header.Set("UserName", uc.Name)

		next(w, r)
	}
}
刷新 Authorization
@handler RefreshAuthorization
get /refresh/authorization(RefreshAuthorizationRequest) returns (RefreshAuthorizationReply)

type RefreshAuthorizationRequest {}

type RefreshAuthorizationReply {
	Token string `json:"token"`
	RefreshToken string `json:"refresh_token"`
}

两个 Token:

  • Token 有效期较短,用于对用户做鉴权操作
  • RefreshToken 有效期比较长,用于刷新上面那个 Token

刷新 Token 业务逻辑

  • 解析本次请求 Header 中的 Authorization,解析出 UserClaim 信息
  • 根据 UserClaim 中的信息生成新的 Token 和 RefreshToken,返回给前端
func (l *RefreshAuthorizationLogic) RefreshAuthorization(req *types.RefreshAuthorizationRequest, authorization string) (resp *types.RefreshAuthorizationReply, err error) {
	// 解析 Authorization 获取 UserClaim
	uc, err := helper.AnalyzeToken(authorization)
	if err != nil {
		return
	}
	// 根据 UserClaim 中的信息,生成新的 Token
	token, err := helper.GenerateToken(uc.Id, uc.Identity, uc.Name, define.TokenExpire)
	if err != nil {
		return
	}
	// 生成新的 Refresh Token
	refreshToken, err := helper.GenerateToken(uc.Id, uc.Identity, uc.Name, define.RefreshTokenExpire)
	if err != nil {
		return
	}

	resp = new(types.RefreshAuthorizationReply)
	resp.Token = token
	resp.RefreshToken = refreshToken
	return
}
邮箱注册
测试代码

Go 邮箱库:jordan-wright/email: Robust and flexible email library for Go)

测试代码:mail_test.go

func TestSendMial(t *testing.T) {
	e := email.NewEmail()
	e.From = "Yusael <xxxx@163.com>"
	e.To = []string{"xxxx@qq.com"}
	e.Subject = "验证码发送测试"
	e.HTML = []byte("您的验证码为:<h1>12345</h1>")
	err := e.SendWithTLS("smtp.163.com:465",
		// 注意这里的密码不是邮箱登录密码, 是开启 smtp 服务后获取的一串验证码
		smtp.PlainAuth("", "xxxx@163.com", "xxx", "smtp.163.com"),
		&tls.Config{InsecureSkipVerify: true, ServerName: "smtp.163.com"})
	if err != nil {
		t.Fatal(err)
	}
}

Go Redis 库:go-redis/redis: Type-safe Redis client for Golang

测试代码:redis_test.go

var ctx = context.Background()

var rdb = redis.NewClient(&redis.Options{
	Addr:     "localhost:6379",
	Password: "", // no password set
	DB:       0,  // use default DB
})

func TestSetValue(t *testing.T) {
	err := rdb.Set(ctx, "key", "value", time.Second*10).Err()
	if err != nil {
		t.Error(err)
	}
}

func TestGetValue(t *testing.T) {
	val, err := rdb.Get(ctx, "key").Result()
	if err != nil {
		t.Error(err)
	}
	t.Log(val)
}

Go UUID 库:satori/go.uuid: UUID package for Go

  • Version 1,基于 timestamp 和 MAC address (RFC 4122)
  • Version 2,基于 timestamp, MAC address 和 POSIX UID/GID (DCE 1.1)
  • Version 3, 基于 MD5 hashing (RFC 4122)
  • Version 4, 基于 random numbers (RFC 4122)
  • Version 5, 基于 SHA-1 hashing (RFC 4122)
import (
	"fmt"
	"testing"

	uuid "github.com/satori/go.uuid"
)

func TestGenerateUUID(t *testing.T) {
	v4 := uuid.NewV4()
	fmt.Println(v4)
}
业务逻辑

邮箱发送验证码模块:

// 验证码发送
@handler MailCodeSendRegister
post /mail/code/send/register(MailCodeSendRequest) returns(MailCodeSendReply)

type MailCodeSendRequest {
	Email string `json:"email"`
}

type MailCodeSendReply {
	Code string `json:"code"`
}

邮箱发送验证码逻辑:

  • 随机生成验证码,并存储到 Redis 中(设置一个过期时间)
  • 往 Email 发送邮件
func (l *MailCodeSendRegisterLogic) MailCodeSendRegister(req *types.MailCodeSendRequest) (resp *types.MailCodeSendReply, err error) {
	// 查询邮箱是否已经被注册
	cnt, err := l.svcCtx.Engine.Where("email = ?", req.Email).Count(new(models.UserBasic))
	if err != nil {
		return
	}
	if cnt > 0 {
		err = errors.New("该邮箱已被注册")
		return
	}

	// 生成验证码
	code := helper.RandCode()
	// 存储验证码到 Redis(设置过期时间)
	l.svcCtx.RDB.Set(l.ctx, req.Email, code, time.Second*time.Duration(define.CodeExpire))
	// 发送验证码
	err = helper.MailSendCode(req.Email, code)

	return
}

用户注册模块:

// 用户注册
@handler UserRegister
post /user/register(UserRegisterRequest) returns(UserRegisterReply)

type UserRegisterRequest {
	// 用户名
	Name string `json:"name"`
	// 密码
	Password string `json:"password"`
	// 邮箱
	Email string `json:"email"`
	// 验证码
	Code string `json:"code"`
}

type UserRegisterReply {}

用户注册逻辑:

  • 判断 code 是否和 Redis 中存的一致
  • 判断用户是否已经存在
  • 往数据库插入用户数据(生成 UUID,密码加密)
func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *types.UserRegisterReply, err error) {
	// 判断 code 是否一致
	code, err := l.svcCtx.RDB.Get(l.ctx, req.Email).Result()
	if err != nil {
		return nil, errors.New("该邮箱的验证码为空,请重新发送验证码")
	}

	if code != req.Code {
		return nil, errors.New("验证码错误")
	}
	// 判断用户是否已经存在
	cnt, err := l.svcCtx.Engine.Where("name = ?", req.Name).Count(new(models.UserBasic))
	if err != nil {
		return
	}
	if cnt > 0 {
		return nil, errors.New("用户名已存在")
	}

	// 数据入库
	user := &models.UserBasic{
		Identity: helper.UUID(),
		Name:     req.Name,
		Password: helper.Md5(req.Password),
		Email:    req.Email,
	}

	_, err = l.svcCtx.Engine.Insert(user)
	if err != nil {
		return
	}
	return
}

存储池模块

中心存储池资源管理

腾讯云 COS 后台地址:https://console.cloud.tencent.com/cos/bucket

腾讯云 COS 帮助文档:https://cloud.tencent.com/document/product/436/31215

开发好习惯:先跑通测试,然后再开始开发

用户-文件上传
@handler FileUpload
post /file/upload(FileUploadRequest) returns(FileUploadReply)

type FileUploadRequest {
	Hash string `json:"hash,optional"`
	Name string `json:"name,optional"`
	Ext  string `json:"ext,optional"`
	Size int64  `json:"size,optional"`
	Path string `json:"path,optional"`
}

type FileUploadReply {
	Identity string `json:"identity"`
	Ext      string `json:"ext"`
	Name     string `json:"name"`
}

文件上传业务逻辑:

  • 抽取出 上传文件到腾讯云 的方法放到工具类中
  • 在 handler 层做一些业务处理:在数据库中根据 hash 查询文件信息
    • 文件已经存在,则直接返回其信息
    • 文件不存在,则往 Cos 中存储文件,将 request 传递到 logic 层
  • 在 logic 层往数据库中插入文件记录,并返回文件信息

file_upload_handler 中 handler 层的代码:

func FileUploadHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req types.FileUploadRequest
		if err := httpx.Parse(r, &req); err != nil {
			httpx.Error(w, err)
			return
		}

		// 获取上传的文件(FormData)
		file, fileHeader, err := r.FormFile("file")
		if err != nil {
			return
		}

		// 判断文件在数据库中是否已经存在
		b := make([]byte, fileHeader.Size)
		_, err = file.Read(b)
		if err != nil {
			return
		}
		hash := fmt.Sprintf("%x", md5.Sum(b))
		rp := new(models.RepositoryPool)
		has, err := svcCtx.Engine.Where("hash = ?", hash).Get(rp)
		if err != nil {
			return
		}
		if has {
			// 文件已经存在,直接返回信息
			httpx.OkJson(w, &types.FileUploadReply{
				Identity: rp.Identity,
				Ext:      rp.Ext,
				Name:     rp.Name,
			})
			return
		}

		// 往 COS 中存储文件
		cosPath, err := helper.CosUpload(r)
		if err != nil {
			return
		}

		// 往 logic 传递 request
		req.Name = fileHeader.Filename
		req.Ext = path.Ext(fileHeader.Filename)
		req.Size = fileHeader.Size
		req.Hash = hash
		req.Path = cosPath

		l := logic.NewFileUploadLogic(r.Context(), svcCtx)
		resp, err := l.FileUpload(&req)
		if err != nil {
			httpx.Error(w, err)
		} else {
			httpx.OkJson(w, resp)
		}
	}
}

file_upload_logic 中 logic 层的代码:

func (l *FileUploadLogic) FileUpload(req *types.FileUploadRequest) (resp *types.FileUploadReply, err error) {
	// 数据入库
	rp := &models.RepositoryPool{
		Identity: helper.UUID(),
		Hash:     req.Hash,
		Name:     req.Name,
		Ext:      req.Ext,
		Size:     req.Size,
		Path:     req.Path,
	}
	_, err = l.svcCtx.Engine.Insert(rp)
	if err != nil {
		return
	}

	resp = new(types.FileUploadReply)
	resp.Identity = rp.Identity
	resp.Ext = rp.Ext
	resp.Name = rp.Name

	return
}
个人存储池资源管理
用户 - 文件关联存储
@handler UserRepositorySave
post /user/repository/save(UserRepositorySaveRequest) returns(UserRepositorySaveReply)

type UserRepositorySaveRequest {
	ParentId           int64  `json:"parentId"`
	RepositoryIdentity string `json:"repositoryIdentity"`
	Ext                string `json:"ext"`
	Name               string `json:"name"`
}
type UserRepositorySaveReply {}

用户文件关联存储逻辑:

  • 在 handler 层,将 Header 中的 UserIdentity 取出传到 logic 层
    r.Header.Get("UserIdentity")
  • 在 logic 层,新增用户文件关联存储数据到数据库

handler 层传递鉴权中间件中存储的数据:

resp, err := l.UserRepositorySave(&req, r.Header.Get("UserIdentity"))

logic 层执行业务:

func (l *UserRepositorySaveLogic) UserRepositorySave(req *types.UserRepositorySaveRequest, userIdentity string) (resp *types.UserRepositorySaveReply, err error) {
	// 数据入库
	ur := &models.UserRepository{
		Identity:           helper.UUID(),
		UserIdentity:       userIdentity,
		ParentId:           req.ParentId,
		RepositoryIdentity: req.RepositoryIdentity,
		Ext:                req.Ext,
		Name:               req.Name,
	}
	_, err = l.svcCtx.Engine.Insert(ur)
	return
}
用户 - 文件列表

查看文件列表的逻辑:

// 用户文件列表
@handler UserFileList
get /user/file/list(UserFileListRequest) returns (UserFileReply)

type UserFileListRequest {
	Id   int64 `json:"id,optional"`
	Page int   `json:"page,optional"`
	Size int   `json:"size,optional"`
}

type UserFileListReply {
	List  []*UserFile `json:"list"`
	Count int64       `json:"count"`
}

type UserFile {
	Id                 int64  `json:"id"`
	Identity           string `json:"identity"`
	RepositoryIdentity string `json:"repository_identity"`
	Name               string `json:"name"`
	Ext                string `json:"ext"`
	Path               string `json:"path"`
	Size               int64  `json:"size"`
}

业务逻辑:

  • handler 层中取出 UserIdentity 传递给 logic 层
  • 根据 page、size 计算 Mysql 分页参数
  • 在数据库查询用户文件列表数据,以及 总数,返回给前端
func (l *UserFileListLogic) UserFileList(req *types.UserFileListRequest, userIdentity string) (resp *types.UserFileListReply, err error) {
	uf := make([]*types.UserFile, 0)
	resp = new(types.UserFileListReply)

	// 分页参数
	size := req.Size
	if size <= 0 {
		size = define.PageSize
	}
	page := req.Page
	if page <= 0 {
		page = 1
	}
	offset := (page - 1) * size

	// 去数据库查询用户文件列表
	err = l.svcCtx.Engine.Table("user_repository").
		Where("parent_id = ? AND user_identity = ? ", req.Id, userIdentity).
		Select("user_repository.id, user_repository.identity, user_repository.repository_identity, "+
			"user_repository.ext, user_repository.name, repository_pool.path, repository_pool.size").
		Join("LEFT", "repository_pool", "user_repository.repository_identity = repository_pool.identity").
		Where("user_repository.deleted_at = ? OR user_repository.deleted_at IS NULL", time.Time{}.Format(define.Datetime)).
		Limit(size, offset).
		Find(&uf)
	if err != nil {
		return
	}

	// 查询用户文件总数
	cnt, err := l.svcCtx.Engine.
		Where("parent_id = ? AND user_identity = ? ", req.Id, userIdentity).
		Count(new(models.UserRepository))
	if err != nil {
		return
	}

	resp.List = uf
	resp.Count = cnt
	return
}
用户 - 文件名称修改
@handler UserFileNameUpdate
post /user/file/name/update(UserFileNameUpdatedRequest) returns(UserFieNameUpdateReply)

type UserFileNameUpdatedRequest {
	Identity string `json:"identity"`
	Name     string `json:"name"`
}

type UserFieNameUpdateReply {}

业务逻辑:

  • 判断当前名称在该层级下是否存在,存在则返回错误 “该名称已存在”
  • 根据 identity 去 user_repository 表修改为传过来的 name
func (l *UserFileNameUpdateLogic) UserFileNameUpdate(req *types.UserFileNameUpdatedRequest, userIdentity string) (resp *types.UserFieNameUpdateReply, err error) {
	// 判断当前名称在该层级下是否存在
	cnt, err := l.svcCtx.Engine.Where("name = ? AND parent_id = (SELECT parent_id FROM user_repository ur WHERE ur.identity = ?)",
		req.Name, req.Identity).Count(new(models.UserRepository))
	if err != nil {
		return
	}
	if cnt > 0 {
		return nil, errors.New("该名称已存在")
	}
	// 文件名称修改
	data := &models.UserRepository{Name: req.Name}
	_, err = l.svcCtx.Engine.Where("identity = ? AND user_identity = ? ", req.Identity, userIdentity).Update(data)
	return
}
用户- 文件夹创建
@handler UserFolderCreate
post /user/folder/create(UserFolderCreateRequest) returns(UserFolderCreateReply)

type UserFolderCreateRequest {
	ParentId int64  `json:"parent_id"`
	Name     string `json:"name"`
}

type UserFolderCreateReply {
	Identity string `json:"identity"`
}

业务逻辑:

  • 判断当前名称在该层级下是否存在,存在则返回错误 “该名称已存在”
  • 创建文件夹数据插入到数据库中,并返回其 identity
func (l *UserFolderCreateLogic) UserFolderCreate(req *types.UserFolderCreateRequest, userIdentity string) (resp *types.UserFolderCreateReply, err error) {
	// 判断当前名称在该层级下是否存在
	cnt, err := l.svcCtx.Engine.Where("name = ? AND parent_id = ?", req.Name, req.ParentId).Count(new(models.UserRepository))
	if err != nil {
		return nil, err
	}
	if cnt > 0 {
		return nil, errors.New("该名称已存在")
	}
	// 创建文件夹
	data := &models.UserRepository{
		Identity:     helper.UUID(),
		UserIdentity: userIdentity,
		ParentId:     req.ParentId,
		Name:         req.Name,
	}
	_, err = l.svcCtx.Engine.Insert(data)
	if err != nil {
		return
	}

	resp = new(types.UserFolderCreateReply)
	resp.Identity = data.Identity
	return
}
用户 - 文件删除
@handler UserFileDelete
delete /user/file/delete(UserFileDeleteRequest) returns(UserFileDeleteReply)

type UserFileDeleteRequest {
	Identity string `json:"identity"`
}

type UserFileDeleteReply {}

业务逻辑:

  • 根据 identity 去 user_repository 表中进行文件删除
func (l *UserFileDeleteLogic) UserFileDelete(req *types.UserFileDeleteRequest, userIdentity string) (resp *types.UserFileDeleteReply, err error) {
	_, err = l.svcCtx.Engine.
		Where("user_identity = ? AND identity = ?", userIdentity, req.Identity).
		Delete(new(models.UserRepository))
	return
}
用户 - 文件移动
@handler UserFileMove
put /user/file/move(UserFileMoveRequest) returns(UserFileMoveReply)

type UserFileMoveRequest {
	ParentIdentity string `json:"parent_identity"`
	Identity       string `json:"identity"`
}

type UserFileMoveReply {}
  • 判断父级文件夹是否存在,不存在返回错误
  • 存在则根据 identity 更新它的 parent_identity 为传来的值
func (l *UserFileMoveLogic) UserFileMove(req *types.UserFileMoveRequest, userIdentity string) (resp *types.UserFileMoveReply, err error) {
	// 判断父级文件是否存在
	parentData := new(models.UserRepository)
	has, err := l.svcCtx.Engine.Where("identity = ? AND user_identity = ?", req.ParentIdentity, userIdentity).Get(parentData)
	if err != nil {
		return nil, err
	}
	if !has {
		return nil, errors.New("文件夹不存在")
	}
	// 更新记录的 ParentID
	_, err = l.svcCtx.Engine.Where("identity = ?", req.Identity).Update(models.UserRepository{ParentId: int64(parentData.Id)})
	return
}

文件分享模块

用户 - 创建分享记录
@handler ShareBasicCreate
post /share/basic/create(ShareBasicCreateRequest) returns(ShareBasicCreateReply)

type ShareBasicCreateRequest {
	UserRepositoryIdentity string `json:"user_repository_identity"`
	ExpiredTime            int    `json:"expired_time"`
}

type ShareBasicCreateReply {
	Identity string `json:"identity"`
}

业务逻辑:

  • 判断用户存储池中是否有该文件
  • 生成分享记录插入到数据库中,并返回该数据
func (l *ShareBasicCreateLogic) ShareBasicCreate(req *types.ShareBasicCreateRequest, userIdentity string) (resp *types.ShareBasicCreateReply, err error) {
	// 判断用户存储池中文件是否存在
	ur := new(models.UserRepository)
	has, err := l.svcCtx.Engine.Where("identity = ?", req.UserRepositoryIdentity).Get(ur)
	if err != nil {
		return
	}
	if !has {
		return nil, errors.New("user repository not found")
	}

	// 生成分享记录
	uuid := helper.UUID()
	data := &models.ShareBasic{
		Identity:               uuid,
		UserIdentity:           userIdentity,
		RepositoryIdentity:     ur.RepositoryIdentity,
		UserRepositoryIdentity: req.UserRepositoryIdentity,
		ExpiredTime:            req.ExpiredTime,
	}
	_, err = l.svcCtx.Engine.Insert(data)
	if err != nil {
		return
	}
	resp = &types.ShareBasicCreateReply{Identity: uuid}
	return
}
获取资源详情

该接口无需鉴权,所有人都可以访问

@handler ShareBasicDetail
get /share/basic/detail(ShareBasicDetailRequest) returns(ShareBasicDetailReply)

type ShareBasicDetailRequest {
	Identity string `json:"identity"`
}

type ShareBasicDetailReply {
	RepositoryIdentity string `json:"repository_identity"`
	Name               string `json:"name"`
	Ext                string `json:"ext"`
	Size               int64  `json:"size"`
	Path               string `json:"path"`
}

业务逻辑:

  • 对分享记录的点击次数进行 + 1
  • 根据 identity 关联 repository_pool 和 user_repository 查询出文件详细信息
func (l *ShareBasicDetailLogic) ShareBasicDetail(req *types.ShareBasicDetailRequest) (resp *types.ShareBasicDetailReply, err error) {
	// 对分享记录的点击次数进行 + 1
	_, err = l.svcCtx.Engine.Exec("UPDATE share_basic SET click_num = click_num + 1 WHERE identity = ?", req.Identity)
	if err != nil {
		return
	}
	// 获取文件的详细信息
	resp = new(types.ShareBasicDetailReply)
	_, err = l.svcCtx.Engine.Table("share_basic").
		Select("share_basic.repository_identity, user_repository.name, repository_pool.ext, repository_pool.size, repository_pool.path").
		Join("LEFT", "repository_pool", "share_basic.repository_identity = repository_pool.identity").
		Join("LEFT", "user_repository", "user_repository.identity = share_basic.user_repository_identity").
		Where("share_basic.identity = ?", req.Identity).Get(resp)
	return
}

用户 - 资源保存
@handler ShareBasicSave
post /share/basic/save(ShareBasicSaveRequest) returns(ShareBasicSaveReply)

type ShareBasicSaveRequest {
	RepositoryIdentity string `json:"repository_identity"`
	ParentId           int64  `json:"parent_id"`
}

type ShareBasicSaveReply {
	Identity string `json:"identity"`
}

业务逻辑:

  • 根据 repository_identity 获取资源详情
  • 将上面拿到的数据保存到 user_repository 中
func (l *ShareBasicSaveLogic) ShareBasicSave(req *types.ShareBasicSaveRequest, userIdentity string) (resp *types.ShareBasicSaveReply, err error) {
	// 获取资源详情
	rp := new(models.RepositoryPool)
	has, err := l.svcCtx.Engine.Where("identity = ?", req.RepositoryIdentity).Get(rp)
	if err != nil {
		return
	}
	if !has {
		return nil, errors.New("资源不存在")
	}
	// user_repository 资源保存
	ur := &models.UserRepository{
		Identity:           helper.UUID(),
		UserIdentity:       userIdentity,
		ParentId:           req.ParentId,
		RepositoryIdentity: req.RepositoryIdentity,
		Ext:                rp.Ext,
		Name:               rp.Name,
	}
	_, err = l.svcCtx.Engine.Insert(ur)
	resp = new(types.ShareBasicSaveReply)
	resp.Identity = ur.Identity
	return
}

文件分片上传

流程测试:文件分片、合并分片、校验一致性

文件分片:

func TestGenerateChunkFile(t *testing.T) {
	// 读取文件
	fileInfo, err := os.Stat("test.mp4")
	if err != nil {
		t.Fatal(err)
	}

	// 分片个数 = 文件大小 / 分片大小
	// 390 / 100 ==> 4, 向上取整
	chunkNum := math.Ceil(float64(fileInfo.Size()) / chunkSize)
	// 只读方式打开文件
	myFile, err := os.OpenFile("test.mp4", os.O_RDONLY, 0666)
	if err != nil {
		t.Fatal(err)
	}
	// 存放每一次的分片数据
	b := make([]byte, chunkSize)
	// 遍历所有分片
	for i := 0; i < int(chunkNum); i++ {
		// 指定读取文件的起始位置
		myFile.Seek(int64(i*chunkSize), 0)
		// 最后一次的分片数据不一定是整除下来的数据
		// 例如: 文件 120M, 第一次读了 100M, 剩下只有 20M
		if chunkSize > fileInfo.Size()-int64(i*chunkSize) {
			b = make([]byte, fileInfo.Size()-int64(i*chunkSize))
		}
		myFile.Read(b)

		f, err := os.OpenFile("./"+strconv.Itoa(i)+".chunk", os.O_CREATE|os.O_WRONLY, os.ModePerm)
		if err != nil {
			t.Fatal(err)
		}
		f.Write(b)
		f.Close()
	}
	myFile.Close()
}

分片文件的合并:

func TestMergeChunkFile(t *testing.T) {
	myFile, err := os.OpenFile("test2.mp4", os.O_CREATE|os.O_WRONLY|os.O_APPEND, os.ModePerm)
	if err != nil {
		t.Fatal(err)
	}

	// 计算分片个数, 正常应该由前端传来, 这里测试时自行计算
	fileInfo, err := os.Stat("test.mp4")
	if err != nil {
		t.Fatal(err)
	}
	// 分片个数 = 文件大小 / 分片大小
	chunkNum := math.Ceil(float64(fileInfo.Size()) / chunkSize)

	// 合并分片
	for i := 0; i < int(chunkNum); i++ {
		f, err := os.OpenFile("./"+strconv.Itoa(i)+".chunk", os.O_RDONLY, os.ModePerm)
		if err != nil {
			t.Fatal(err)
		}
		b, err := ioutil.ReadAll(f)
		if err != nil {
			t.Fatal(err)
		}
		myFile.Write(b)
		f.Close()
	}
	myFile.Close()
}

文件一致性校验:

func TestCheck(t *testing.T) {
	// 获取第一个文件的信息
	f1, err := os.OpenFile("test.mp4", os.O_RDONLY, 0666)
	if err != nil {
		t.Fatal(err)
	}
	b1, err := ioutil.ReadAll(f1)
	if err != nil {
		t.Fatal(err)
	}

	// 获取第二个文件的信息
	f2, err := os.OpenFile("test2.mp4", os.O_RDONLY, 0666)
	if err != nil {
		t.Fatal(err)
	}
	b2, err := ioutil.ReadAll(f2)
	if err != nil {
		t.Fatal(err)
	}

	s1 := fmt.Sprintf("%x", md5.Sum(b1))
	s2 := fmt.Sprintf("%x", md5.Sum(b2))

	fmt.Println(s1)
	fmt.Println(s2)
	fmt.Println(s1 == s2)
}
Cos 分片上传流程(测试)
func TestInitPartUpload(t *testing.T) {
	u, _ := url.Parse("https://szluyu99-1259132563.cos.ap-nanjing.myqcloud.com")
	b := &cos.BaseURL{BucketURL: u}
	client := cos.NewClient(b, &http.Client{
		Transport: &cos.AuthorizationTransport{
			SecretID:  define.TencentSecretID,
			SecretKey: define.TencentSecretKey,
		},
	})

	key := "cloud-disk/exampleobject.jpeg"
	v, _, err := client.Object.InitiateMultipartUpload(context.Background(), key, nil)
	if err != nil {
		t.Fatal(err)
	}
	UploadID := v.UploadID // 165320975357e514f921b93de385fc75fb5c1f9702a5da1bacd086900c6c1a613805b15df1
	fmt.Println(UploadID)
}

// 分片上传
func TestPartUpload(t *testing.T) {
	u, _ := url.Parse("https://szluyu99-1259132563.cos.ap-nanjing.myqcloud.com")
	b := &cos.BaseURL{BucketURL: u}
	client := cos.NewClient(b, &http.Client{
		Transport: &cos.AuthorizationTransport{
			SecretID:  define.TencentSecretID,
			SecretKey: define.TencentSecretKey,
		},
	})

	key := "cloud-disk/exampleobject.jpeg"
	UploadID := "165320975357e514f921b93de385fc75fb5c1f9702a5da1bacd086900c6c1a613805b15df1"
	f, err := os.ReadFile("0.chunk") // md5 : 8f10a58845f83846ca5aa422edc0087d
	if err != nil {
		t.Fatal(err)
	}
	// opt可选
	resp, err := client.Object.UploadPart(
		context.Background(), key, UploadID, 1, bytes.NewReader(f), nil,
	)
	if err != nil {
		t.Fatal(err)
	}
	PartETag := resp.Header.Get("ETag")
	fmt.Println(PartETag)
}

// 分片上传完成
func TestPartUploadComplete(t *testing.T) {
	u, _ := url.Parse("https://szluyu99-1259132563.cos.ap-nanjing.myqcloud.com")
	b := &cos.BaseURL{BucketURL: u}
	client := cos.NewClient(b, &http.Client{
		Transport: &cos.AuthorizationTransport{
			SecretID:  define.TencentSecretID,
			SecretKey: define.TencentSecretKey,
		},
	})

	key := "cloud-disk/exampleobject.jpeg"
	UploadID := "165320975357e514f921b93de385fc75fb5c1f9702a5da1bacd086900c6c1a613805b15df1"

	opt := &cos.CompleteMultipartUploadOptions{}
	opt.Parts = append(opt.Parts, cos.Object{
		PartNumber: 1, ETag: "8f10a58845f83846ca5aa422edc0087d"},
	)
	_, _, err := client.Object.CompleteMultipartUpload(
		context.Background(), key, UploadID, opt,
	)
	if err != nil {
		t.Fatal(err)
	}
}
文件分片上传
封装方法

helper.go 中关于文件分片上传 Cos 的封装的函数:

// Cos 分片上传初始化
func CosInitPart(ext string) (string, string, error) {
	u, _ := url.Parse(define.CosBucket)
	b := &cos.BaseURL{BucketURL: u}
	client := cos.NewClient(b, &http.Client{
		Transport: &cos.AuthorizationTransport{
			SecretID:  define.TencentSecretID,
			SecretKey: define.TencentSecretKey,
		},
	})

	key := "cloud-disk/" + UUID() + ext
	v, _, err := client.Object.InitiateMultipartUpload(context.Background(), key, nil)
	if err != nil {
		return "", "", err
	}
	return key, v.UploadID, nil
}

// 分片上传
func CosPartUpload(r *http.Request) (string, error) {
	u, _ := url.Parse(define.CosBucket)
	b := &cos.BaseURL{BucketURL: u}
	client := cos.NewClient(b, &http.Client{
		Transport: &cos.AuthorizationTransport{
			SecretID:  define.TencentSecretID,
			SecretKey: define.TencentSecretKey,
		},
	})

	key := r.PostForm.Get("key")
	UploadID := r.PostForm.Get("upload_id")
	partNumber, err := strconv.Atoi(r.PostForm.Get("part_number"))

	if err != nil {
		return "", err
	}

	f, _, err := r.FormFile("file")
	if err != nil {
		return "", nil
	}

	buf := bytes.NewBuffer(nil)
	io.Copy(buf, f)

	// opt可选
	resp, err := client.Object.UploadPart(
		context.Background(), key, UploadID, partNumber, bytes.NewReader(buf.Bytes()), nil,
	)
	if err != nil {
		return "", nil
	}
	return strings.Trim(resp.Header.Get("ETag"), "\""), nil
}

// 分片上传完成
func CosPartUploadComplete(key, uploadId string, co []cos.Object) error {
	u, _ := url.Parse(define.CosBucket)
	b := &cos.BaseURL{BatchURL: u}
	client := cos.NewClient(b, &http.Client{
		Transport: &cos.AuthorizationTransport{
			SecretID:  define.TencentSecretID,
			SecretKey: define.TencentSecretKey,
		},
	})

	opt := &cos.CompleteMultipartUploadOptions{}
	opt.Parts = append(opt.Parts, co...)
	_, _, err := client.Object.CompleteMultipartUpload(
		context.Background(), key, uploadId, opt,
	)
	return err
}
文件上传前的准备
@handler FileUploadPrepare
post /file/upload/prepare(FileUploadPrepareRequest) returns (FileUploadPrepareReply)

type FileUploadPrepareRequest {
	Md5  string `json:"md5"`
	Name string `json:"name"`
	Ext  string `json:"ext"`
}

type FileUploadPrepareReply {
	Identity string `json:"identity"`
	UploadId string `json:"upload_id"`
	Key      string `json:"key"`
}

业务逻辑:

  • 根据 md5 值去数据库中查询,是否存在对应的文件
    • 存在则可以秒传,直接返回 Identity
    • 不存在则获取 Key,UploadId 并返回,用于进行分片上传
文件分片上传
@handler FileUploadChunk
post /file/upload/chunk(FileUploadChunkRequest) returns (FileUploadChunkReply)

type FileUploadChunkRequest { // formdata
	// key
	// upload_id
	// part_number
}

type FileUploadChunkReply {
	Etag string `json:"etag"` // MD5
}
文件分片上传完成
@handler FileUploadChunkComplete
post /file/upload/chunk/complete(FileUploadChunkCompleteRequest) returns (FileUploadChunkCompleteReply)

type FileUploadChunkCompleteRequest {
	Md5        string      `json:"md5"`
	Name       string      `json:"name"`
	Ext        string      `json:"ext"`
	Size       int64       `json:"size"`
	Key        string      `json:"key"`
	UploadId   string      `json:"upload_id"`
	CosObjects []CosObject `json:"cos_objects"`
}

type CosObject {
	PartNumber int    `json:"part_number"`
	Etag       string `json:"etag"`
}

type FileUploadChunkCompleteReply {
	Identity string `json:"identity"` // 存储池identity
}
Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐