第四章--美食社交--feed服务

前言: 本篇博客 Feed服务 是美食社交项目内第六篇文章, 是写在好友服务之后的一篇博客,大家可以先尝试阅读并编写第五篇–美食社交好友服务的文章之后再阅读此文,效果更佳;因为两者是有联系的!

1. 概念

移动互联网时代,Feed流产品非常常见,比如 朋友圈,微博,非常典型的Feed流产品,还有图片分享网站Pinterest,花瓣网等又是另一种形式的Feed流产品。除此之外,很多App的都会有一个模块,要么叫动态,要么叫消息广场,这些也是Feed流产品,可以说,Feed流产品是遍布天下所有的App中。
image.png

  • Feed:Feed流中的每一条状态或者消息都是Feed,比如朋友圈中的一个状态就是一个Feed,微博中的一条微博就是一个Feed。
  • Feed流:持续更新并呈现给用户内容的信息流。每个人的朋友圈,微博关注页等等都是一个Feed流。
  • Timeline:Timeline其实是一种Feed流的类型,微博,朋友圈都是Timeline类型的Feed流,但是由于Timeline类型出现最早,使用最广泛,最为人熟知,有时候也用Timeline来表示Feed流。
  • 关注页Timeline:展示其他人Feed消息的页面,比如朋友圈,微博的首页等。
  • 个人页Timeline:展示自己发送过的Feed消息的页面,比如微信中的相册,微博的个人页等。

2. 特征

  • 多账号内容流:Feed流系统中肯定会存在成千上万的账号,账号之间可以关注,取关,加好友和拉黑等操作。只要满足这一条,那么就可以当做Feed流系统来设计。
  • 非稳定的账号关系:由于存在关注,取关等操作,所以系统中的用户之间的关系就会一直在变化,是一种非稳定的状态。
  • 读写比例100:1:读写严重不平衡,读多写少,一般读写比例在10:1,甚至100:1以上。
  • 消息必达性要求高:比如发送了一条朋友圈后,结果部分朋友看到了,部分朋友没看到,如果偏偏女朋友没看到,那么可能会产生很严重的感情矛盾,后果很严重。

3. 分类

  • Timeline:按发布的时间顺序排序,先发布的先看到,后发布的排列在最顶端,类似于微信朋友圈,微博等。这也是一种最常见的形式。产品如果选择Timeline类型,那么就是认为 Feed流中的Feed不多,但是每个Feed都很重要,都需要用户看到 。
  • Rank:按某个非时间的因子排序,一般是按照用户的喜好度排序,用户最喜欢的排在最前面,次喜欢的排在后面。这种一般假定用户可能看到的Feed非常多,而用户花费在这里的时间有限,那么就为用户选择出用户最想看的Top N结果,场景的应用场景有图片分享、新闻推荐类、商品推荐等。

4. 实现

解决Feed流最核心的两个问题:一个是存储,另一个是推送

4.1 存储

因为该项目中Feed比较简单,就类比于空间说说,因此可以使用MySQL关系型数据库存储,如果对于数据结构比较复杂的Feed流就要使用NoSQL数据库,这样存储更方便与高效,比如MongoDB或者HBase

4.2 实现

在推送方案里面的,有三种方案,分别是:

  • 拉方案:也称为 读扩散 ,用户主动去拉取关注人的Feed内容
  • 推方案:也成为 写扩散 ,当用户添加Feed时,会自动将Feed通知给关注的人(优选)

使用Redis Sorted Sets(方便按时间排序Timeline)维护粉丝的Feed集合,当博主添加Feed时,主动将内容推送到粉丝的Feed集合中,这样用户可以很方便快速从集合中读取

  • 推拉结合:比如微博,大部分用户的账号关系都是几百个,但是有个别用户是1000万以上才使用。image.png

5. 表结构设计

CREATE TABLE `t_feeds` (
`id`  int(11) NOT NULL AUTO_INCREMENT ,
`content`  varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL
DEFAULT NULL COMMENT '内容' ,
`fk_diner_id`  int(11) NULL DEFAULT NULL ,
`praise_amount`  int(11) NULL DEFAULT NULL COMMENT '点赞数量' ,
`comment_amount`  int(11) NULL DEFAULT NULL COMMENT '评论数量' ,
`fk_restaurant_id`  int(11) NULL DEFAULT NULL ,
`create_date`  datetime NULL DEFAULT NULL ,
`update_date`  datetime NULL DEFAULT NULL ,
`is_valid`  tinyint(1) NULL DEFAULT NULL ,
PRIMARY KEY (`id`)
)
ENGINE=InnoDB
DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_general_ci
AUTO_INCREMENT=14
ROW_FORMAT=COMPACT
;

6. 构建模块

创建 fs_feeds 子模块
POM.XML导入依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>food_social</artifactId>
        <groupId>com.itkaka</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>fs_feeds</artifactId>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
    </properties>

    <dependencies>
        <!-- eureka client -->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <!-- spring web -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- mysql -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <!-- sprin data redis -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!-- mybatis -->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
        </dependency>
        <!-- commons 公共项目 -->
        <dependency>
            <groupId>com.itkaka</groupId>
            <artifactId>fs_commons</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>
    </dependencies>

    <!-- 集中定义项目所需插件 -->
    <build>
        <plugins>
            <!-- spring boot maven 项目打包插件 -->
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

配置文件

server:
  port: 8095 # 端口

spring:
  application:
    name: fs_feeds # 应用名
  # 数据库
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: root
    url: jdbc:mysql://127.0.0.1:3306/db_lezijie_food_social?serverTimezone=Asia/Shanghai&characterEncoding=utf8&useUnicode=true&useSSL=false
  # Redis
  redis:
    port: 6379
    host: 192.168.10.101
    timeout: 3000
    password: 123456
    database: 2
  # Swagger
  swagger:
    base-package: com.imooc.feeds
    title: 美食社交Feed流API接口文档

# 配置 Eureka Server 注册中心
eureka:
  instance:
    prefer-ip-address: true
    instance-id: ${spring.cloud.client.ip-address}:${server.port}
  client:
    service-url:
      defaultZone: http://localhost:8090/eureka/

service:
  name:
    fs-oauth-server: http://fs_oauth/
    fs-diners-server: http://fs_diners/
    fs-follow-server: http://fs_follow/

mybatis:
  configuration:
    map-underscore-to-camel-case: true # 开启驼峰映射

# 配置日志
logging:
  pattern:
    console: '%d{2100-01-01 13:14:00.666} [%thread] %-5level %logger{50} - %msg%n'

7. 编写实体类

package com.itkaka.feeds.model.pojo;

import com.itkaka.commons.model.base.BaseModel;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Getter;
import lombok.Setter;

@ApiModel(description = "Feed信息类")
@Getter
@Setter
public class Feeds extends BaseModel {

    @ApiModelProperty("内容")
    private String content;
    @ApiModelProperty("食客")
    private Integer fkDinerId;
    @ApiModelProperty("点赞")
    private int praiseAmount;
    @ApiModelProperty("评论")
    private int commentAmount;
    @ApiModelProperty("关联的餐厅")
    private Integer fkRestaurantId;

}

7.1 配置类以及全局异常处理

Redis配置类和Rest配置类,还有全局异常处理。 同之前模块一样,拷贝即可

7.2 此时项目结构如下图:

image.png

8. 添加Feed功能

8.1 FeedsMapper添加操作

package com.itkaka.feeds.mapper;

import com.itkaka.feeds.model.pojo.Feeds;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Options;

/**
 * Feed服务 Mapper
 */
public interface FeedsMapper {

    // Feed 添加
    @Insert("insert into t_feeds (content, fk_diner_id, praise_amount, comment_amount,fk_restaurant_id, create_date, update_date, is_valid)" +
    " values (#{content}, #{fkDinerId}, #{praiseAmount}, #{commentAmount},#{fkRestaurantId}, now(), now(), 1)")
    @Options(useGeneratedKeys = true,keyProperty = "id")
    int save(Feeds feeds);


}

8.2 RedisKeyConstant

package com.itkaka.commons.constant;

import lombok.Getter;

@Getter
public enum RedisKeyConstant {

    verify_code("verify_code:", "验证码"),
    seckill_vouchers("seckill_vouchers:", "秒杀券的key"),
    lock_key("lockby:", "分布式锁的key"),
    following("following:", "关注集合Key"),
    followers("followers:", "粉丝集合Key"),
    // 新增这部分
    following_feeds("following_feeds:", "我关注的好友的FeedsKey"),
    ;

     private String key;
    private String desc;

    RedisKeyConstant(String key, String desc) {
        this.key = key;
        this.desc = desc;
    }

}

8.3 FeedsService中添加业务逻辑

package com.itkaka.feeds.service;

import cn.hutool.core.bean.BeanUtil;
import com.itkaka.commons.constant.ApiConstant;
import com.itkaka.commons.constant.RedisKeyConstant;
import com.itkaka.commons.exception.ParameterException;
import com.itkaka.commons.model.domain.ResultInfo;
import com.itkaka.commons.model.vo.SignInDinerInfo;
import com.itkaka.commons.utils.AssertUtil;
import com.itkaka.feeds.mapper.FeedsMapper;
import com.itkaka.feeds.model.pojo.Feeds;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;

import javax.annotation.Resource;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * Feed服务 Service
 */
@Service
public class FeedsService {

    @Value("${service.name.fs_oauth-server}")
    private String oauthServerName;
    @Value("${service.name.fs_diners-server}")
    private String dinersServerName;
    @Value("${service.name.fs_follow-server}")
    private String followServerName;
    @Resource
    private RestTemplate restTemplate;
    @Resource
    private RedisTemplate redisTemplate;
    @Resource
    private FeedsMapper feedsMapper;

    /**
     * 添加 Feed
     *
     * @param feeds
     * @param accessToken
     */
    @Transactional(rollbackFor = Exception.class)
    public void create(Feeds feeds,String accessToken){
        // 非空校验
        AssertUtil.isNotEmpty(feeds.getContent(),"其内容不为空,请输入内容!");
        AssertUtil.isTrue(feeds.getContent().length() > 255,"输入内容太多,请重新输入");

        //获取登录用户信息
        SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
        // Feed 关联用户信息
        feeds.setFkDinerId(dinerInfo.getId());
        // 添加 Feed
        int count = feedsMapper.save(feeds);
        AssertUtil.isTrue(count == 0,"添加失败!");
        //推送到粉丝列表 首先拿我的粉丝列表
        List<Integer> followers = findFollowers(dinerInfo.getId());
        // 推送 Feeds, 以时间作为分数存储到 Redis ZSet
        long now = System.currentTimeMillis();
        followers.forEach(follower -> {
            String key = RedisKeyConstant.following_feeds.getKey() + follower;
            redisTemplate.opsForZSet().add(key,feeds.getId(),now);
        });

    }

    //获取粉丝列表篇
    private List<Integer> findFollowers(Integer dinerId) {
        String url = followServerName + "followers/" +dinerId;
        ResultInfo resultInfo = restTemplate.getForObject(url,ResultInfo.class);

        if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE){
            throw new ParameterException(resultInfo.getCode(),resultInfo.getMessage());
        }

        return (List<Integer>) resultInfo.getData();
    }

    //获取登录用户信息
    private SignInDinerInfo loadSignInDinerInfo(String accessToken) {
        // 令牌是否存在
        AssertUtil.mustLogin(accessToken);
        // 拼接 URL
        String url = oauthServerName + "user/me?access_token={accessToken}";
        // 发送请求
        ResultInfo resultInfo = restTemplate.getForObject(url,ResultInfo.class,accessToken);

        if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE){
            throw new ParameterException(resultInfo.getCode(),resultInfo.getMessage());
        }

        SignInDinerInfo dinerInfo = BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(),new SignInDinerInfo(),false);
        return dinerInfo;
    }


}

8.4 FeedsController方法

package com.itkaka.feeds.controller;

import com.itkaka.commons.model.domain.ResultInfo;
import com.itkaka.commons.utils.ResultInfoUtil;
import com.itkaka.feeds.model.pojo.Feeds;
import com.itkaka.feeds.service.FeedsService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;

/**
 * Feed服务 Controller
 */
@RestController
public class FeedsController {

    @Resource
    private FeedsService feedsService;
    @Resource
    private HttpServletRequest request;

    /**
     * 添加 Feed
     *
     * @param feeds
     * @param access_token
     * @return
     */
    @PostMapping
    public ResultInfo<String> create(@RequestBody Feeds feeds, String access_token) {
        feedsService.create(feeds, access_token);
        return ResultInfoUtil.buildSuccess(request.getServletPath(), "添加成功");
    }

}

8.5 网关配置

:::info
注意配置文件的缩进
:::

   - id: fs_feeds
     uri: lb://fs-feeds
     predicates:
       - Path=/feeds/**
     filters:
       - StripPrefix=1
server:
  port: 80 # 端口

spring:
  application:
    name: fs_gateway # 应用名
  cloud:
    gateway:
      discovery:
        locator:
          enabled: true # 开启配置注册中心进行路由功能
          lower-case-service-id: true # 将服务名称转小写
      routes:
        - id: fs_diners
          uri: lb://fs_diners
          predicates:
            - Path=/diners/**
          filters:
            - StripPrefix=1

        - id: fs_oauth
          uri: lb://fs_oauth
          predicates:
            - Path=/auth/**
          filters:
            - StripPrefix=1

        - id: fs_seckill
          uri: lb://fs_seckill
          predicates:
            - Path=/seckill/**
          filters:
            - StripPrefix=1

        - id: fs_follow
          uri: lb://fs_follow
          predicates:
            - Path=/follow/**
          filters:
            - StripPrefix=1

        - id: fs_feeds
          uri: lb://fs_feeds
          predicates:
            - Path=/feeds/**
          filters:
            - StripPrefix=1


secure:
  ignore:
    urls: # 配置白名单路径
      - /actuator/**
      - /auth/oauth/**
      - /diners/signin
      - /diners/send
      - /diners/checkPhone
      - /diners/register
      - /seckill/add
      - /restaurants/detail/**

# 配置 Eureka Server 注册中心
eureka:
  instance:
    # 开启 ip 注册
    prefer-ip-address: true
    instance-id: ${spring.cloud.client.ip-address}:${server.port}
  client:
    service-url:
      defaultZone: http://localhost:8090/eureka/

# 配置日志
logging:
  pattern:
    console: '%d{2100-01-01 13:14:00.666} [%thread] %-5level %logger{50} - %msg%n'

获取粉丝列表
通过关注微服务获取用户的粉丝列表ID
fs-follow微服务中添加获取粉丝列表的方法
fs-follow的FollowService中添加获取粉丝方法

/**
* 获取粉丝列表
*
* @param dinerId
* @return
*/
public Set<Integer> findFollowers(Integer dinerId) {
  AssertUtil.isNotNull(dinerId, "请选择查看的用户");
  Set<Integer> followers = redisTemplate.opsForSet()
     .members(RedisKeyConstant.followers.getKey() + dinerId);
  return followers;
}

fs-follow的FollowController中添加

/**
* 获取粉丝列表
*
* @param dinerId
* @return
*/
@GetMapping("followers/{dinerId}")
public ResultInfo findFollowers(@PathVariable Integer dinerId) {
  Set<Integer> followers = followService.findFollowers(dinerId);
  return ResultInfoUtil.buildSuccess(request.getServletPath(), followers);
}

测试

访问:http://localhost/feeds?access_token=a37b83ee-81e7-4bf1-8255-d3ea83bc327f
image.png
image.png

9. 删除 Feed

9.1 FeedsMapper操作

    // 逻辑删除 Feed
    @Update("update t_feeds set is_valid = 0, update_date = now() where id = #{id} and is_valid = 1")
    int delete(@Param("id") Integer id);

9.2 FeedsService编写删除逻辑

package com.itkaka.feeds.service;

import cn.hutool.core.bean.BeanUtil;
import com.itkaka.commons.constant.ApiConstant;
import com.itkaka.commons.constant.RedisKeyConstant;
import com.itkaka.commons.exception.ParameterException;
import com.itkaka.commons.model.domain.ResultInfo;
import com.itkaka.commons.model.vo.SignInDinerInfo;
import com.itkaka.commons.utils.AssertUtil;
import com.itkaka.feeds.mapper.FeedsMapper;
import com.itkaka.feeds.model.pojo.Feeds;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;

import javax.annotation.Resource;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * Feed服务 Service
 */
@Service
public class FeedsService {

    @Value("${service.name.fs_oauth-server}")
    private String oauthServerName;
    @Value("${service.name.fs_diners-server}")
    private String dinersServerName;
    @Value("${service.name.fs_follow-server}")
    private String followServerName;
    @Resource
    private RestTemplate restTemplate;
    @Resource
    private RedisTemplate redisTemplate;
    @Resource
    private FeedsMapper feedsMapper;

    /**
     * 添加 Feed
     *
     * @param feeds
     * @param accessToken
     */
    @Transactional(rollbackFor = Exception.class)
    public void create(Feeds feeds,String accessToken){
        // 非空校验
        AssertUtil.isNotEmpty(feeds.getContent(),"其内容不为空,请输入内容!");
        AssertUtil.isTrue(feeds.getContent().length() > 255,"输入内容太多,请重新输入");

        //获取登录用户信息
        SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
        // Feed 关联用户信息
        feeds.setFkDinerId(dinerInfo.getId());
        // 添加 Feed
        int count = feedsMapper.save(feeds);
        AssertUtil.isTrue(count == 0,"添加失败!");
        //推送到粉丝列表 首先拿我的粉丝列表
        List<Integer> followers = findFollowers(dinerInfo.getId());
        // 推送 Feeds, 以时间作为分数存储到 Redis ZSet
        long now = System.currentTimeMillis();
        followers.forEach(follower -> {
            String key = RedisKeyConstant.following_feeds.getKey() + follower;
            redisTemplate.opsForZSet().add(key,feeds.getId(),now);
        });

    }

    //获取粉丝列表篇
    private List<Integer> findFollowers(Integer dinerId) {
        String url = followServerName + "followers/" +dinerId;
        ResultInfo resultInfo = restTemplate.getForObject(url,ResultInfo.class);

        if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE){
            throw new ParameterException(resultInfo.getCode(),resultInfo.getMessage());
        }

        return (List<Integer>) resultInfo.getData();
    }

    //获取登录用户信息
    private SignInDinerInfo loadSignInDinerInfo(String accessToken) {
        // 令牌是否存在
        AssertUtil.mustLogin(accessToken);
        // 拼接 URL
        String url = oauthServerName + "user/me?access_token={accessToken}";
        // 发送请求
        ResultInfo resultInfo = restTemplate.getForObject(url,ResultInfo.class,accessToken);

        if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE){
            throw new ParameterException(resultInfo.getCode(),resultInfo.getMessage());
        }

        SignInDinerInfo dinerInfo = BeanUtil.fillBeanWithMap((LinkedHashMap) resultInfo.getData(),new SignInDinerInfo(),false);
        return dinerInfo;
    }

    /**
     * 删除 Feed
     *
     * @param id
     * @param accessToken
     */
    @Transactional(rollbackFor = Exception.class)
    public void delete(Integer id,String accessToken){
        // 参数校验
        AssertUtil.isTrue(id == null || id <1,"请选择要删除的Feed");
        // 获取登录用户
        SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
        // 获取 Feed
        Feeds feeds = feedsMapper.findById(id);
        // 判断 Feed 是否已被删除 且 只能删除自己的 Feed
        AssertUtil.isTrue(feeds == null,"该 Feed 已被删除!");
        AssertUtil.isTrue(!feeds.getFkDinerId().equals(dinerInfo.getId()),"只能删除自己的Feed");
        // 执行删除操作
        int count = feedsMapper.delete(id);
        if (count == 0){
            return;
        }
        // 将内容从粉丝关注的 Feed 集合中删除 先拿我的粉丝列表
        List<Integer> followers = findFollowers(dinerInfo.getId());
        followers.forEach(follower ->{
            String key = RedisKeyConstant.following_feeds.getKey() + follower;
            redisTemplate.opsForZSet().remove(key,feeds.getId());
        });
    }

}

由于调用查询接口,因此需要在持久层新建查询语句,完善之后的mapper如下

package com.itkaka.feeds.mapper;

import com.itkaka.feeds.model.pojo.Feeds;
import org.apache.ibatis.annotations.*;

/**
 * Feed服务 Mapper
 */
public interface FeedsMapper {

    // Feed 添加
    @Insert("insert into t_feeds (content, fk_diner_id, praise_amount, comment_amount,fk_restaurant_id, create_date, update_date, is_valid)" +
    " values (#{content}, #{fkDinerId}, #{praiseAmount}, #{commentAmount},#{fkRestaurantId}, now(), now(), 1)")
    @Options(useGeneratedKeys = true,keyProperty = "id")
    int save(Feeds feeds);

    // 逻辑删除 Feed
    @Update("update t_feeds set is_valid = 0, update_date = now() where id = #{id} and is_valid = 1")
    int delete(@Param("id") Integer id);

    // 查询 Feed
    @Select("select id,content, fk_diner_id, praise_amount, comment_amount,fk_restaurant_id, create_date, update_date, is_valid" +
    " from t_feeds where id = #{id} and is_valid = 1")
    Feeds findById(@Param("id") Integer id);
}

9.3 FeedsController编写API方法

package com.itkaka.feeds.controller;

import com.itkaka.commons.model.domain.ResultInfo;
import com.itkaka.commons.utils.ResultInfoUtil;
import com.itkaka.feeds.model.pojo.Feeds;
import com.itkaka.feeds.service.FeedsService;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;

/**
 * Feed服务 Controller
 */
@RestController
public class FeedsController {

    @Resource
    private FeedsService feedsService;
    @Resource
    private HttpServletRequest request;

    /**
     * 添加 Feed
     *
     * @param feeds
     * @param access_token
     * @return
     */
    @PostMapping
    public ResultInfo<String> create(@RequestBody Feeds feeds, String access_token) {
        feedsService.create(feeds, access_token);
        return ResultInfoUtil.buildSuccess(request.getServletPath(), "添加成功");
    }

    /**
     * 删除 Feed
     *
     * @param id
     * @param access_token
     * @return
     */
    @DeleteMapping("{id}")
    public ResultInfo<String> delete(@PathVariable Integer id, String access_token) {
        feedsService.delete(id, access_token);
        return ResultInfoUtil.buildSuccess(request.getServletPath(), "删除成功");
    }

}

9.4 Postman测试

访问:http://localhost/feeds/15?access_token=a37b83ee-81e7-4bf1-8255-d3ea83bc327f
image.png
image.png

10. [修改Feed] 关注/取关时处理用户

当A用户关注B用户时,那么要实时的将B的所有Feed推送到A用户的Feed集合中,同样如果A用户取关B用户,那么要将B用户所有的Feed从A用户的Feed集合中移除。

10.1 FeedsMapper查询方法

    /**
     * 根据食客 ID 查询 Feed
     */
    @Select("SELECT id, content, update_date FROM t_feeds " +
            "WHERE fk_diner_id = #{dinerId} AND is_valid = 1")
    List<Feeds> findByDinerId(@Param("dinerId") Integer dinerId);

10.2 Service层处理添加和移除逻辑

    /**
     * 变更 Feed
     *
     * @param followingDinerId 关注的好友的 ID
     * @param accessToken      登录用户的 token
     * @param type             1 关注 0 取关
     */
    @Transactional(rollbackFor = Exception.class)
    public void addFollowingFeeds(Integer followingDinerId, String accessToken, int type) {
        // 参数校验
        AssertUtil.isTrue(followingDinerId == null || followingDinerId < 1,
                "请选择关注或取关的好友");
        
        // 获取登录用户
        SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
        
        // 获取关注/取关食客所有 Feed
        List<Feeds> followingFeeds = feedsMapper.findByDinerId(followingDinerId);
        if (followingFeeds == null || followingFeeds.isEmpty()) {
            return;
        }
        
        // 我关注的好友的 FeedsKey
        String key = RedisKeyConstant.following_feeds.getKey() + dinerInfo.getId();
        
        // 取关
        if (type == 0) {
            List<Integer> feedIds = followingFeeds.stream()
                    .map(feed -> feed.getId())
                    .collect(Collectors.toList());
            redisTemplate.opsForZSet().remove(key, feedIds.toArray(new Integer[]{}));
        } else {
            // 关注
            Set<ZSetOperations.TypedTuple> typedTuples = followingFeeds.stream()
                    .map(feed -> new DefaultTypedTuple<>(feed.getId(), (double) feed.getUpdateDate().getTime()))
                    .collect(Collectors.toSet());
            redisTemplate.opsForZSet().add(key, typedTuples);
        }
    }

10.3 在Controller层变API方法

    /**
     * 变更 Feed
     *
     * @param followingDinerId
     * @param access_token
     * @param type
     * @return
     */
    @PostMapping("updateFollowingFeeds/{followingDinerId}")
    public ResultInfo<String> addFollowingFeeds(@PathVariable Integer followingDinerId,
                                                String access_token, int type) {
        feedsService.addFollowingFeeds(followingDinerId, access_token, type);
        return ResultInfoUtil.buildSuccess(request.getServletPath(), "操作成功");
    }

10.4 follow微服务中的关注和取关接口中添加Feed操作的方法

service:
	name:
     fs-oauth-server: http://fs_oauth/
     fs-diners-server: http://fs_diners/
     fs-feeds-server: http://fs_feeds/

在以下地方进行修改:

@Value("${service.name.fs-feeds-server}")
private String feedsServerName;
/**
* 发送请求添加或者移除关注人的Feed列表
*
* @param followDinerId 		关注好友的ID
* @param accessToken  		当前登录用户token
* @param type     			0=取关 1=关注
*/
private void sendSaveOrRemoveFeed(Integer followDinerId, String accessToken, int type) {
  String feedsUpdateUrl = feedsServerName + "updateFollowingFeeds/" + followDinerId + "?access_token=" + accessToken;
  
    // 构建请求头
  HttpHeaders headers = new HttpHeaders();
  headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
  
    // 构建请求体(请求参数)
  MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
  body.add("type", type);
  HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body,headers);
  restTemplate.postForEntity(feedsUpdateUrl, entity, ResultInfo.class);
}

以下地方也需修改完善:

// 如果没有关注信息,且要进行关注操作
if (follow == null && isFollowed == 1) {
  // 添加关注信息
  int count = followMapper.save(dinerInfo.getId(), followDinerId);
  // 添加关注列表到 Redis
  if (count == 1) {
    addToRedisSet(dinerInfo.getId(), followDinerId);
    // 保存 Feed
    sendSaveOrRemoveFeed(followDinerId, accessToken, 1);
 }
  return ResultInfoUtil.build(ApiConstant.SUCCESS_CODE,"关注成功", path, "关注成功");
}


// 如果有关注信息,且目前处于取关状态,且要进行关注操作
if (follow != null && follow.getIsValid() == 0 && isFollowed == 1) {
  // 重新关注
  int count = followMapper.update(follow.getId(), isFollowed);
  // 添加关注列表
  if (count == 1) {
    addToRedisSet(dinerInfo.getId(), followDinerId);
    // 保存 Feed
    sendSaveOrRemoveFeed(followDinerId, accessToken, 1);
 }
  return ResultInfoUtil.build(ApiConstant.SUCCESS_CODE, "关注成功", path, "关注成功");
}


// 如果有关注信息,且目前处于关注中状态,且要进行取关操作
if (follow != null && follow.getIsValid() == 1 && isFollowed == 0) {
  // 取关
  int count = followMapper.update(follow.getId(), isFollowed);
  if (count == 1) {
    // 移除 Redis 关注列表
    removeFromRedisSet(dinerInfo.getId(), followDinerId);
    // 移除 Feed
    sendSaveOrRemoveFeed(followDinerId, accessToken, 0);
 }
  return ResultInfoUtil.build(ApiConstant.SUCCESS_CODE, "成功取关", path, "成功取关");
}

Postman测试

访问:http://localhost/follow/2?isFollowed=1&access_token=116af711-645c-4ce9-bc32-cd304bdda020
先让 ID 5 用户关注 ID 2 用户。
image.png
访问:http://localhost/follow/2?isFollowed=0&access_token=116af711-645c-4ce9-bc32-cd304bdda020
然后让 ID 5 用户取关 ID 2 用户。
image.png

11. 查询 Feed

11.1 构建返回的FeedsVO

package com.itkaka.feeds.model.vo;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.itkaka.commons.model.base.BaseModel;
import com.itkaka.commons.model.vo.ShortDinerInfo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Getter;
import lombok.Setter;

import java.util.Date;
@ApiModel(description = "Feed显示信息")
@Getter
@Setter
public class FeedsVo extends BaseModel {

    @ApiModelProperty("主键")
    private Integer id;
    @ApiModelProperty("内容")
    private String content;
    @ApiModelProperty("点赞数")
    private int praiseAmount;
    @ApiModelProperty("评论数")
    private int commentAmount;
    @ApiModelProperty("餐厅")
    private Integer fkRestaurantId;
    @ApiModelProperty("用户ID")
    private Integer fkDinerId;
    @ApiModelProperty("用户信息")
    private ShortDinerInfo dinerInfo;
    @ApiModelProperty("显示时间")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm")
    public Date createDate;


}

⭐11.2 FeedsMapper查询方法

 /**
     * 根据多主键查询 Feed
     */
    @Select("<script> " +
            " select id, content, fk_diner_id, praise_amount, comment_amount, " +
            " fk_restaurant_id, create_date, update_date, is_valid " +
            " from t_feeds where is_valid = 1 and id in " +
            " <foreach item='id' collection='feedIds' open='(' separator=',' close=')'> " +
            " #{id} " +
            " </foreach> order by id desc" +
            " </script>")
    List<Feeds> findFeedsByIds(@Param("feedIds") Set<Integer> feedIds);

11.3 ApiConstant

// Feed 默认每页条数
public static final int PAGE_SIZE = 20;

⭐11.4 编写service查询Feed的方法

  1. 获取登录用户信息
  2. 构建分页查询的参数start,end
  3. 从Redis的sorted sets中按照score的降序进行读取Feed的id
  4. 从数据库中获取Feed的信息
  5. 构建Feed关联的用户信息(不是循环逐条读取,而是批量获取)
    /**
     * 根据时间由近到远,每次查询 20 条 Feed
     *
     * @param page              页码
     * @param accessToken       登录用户信息
     * @return
     */
    public List<FeedsVo> selectForPage(Integer page, String accessToken) {
        if (page == null) {
            page = 1;
        }
        // 获取登录用户信息
        SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
        // 我关注的好友的 FeedsKey
        String key = RedisKeyConstant.following_feeds.getKey() + dinerInfo.getId();
        // SortedSet 的 ZREVRANGE 命令是闭区间(左闭右闭)
        long start = (page - 1) * ApiConstant.PAGE_SIZE;
        long end = page * ApiConstant.PAGE_SIZE - 1;
        // 获取 20 条 Feed ID
        Set<Integer> feedIds = redisTemplate.opsForZSet().reverseRange(key, start, end); // 闭区间,左闭右闭
        if (feedIds == null || feedIds.isEmpty()) {
            return Lists.newArrayList();
        }
        // 根据多主键查询 Feed
        List<Feeds> feeds = feedsMapper.findFeedsByIds(feedIds);
        // 初始化关注好友 ID 集合
        List<Integer> followindDinerIds = new ArrayList<>();
        // 添加用户 ID 至集合,顺带将 Feeds 转为 VO 对象
        List<FeedsVo> feedsVos = feeds.stream()
                .map(feed -> {
                    FeedsVo feedsVo = new FeedsVo();
                    BeanUtil.copyProperties(feed, feedsVo);
                    // 添加用户 ID 至集合
                    followindDinerIds.add(feed.getFkDinerId());
                    return feedsVo;
                }).collect(Collectors.toList());
        // 获取 Feed 中用户信息
        ResultInfo resultInfo = restTemplate.getForObject(dinersServerName +
                        "findByIds?access_token={accessToken}&ids={ids}", ResultInfo.class,
                accessToken, StrUtil.join(",", followindDinerIds));
        if (resultInfo.getCode() != ApiConstant.SUCCESS_CODE) {
            throw new ParameterException(resultInfo.getCode(), resultInfo.getMessage());
        }
        List<LinkedHashMap> dinerInfoMaps = (List<LinkedHashMap>) resultInfo.getData();
        // 构建一个 Key 为用户 ID,Value 为 ShortDinerInfo 的 Map
        Map<Integer, ShortDinerInfo> dinerInfos = dinerInfoMaps.stream()
                .collect(Collectors.toMap(
                        // key
                        diner -> (Integer) diner.get("id"),
                        // value
                        diner -> BeanUtil.fillBeanWithMap(diner, new ShortDinerInfo(), false)
                ));
        // 循环 vo 集合,根据用户 ID 从 Map 中获取用户信息并设置至 vo 对象
        feedsVos.forEach(feedsVo -> feedsVo.setDinerInfo(dinerInfos.get(feedsVo.getFkDinerId())));
        return feedsVos;
    }

11.5 添加controller查询方法

传入页码进行查询,默认是20条

    /**
     * 分页获取关注的 Feed 数据
     *
     * @param page
     * @param access_token
     * @return
     */
    @GetMapping("{page}")
    public ResultInfo selectForPage(@PathVariable Integer page, String access_token) {
        List<FeedsVo> feedsVos = feedsService.selectForPage(page, access_token);
        return ResultInfoUtil.buildSuccess(request.getServletPath(), feedsVos);
    }

Postman测试

访问:http://localhost/feeds/1?access_token=116af711-645c-4ce9-bc32-cd304bdda020
image.png

写在最后

Feed功能
这个功能中我们实现了添加 Feed、删除 Feed、关注取关时变更 Feed、查询 Feed 功能。
这个功能中 Redis 主要用于存储每个用户关注好友添加的 Feed 流集合,使用了 Sorted Set 数据类型。

👉 💕美好的一天,从现在开始,一起加油!后续持续更新,码字不易,麻烦大家小手一点 , 点赞或关注 , 感谢大家的支持!!
🌙后续还会有签到、积分、评论等功能模块,不见不散奥~

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值