8.达人探店、好友关注、附近商铺搜索、用户签到、UV统计

目录

一、达人探店

1.发布探店笔记

2.查看博客

3.点赞笔记

关于点赞的分析

实现步骤

4.点赞排行榜

分析使用Redis的哪种数据类型

使用sortedSet

修改之前的代码

二、好友关注

1.关注和取关

2.查看用户主页

3.博客分页查询优化:游标分页

传统分页(OFFSET+LIMIT)的局限

游标分页方案(基于指针)

4.共同关注

5.关注推送

推送到粉丝收件箱

实现分页查询收邮箱

三、附近商铺搜索

1.GEO数据结构

 2.数据预热,将店铺数据按照 typeId 批量存入Redis。

3.查找附近商户

四、用户签到

1.BitMap

2.签到功能 

3.签到统计

五、UV统计

1.两个概念

2.Hyperloglog


项目地址https://github.com/liwook/PublicReview

一、达人探店

1.发布探店笔记

先上传图片,之后发布。

新建 handler/blog/blog.go文件。添加两个函数。

  1. 处理图片上传的,成功会返回图片的存储位置 (图片的url)。
  2. 处理发布的,添加数据到数据库中,成功会返回blogId。

图片文件上传,使用gin中的文件处理。

const (
	savePath    = "/images"
	maxFileSize = 5 << 20 // 5MB
)

const (
	formFileKey  = "file"
)

// 上传博客图片
// post /api/v1/blog/images
func UploadImages(c *gin.Context) {
	file, err := c.FormFile(formFileKey)
	if err != nil {
		slog.Error("上传图片失败", "err", err)
		response.Error(c, response.ErrDecodingFailed)
		return
	}

	// 验证文件大小 (例如限制5MB)
	if file.Size > maxFileSize {
		slog.Error("文件大小超出限制", "size", file.Size)
		response.Error(c, response.ErrValidation, "文件大小不能超过5MB")
		return
	}

	// 验证文件类型
	if !util.IsValidImageType(file.Filename) {
		slog.Error("不支持的文件类型", "filename", file.Filename)
		response.Error(c, response.ErrValidation, "这个是不支持的文件类型")
		return
	}

	//按照 日期 来保存图片
	// images/202401/01/xxxx.jpg	就是按照 年月/日/xxx.jpg 来保存图片
	//找到当前年月
	currentTime := time.Now()
	yearMonth := currentTime.Format("2006-01")
	day := currentTime.Format("02")
	name := util.CreateNewFileName(file.Filename)
	dst := filepath.Join(savePath, yearMonth, day, name)
	// 检查,要是没有该目录,就创建目标目录
	dstDir := filepath.Dir(dst)
	if _, err := os.Stat(dstDir); os.IsNotExist(err) {
		if err := os.MkdirAll(dstDir, 0755); err != nil {
			slog.Error("创建目录失败", "path", dstDir, "err", err)
			response.Error(c, response.ErrCreateFailed)
			return
		}
	}

	//保存文件
	if err := c.SaveUploadedFile(file, dst); err != nil {
		slog.Error("保存文件失败", "path", dst, "err", err)
		response.Error(c, response.ErrSaveFailed)
		return
	}

	// 返回图片url
	publicURL := filepath.Join("/images", yearMonth, day, name)
	publicURL = filepath.ToSlash(publicURL) //将路径中的分隔符统一转换为正斜杠(/)
	response.Success(c, gin.H{"url": publicURL, "filename": name})
}

一般来讲企业会将图片等文件上传到一个专门的文件服务器上。这里为了简便,就只上传到本地。

图片保存后,还需要把笔记的信息存储到数据库中,方便后续通过用户id或者商户id查找博客。

// 发布博客内容,保存到数据库
// post /api/v1/blogs
func SaveBlog(c *gin.Context) {
	var blog model.TbBlog
	if err := c.ShouldBindJSON(&blog); err != nil {
		slog.Error("绑定JSON失败", "err", err)
		response.Error(c, response.ErrBind)
		return
	}

	// 将博客模型保存到数据库中
	if err := query.TbBlog.Create(&blog); err != nil {
		slog.Error("保存博客失败", "err", err)
		response.Error(c, response.ErrDatabase)
		return
	}

	// 返回保存成功的博客ID
	response.Success(c, gin.H{"blogId": blog.ID})
}

2.查看博客

通过blogId来查看。服务端返回该博客的用户名图标,方便查看者可以顺手关注博主。

添加返回给客户端的数据结构

//handler/blog/type.go
// API响应专用结构体
type blogDTO struct {
	ID           uint64    `json:"id"`
	Title        string    `json:"title"`
	Content      string    `json:"content"`
	Images       []string  `json:"images"`     // 将逗号分隔字符串转换为数组
	LikeCount    uint32    `json:"like_count"` // 重命名字段,更符合API规范
	CommentCount uint32    `json:"comment_count"`
	CreateTime   time.Time `json:"create_time"`
	UpdateTime   time.Time `json:"update_time"`
	AuthorID     uint64    `json:"author_id"` // 新增字段,博客作者的主键ID
	// 注意:不包含 ShopID 等内部字段
}

// SingleBlogResponse 定义获取单个博客信息接口的响应结构体
type singleBlogResponse struct {
	Blog     BlogDTO `json:"blog"`
	Username string  `json:"username"`
	Icon     string  `json:"icon"`
	IsLiked  bool    `json:"is_liked,omitempty"`
}

func convertOneTbBlogPtrToResponse(blog *model.TbBlog) BlogDTO {
	var images []string
	if blog.Images != "" {
		images = strings.Split(blog.Images, ",")
	}

	return BlogDTO{
		ID:           blog.ID,
		Title:        blog.Title,
		Content:      blog.Content,
		Images:       images,
		LikeCount:    blog.Liked,
		CommentCount: blog.Comments,
		CreateTime:   blog.CreateTime,
		UpdateTime:   blog.UpdateTime,
		AuthorID:     blog.UserID, // 赋值作者ID
	}
}

 添加查询博客的路由函数:

// get /api/v1/blogs/:blogId
func GetBlogById(c *gin.Context) {
	// 获取url中的id参数
	id := c.Param(consts.BlogIdKey)
	// 将id转换为int类型
	idInt, err := strconv.Atoi(id)
	if err != nil || idInt <= 0 {
		response.Error(c, response.ErrValidation, "id必须是正整数")
		return
	}

	//通过blog找到该blog的用户id
	queryBlog := query.TbBlog
	// 查询该id的blog
	blog, err := queryBlog.Where(queryBlog.ID.Eq(uint64(idInt))).First()
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			response.Error(c, response.ErrNotFound, "博客不存在")
			return
		}
		slog.Error("查询博客失败", "err", err)
		response.Error(c, response.ErrDatabase)
		return
	}

	// 查询该blog的用户
	user, err := query.TbUser.Where(query.TbUser.ID.Eq(blog.UserID)).First()
	// 如果查询不到该用户,则返回错误
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			response.Error(c, response.ErrNotFound)
			return
		}
		// 记录错误日志
		slog.Error("查询用户失败", "err", err)
		response.Error(c, response.ErrDatabase)
		return
	}

	// 返回查询结果
	resp := singleBlogResponse{
		Blog:     convertOneTbBlogPtrToResponse(blog),
		Username: user.NickName,
		Icon:     user.Icon, // 头像
	}
	response.Success(c, resp)
}

3.点赞笔记

关于点赞的分析

保存点赞数,方便查找高赞的博客。像朋友圈一样,一人只能点赞一次,再次点击则取消点赞。

对于点赞这种高频变化的数据,如果我们使用MySql是性价比不高的。可以使用Redis。

我们又该选择哪种数据结构才更合适呢?

其中一个重要的点是 不能对同一篇文章重复点赞。不能重复,那会想到使用Set

Set类型的特点:

  1. 不重复,符合业务的特点,一个用户只能点赞一次
  2. 高性能,Set集合内部实现了高效的数据结构(Hash表)
  3. 灵活性,Set集合可以实现一对多,一个用户可以点赞多个博客,符合实际的业务逻辑

当然也可以选择使用Hash(Hash占用空间比Set更小),如果想要点赞排序也可以选用Sorted Set 。

还有如果当前用户已点赞,则点赞按钮高亮显示(前端实现,通过后端返回的Blog类的isLike字段判断)。

实现步骤

1. 查看博客的时候,是会显示自己是否有点赞。修改之前的查看博客的函数,返回当前登录用户是否已点赞

func GetBlogById(c *gin.Context) {
    ......................
	// 返回查询结果
	resp := singleBlogResponse{
		Blog:     convertOneTbBlogPtrToResponse(blog),
		Username: user.NickName,
		Icon:     user.Icon, // 头像
	}

	// 判断是否登录
	IsLogin := c.GetBool(middleware.CtxKeyIsAuthenticated)
	if IsLogin {
		resp.IsLiked = isBlogLiked(idInt, c.GetInt64(middleware.CtxKeyUserId))
	}
	response.Success(c, resp)
}

2. 进行点赞,要是已点赞,就会取消点赞;反之,就需要再数据库和Redis中添加点赞。

  • 判断是否点赞。
// blog_helper.go
const blogLikedKey = "blog:liked:"

// 判断是否有点赞过,通过redis的set
func isBlogLiked(blogId int, userId int64) bool {
	key := blogLikedKey + strconv.Itoa(blogId)
	success, err := db.RedisDb.SIsMember(context.Background(), key, strconv.FormatInt(userId, 10)).Result()
	if err != nil {
		slog.Error("检查点赞状态失败", "blogId", blogId, "userId", userId, "err", err)
		return false
	}
	return success
}
  • 进行点赞。 
// POST /api/v1/blogs/:blogId/like
func LikeBlog(c *gin.Context) {
	//获取blogId,被点赞的博客id
	id := c.Param(consts.BlogIdKey)
	idInt, err := strconv.Atoi(id)
	if err != nil || idInt <= 0 {
		response.Error(c, response.ErrValidation, "id必须是正整数")
		return
	}

	//获取当前登录的用户id, 从Header中获取
    userId := c.GetInt64(middleware.CtxKeyUserId)
	isLiked := isBlogLiked(idInt, userId)
	redisKey := BlogLikedKey + id
	blogQuery := query.TbBlog.Where(query.TbBlog.ID.Eq(uint64(idInt)))
	var info gen.ResultInfo

	// tbBlog := query.TbBlog
	if isLiked { //已经点赞过了,则取消点赞
		info, err = blogQuery.UpdateSimple(query.TbBlog.Liked.Sub(1))
	} else {
		info, err = blogQuery.UpdateSimple(query.TbBlog.Liked.Add(1))
	}

	if err != nil {
		action := map[bool]string{true: "取消点赞", false: "点赞"}[isLiked]
		slog.Error(action+"失败", "err", err)
		response.Error(c, response.ErrDatabase, action+"失败")
		return
	}
	if info.RowsAffected == 0 { //没有更新到数据,则说明没有点赞过
		response.Error(c, response.ErrNotFound, "博客不存在")
		return
	}

	if isLiked {
		db.RedisDb.SRem(context.Background(), redisKey, userId)
	} else {
		if err := db.RedisDb.SAdd(context.Background(), redisKey, userId).Err(); err != nil {
			slog.Error("Redis操作失败", "err", err)
		}
	}
	msg := map[bool]string{true: "取消点赞成功", false: "点赞成功"}[isLiked]
	response.Success(c, msg)
}

4.点赞排行榜

微信、QQ的点赞,会把点赞的人显示出来,其都是默认按照时间顺序对点赞的用户进行排序,后点赞的用户会排在最前面。 那我们也按照时间顺序来排序,比如显示最早点赞的TOP5,形成点赞排行榜

分析使用Redis的哪种数据类型

那当前使用set就不可行了,因为set是不排序的。

现在我们的需求是,一个key存储多个元素,即是集合,元素唯一,可以快速找到元素,可以按照一定规则排序。

所以使用SortedSet,它完美的满足了我们所有的需求:唯一有序查找效率高

使用sortedSet

其添加命令:

zadd key [NX|XX] [GT|LT] [CH] [INCR] score member [score member ...]

那么我们把点赞时间作为score用户id作为member。 

对比之前使用的Set:

  • Set是使用isMember判断用户是否存在,而SortedSet,使用ZSCORE方法进行判断。
  • Set没有提供范围查询,无法获排行榜前几名的数据;SortedSet可以使用ZRANGE进行范围查询
修改之前的代码

1)修改函数isBlogLiked。通过用户id作为member去查找是否有该score,若是没有,则表明没有点赞。

// 判断是否有点赞过
func isBlogLiked(blogId int, userId int64) bool {
	// key := BlogLikedKey + strconv.Itoa(blogId)
	// success, err := db.RedisDb.SIsMember(context.Background(), key, strconv.FormatInt(userId, 10)).Result()
	// if err != nil {
	// 	slog.Error("检查点赞状态失败", "blogId", blogId, "userId", userId, "err", err)
	// 	return false
	// }
	// return success

	// 通过redis的sortedSet
	//当用户没有点赞时,ZScore 会返回错误(通常是 redis.Nil),函数返回 true
	key := blogLikedKey + strconv.Itoa(blogId)
	_, err := db.RedisDb.ZScore(context.Background(), key, strconv.FormatInt(userId, 10)).Result()
	if errors.Is(err, redis.Nil) { // 用户没有点赞过
		return false
	} else if err != nil {
		slog.Error("检查点赞状态失败", "blogId", blogId, "userId", userId, "err", err)
		return false // 发生错误时默认认为没有点赞
	}
	return true
}

2)修改在redis中点赞的操作LikeBlog。

// 点赞博客
// POST /api/v1/blogs/:id/like
func LikeBlog(c *gin.Context) {
    .........
	if isLiked {
		// db.RedisDb.SRem(context.Background(), redisKey, userId)
		err := db.RedisDb.ZRem(context.Background(), redisKey, userId).Err()
		if err != nil {
			slog.Error("Redis操作失败", "err", err)
		}
	} else {

		// if err := db.RedisDb.SAdd(context.Background(), redisKey, userId).Err(); err != nil {
		// 	slog.Error("Redis操作失败", "err", err)
		// }
		err := db.RedisDb.ZAdd(context.Background(), redisKey, redis.Z{Score: float64(time.Now().Unix()), Member: userId}).Err()
		if err != nil {
			slog.Error("Redis操作失败", "err", err)
		}
	}


	msg := map[bool]string{true: "取消点赞成功", false: "点赞成功"}[isLiked]
	response.Success(c, msg)
}

3)添加查询所有点赞博客的用户,返回TOP5。

我们的需求是,先点赞的排在前面,后点赞的排在后面。

在MySQL中如果我们使用in进行条件查询,我们的查询默认是数据库顺序查询,数据库中的记录默认都是按照ID自增的,所以查出来的结果默认是按照ID自增排序的。这就涉及到MySQL的一些知识,需要使用order by field(id ,.....)

// handler/blog/type.go
// likedUser 定义点赞用户信息的结构体
type likedUser struct {
	UserID   uint64 `json:"user_id"`   // 点赞用户的 ID
	NickName string `json:"nick_name"` // 点赞用户的昵称
	Icon     string `json:"icon"`      // 点赞用户的头像
}

// BlogLikedUsersResponse 定义获取博客点赞用户列表接口的响应结构体
type blogLikedUsersResponse struct {
	LikedUsers []likedUser `json:"liked_users"` // 点赞用户列表
}
// GET /api/v1/blogs/:id/likes
func GetBlogLikes(c *gin.Context) {
	//获取blogId,验证ID
	id := c.Param(paramBlogId)
	idInt, err := strconv.Atoi(id)
	if err != nil || idInt <= 0 {
		response.Error(c, response.ErrValidation, "id必须是正整数")
		return
	}

	//获取点赞的用户id列表
	userIds, err := db.RedisDb.ZRevRangeWithScores(context.Background(), BlogLikedKey+id, 0, 4).Result()
	if err != nil {
		if errors.Is(err, redis.Nil) {
			// 没有点赞记录,返回空列表
			response.Success(c, blogLikedUsersResponse{
				LikedUsers: convertTbUsersToLikedUsers([]model.TbUser{}),
			})
			return
		}
		slog.Error("获取点赞用户列表失败", "err", err)
		response.Error(c, response.ErrDatabase)
		return
	}
	if len(userIds) == 0 {
		response.Success(c, blogLikedUsersResponse{
			LikedUsers: convertTbUsersToLikedUsers([]model.TbUser{}),
		})
		return
	}

	Ids := make([]string, 0, len(userIds))
	placeholders := make([]string, 0, len(userIds))
	for _, userId := range userIds {
		if id, ok := userId.Member.(string); ok {
			Ids = append(Ids, id)
			placeholders = append(placeholders, "?")
		}
	}

	// 构建安全的SQL查询,防止sql注入
	inClause := strings.Join(placeholders, ",")

	// 合并参数:IN子句的参数 + FIELD函数的参数
	allArgs := make([]any, len(Ids)*2)
	for i, id := range Ids {
		allArgs[i] = id          // 用于IN子句
		allArgs[len(Ids)+i] = id // 用于FIELD函数
	}

	// 在数据库中查找到该些用户的信息和图标
	var dbUsers []model.TbUser
	sql := fmt.Sprintf("SELECT id, nick_name, icon FROM tb_user WHERE id IN (%s) ORDER BY FIELD(id,%s)",
		inClause, inClause)
	err = db.DBEngine.Raw(sql, allArgs...).Scan(&dbUsers).Error
	if err != nil {
		slog.Error("获取点赞用户信息失败", "err", err)
		response.Error(c, response.ErrDatabase)
		return
	}

	response.Success(c, blogLikedUsersResponse{
		LikedUsers: convertTbUsersToLikedUsers(dbUsers),
	})
}

二、好友关注

1.关注和取关

在探店图文的详情页面上,可以关注发布笔记的博主。

关注是用户之间的关系,是博主与粉丝的关系,数据库中有一张表tb_follow来标示。

 CREATE TABLE `tb_follow` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_id` bigint unsigned NOT NULL COMMENT '用户id',
  `follow_user_id` bigint unsigned NOT NULL COMMENT '关联的用户id',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`) USING BTREE
);

代码实现

关注用户功能的实现,根据前端传递的isFollow的值判断,用户是否已关注该博主,若未关注,传过来的是true,则有关注资格,之后保存到数据库表中。

添加handler/user/follow.go文件。

// 判断是否关注
// GET /api/v1/users/:userId/follow/:followId
func IsFollow(c *gin.Context) {
	userId, followId, err := validateFollowParams(c.Param(consts.UserIdKey), c.Param(consts.FollowIdKey))
	if err != nil {
		response.HandleBusinessError(c, err)
		return
	}

	f := query.TbFollow
	count, err := f.Where(f.UserID.Eq(uint64(userId)), f.FollowUserID.Eq(uint64(followId))).Count()
	if err != nil {
		slog.Error("查询关注关系失败", "userId", userId, "followId", followId, "err", err)
		response.Error(c, response.ErrDatabase)
		return
	}
	response.Success(c, gin.H{"isAlreadyFollow": count > 0})
}

// 关注
// POST /api/v1/users/:userId/follow/:followId
func FollowUser(c *gin.Context) {
	userId, followId, err := validateFollowParams(c.Param(consts.UserIdKey), c.Param(consts.FollowIdKey))
	if err != nil {
		response.HandleBusinessError(c, err)
		return
	}

	f := query.TbFollow
	// 检查是否已经关注
	count, err := f.Where(f.UserID.Eq(uint64(userId)), f.FollowUserID.Eq(uint64(followId))).Count()
	if err != nil {
		slog.Error("查询关注状态失败", "userId", userId, "followId", followId, "err", err)
		response.Error(c, response.ErrDatabase)
		return
	}
	if count > 0 {
		response.Error(c, response.ErrValidation, "already followed")
		return
	}

	err = f.Create(&model.TbFollow{
		UserID:       uint64(userId),
		FollowUserID: uint64(followId),
	})
	if err != nil {
		slog.Error("创建关注关系失败", "userId", userId, "followId", followId, "err", err)
		response.Error(c, response.ErrDatabase, "follow failed")
		return
	}
	response.Success(c, "follow success")

}

// 取消关注
// DELETE /api/v1/users/:userId/follow/:followId
func UnfollowUser(c *gin.Context) {
	userId, followId, err := validateFollowParams(c.Param(consts.UserIdKey), c.Param(consts.FollowIdKey))
	if err != nil {
		response.HandleBusinessError(c, err)
		return
	}

	f := query.TbFollow
	info, err := f.Where(f.UserID.Eq(uint64(userId)), f.FollowUserID.Eq(uint64(followId))).Delete()
	if err != nil {
		slog.Error("取消关注失败", "userId", userId, "followId", followId, "err", err)
		response.Error(c, response.ErrDatabase, "unfollow failed")
		return
	}
	if info.RowsAffected == 0 {
		response.Error(c, response.ErrNotFound, "关注关系不存在")
		return
	}

	response.Success(c, "unfollow success")
}

func validateFollowParams(userId, followId string) (userIdInt, followIdInt int, err error) {
	// 检查参数是否为空
	if userId == "" || followId == "" {
		return 0, 0, response.NewBusinessError(response.ErrValidation, "userId or followId is empty")
	}

	// 验证userId
	userIdInt, err = strconv.Atoi(userId)
	if err != nil || userIdInt <= 0 {
		return 0, 0, response.WrapBusinessError(response.ErrValidation, err, "userId must be a positive integer")
	}

	// 验证followId
	followIdInt, err = strconv.Atoi(followId)
	if err != nil || followIdInt <= 0 {
		return 0, 0, response.WrapBusinessError(response.ErrValidation, err, "followId must be a positive integer")
	}

	// 检查用户不能关注自己
	if userIdInt == followIdInt {
		return 0, 0, response.NewBusinessError(response.ErrValidation, "用户不能关注自己")
	}

	return userIdInt, followIdInt, nil
}

2.查看用户主页

新建 handler/user/user.go文件。

// 查看用户主页
// GET /api/v1/user/:userId
func QueryUserById(c *gin.Context) {
	id := c.Param(consts.UserIdKey)
	idInt, err := strconv.Atoi(id)
	if err != nil || idInt <= 0 {
		slog.Error("参数验证失败", "id", id, "err", err)
		response.Error(c, response.ErrValidation, "id must be a positive integer")
		return
	}
	u := query.TbUserInfo
	info, err := u.Where(u.UserID.Eq(uint64(idInt))).First()
	if errors.Is(err, gorm.ErrRecordNotFound) {
		response.Error(c, response.ErrNotFound, "用户不存在")
		return
	}
	if err != nil {
		slog.Error("Database error", "err", err)
		response.Error(c, response.ErrDatabase)
		return
	}
	response.Success(c, userInfo{
		UserID:    info.UserID,
		City:      info.City,
		Introduce: info.Introduce,
		Fans:      info.Fans,
		Followee:  info.Followee,
		Gender:    info.Gender,
		Credits:   info.Credits,
		Level:     info.Level,
		Birthday:  info.Birthday,
	})
}

3.博客分页查询优化:游标分页

传统分页(OFFSET+LIMIT)的局限
  • ​性能问题​​:OFFSET值越大,查询需跳过越多的行,效率越低。
  • ​数据一致性风险​​:分页期间数据增删可能导致记录重复或遗漏。
  • ​内存开销大​​:大OFFSET会加载过多中间数据到内存。
游标分页方案(基于指针)

不依赖OFFSET,而是通过​​上一页最后一条记录的唯一值​​(如ID)定位下一页数据。
​示例​​:

SELECT * FROM users WHERE id < last_seen_id ORDER BY id desc LIMIT 10;

核心优势

  • ​查询更快​​:直接定位目标位置,无需跳过历史记录。
  • ​内存高效​​:仅加载必要数据,减少中间结果处理。
  • ​一致性强​​:锚定固定记录点,规避数据变动影响。

使用注意

  1. ​降序排序​​:建议按时间倒序(如ID降序),优先展示最新内容。
  2. ​多查一条判底​​:每次查询数量=页面大小+1,通过是否多出数据判断是否到底。
// handler/blog/type.go
// API响应专用结构体,返回数据给客户端时候,就不返回数据库表结构,更安全
type blogDTO struct {
	ID           uint64    `json:"id"`
	Title        string    `json:"title"`
	Content      string    `json:"content"`
	Images       []string  `json:"images"`     // 将逗号分隔字符串转换为数组
	LikeCount    uint32    `json:"like_count"` // 重命名字段,更符合API规范
	CommentCount uint32    `json:"comment_count"`
	CreateTime   time.Time `json:"create_time"`
	UpdateTime   time.Time `json:"update_time"`
	// 注意:不包含 ShopID, UserID 等内部字段
}

type blogListResponse struct {
	IsEnd  bool      `json:"is_end"`
	Blogs  []blogDTO `json:"blogs"`
	NextId *uint64   `json:"nextId,omitempty"` // 下一页的游标,使用指针可以明确区分"没有值"和"值为0"的情况
}
//blog.go
const (
	defaultBlogsPerPage = 10
)

//使用形式: /api/v1/users/123/blogs?lastId=456
// GET /api/v1/users/:userId/blogs
func GetBlogsByUserId(c *gin.Context) {
	userId := c.Param(consts.UserIdKey)
	lastId := c.Query(consts.LastIdKey) // 游标值,lastId作为查询参数
	//使用形式: /api/v1/users/123/blogs?lastId=456
	userIdInt, err := strconv.Atoi(userId)
	if err != nil || userIdInt <= 0 {
		slog.Error("参数验证失败", "userId", userId, "err", err)
		response.Error(c, response.ErrValidation, "userId must be a positive integer")
		return
	}

	var lastIdInt uint64 = 0
	if lastId != "" {
		parsed, err := strconv.ParseUint(lastId, 10, 64)
		if err != nil {
			slog.Error("参数验证失败", "lastId", lastId, "err", err)
			response.Error(c, response.ErrValidation, "lastId must be a valid integer")
			return
		}
		lastIdInt = parsed
	}

	tbBlog := query.TbBlog
	blogQuery := getTbBlogSelect().Where(tbBlog.UserID.Eq(uint64(userIdInt))) //这种是lastId为空或者为0的情况
	if lastIdInt > 0 {
		// 游标分页,查询ID小于lastId的记录(降序分页)
		blogQuery = getTbBlogSelect().Where(tbBlog.UserID.Eq(uint64(userIdInt)), tbBlog.ID.Lt(uint64(lastIdInt)))
	}

	// 多查一条来判断是否还有下一页
	blogs, err := blogQuery.Order(tbBlog.ID.Desc()).Limit(defaultBlogsPerPage + 1).Find()
	if err != nil {
		slog.Error("查询博客列表失败", "userId", userIdInt, "lastId", lastIdInt, "err", err)
		response.Error(c, response.ErrDatabase)
		return
	}
	// 判断是否还有下一页
	hasMore := len(blogs) > defaultBlogsPerPage
	if hasMore {
		blogs = blogs[:defaultBlogsPerPage]
	}

	// 构建响应
	resp := BlogListResponse{
		IsEnd: !hasMore,
		Blogs: convertTbBlogPtrToResponse(blogs),
	}

	// 如果还有更多数据,设置下一页的游标
	if hasMore && len(blogs) > 0 {
		resp.NextId = &blogs[len(blogs)-1].ID
	}

	response.Success(c, resp)
}

func getTbBlogSelect() query.ITbBlogDo {
	return query.TbBlog.Select(query.TbBlog.CreateTime, query.TbBlog.ID, query.TbBlog.Title, query.TbBlog.Content, query.TbBlog.Liked, query.TbBlog.Comments, query.TbBlog.Images, query.TbBlog.UpdateTime)
}

4.共同关注

  • 可以只使用数据库中的数据。比如查找用户a和b的共同关注对象。那就先在表tb_follow中找到用户a关注的,之后再找用户b关注的,之后再求交集即可。但是这种可能就性能不太好。
  • redis处理共同关注。在关注某位用户之后,同时将被关注的用户id存入到Redis中。查看共同关注的时候,只需要求被查看的用户与自己关注列表的交集即可,那即是使用set

更改关注功能的函数(Follow)

不仅要把数据存入数据库follow表中,还有把userId存入redis的set集合里。key就是userid,集合就是关注的对象。

const followUserId = "follow:userId:"

// 关注,已关注就进行取关
// post /user/follow
func Follow(c *gin.Context) {
    ....................
  	err = f.Create(&model.TbFollow{
		UserID:       uint64(userId),
		FollowUserID: uint64(followId),
	})
	if err != nil {
		slog.Error("创建关注关系失败", "userId", userId, "followId", followId, "err", err)
		response.Error(c, response.ErrDatabase, "follow failed")
		return
	}


	//在redis中set添加关注的对象
	err = db.RedisDb.SAdd(context.Background(), followUserId+strconv.Itoa(userId), followId).Err()
	if err != nil {
		slog.Error("Redis添加关注失败", "userId", userId, "followId", followId, "err", err)
		// 这里可以选择是否要回滚数据库操作,或者仅记录日志
	}


	response.Success(c, "follow success")
}

// 取消关注
// DELETE /api/v1/users/:userId/follow/:followId
func UnfollowUser(c *gin.Context) {
	............
	if info.RowsAffected == 0 {
		response.Error(c, response.ErrNotFound, "关注关系不存在")
		return
	}


	//在redis中移除关注的对象
	err = db.RedisDb.SRem(context.Background(), followUserId+strconv.Itoa(int(userId)), followId).Err()
	if err != nil {
		slog.Error("Redis移除关注失败", "userId", userId, "followId", followId, "err", err)
	}


	response.Success(c, "unfollow success")
}

编写求交集的方法

这个函数内容多是校验参数的,真正要执行的代码是比较少的。

// GET /api/v1/users/follow/commons
func FollowCommons(c *gin.Context) {
	// GET /api/v1/users/follow/commons?user1=123&user2=456
	user1 := c.Query(consts.User1Key)
	user2 := c.Query(consts.User2Key)

	// 参数验证
	if user1 == "" || user2 == "" {
		response.Error(c, response.ErrValidation, "user1和user2参数不能为空")
		return
	}

	// 验证user1
	user1Int, err := strconv.Atoi(user1)
	if err != nil || user1Int <= 0 {
		slog.Error("参数验证失败", "user1", user1, "err", err)
		response.Error(c, response.ErrValidation, "user1必须是正整数")
		return
	}

	// 验证user2
	user2Int, err := strconv.Atoi(user2)
	if err != nil || user2Int <= 0 {
		slog.Error("参数验证失败", "user2", user2, "err", err)
		response.Error(c, response.ErrValidation, "user2必须是正整数")
		return
	}

	// 检查是否查询同一个用户的共同关注(无意义)
	if user1 == user2 {
		response.Error(c, response.ErrValidation, "不能查询同一个用户的共同关注")
		return
	}

	res, err := db.RedisDb.SInter(context.Background(), FollowUserId+user1, FollowUserId+user2).Result()
	if err != nil {
		slog.Error("查询共同关注失败", "user1", user1, "user2", user2, "err", err)
		response.Error(c, response.ErrDatabase, "查询共同关注失败")
		return
	}

	// 添加成功日志
	slog.Info("查询共同关注成功", "user1", user1, "user2", user2, "count", len(res))
	response.Success(c, gin.H{"followCommons": res})
}

5.关注推送

关注推送也叫做 Feed 流,直译为投喂。为用户持续的提供 “沉浸式” 的体验,通过无限下拉刷新获取新的信息。

Feed流产品有两种常见模式:

时间排序(Timeline):不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈

  • 优点:信息全面,不会有缺失。并且实现也相对简单
  • 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低

智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户

  • 优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷
  • 缺点:如果算法不精准,可能起到反作用

本例中的个人页面,是基于关注的好友来做Feed流,因此采用Timeline的模式。该模式的实现方案有三种:

拉模式:也叫做读扩散。在拉模式中,终端用户或应用程序主动发送请求来获取最新的数据流。它是一种按需获取数据的方式,用户可以在需要时发出请求来获取新数据。在Feed流中,数据提供方将数据发布到实时数据源中,而终端用户或应用程序通过订阅或请求来获取新数据。

  • 优点:节约空间,可以减少不必要的数据传输,只需要获取自己感兴趣的数据,因为赵六在读信息时,并没有重复读取,而且读取完之后可以把他的收件箱进行清除。
  • 缺点:延迟较高,当用户读取数据时才去关注的人里边去读取数据,假设用户关注了大量的用户,那么此时就会拉取海量的内容,对服务器压力巨大。 

推模式:也叫做写扩散。在推模式中,数据提供方主动将最新的数据推送给终端用户或应用程序。数据提供方会实时地将数据推送到终端用户或应用程序,而无需等待请求。 

  • 优点:数据延迟低,不用临时拉取。
  • 缺点:内存耗费大。加速一个dav写信息,很多人关注他,就会写很多份数据到粉丝那边去。

推拉结合:即读写混合模式,兼具推和拉的优点。数据提供方主动推送最新数据,同时也支持用户拉取数据,可实现实时更新且满足按需获取。

  • 对于发件人端,普通人采用写扩散方式将数据写入粉丝处,因其粉丝量小无压力;大 V 先将数据写入发件箱,再写入活跃粉丝收件箱。
  • 从收件人端看,活跃粉丝接收大 V 和普通人的直接写入,普通粉丝上线不频繁,上线时从发件箱拉取信息。

那么,我们基于推模式实现关注推送功能。这也是因为这样实现比较简单。 

推送到粉丝收件箱

需求

  1. 修改发布笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
  2. 收件箱可以根据时间戳排序,用Redis的数据结构zset实现
  3. 查询收件箱数据时,可以实现分页查询

按照需求1,把博客保存到数据库后,还需要把对应的博客id存入到redis中。使用zset,key是用户id(粉丝),value的score是时间戳,member是博客id。

const feedKey = "feed:"
// 发布博客内容,保存到数据库
func SaveBlog(c *gin.Context) {
    .......
	// 将博客模型保存到数据库中
	if err := query.TbBlog.Create(&blog); err != nil {
		slog.Error("保存博客失败", "err", err)
		response.Error(c, response.ErrDatabase)
		return
	}

	// 将博客内容推送给粉丝,异步操作
	go pushBlogToFans(blog.ID, blog.UserID)

	// 返回保存成功的博客ID
	response.Success(c, gin.H{"blogId": blog.ID})
}

func pushBlogToFans(blogId, userId uint64) {
	// 查询粉丝列表
	follow := query.TbFollow
	res, err := follow.Where(follow.FollowUserID.Eq(userId)).Select(follow.UserID).Find()
	if err != nil {
		slog.Error("异步推送:查询粉丝列表失败", "userId", userId, "err", err)
		return
	}

	// 推送给粉丝
	for _, v := range res {
		err := db.RedisDb.ZAdd(context.Background(), feedKey+fmt.Sprint(v.UserID), redis.Z{
			Score:  float64(time.Now().UnixMicro()),
			Member: blogId,
		}).Err()
		if err != nil {
			slog.Error("异步推送失败", "fanUserId", v.UserID, "blogId", blogId, "err", err)
		}
	}

	slog.Info("博客推送完成", "blogId", blogId, "fanCount", len(res))
}
实现分页查询收邮箱

当粉丝⽤户需要按分页模式来读取收件箱的信息时,不能采⽤传统的分页模式(按数据的⾓标开始查)。

因为Feed 流中的数据会不断更新,所以数据的⾓标也在不断变化。传统的分页模式,会出现消息重复读的问题。 

滚动分页(即是游标分页)

具体操作

  1. 每次查询完成后,我们要得到查询数据的最小时间戳,这个值会作为下一次查询的条件。这个是滚动查询:每一次都记住上一次查询分数的最小值,将最小值作为下一次的最大值。
  2. 我们需要找到与上一次查询相同的查询个数作为偏移量,下次查询时,跳过这些查询过的数据,拿到我们需要的数据

综上,我们的请求参数中就需要携带2个参数:

  • 上一次查询的最小时间戳minTime
  • 偏移量offset

带有偏移量是为了处理有多条同一时间戳的数据。这两个参数第一次会由前端来指定,以后的查询就根据上一次的后端结果作为当前条件,再次传递到后端。

  • 这里的minTime就表示 当前时间戳 或者 上一次查询的最小时间戳。
  • offset就是 0(第一次查询) 或者 在上一次结果中与最小值一样的元素的个数。

还有一些具体的细节在代码中有注释。 

// GET /api/v1/users/:userId/following-blogs?offset=0&maxTime=1234567890
func QueryBlogOfFollow(c *gin.Context) {
	userid := c.Param(consts.UserIdKey)
	offset := c.Query(consts.OffsetKey)
	maxTime := c.DefaultQuery(consts.MaxTimeKey, strconv.FormatInt(time.Now().UnixMicro(), 10))
	// 参数验证
	if userid == "" {
		response.Error(c, response.ErrValidation, "userId is required")
		return
	}

	offsetInt, err := strconv.Atoi(offset)
	if err != nil || offsetInt < 0 {
		response.Error(c, response.ErrValidation, "offset must be a non-negative integer")
		return
	}

	//按照score从大到小排序,即是按照时间戳从大到小排序
	res, err := db.RedisDb.ZRevRangeByScoreWithScores(context.Background(), feedKey+userid,
		&redis.ZRangeBy{Min: "0", Max: maxTime, Offset: int64(offsetInt), Count: defaultBlogsPerPage}).Result()
	if err != nil {
		slog.Error("Redis操作失败", "err", err)
		response.Error(c, response.ErrDatabase)
		return
	}

	//解析数据: blogId,minTime(时间戳), offset
	minTime := int64(0) //这个minTime是上次查询的最小时间戳,作为当次查询的最大时间戳来开始查
	resOffset := 0
	ids := make([]string, 0, len(res))
	for _, v := range res {
		//安全的类型断言
		blogId, ok := v.Member.(string)
		if !ok {
			slog.Warn("Invalid member type in Redis result", "member", v.Member)
			continue
		}
		ids = append(ids, blogId)

		//获取分数      判读得到最后一次的时间戳,以及偏移量
		currentTime := int64(v.Score)
		if minTime == 0 {
			//第一条记录,初始化
			minTime = currentTime
			resOffset = 1 // 设置为1,因为当前记录就是第一个具有这个时间戳的记录
		} else if currentTime == minTime { //该时间戳有相同的,则偏移量加1。这样就可以去掉了因为socore相同(即时间戳相同),导致返回数据重复的问题
			resOffset++
		} else if currentTime < minTime { // 找到更小的时间戳,更新minTime并重置offset
			minTime = currentTime
			resOffset = 1 // 设置为1,因为当前记录就是第一个具有这个时间戳的记录
		}
	}

	if len(ids) == 0 {
		resp := followBlogsResponse{
			Blogs:   convertTbBlogToResponse([]model.TbBlog{}),
			Offset:  0,
			MinTime: 0,
		}
		response.Success(c, resp)
		return
	}

	// 构建安全的参数化查询,防止sql注入
	placeholders := make([]string, len(ids))
	args := make([]any, len(ids)*2)

	for i, id := range ids {
		placeholders[i] = "?"
		args[i] = id          // 用于IN子句
		args[len(ids)+i] = id // 用于FIELD函数
	}

	inClause := strings.Join(placeholders, ",")

	var blogs []model.TbBlog
	err = db.DBEngine.Raw(fmt.Sprintf(rawBlogSql, inClause, inClause), args...).Scan(&blogs).Error
	if err != nil {
		slog.Error("查询博客详情失败", "userId", userid, "err", err)
		response.Error(c, response.ErrDatabase)
		return
	}

	resp := followBlogsResponse{
		Blogs:   convertTbBlogToResponse(blogs),
		Offset:  resOffset,
		MinTime: minTime,
	}
	response.Success(c, resp)
}

三、附近商铺搜索

搜索附近的商铺,就需要知道商铺的地理位置(经纬度)。而Redis的数据结构GEO就很适合存储地理位置。

1.GEO数据结构

GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。常见的命令有:

1. GEOADD

用于添加地理空间位置数据(经度、纬度和名称)到指定的key中。

  • 语法GEOADD key longitude latitude member [longitude latitude member ...]

2. GEODIST

用于计算两个地点之间的距离,默认单位为米,也可以指定单位为km、miles或ft。

  • 语法GEODIST key member1 member2 [unit]

3. GEOHASH

GEOHASH命令返回一个或多个位置元素的Geohash字符串。该字符串可以用于近似表示位置的唯一标识。

  • 语法GEOHASH key member [member ...]

4.GEOPOS

返回指定member的坐标

  • 语法: GEOPOS key member [member ...]

5. GEORADIUS

指定圆心、半径,找到该圆内包含的所有member,并按照与圆心之间的距离排序后返回。6.2以后已废弃
 

6. GEOSEARCH

在指定范围内搜索member,并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形。6.2版本的新功能

GEOSEARCH key 
[FROMMEMBER member] [FROMLONLAT long lat] 
[BYRADIUS radius unit] [BYBOX width height unit] 
[WITHCOORD] [WITHDIST] [WITHHASH] 
[COUNT count] [ASC|DESC]

命令的使用说明:
该API返回使用GEOADD填充的地理空间信息有序集合中位于给定形状所划定的区域边界内的所有成员。
其中搜索中心有两种指定方式:
• FROMMEMBER:从已经存在的key中读取经纬度。
• FROMLONLAT:从用户参数传递经纬度。
搜索条件按照下面两种:
• BYRADIUS:根据给定半径长度按照圆形搜索,命令效果等同于GEORADIUS。
• BYBOX:根据给定的width和height按照矩形搜索,矩形是轴对称矩形。
后面更多的可选参数如下:
• WITHCOORD:返回匹配的经纬度坐标。
• WITHDIST:返回距离,距离单位按照radius或者height/width单位转换。
• WITHHASH:返回GeoHash计算的值。
• COUNT count:只返回count个元素。注意,这里的count是全部搜索完成之后才过滤的,也就是不能减少搜索的CPU消耗,但是返回元素少,可以相应降低网络带宽的利用率。

7. GEOSEARCHSTORE

与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key。 6.2后新功能

127.0.0.1:5678> geoadd station 116.378248 39.865275 bjnz 116.42803 39.903738 bjz 116.322287 39.893729 bjxz
(integer) 3
127.0.0.1:5678> GEODIST station bjnz bjxz km
"5.7300"
127.0.0.1:5678> GEOSEARCH station FROMLONLAT 116.397904 39.909005 BYRADIUS 10 km WITHDIST
1) 1) "bjz"
   2) "2.6361"
2) 1) "bjnz"
   2) "5.1452"
3) 1) "bjxz"
   2) "6.6723"

//FROMLONLAT表示从用户参数传递经纬度。
//BYRADIUS 根据给定半径长度按照圆形搜索
//WITHDIST 表示返回距离,距离单位按照radius或者height/width单位转换

 2.数据预热,将店铺数据按照 typeId 批量存入Redis。

  • GEO存储经度(longitude)和维度(latitude)还有值(member),为了节约内存,我们在memboer中值存储店铺id
  • geoadd中有key,该key可以传入多个经纬度,即是可以传入多个商户。可以这样,前端传来一个type参数,后端按照商铺类型进行分组,以typeId作为key存入一个GEO集合中。

在internal/shopservice目录添加location.go文件。在main函数中调用LoadShopListToCache。

const geoKeyPrefix   = "geo:"

func LoadShopListToCache() error {
	tbshop := query.TbShop
	shops, err := tbshop.Select(tbshop.ID, tbshop.X, tbshop.Y, tbshop.TypeID).Find()
	if err != nil {
		slog.Error("LoadShopListToCache error", "err", err)
		return err
	}
	//将shop按照typeId进行分类
	shopMap := make(map[uint64][]*model.TbShop)
	for _, shop := range shops {
		shopMap[shop.TypeID] = append(shopMap[shop.TypeID], shop)
	}

	//使用管道,一次性输入
	pipeline := db.RedisDb.Pipeline()
	for _, shops := range shopMap {
		for _, shop := range shops {
			pipeline.GeoAdd(context.Background(), GeoKeyPrefix+strconv.Itoa(int(shop.TypeID)), &redis.GeoLocation{Longitude: shop.X, Latitude: shop.Y, Name: strconv.Itoa(int(shop.ID))})
		}
	}
	_, err = pipeline.Exec(context.Background())
	if err != nil {
		slog.Error("Failed to execute Redis pipeline", "err", err)
		return err
	}
	return nil
}

3.查找附近商户

前端就需要传入key(即是typeId)、坐标(x,y)、距离。后端调用GeoSearch。

// handler/shopservice/type.go
//比之前的是多了Distance字段,还有Images字段类型是[]string
type shopLoationDTO struct {
	ID        uint64   `json:"id"`         // 主键
	Name      string   `json:"name"`       // 商铺名称
	TypeID    uint64   `json:"type_id"`    // 商铺类型的id
	Images    []string `json:"images"`     // 商铺图片
	Area      string   `json:"area"`       // 商圈,例如陆家嘴
	Address   string   `json:"address"`    // 地址
	X         float64  `json:"x"`          // 经度
	Y         float64  `json:"y"`          // 维度
	AvgPrice  uint64   `json:"avg_price"`  // 均价,取整数
	Sold      uint32   `json:"sold"`       // 销量
	Comments  uint32   `json:"comments"`   // 评论数量
	Score     uint32   `json:"score"`      // 评分,1~5分,乘10保存,避免小数
	OpenHours string   `json:"open_hours"` // 营业时间,例如 10:00-22:00
	Distance  float64  `json:"distance"`   // 距离,单位米
}


type shopQueryParams struct {
	TypeID      int     `json:"type_id"`
	CurrentPage int     `json:"current_page"`
	Longitude   float64 `json:"longitude,omitempty"`
	Latitude    float64 `json:"latitude,omitempty"`
	Distance    float64 `json:"distance,omitempty"`
	UseGeoQuery bool    `json:"use_geo_query"`
}
const (
	defaultStoresPerPage = 10

	queryLongitude   = "longitude"
	queryLatitude    = "latitude"
	queryDistance    = "distance"
	queryTypeId      = "typeId"
	queryCurrentPage = "currentPage"
	rawShopSql       = "select * from tb_shop where type_id = ? and id in (%s) order by field(id,%s)"
)

const (
	maxLongitude = 180.0
	minLongitude = -180.0
	maxLatitude  = 90.0
	minLatitude  = -90.0
	maxDistance  = 100.0 // 最大查询距离(公里)
)

// GET /api/v1/shop/distance-list
func QueryShopDistance(c *gin.Context) {
	params, err := parseAndValidateParams(c)
	if err != nil {
		response.HandleBusinessError(c, err)
		return
	}

	var result []shopLoationDTO
	if params.UseGeoQuery {
		result, err = queryShopByGeoLocation(params)
	} else {
		result, err = queryShopByType(params)
	}

	response.HandleBusinessResult(c, err, result)
}

func parseAndValidateParams(c *gin.Context) (*shopQueryParams, error) {
	params := &shopQueryParams{}

	// 验证必需参数
	typeIdStr := c.Query(queryTypeId)
	if typeIdStr == "" {
		return nil, response.NewBusinessError(response.ErrValidation, "typeId 参数不能为空")
	}

	id, err := strconv.Atoi(typeIdStr)
	if err != nil || id <= 0 {
		return nil, response.NewBusinessError(response.ErrValidation, "typeId 必须为正整数")
	}
	params.TypeID = id

	// 验证分页参数
	currentPageStr := c.Query(queryCurrentPage)
	if currentPageStr == "" {
		params.CurrentPage = 1 // 默认第一页
	} else {
		page, err := strconv.Atoi(currentPageStr)
		if err != nil || page < 1 {
			return nil, response.NewBusinessError(response.ErrValidation, "currentPage 必须为大于等于 1 的整数")
		}
		params.CurrentPage = page
	}

	// 检查是否需要地理位置查询
	longitude := c.Query(queryLongitude)
	latitude := c.Query(queryLatitude)
	distance := c.Query(queryDistance)

	// 如果任一地理位置参数为空,则使用普通查询
	if longitude == "" || latitude == "" || distance == "" {
		params.UseGeoQuery = false
		return params, nil
	}

	// 验证地理位置参数
	if err := validateGeoParams(longitude, latitude, distance, params); err != nil {
		return nil, err
	}

	params.UseGeoQuery = true
	return params, nil
}

func validateGeoParams(longitude, latitude, distance string, params *shopQueryParams) error {
	// 验证距离
	distanceFloat, err := strconv.ParseFloat(distance, 64)
	if err != nil || distanceFloat <= 0 || distanceFloat > maxDistance {
		return response.NewBusinessError(response.ErrValidation, fmt.Sprintf("distance 必须为 0 到 %.1f 之间的正数", maxDistance))
	}
	params.Distance = distanceFloat

	// 验证经度
	lng, err := strconv.ParseFloat(longitude, 64)
	if err != nil || lng < minLongitude || lng > maxLongitude {
		return response.NewBusinessError(response.ErrValidation, fmt.Sprintf("longitude 必须在 %.1f 到 %.1f 之间", minLongitude, maxLongitude))
	}
	params.Longitude = lng

	// 验证纬度
	lat, err := strconv.ParseFloat(latitude, 64)
	if err != nil || lat < minLatitude || lat > maxLatitude {
		return response.NewBusinessError(response.ErrValidation, fmt.Sprintf("latitude 必须在 %.1f 到 %.1f 之间", minLatitude, maxLatitude))
	}
	params.Latitude = lat

	return nil
}

func queryShopByType(params *ShopQueryParams) ([]shopLoationDTO, error) {
	tbShop := query.TbShop
	offset := (params.CurrentPage - 1) * defaultStoresPerPage

	shops, err := tbShop.Where(tbShop.TypeID.Eq(uint64(params.TypeID))).Limit(defaultStoresPerPage).Offset(offset).Find()

	if err != nil {
		return nil, response.WrapBusinessError(response.ErrDatabase, err, "查询店铺失败")
	}

	return convertTbShopPtrToResponse(shops), nil
}

func queryShopByGeoLocation(params *ShopQueryParams) ([]shopLoationDTO, error) {
	from := (params.CurrentPage - 1) * defaultStoresPerPage
	end := params.CurrentPage * defaultStoresPerPage
	res, err := db.RedisDb.GeoSearchLocation(context.Background(), geoKeyPrefix+strconv.Itoa(params.TypeID), &redis.GeoSearchLocationQuery{
		GeoSearchQuery: redis.GeoSearchQuery{
			Longitude:  params.Longitude,
			Latitude:   params.Latitude,
			Radius:     params.Distance,
			RadiusUnit: "km",
			Sort:       "ASC", //升序,从小排到大
			Count:      end,
		},

		WithDist: true, //返回距离,用于显示
		// WithCoord: true, //返回经纬度, 数据库中有,所以不需要返回了
	}).Result()
	if err != nil {
		if errors.Is(err, redis.Nil) {
			return nil, response.NewBusinessError(response.ErrNotFound, "没有找到相关店铺")
		}
		return nil, response.WrapBusinessError(response.ErrDatabase, err, "")
	}

	if len(res) <= from { //表示没有下一页了,直接返回空
		return []shopLoationDTO{}, nil
	}
	if len(res) < end {
		end = len(res)
	}
	tmp := res[from:end] //截取需要的数据

	distanceMap := make(map[uint64]float64) //key是shopId,value是距离
	shopIds := make([]uint64, 0, len(tmp))
	for _, v := range tmp {
		id, err := strconv.ParseUint(v.Name, 10, 64)
		if err != nil {
			slog.Error("ParseUint error", "err", err, "name", v.Name)
			continue
		}
		shopIds = append(shopIds, id)
		distanceMap[id] = v.Dist
	}

	// 构建安全的参数化查询,防止sql注入
	placeholders := make([]string, len(shopIds))
	args := make([]any, len(shopIds)*2)
	for i, id := range shopIds {
		placeholders[i] = "?"
		args[i] = id              // 用于IN子句
		args[len(shopIds)+i] = id // 用于FIELD函数
	}
	inClause := strings.Join(placeholders, ",")

	var shops []model.TbShop
	err = db.DBEngine.Raw(fmt.Sprintf(rawShopSql, inClause, inClause), params.TypeID, args).Scan(&shops).Error
	if err != nil {
		slog.Error(err.Error())
		return nil, response.WrapBusinessError(response.ErrDatabase, err, "")
	}

	result := convertTbShopToResponse(shops)
	for i, shop := range result {
		if dist, exists := distanceMap[shop.ID]; exists {
			result[i].Distance = dist
		}
	}

	return result, nil
}

四、用户签到

每天都可以签到。简单的想,可以存在数据库中。

CREATE TABLE `tb_sign` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_id` bigint unsigned NOT NULL COMMENT '用户id',
  `year` year NOT NULL COMMENT '签到的年',
  `month` tinyint NOT NULL COMMENT '签到的月',
  `date` date NOT NULL COMMENT '签到的日期',
  `is_backup` tinyint unsigned DEFAULT NULL COMMENT '是否补签',
  PRIMARY KEY (`id`) USING BTREE
);  

假如用户数量庞大有1000w,平均每人每年签到次数为 10 次,则这张表一年的数据量为 1 亿条

每签到一次需要使用(8 + 8 + 1 + 1 + 3 + 1)共 22 字节的内存,一个月则最多需要 600 多字节。那用户量大的话占用的磁盘空间就会大了。

1.BitMap

我们按月来统计用户签到信息,签到记录为 1,未签到则记录为 0,可以使用redis来存储。

把每一个 bit 位对应当月的每一天,形成了映射关系。用0和1标示业务状态,这样一个月也只消耗31位(4字节)这种思路就称为位图(BitMap)。

Redis 中 是利用 string 类型数据结构实现 BitMap,因此最大上限是 512M,转换为 bit 则是 2^32个 bit 位。其操作命令有:

SETBIT:向指定位置(offset)存入一个 0 或 1
GETBIT :获取指定位置(offset)的 bit 值
BITCOUNT :统计 BitMap 中值为 1 的 bit 位的数量
BITFIELD :操作(查询、修改、自增)BitMap中bit数组中的指定位置(offset)的值
BITFIELD_RO :获取 BitMap 中 bit 数组,并以十进制形式返回
BITOP :将多个 BitMap 的结果做位运算(与 、或、异或)
BITPOS :查找 bit 数组中指定范围内第一个 0 或 1 出现的位置

2.签到功能 

//user.go

const (
	signKeyPre      = "sign:"
	yearMonthFormat = "200601" // YYYYMM格式,用于Redis键名
	expireDays      = 90       // 签到数据过期天数
	expireDuration  = expireDays * 24 * time.Hour
)

// post /api/v1/user/:userId/signIn
func SignIn(c *gin.Context) {
	userId := c.Param(consts.UserIdKey)
	key := signKeyPre + userId + ":" + time.Now().Format(yearMonthFormat)
	dayOfMonth := time.Now().Day()

	_, err := db.RedisDb.SetBit(context.Background(), key, int64(dayOfMonth)-1, 1).Result() // 1为签到,0为未签到
	if err != nil {
		slog.Error("Database error", "err", err)
		response.Error(c, response.ErrDatabase)
		return
	}
	response.Success(c, nil)
}

3.签到统计

统计当前用户截止当前时间在本月的连续签到次数。


type signInStats struct {
	Count int `json:"count"`
}

// post /api/v1/user/:userId/signIn
func SignIn(c *gin.Context) {
	userId := c.Param(consts.UserIdKey)
	userIdInt, err := strconv.Atoi(userId)
	if err != nil || userIdInt <= 0 {
		response.Error(c, response.ErrValidation, "invalid userId")
		return
	}

	key := signKeyPre + userId + ":" + time.Now().Format(YearMonthFormat)
	dayOfMonth := time.Now().Day()
	//(从右到左读取,右边是最低位),使用int64(dayOfMonth)-1,所以最低位是第一天; 1号:dayOfMonth = 1,索引 = 1-1 = 0(最低位)
	oldBit, err := db.RedisDb.SetBit(context.Background(), key, int64(dayOfMonth)-1, 1).Result() // 1为签到,0为未签到
	if err != nil {
		slog.Error("Database error", "err", err)
		response.Error(c, response.ErrDatabase)
		return
	}
	if oldBit == 1 { // 重复签到
		response.Success(c, "already signed in today")
		return
	}
	// 首次签到
	// 设置签到数据3个月后过期,防止Redis内存占用过多
	db.RedisDb.Expire(context.Background(), key, expireDuration)

	response.Success(c, "sign in successful")
}

// 统计到当前时间的连续签到次数
// get  /api/v1/user/:userId/signin-statistics
func ContinuousSigninStatistics(c *gin.Context) {
	userId := c.Param(consts.UserIdKey)
	userIdInt, err := strconv.Atoi(userId)
	if err != nil || userIdInt <= 0 {
		response.Error(c, response.ErrValidation, "invalid userId")
		return
	}

	key := signKeyPre + userId + ":" + time.Now().Format(YearMonthFormat)
	dayOfMonth := time.Now().Day()

	// 类型u代表无符号十进制,i代表带符号十进制
	//0表示偏移量。 从偏移量offset=0开始取dayOfMonth位,获取无符号整数的值(将前dayOfMonth-1位二进制转为无符号10进制返回)
	//res 是一个 []int64 数组,其长度等于 BITFIELD 命令中子命令的数量(此处只有 1 个子命令 GET,因此 len(res) == 1)
	res, err := db.RedisDb.BitField(context.Background(), key, "GET", "u"+strconv.Itoa(dayOfMonth), 0).Result()
	if err != nil {
		if errors.Is(err, redis.Nil) {
			response.Error(c, response.ErrNotFound, "用户没有签到记录")
			return
		}
		slog.Error("BitField error", "err", err)
		response.Error(c, response.ErrDatabase)
		return
	}

	if len(res) == 0 {
		response.Success(c, SignInStats{Count: 0})
		return
	}

	count := getSignInStats(res[0], dayOfMonth)
	response.Success(c, signInStats{Count: count})
}

func getSignInStats(num int64, dayOfMonth int) int {
	// 从当天开始找到最近的签到日期
	lastSignDay := -1
	for day := dayOfMonth; day >= 1; day-- {
		bitPos := day - 1
		if (num>>bitPos)&1 == 1 {
			lastSignDay = day
			break
		}
	}

	if lastSignDay == -1 {
		return 0
	}

	// 从最近签到日期开始向前统计连续天数
	count := 0
	for day := lastSignDay; day >= 1; day-- {
		bitPos := day - 1
		if (num>>bitPos)&1 == 0 {
			break
		}
		count++
	}
	return count
}

五、UV统计

1.两个概念

  • UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次
  • PV:全称Page View,也叫页面访问量或点击量。用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。

UV统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。那这时可以想到使用set,set可以去重,还可以计算个数。但如果某文章非常火爆达,一个 Set 集合就保存了百万/千万个用户的 ID,那消耗的内存也太大了。

2.Hyperloglog

Hyperloglog(HLL)是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值,很适合做海量数据的统计工作。

Redis中的HLL是基于string结构实现的。

  • 单个HLL的内存永远小于16kb,内存占用很低,性能好。它所需的内存并不会因为集合的大小而改变,无论集合包含的元素有多少个,其进行计算所需的内存总是固定的,并且是很少的。
  • 缺点是有一定的误差。其测量结果是概率性的,有小于0.81%的误差。

HyperLogLog常用指令:

  • PFADD key element [element...] :添加指定元素到 HyperLogLog 中
  • PFCOUNT key [key ...]:返回给定 HyperLogLog 的基数估算值
  • PFMERGE destkey sourcekey [sourcekey ...]:将多个 HyperLogLog 合并为一个 HyperLogLog

添加handler/user/uniquevisitor.go文件。

const (
	uvKeyPrefix = "UV:"
	dateFormat  = "20060102"
)

type uvStatistics struct {
	BlogId int `json:"blogId"`
	UserId int `json:"userId"`
}

// post /api/v1/unique-visitor
func AddUniqueVisitor(c *gin.Context) {
	var uv uvStatistics
	err := c.ShouldBindJSON(&uv)
	if err != nil {
		slog.Error("bind json error", "error", err)
		response.Error(c, response.ErrBind)
		return
	}
	if uv.BlogId <= 0 || uv.UserId <= 0 {
		response.Error(c, response.ErrValidation, "invalid blogId or userId")
		return
	}

	now := time.Now().Format(dateFormat)
	err = db.RedisDb.PFAdd(context.Background(), buildUVKey(now, strconv.Itoa(uv.BlogId)), uv.UserId).Err()
	if err != nil {
		slog.Error("add unique visitor error", "error", err)
		response.Error(c, response.ErrDatabase)
		return
	}
	response.Success(c, nil)
}

// GET /api/v1/blogs/:blogId/unique-visitor?date=20240101
func GetUniqueVisitor(c *gin.Context) {
	blogId := c.Param(consts.BlogIdKey)
	date := c.Query("date")

	if date == "" {
		date = time.Now().Format(dateFormat)
	}
	if blogId == "" {
		response.Error(c, response.ErrValidation, "blogId is required")
		return
	}
	// 验证日期格式
	if _, err := time.Parse(dateFormat, date); err != nil {
		response.Error(c, response.ErrValidation, "invalid date format, expected YYYYMMDD")
		return
	}
	if _, err := strconv.Atoi(blogId); err != nil {
		response.Error(c, response.ErrValidation, "invalid blogId format")
		return
	}
	res, err := db.RedisDb.PFCount(context.Background(), buildUVKey(date, blogId)).Result()
	if err != nil {
		if errors.Is(err, redis.Nil) {
			response.Success(c, 0)
			return
		}

		slog.Error("get unique visitor count error", "error", err, "blogId", blogId, "date", date)
		response.Error(c, response.ErrDatabase)
		return
	}
	response.Success(c, res)
}

func buildUVKey(date, blogId string) string {
	return uvKeyPrefix + date + ":" + blogId
}

进行一个测试,来测试内存占用是否极低。

func TestHyperLogLog() {
	//values := make([]int, 1000)  用整数类型的数组,会出现错误redis: can't marshal []uint32 (implement encoding.BinaryMarshaler)
	values := make([]string, 1000)

	j := 0
	for i := 0; i < 1000000; i++ {
		values[j] = strconv.Itoa(i)
		j++
		if j == 999 {
			j = 0
			global.RedisClient.PFAdd(context.Background(), "testHll", values)
		}
	}

	count, err := global.RedisClient.PFCount(context.Background(), "testHll").Result()
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println("count= ", count)
}

效果:存了100w条,结果显示是1009972,误差为(1009972-100w)/100w=0.9972%。是大于官方的0.81%,但也是在误差范围内的。使用info server命令查看,占用的内存大小也是很小的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值