12 Feed流系统

一.Feed流系统简介

1.Feed流的定义

Feed流是Feed + 流,Feed的本意是饲料,Feed流的本意就是有人一直在往一个地方投递新鲜的饲料,如果需要饲料,只需要盯着投递点就可以了,这样就能源源不断获取到新鲜的饲料。

当前最流行的Feed流产品有微博、微信朋友圈、头条的资讯推荐、快手抖音的视频推荐等,还有一些变种,比如私信、通知等,这些系统都是Feed流系统,接下来我们会介绍如何设计一个Feed流系统架构。

2.Feed流系统特点

Feed流本质上是数据流,是服务端系统将 “多个发布者的信息内容” 通过 “关注收藏等关系” 推送给 “多个接收者”:

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

3.Feed流系统分类

Feed流的分类有很多种,但最常见的分类有两种:

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

上面两种是最典型,也是最常见的分类方式,另外的话,也有其他的分类标准,在其他的分类标准中的话,会多出两种类型:

  • Aggregate:聚合类型,比如好几个朋友都看了同一场电影,这个就可以聚合为一条Feed:A,B,C看了电影《你的名字》,这种聚合功能比较适合在客户端做。一般的Aggregate类型是Timeline类型 + 客户端聚合。
  • Notice:通知类型,这种其实已经是功能类型了,通知类型一般用于APP中的各种通知,私信等常见。这种也是Timeline类型,或者是Aggregate类型。

4.设计Feed流系统的2个核心

Feed流系统是一个数据流系统,如果要设计一个Feed流系统,最关键的两个核心,一个是数据存储(发布Feed),一个是数据推送(读取Feed)。

这两个核心我们稍后再谈,我们先从数据层面看,数据分为三类,分别是:

  • 发布者的数据:发布者发布数据,然后数据需要按照关注者进行组织,需要根据关注者查到所有数据,

    ​ 比如微博的个人页面、朋友圈的个人相册等。

  • 关注关系:系统中个体间的关系,微博中是关注,是单向流,朋友圈是好友,是双向流。

    ​ 不管是单向还是双向,当发布者发布一条信息时,该条信息的流动永远是单向的。

  • 粉丝的数据:从不同发布者那里获取到的数据,然后通过某种顺序(一般为时间timeline)组织到一起,

    ​ 比如微博首页、朋友圈首页等。

    ​ 这些数据具有时间热度属性,越新的数据越有价值,越新的数据就要排在最前面。

Feed数据

针对这三类数据,我们可以定义为:

  • 存储库:存储发布者的Feed数据,永久保存。我们已经存放到mysql中
  • 关注表:用户关系表,永久保存。
  • 同步库[未读池]:存储接收者的时间热度数据,只需要保留最近一段时间的数据即可。

数据存储

Feed消息的特点:

  • Feed信息的最大特点就是数据量大,而且在Feed流系统里面很多时候都会选择写扩散(推模式)模式,这时候数据量会再膨胀几个数量级,所以这里的数据量很容易达到100TB,甚至PB级别。

  • 数据格式简单

  • 数据不能丢失,可靠性要求高

  • 自增主键功能,保证个人发的Feed的消息ID在个人发件箱中都是严格递增的,这样读取时只需要一个范围读取即可。

根据上述这些Feed数据的特征,最佳的系统应该是具有主键自增功能的分布式NoSQL数据库,但是在开源系统里面没有,所以常用的做法有两种:

  • 关系型数据库 + 分库分表
  • 关系型数据库 + 分布式NoSQL数据库:其中 关系型数据库提供主键自增功能。

基于上述原因,部分技术公司早已经开始考虑使用表格存储(TableStore)。

表格存储是一个具有自增主键功能的分布式NoSQL数据库,这样就只需要使用一种系统即可,除此之外表格存储还有以下的特点:

  • 天然分布式数据库,无需分库分表,单表可达10PB,10万亿行,可支持千万级TPS/QPS(每秒处理事务条数和每秒查询数据次数)
  • 号称SLA(软件可靠性)可用性可达到10个9,0.999999...,Feed内容不容易丢失。
  • 主键自增功能性能极佳,其他所有系统在做自增功能的时候都需要加锁,但是表格存储的主键自增功能在写入自增列行的时候,完全不需要锁,既不需要表锁,也不需要行锁。

大家可以去阿里云官网去看一下表格存储功能

免费开通使用服务即可

数据推送

数据推送的实现,有3种方案,分别是:

  • 拉方案:也称为读扩散。很多Feed流产品的第一版会采用这种方案,因为需要用户主动拉取数据, 请求量太大, 但很快就抛弃了。
  • 推方案:也成为写扩散。Feed流系统中最常用、有效的模式,发布者信息推到未读池,用户上线主动从未读池推送信息给用户, 用户关系数比较均匀,或者有上限,比较出名的有微信朋友圈。
  • 推拉组合:大部分用户的账号关系都是几百个,但是有个别用户是1000万以上,比如微博。
类型 推模式 拉模式 推拉结合模式
写放大
读放大
用户读取延时 毫秒
读写比例 1:99 99:1 50:50
系统要求 写能力强 读能力强 读写都适中
常见系统 分布式NoSQL 内存缓存或搜索系统
(推荐排序场景)
两者结合
架构复杂度 简单 复杂 更复杂
  • 如果产品中是双向关系,那么就采用推模式。
  • 如果产品中是单向关系,且用户数少于1000万,那么也采用推模式,足够了。
  • 如果产品是单向关系,单用户数大于1000万,那么采用推拉结合模式,这时候可以从推模式演进过来,不需要额外重新推翻重做。
  • 永远不要只用拉模式。
  • 如果是一个初创企业,先用推模式,快速把系统设计出来,然后让产品去验证、迭代,等客户数大幅上涨到1000万后,再考虑升级为推拉结合模式。

所以,接下来我们选择的先是写扩散,然后推拉组合。

同步库表设计结构:

表名:user_message_table

主键列 第1列主键 第2列主键 第3列主键 第4列主键 属性列
列名 user_id sequence_id sender_id message_id other
解释 接收者ID 消息顺序ID,要求自增。 发送者的用户ID 消息ID。通过sender_id和message_id可以到存储库中查询到消息内容,这里表示我们的文章id 其他字段内容,同步库中不需要包括消息内容。

关注或好友关系表设计结构:

表名:user_relation_table

主键顺序 第1列主键 第2列主键 属性列 属性列
Table字段名 user_id follow_user_id timestamp other
备注 用户ID 粉丝用户ID 关注时间 其他属性列

未读池表设计结构:

表名: user_message_session_table

主键列顺序 第一列主键 属性列
列名 user_id last_sequence_id
备注 接收者用户ID 该接收者已经推送给客户端的最新的顺序ID

二.Feed流系统的实现前置准备

1.阿里云:tablestore

  1. 开通阿里云表格存储服务

    进入阿里云,搜索表格存储,开通服务

  2. 获取python操作TableStore的SDK

    https://help.aliyun.com/document_detail/31723.html?spm=a2c4g.11186623.6.891.563c3d76sdVMpI

pip install tablestore

settings/dev.py,添加TableStore的API接口配置

# tablestore
OTS_ID = "LTAI4FxkXpCaQwsNTMzxGmMk"  #accesskey_id
OTS_SECRET = "AxS8Y0m3US8prS8XJuqkHDpd2XGRii" #accesskey的secret
OTS_INSTANCE = "renranzixun"  #表格存储的实例名称
OTS_ENDPOINT = "https://renranzixun.cn-hangzhou.ots.aliyuncs.com" #表格存储实例中的公网地址

Tablestore目前只支持四种数据类型:INTEGER、STRING、DOUBLE和BOOLEAN。其中DOUBLE类型不能做主键类型,BOOLEAN不可以做主键的第一列(分区键)。

为了方便演示,所以我们另外创建一个单独的子应用store来编写tablestore的代码.

cd renranapi/apps
python ../../manage.py startapp store

在settings/dev.py注册,

INSALL_APPS = [
    'store', # 用于演示tableStore,后续删除掉即可
]

表操作

tablestore里面创建表的时候必须设置表名,主键列,还有表元信息和表的描述项[有效期,版本,吞吐量]

创建表的时候,除了主键列以外,还可以设置预设字段列,这个不常用,因为当前使用tablestore是NoSQL数据,所以我们表结构的字段列可以在添加数据的时候再指定。

视图代码:

from tablestore import *

from django.conf import settings
# from rest_framework.views import APIView
from rest_framework.viewsets import ViewSet
from rest_framework.response import Response


# 表格数据操作
class TableStoreViewSet(ViewSet):

    # 实例化表对象
    @property
    def client(self):
        return OTSClient(settings.OTS_ENDPOINT, settings.OTS_ID, settings.OTS_SECRET, settings.OTS_INSTANCE)

    # 创建表
    def add_table(self, request):

        # 设置表名
        table_name = "user_message_table"

        # 设置主键和字段
        schema_of_primary_key = [
            ('user_id', 'INTEGER'),
            ('sequence_id', 'INTEGER', PK_AUTO_INCR), # 主键自增
            ("sender_id", 'INTEGER'),
            ("message_id", 'INTEGER')
        ]
        # 设置表的元信息
        table_meta = TableMeta(table_name, schema_of_primary_key)
        # 设置数据的有效期
        table_option = TableOptions(7 * 86400, 5)
        # 设置数据的预留读写吞吐量--(0,0)无上线
        reserved_throughput = ReservedThroughput(CapacityUnit(0, 0))
        # 创建数据
        self.client.create_table(table_meta, table_option, reserved_throughput)

        return Response({"message": "ok"})

    def delete(self, request):
        """删除表"""
        table = "user_message_table"
        self.client.delete_table(table)
        return Response({"message": "ok"})

    def get(self, request):
        """列出所有的表"""
        table_list = self.client.list_table()
        for table in table_list:
            print(table)

        return Response({"message": "ok"})
注意:
1. 创建表后需要 1 分钟进行加载,在此期间对该表的读/写数据操作均会失败。
  应用程序应该等待表加载完毕后再进行数据操作。
2. 创建表格存储的表时必须指定表的主键。
  主键包含 1~4 个主键列,每一个主键列都有名字和类型。

路由:

from django.urls import path,re_path
from . import views
urlpatterns = [
    path(r'table/', views.TableStoreView.as_view({'post': 'add_table', 'get': 'get', 'delete':'delete'}))
]

# 总路由
    path('store/', include("store.urls")),

一条数据的操作

from datetime import datetime
class DataAPIView(ViewSet):
    @property
    def client(self):
        ots_client = OTSClient(
            settings.OTS_ENDPOINT, settings.OTS_ID, settings.OTS_SECRET, settings.OTS_INSTANCE
        )

        return ots_client

    def post(self,requset):
        """添加数据到表格中"""
        table_name = "user_message_table"
        # 主键列
        primary_key = [
            # ('主键名', 值),
            ('user_id', 3),  # 接收Feed的用户ID
            ('sequence_id', PK_AUTO_INCR),  # 如果是自增主键,则值就是 PK_AUTO_INCR
            ("sender_id", 1),  # 发布Feed的用户ID
            ("message_id", 4),  # 文章ID
        ]


        attribute_columns = [('recevice_time', datetime.now().timestamp()), ('read_status', False)]
        row = Row(primary_key, attribute_columns)
        consumed, return_row = self.client.put_row(table_name, row)
        print(consumed)
        print(return_row)

        return Response({"message": "ok"})


    def get(self,request):
        """获取指定数据"""
        table_name = "user_message_table"

        primary_key = [('user_id', 3), ('sequence_id', 1617958144007000),("sender_id",1), ("message_id",4)]

        # 需要返回的属性列:。如果columns_to_get为[],则返回所有属性列。
        columns_to_get = []
        # columns_to_get = ['recevice_time', 'read_status', 'age', 'sex']

        consumed, return_row, next_token = self.client.get_row(table_name, primary_key, columns_to_get)

        print(return_row.primary_key)
        print(return_row.attribute_columns)
        # print(next_token)
        # [('read_status', False, 1579245502645), ('recevice_time', 1579245502137.347, 1579245502645)]

        return Response({"message":"ok"})

路由,代码:

    path(r"data/", views.DataAPIView.as_view({'post': 'post'})),

多条数据的操作

from tablestore import INF_MAX,INF_MIN,CompositeColumnCondition,LogicalOperator,SingleColumnCondition,ComparatorType,Direction,Condition,RowExistenceExpectation,PutRowItem
from tablestore import BatchWriteRowRequest,TableInBatchWriteRowItem
class RowAPIView(APIView):
    @property
    def client(self):
        return OTSClient(settings.OTS_ENDPOINT, settings.OTS_ID, settings.OTS_SECRET, settings.OTS_INSTANCE)

    """多行数据操作"""
    def get(self,request):
        """按范围获取多行数据"""

        table_name = "user_message_table"

        # 范围查询的起始主键
        inclusive_start_primary_key = [
            ('user_id', 3),
            ('sequence_id', INF_MIN),
            ('sender_id', INF_MIN),
            ('message_id', INF_MIN)
        ]

        # 范围查询的结束主键
        exclusive_end_primary_key = [
            ('user_id', 3),
            ('sequence_id', INF_MAX),
            ('sender_id', INF_MAX),
            ('message_id', INF_MAX)
        ]

        # 查询所有列
        columns_to_get = [] # 表示返回所有列
        limit = 5

        # 设置多条件
        # cond = CompositeColumnCondition(LogicalOperator.AND) # 逻辑条件
        # cond = CompositeColumnCondition(LogicalOperator.OR)
        # cond = CompositeColumnCondition(LogicalOperator.NOT)

        # 多条件下的子条件
        # cond.add_sub_condition(SingleColumnCondition("read_status", False, ComparatorType.EQUAL)) #  比较运算符: 等于
        # cond.add_sub_condition(SingleColumnCondition("属性列", '属性值', ComparatorType.NOT_EQUAL)) #  比较运算符: 不等于
        # cond.add_sub_condition(SingleColumnCondition("属性列", '属性值', ComparatorType.GREATER_THAN)) #  比较运算符: 大于
        # cond.add_sub_condition(SingleColumnCondition("recevice_time", 1579246049, ComparatorType.GREATER_EQUAL)) #  比较运算符: 大于等于
        # cond.add_sub_condition(SingleColumnCondition("属性列", '属性值', ComparatorType.LESS_THAN)) #  比较运算符: 小于
        # cond.add_sub_condition(SingleColumnCondition("recevice_time", 1579246049, ComparatorType.LESS_EQUAL)) #  比较运算符: 小于等于

        consumed, next_start_primary_key, row_list, next_token = self.client.get_range(
            table_name, # 操作表明
            Direction.FORWARD, # 范围的方向,字符串格式,取值包括'FORWARD'和'BACKWARD'。
            inclusive_start_primary_key, exclusive_end_primary_key, # 取值范围
            columns_to_get, # 返回字段列
            limit, # 结果数量
            # column_filter=cond, # 条件
            max_version=1         # 返回版本数量
        )

        print("一共返回了:%s" % len(row_list))

        for row in row_list:
            print ( row.primary_key, row.attribute_columns )

        return Response({"message":"ok"})

    def post(self,request):
        """添加多条数据"""

        table_name = "user_message_table"

        put_row_items = []

        for i in range(0, 10):
            # 主键列
            primary_key = [  # ('主键名', 值),
                ('user_id', i), # 接收Feed的用户ID
                ('sequence_id', PK_AUTO_INCR), # 如果是自增主键,则值就是 PK_AUTO_INCR
                ("sender_id",1), # 发布Feed的用户ID
                ("message_id",5), # 文章ID
            ]

            attribute_columns = [('recevice_time', datetime.now().timestamp()), ('read_status', False)]
            row = Row(primary_key, attribute_columns)
            condition = Condition(RowExistenceExpectation.IGNORE)
            item = PutRowItem(row, condition)
            put_row_items.append(item)

        request = BatchWriteRowRequest()
        request.add(TableInBatchWriteRowItem(table_name, put_row_items))
        result = self.client.batch_write_row(request)
        print(result)
        print(result.is_all_succeed())

        return Response({"message":"ok"})

多行路由,代码:

path("row/", views.RowAPIView.as_view({'post': 'post'})),

三.荏苒项目操作

1.Django自定义终端命令

  1. 在app子应用 home目录下创建management包,并在management包下面创建命令包目录commandscommands下面就可以创建命令模块文件了。【注意,app子应用必须注册到INSTALL_APPS应用列表中】

  2. 在commands包下面创建命令文件,并在文件中声明命令类,例如:tablestore.py,代码:

    from django.core.management import BaseCommand
    
    class Command(BaseCommand):
        help = """测试命令的帮助文档"""
    
        def add_arguments(self,parser):
            """参数设置"""
            parser.add_argument("argument",nargs="*", help="必填参数的说明") # 位置参数
            parser.add_argument("--option",'-p', default=None, help="可选参数的说明") # 选项参数
    
        def handle(self, *args, **options):
            """命令主方法
            options: 参数列表
            """
            argument = options.get("argument") # 获取位置参数
            option = options.get("option") # 获取位置参数
    
            self.stdout.write("argument: %s" % argument)
            self.stdout.write("option: %s" % option)
    
            if option is None:
                self.stdout.write("没有设置option选项参数")
    

    注意:

    1. 命令类必须继承于django.core.management.BaseCommand,并且类名必须叫Command。
    2. 命令名称就是文件名,例如,命令文件叫tablestore,则终端下调用命令为: python manage.py tablestore
    3. 命令参数左边加上--,则表示可选参数,可选参数建议设置默认值,方便在handle方法中判断进行默认处理。
    4. 命令参数如果没有--,则表示位置参数,则调用命令时,必须为当前命令传递参数,否则报错!
    

    接下来我们直接在项目中提供TableStore的操作用于创建Feed系统的表结构。

    tablestore.py

    from django.core.management import BaseCommand
    from django.conf import settings
    from tablestore import OTSClient,TableMeta, CapacityUnit, TableOptions, ReservedThroughput,PK_AUTO_INCR
    
    class Command(BaseCommand):
        help = """表格存储命令必须接收而且只接收1个命令参数,如下:
            create  表示创建项目使用的表格
            delete  表示删除项目使用的表格
            """
        def add_arguments(self,parser):
            """参数设置"""
            parser.add_argument("argument",nargs="*", help="操作类型") # 位置参数
    
        def handle(self, *args, **options):
            """表格存储的初始化"""
            argument = options.get("argument")
            if len(argument)==1:
                if argument[0] == "create":
                    """创建表格"""
                    self.create_table()
    
                elif argument[0] == "delete":
                    """删除表格"""
                    self.delete_table()
                else:
                    # 打印
                    self.stdout.write(self.help)
            else:
                self.stdout.write(self.help)
    
        # 实例化表格对象        	
        @property
        def client(self):
            return OTSClient(settings.OTS_ENDPOINT, settings.OTS_ID, settings.OTS_SECRET, settings.OTS_INSTANCE)
    
    
        def set_table(self,table_name,schema_of_primary_key,time_to_live=-1):
            # 设置表的元信息
            table_meta = TableMeta(table_name, schema_of_primary_key)
            # 设置数据的有效型
            table_option = TableOptions(time_to_live=time_to_live, max_version=5)
            # 设置数据的预留读写吞吐量
            reserved_throughput = ReservedThroughput(CapacityUnit(0, 0))
            # 创建数据
            self.client.create_table(table_meta, table_option, reserved_throughput)
    
    
        def create_table(self):
            # 创建存储库
            table_name = "user_message_table"
            schema_of_primary_key = [  # 主键列
                ('user_id', 'INTEGER'),
                ('sequence_id', 'INTEGER', PK_AUTO_INCR),
                ("sender_id", 'INTEGER'),
                ("message_id", 'INTEGER')
            ]
    
            self.set_table(table_name, schema_of_primary_key, time_to_live=7 * 86400)
            self.stdout.write("创建表格%s完成" % table_name)
    
            # 关系库
            table_name = "user_relation_table"
            # 主键列
            schema_of_primary_key = [
                ('user_id', 'INTEGER'),
                ("follow_user_id", 'INTEGER'),
            ]
            self.set_table(table_name, schema_of_primary_key)
            self.stdout.write("创建表格%s完成" % table_name)
    
            # 未读池
            table_name = "user_message_session_table"
            # 主键列
            schema_of_primary_key = [
                ('user_id', 'INTEGER'),
                ("last_sequence_id", 'INTEGER'),
            ]
            self.set_table(table_name, schema_of_primary_key)
            self.stdout.write("创建表格%s完成" % table_name)
    
        def delete_table(self):
            """删除表"""
            table_list = self.client.list_table()
            for table in table_list:
                self.client.delete_table(table)
                self.stdout.write("删除%s完成" % table)
    

以后,我们创建项目相关的表格,就可以使用python manage.py tablestore create,如果删除表格则python manage.py tablestore delete.

2.判断访问者是否关注了作者

在文章详情页中判断当前用户是否关注了文章作者

article/views.py,代码;

from renranapi.utils.ots import TableStore
# 文章详细信息
class ArticleDetailView(RetrieveAPIView):
    queryset = models.Article.objects.filter(is_delete=False,is_show=True)
    serializer_class = ArticleDetailModelSerializer

    # 重写父类方法
    def retrieve(self, request, *args, **kwargs):
        instance = self.get_object()
        serializer = self.get_serializer(instance)
        ret = serializer.data

        # 判断用户是否登录了
        print(request.user.id != ret["user"]["id"])
        if request.user.id:
            """已登录用户"""
            if request.user.id != ret["user"]["id"]:
                """非文章作者"""
                print(request.user.id)
                # 从关系库中获取当前访问者与作者之间的关注关系
                ts = TableStore()
                data = ts.get_one(
                    "user_relation_table",
                    [('user_id', ret["user"]["id"]), ('follow_user_id', request.user.id)])
                if data:
                    ret["is_focus"] = 2 # 登录已经关注了作者
                else:
                    ret["is_focus"] = 3 # 登录用户未关注作者
            else:
                ret["is_focus"] = 1 # 当前访问就是作者
        else:
            """未登录"""
            ret["is_focus"] = 0 # 游客
        return Response(ret)

封装tablestore的操作方法,renranapi.utils.ots,代码:

from tablestore import OTSClient
from django.conf import settings
class TableStore(object):
    """表格存储工具类"""

    @property
    def client(self):
        ots_client = OTSClient(
            settings.OTS_ENDPOINT, settings.OTS_ID, settings.OTS_SECRET, settings.OTS_INSTANCE
        )

        return ots_client

    def get_one(self,table_name,primary_key):
        """获取一条数据"""
        # 需要返回的属性列:。如果columns_to_get为[],则返回所有属性列。
        columns_to_get = []

        consumed, return_row, next_token = self.client.get_row(table_name, primary_key, columns_to_get)
        if return_row:
            return return_row.primary_key,return_row.attribute_columns

        return None

在客户端Article.vue中根据返回的关注状态is_focus来判断显示关注按钮

          <button data-locale="zh-CN" type="button" class="_3kba3h _1OyPqC _3Mi9q9 _34692-" v-if="article_detail_data.is_focus==0"><span>关注</span></button>
          <button data-locale="zh-CN" type="button" class="_3kba3h _1OyPqC _3Mi9q9 _34692-" v-if="article_detail_data.is_focus==2"><span>已关注</span></button>
          <button data-locale="zh-CN" type="button" class="_3kba3h _1OyPqC _3Mi9q9 _34692-" v-if="article_detail_data.is_focus==3"><span>关注</span></button>

3.用户关注或取消关注文章的作者

服务端实现用户关注作者的api接口

路由代码:users/urls.py

path("focus/", views.UserFocusAPIView.as_view()),

users/views.py,代码:


from rest_framework.permissions import IsAuthenticated
from renranapi.utils.ots import TableStore
from datetime import datetime

class UserFocusAPIView(APIView):
    """关注和取消关注"""
    permission_classes = [IsAuthenticated]

    def post(self, request):
        # 关注者[粉丝]
        user = request.user
        # 作者
        author_id = request.data.get("author_id")
        # 查询作者是否存在
        try:
            User.objects.get(pk=author_id)
        except User.DoesNotExist:
            return Response("对不起, 您关注的用户不存在!")

        # 获取关注状态[false表示取消关注,true表示关注]
        focus = request.data.get("focus")

        # 阿里表格存储
        ts = TableStore()
        if focus:
            """关注"""
            # 添加一条数据
            ret = ts.add_one(
                "user_relation_table",
                [('user_id', author_id), ('follow_user_id', user.id)],
                [('focus_time', datetime.now().timestamp())]),
        else:
            """取消关注"""
            # 删除一条数据
            ret = ts.del_one("user_relation_table",
                 [('user_id', author_id), ('follow_user_id', user.id)])
        if ret:
            return Response("操作成功!")
        else:
            return Response("操作失败!")

tablestore工具类,代码:

from tablestore import OTSClient,Row
from django.conf import settings
class TableStore(object):
    """表格存储工具类"""
    # 实例化表格对象
    @property
    def client(self):
        ots_client = OTSClient(
            settings.OTS_ENDPOINT, settings.OTS_ID, settings.OTS_SECRET, settings.OTS_INSTANCE
        )

        return ots_client

    def get_one(self,table_name,primary_key):
        """获取一条数据"""
        # 需要返回的属性列:。如果columns_to_get为[],则返回所有属性列。
        columns_to_get = []

        consumed, return_row, next_token = self.client.get_row(table_name, primary_key, columns_to_get)
        # 判断是否能获取到数据
        if return_row:
            return return_row.primary_key,return_row.attribute_columns

        return None

    # 添加一条记录
    def add_one(self,table_name,primary_key,attribute_columns):
        row = Row(primary_key, attribute_columns)
        consumed, return_row = self.client.put_row(table_name, row)
        return return_row

    # 删除一条记录
    def del_one(self,table_name,primary_key,condition=None):
        row = Row(primary_key)
        consumed, return_row = self.client.delete_row(table_name, row, condition)
        return return_row

客户端发送请求,申请 关注/取消关注

article.vue

<template>
  <div class="_21bLU4 _3kbg6I" @click="boss">
   <Header></Header>
   <div class="_3VRLsv" role="main">
    <div class="_gp-ck">
     <section class="ouvJEz">
      <h1 class="_1RuRku">{{article_detail_data.title}}</h1>
      <div class="rEsl9f">
       <div class="_2mYfmT">
        <a class="_1OhGeD" href="/u/a70487cda447" target="_blank" rel="noopener noreferrer"><img class="_13D2Eh" :src="article_detail_data.user.avatar" alt="" /></a>
        <div style="margin-left: 8px;">
         <div class="_3U4Smb">
          <span class="FxYr8x"><a class="_1OhGeD" href="/u/a70487cda447" target="_blank" rel="noopener noreferrer">{{article_detail_data.user.nickname}}</a></span>
          <button data-locale="zh-CN" type="button" class="_3kba3h _1OyPqC _3Mi9q9 _34692-" @click="user_focus(article_detail_data.is_focus)" v-if="article_detail_data.is_focus===0"><span>关注</span></button>
          <button data-locale="zh-CN" type="button" class="_3kba3h _1OyPqC _3Mi9q9 _34692-" @click="user_focus(article_detail_data.is_focus)" v-if="article_detail_data.is_focus===3"><span>关注</span></button>
          <button data-locale="zh-CN" type="button" class="_3kba3h _1OyPqC _3Mi9q9 _34692-" @click="user_focus(article_detail_data.is_focus)" v-if="article_detail_data.is_focus===2"><span>已关注</span></button>
         </div>
         <div class="s-dsoj">
          <time datetime="2020-01-08T12:01:00.000Z">{{article_detail_data.pub_date}}</time>
          <span>字数 {{article_detail_data.content?article_detail_data.content.length:0}}</span>
          <span>阅读 {{article_detail_data.read_count}}</span>
         </div>
        </div>
       </div>
      </div>
      <article class="_2rhmJa" v-html="article_detail_data.render">
       </article>
      <div></div>
      <div class="_1kCBjS">
       <div class="_18vaTa">
        <div class="_3BUZPB">
         <div class="_2Bo4Th" role="button" tabindex="-1" aria-label="给文章点赞">
          <i aria-label="ic-like" class="anticon">
           <svg width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false" class="">
            <use xlink:href="#ic-like"></use>
           </svg></i>
         </div>
         <span class="_1LOh_5" role="button" tabindex="-1" aria-label="查看点赞列表">{{article_detail_data.like_count}}<i aria-label="icon: right" class="anticon anticon-right">
           <svg viewbox="64 64 896 896" focusable="false" class="" data-icon="right" width="1em" height="1em" fill="currentColor" aria-hidden="true">
            <path d="M765.7 486.8L314.9 134.7A7.97 7.97 0 0 0 302 141v77.3c0 4.9 2.3 9.6 6.1 12.6l360 281.1-360 281.1c-3.9 3-6.1 7.7-6.1 12.6V883c0 6.7 7.7 10.4 12.9 6.3l450.8-352.1a31.96 31.96 0 0 0 0-50.4z"></path>
           </svg></i></span>
        </div>
        <div class="_3BUZPB">
         <div class="_2Bo4Th" role="button" tabindex="-1">
          <i aria-label="ic-dislike" class="anticon">
           <svg width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false" class="">
            <use xlink:href="#ic-dislike"></use>
           </svg></i>
         </div>
        </div>
       </div>
       <div class="_18vaTa">
        <a class="_3BUZPB _1x1ok9 _1OhGeD" href="/nb/38290018" target="_blank" rel="noopener noreferrer"><i aria-label="ic-notebook" class="anticon">
          <svg width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false" class="">
           <use xlink:href="#ic-notebook"></use>
          </svg></i><span>{{article_detail_data.collection.name}}</span></a>
        <div class="_3BUZPB ant-dropdown-trigger">
         <div class="_2Bo4Th">
          <i aria-label="ic-others" class="anticon">
           <svg width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false" class="">
            <use xlink:href="#ic-others"></use>
           </svg></i>
         </div>
        </div>
       </div>
      </div>
      <div class="_19DgIp" style="margin-top:24px;margin-bottom:24px"></div>
      <div class="_13lIbp">
       <div class="_191KSt">
        &quot;小礼物走一走,来荏苒关注我&quot;
       </div>
       <button @click.stop="show_reward" type="button" class="_1OyPqC _3Mi9q9 _2WY0RL _1YbC5u"><span>赞赏支持</span></button>
       <span class="_3zdmIj">还没有人赞赏,支持一下</span>
      </div>
      <div class="d0hShY">
       <a class="_1OhGeD" href="/u/a70487cda447" target="_blank" rel="noopener noreferrer"><img class="_27NmgV" :src="article_detail_data.user.avatar" alt="  " /></a>
       <div class="Uz-vZq">
        <div class="Cqpr1X">
         <a class="HC3FFO _1OhGeD" href="/u/a70487cda447" title="書酱" target="_blank" rel="noopener noreferrer">{{article_detail_data.user.nickname}}</a>
         <span class="_2WEj6j" title="你读书的样子真好看。">{{article_detail_data.title}}</span>
        </div>
        <div class="lJvI3S">
         <span>总资产0</span>
         <span>共写了78.7W字</span>
         <span>获得6,072个赞</span>
         <span>共1,308个粉丝</span>
        </div>
       </div>
          <button data-locale="zh-CN" type="button" class="_3kba3h _1OyPqC _3Mi9q9 _34692-" @click="user_focus(article_detail_data.is_focus)" v-if="article_detail_data.is_focus===0"><span>关注</span></button>
          <button data-locale="zh-CN" type="button" class="_3kba3h _1OyPqC _3Mi9q9 _34692-" @click="user_focus(article_detail_data.is_focus)" v-if="article_detail_data.is_focus===3"><span>关注</span></button>
          <button data-locale="zh-CN" type="button" class="_3kba3h _1OyPqC _3Mi9q9 _34692-" @click="user_focus(article_detail_data.is_focus)" v-if="article_detail_data.is_focus===2"><span>已关注</span></button>

      </div>
     </section>
     <div id="note-page-comment">
      <div class="lazyload-placeholder"></div>
     </div>
    </div>
    <aside class="_2OwGUo">
     <section class="_3Z3nHf">
      <div class="_3Oo-T1">
       <a class="_1OhGeD" href="/u/a70487cda447" target="_blank" rel="noopener noreferrer"><img class="_3T9iJQ" :src="article_detail_data.user.avatar" alt="" /></a>
       <div class="_32ZTTG">
        <div class="_2O0T_w">
         <div class="_2v-h3G">
          <span class="_2vh4fr" title="書酱"><a class="_1OhGeD" href="/u/a70487cda447" target="_blank" rel="noopener noreferrer">{{article_detail_data.user.nickname}}</a></span>
         </div>
          <button data-locale="zh-CN" type="button" class="_3kba3h _1OyPqC _3Mi9q9 _34692-" @click="user_focus(article_detail_data.is_focus)" v-if="article_detail_data.is_focus===0"><span>关注</span></button>
          <button data-locale="zh-CN" type="button" class="_3kba3h _1OyPqC _3Mi9q9 _34692-" @click="user_focus(article_detail_data.is_focus)" v-if="article_detail_data.is_focus===3"><span>关注</span></button>
          <button data-locale="zh-CN" type="button" class="_3kba3h _1OyPqC _3Mi9q9 _34692-" @click="user_focus(article_detail_data.is_focus)" v-if="article_detail_data.is_focus===2"><span>已关注</span></button>
        </div>
        <div class="_1pXc22">
         总资产0
        </div>
       </div>
      </div>
      <div class="_19DgIp"></div>
     </section>
     <div>
      <div class="">
       <section class="_3Z3nHf">
        <h3 class="QHRnq8 QxT4hD"><span>推荐阅读</span></h3>
        <div class="cuOxAY" role="listitem">
         <div class="_3L5YSq" title="这些话没人告诉你,但必须知道的社会规则">
          <a class="_1-HJSV _1OhGeD" href="/p/a3e56a0559ff" target="_blank" rel="noopener noreferrer">这些话没人告诉你,但必须知道的社会规则</a>
         </div>
         <div class="_19haGh">
          阅读 5,837
         </div>
        </div>

       </section>
      </div>
     </div>
    </aside>
   </div>

<!--  打赏弹窗-->
   <div class="_23ISFX-body" v-if="is_show_reward_window" @click.stop="is_show_reward_window=true">
   <div class="_3uZ5OL">
    <div class="_2PLkjk">
     <img class="_2R1-48" src="https://upload.jianshu.io/users/upload_avatars/9602437/8fb37921-2e4f-42a7-8568-63f187c5721b.jpg?imageMogr2/auto-orient/strip|imageView2/1/w/100/h/100/format/webp" alt="" />
     <div class="_2h5tnQ">
      给作者送糖
     </div>
    </div>
    <div class="_1-bCJJ">
     <div class="LMa6S_ el-icon-lollipop" :class="reward_info.money==num?'_1vONvL':''" @click="reward_info.money=num" v-for="(num,index) in reward_list" :key="index"><span>{{num}}</span></div>
    </div>
    <textarea v-model="liuyan" class="_1yN79W" placeholder="给Ta留言..."></textarea>
    <div class="_1_B577">
     选择支付方式
    </div>
    <div class="_1-bCJJ">
     <div class="LMa6S_ _3PA8BN " :class="reward_info.pay_type==type?'_1vONvL':''" @click="reward_info.pay_type=type" v-for="type in pay_type_list"><span>{{type}}</span></div>
    </div>
    <button type="button" class="_3A-4KL _1OyPqC _3Mi9q9 _1YbC5u" @click="payhandler"><span>确认支付</span><span> ¥</span>{{reward_info.money}}</button>
   </div>
  </div>

   <Footer></Footer>
  </div>
</template>

<script>
    import Header from "./common/Header";
    import Footer from "./common/Footer";
    export default {
      name: "Article",
      components:{
        Header,
        Footer,
      },
      data(){
        return{
          token:'',
          article_detail_data:{
            user:{},
            collection:{},
          }, // 存放文章详情数据
          is_show_reward_window:false, // 是否显示支付弹窗
          reward_list:[2,5,10,20,50,100],// 支付金额列表
          pay_type_list: ["支付宝","微信"],
          liuyan:'', // 打赏留言
          reward_info:{   // 打赏信息
              money: 2,
              content:"",
              pay_type:"支付宝",
          },
        }
      },
      created() {
        this.token = this.$settings.check_user_login(this,false);
        this.get_article_detail()
      },
      methods:{
        //关注与取消关注
        user_focus(is_focus){
          if(is_focus===0){
            // 未登录用户,游客,跳转到登录页面
            this.$router.push('/login')
          }else if((is_focus===2)||(is_focus===3)){
            // 关注或取消关注
            this.$axios.post(`${this.$settings.host}/users/focus/`,{
              author_id:this.article_detail_data.user.id,
              focus:is_focus===3,
            },{
            headers:{
                Authorization: "jwt " + this.token,
              }
            }).then((res)=>{
              if(is_focus===2){
                this.article_detail_data.is_focus = 3
              }else {
                this.article_detail_data.is_focus = 2
              }
            }).catch((error)=>{
              this.$message.error('关注或取消关注失败!')
            })
          }
        },

        // 点击确认支付
        payhandler(){
          this.$axios.post(`${this.$settings.host}/payments/alipay/`,{
            article_id : this.$route.params.id,
            money:this.reward_info.money,
            pay_type:0, // 后台0--支付宝,1--微信
            liuyan:this.liuyan,
          },{
            headers:{
                Authorization: "jwt " + this.token,
              }
          }).then((res)=>{
            // 请求成功跳转到支付宝页面
            location.href = res.data.alipay_url
            this.$message.success('请求成功!')
          }).catch((error)=>{
            this.$message.error('支付失败!')
          })
        },

        // 最外层标签点击事件
        boss(){
          this.is_show_reward_window = false
        },

        // 点击打赏,弹出打赏弹窗
        show_reward(){
          this.is_show_reward_window = true
        },

        // 获取文章详细信息
        get_article_detail(){
          let headers = {};
          if (this.token){
            headers={
              Authorization: "jwt " + this.token,
            }
          }
          this.$axios.get(`${this.$settings.host}/article/article_detail/${this.$route.params.id}/`,{
            headers:headers
          }).then((res)=>{
            this.article_detail_data = res.data;
            this.$message.success('获取文章详情数据成功')
          }).catch((error)=>{
            this.$message.error('获取文章详情数据失败!')
          })
        }
      },

    }
</script>

4.作者发布文章以后, 推送Feed流

视图代码,article.views,修改代码:

# 改变文章发布状态
class ChangeArticlePublicView(APIView):
    permission_classes = [IsAuthenticated, ]
    # 发布文章
    def put(self,request,pk):
        is_public = request.data.get('is_public')
        
        # 推送feed,给粉丝推送文章
        # 获取当前作者的粉丝
        ts = TableStore()
        # 获取粉丝类表
        fans_list = ts.get_author_fans(request.user.id)
        if len(fans_list)>0:
            # 给每一个粉丝推送feed
            ts.push_feed(request.user.id,pk, fans_list)

        try:
            Article.objects.filter(pk=pk).update(
                is_public=is_public
            )
            return Response({'msg':'ok'})
        except:
            logger.error(f'id为{pk}的文章,发布失败!')
            return Response({'msg':'not ok'},status=507)

    # 取消发布
    def post(self,request,pk):
        is_public = request.data.get('is_public')
        try:
            Article.objects.filter(pk=pk).update(
                is_public=is_public
            )
            return Response({'msg':'ok'})
        except:
            logger.error(f'id为{pk}的文章,取消发布失败!')
            return Response({'msg':'not ok'},status=507)

tablestore工具类,renranapi.utils.ots,代码:

from tablestore import *
from django.conf import settings
from datetime import datetime

class TableStore(object):
    """表格存储工具类"""
    # 实例化表格对象
    @property
    def client(self):
        ots_client = OTSClient(
            settings.OTS_ENDPOINT, settings.OTS_ID, settings.OTS_SECRET, settings.OTS_INSTANCE
        )

        return ots_client

    def get_one(self,table_name,primary_key):
        """获取一条数据"""
        # 需要返回的属性列:。如果columns_to_get为[],则返回所有属性列。
        columns_to_get = []

        consumed, return_row, next_token = self.client.get_row(table_name, primary_key, columns_to_get)
        # 判断是否能获取到数据
        if return_row:
            return return_row.primary_key,return_row.attribute_columns

        return None

    # 添加一条记录
    def add_one(self,table_name,primary_key,attribute_columns):
        row = Row(primary_key, attribute_columns)
        consumed, return_row = self.client.put_row(table_name, row)
        return return_row

    # 删除一条记录
    def del_one(self,table_name,primary_key,condition=None):
        row = Row(primary_key)
        consumed, return_row = self.client.delete_row(table_name, row, condition)
        return return_row


    def get_author_fans(self,author_id):
        """获取指定作者的粉丝列表"""
        table_name = "user_relation_table"
        # 范围查询的起始主键
        inclusive_start_primary_key = [
            ('user_id', author_id),
            ('follow_user_id', INF_MIN)
        ]

        # 范围查询的结束主键
        exclusive_end_primary_key = [
            ('user_id', author_id),
            ('follow_user_id', INF_MAX)
        ]

        # 查询所有列
        columns_to_get = []  # 表示返回所有列

        # 范围查询接口
        consumed, next_start_primary_key, row_list, next_token = self.client.get_range(
            table_name,  # 操作表明
            Direction.FORWARD,  # 范围的方向,字符串格式,取值包括'FORWARD'和'BACKWARD'。
            inclusive_start_primary_key, exclusive_end_primary_key,  # 取值范围
            columns_to_get,  # 返回字段列
            max_version=1  # 返回版本数量
        )

        fans_list = []
        for row in row_list:
            fans_list.append(int(row.primary_key[1][1]))    # (("user_id",1),("follow_user_id",7))
        print(f"fans_list={fans_list}")
        return fans_list

    # 给每一个粉丝推送文章
    def push_feed(self,author_id, article_id, fans_list):
        """
        给每一个粉丝推送feed
        :param author_id: 作者ID
        :param article_id: 文章id
        :param fans_list: 粉丝列表[成员:用户ID]
        :return:
        """
        put_row_items = [] # 数据列表
        for user_id in fans_list:
            # 主键列
            primary_key = [  # ('主键名', 值),
                ('user_id', user_id),  # 接收Feed的用户ID
                ('sequence_id', PK_AUTO_INCR),  # 如果是自增主键,则值就是 PK_AUTO_INCR
                ("sender_id", int(author_id)),  # 发布Feed的作者ID
                ("message_id", int(article_id)),  # 文章ID
            ]
            # 额外参数
            attribute_columns = [('recevice_time', datetime.now().timestamp()), ('read_status', False)]
            row = Row(primary_key, attribute_columns) # 数据对象
            condition = Condition(RowExistenceExpectation.IGNORE) # 忽略数据库已经存在的数据
            item = PutRowItem(row, condition)
            put_row_items.append(item)

        # 添加数据
        table_name = "user_message_table"
        request = BatchWriteRowRequest()
        request.add(TableInBatchWriteRowItem(table_name, put_row_items))
        result = self.client.batch_write_row(request)
        return result.is_all_succeed()

5.首页数据内容展示

首页数据在Feed流系统中, 需要考虑2大问题:

针对没有登录的游客显示内容的问题:

  1. 纯粹的游客
  2. 已经注册但是没有关注过任何作者
解决:
   1. 从数据库查找到热门内容推送给用户[评论量高的, 赞赏量高的, 点赞量高的]
      如果是登录用户, 在查看了内容后, 针对用户ID保存查看记录, 哪一篇用户看过了就记录到tablestore里面
      如果是游客, 1. 在本地存储中,记录用户的浏览历史
                 2. 在tablestore里面记录当前IP的浏览历史

针对登录的用户显示内容的问题:

  1. 用户关注了很多作者
  2. 用户关注了作者很少, 这些作者可能没有新的内容产生
解决推送内容不足的情况, 接下来我们可以根据用户行为进行分析的实现基于物品的协同过滤算法来计算出用户的兴趣, 进行智能推荐.

综合上面所述,我们必须要显示首页的内容先然后对显示的内容进行一下步骤的过滤

1. 判断用户是否登录
   1.1. 用户登录了
        1.1.1 用户已经关注了其他作者
          1.1.1.1 用户关注作者中,有足够的内容展示给用户
          1.1.1.2 用户关注作者中,没有足够内容展示给用户
        1.1.2 用户没有关注任何的作者
          1.1.1.1 根据浏览历史查找内容进行热度推荐
   1.2. 用户未登录
        1.1.3 根据浏览历史查找内容进行热度推荐

5.1 首页展示10条数据

服务端接口实现

视图接口,home.views代码:

from datetime import datetime
from .serializers import ArticleListSeiralizer
from .paginations import ArticleListPagination
from article.models import Article
class ArticleAPIView(ListAPIView):
    serializer_class = ArticleListSeiralizer
    pagination_class = ArticleListPagination
    def get_queryset(self):
        now = datetime.fromtimestamp( datetime.now().timestamp() - 7 * 24 * 3600 )
        print(now)
        data = Article.objects.filter(
            is_show=True,
            is_delete=False,
            is_public=True,
            created_time__gt=now,
        ).order_by('orders','-read_count','-like_count','-collect_count','-comment_count','-reward_count','-created_time')
        return data

home.serializers,序列化器,代码:

from article.models import Article,User
class ArticlieAuthSerializer(serializers.ModelSerializer):
    """文章作者"""
    class Meta:
        model = User
        fields = ["id","nickname","avatar","username"]

class ArticleListSeiralizer(serializers.ModelSerializer):
    """文章列表的序列化器"""
    user = ArticlieAuthSerializer()
    class Meta:
        model = Article
        fields = ["id","title","user","content","pub_date","read_count","like_count","collect_count","comment_count","reward_count","is_public"]

首页文章列表的分页器,home.paginations,代码:

from rest_framework.pagination import PageNumberPagination
class ArticleListPagination(PageNumberPagination):
    """首页文章列表的分页器"""
    page_size = 10
    max_page_size = 20
    page_query_param = "p"

home.urls,路由,代码:

    path('article/', views.ArticleAPIView.as_view()),

客户端根据用户的登录状态发起请求获取推送数据

<template>
  <div id="home">
    <Header></Header>
    <div class="container">
      <div class="row">
        <div class="main">
          <!-- Banner -->
          <div class="banner">
            <el-carousel height="272px" indicator-position="none" :interval="interval">
              <el-carousel-item v-for="(banner_value,banner_index) in banner_list" :key="banner_index">

                <a :href="banner_value.link">
                  <img :src="banner_value.image" alt="">
                </a>


              </el-carousel-item>
            </el-carousel>
          </div>
          <div id="list-container">
            <!-- 文章列表模块 -->
            <ul class="note-list">
              <li class="" v-for="article in article_list">
                <div class="content">
                  <a class="title" target="_blank" href="">{{article.title}}</a>
                  <p class="abstract">{{article.content}}...</p>
                  <div class="meta">
                    <a class="nickname" target="_blank" href="">{{article.user.nickname}}</a>
                    <a target="_blank" href="">
                      <img src="/static/image/comment.svg" alt=""> {{article.comment_count}}
                    </a>
                    <span><img src="/static/image/like.svg" alt=""> {{article.like_count}}</span>
                    <span><img src="/static/image/like.svg" alt=""> {{article.collect_count}}</span>
                    <span><img src="/static/image/shang.svg" alt=""> {{article.reward_count}}</span>
                  </div>
                </div>
              </li>
<!--              <li class="have-img">-->
<!--                <a class="wrap-img" href="" target="_blank">-->
<!--&lt;!&ndash;                  <img class="img-blur-done" src="/static/image/10907624-107943365323e5b9.jpeg" />&ndash;&gt;-->
<!--                </a>-->
<!--                <div class="content">-->
<!--                  <a class="title" target="_blank" href="">“不耻下问”,正在毁掉你的人生</a>-->
<!--                  <p class="abstract">-->
<!--                    在过去,遇到不懂的问题,你不耻下问,找个人问问就行;在现在,如果你还这么干,多半会被认为是“搜商低”。 昨天,35岁的表姐把我拉黑了。 表姐是医...-->
<!--                  </p>-->
<!--                  <div class="meta">-->
<!--                    <span class="jsd-meta">-->
<!--                      <img src="/static/image/paid1.svg" alt=""> 6.7-->
<!--                    </span>-->
<!--                    <a class="nickname" target="_blank" href="">_飞鱼</a>-->
<!--                    <a target="_blank" href="">-->
<!--                      <img src="/static/image/comment.svg" alt=""> 33-->
<!--                    </a>-->
<!--                    <span><img src="/static/image/like.svg" alt=""> 113</span>-->
<!--                    <span><img src="/static/image/shang.svg" alt=""> 2</span>-->
<!--                  </div>-->
<!--                </div>-->
<!--              </li>-->
            </ul>
            <!-- 文章列表模块 -->
          </div>
        <a href="" class="load-more">阅读更多</a></div>
        <div class="aside">
          <!-- 推荐作者 -->
          <div class="recommended-author-wrap">
            <!---->
            <div class="recommended-authors">
              <div class="title">
                <span>推荐作者</span>
                <a class="page-change"><img class="icon-change" src="/static/image/exchange-rate.svg" alt="">换一批</a>
              </div>
              <ul class="list">
                <li>
                  <a href="" target="_blank" class="avatar">
                    <img src="/static/image/avatar.webp" />
                  </a>
                  <a class="follow" state="0"><img src="/static/image/follow.svg" alt="" />关注</a>
                  <a href="" target="_blank" class="name">董克平日记</a>
                  <p>写了807.1k字 · 2.5k喜欢</p>
                </li>
                <li>
                  <a href="" target="_blank" class="avatar">
                    <img src="/static/image/avatar.webp" />
                  </a>
                  <a class="follow" state="0"><img src="/static/image/follow.svg" alt="" />关注</a>
                  <a href="" target="_blank" class="name">董克平日记</a>
                  <p>写了807.1k字 · 2.5k喜欢</p>
                </li>

              </ul>
              <a href="" target="_blank" class="find-more">查看全部 ></a>
              <!---->
            </div>
          </div>
        </div>
      </div>
    </div>
    <Footer></Footer>
  </div>
</template>
<script>
  import Header from "./common/Header";
  import Footer from "./common/Footer";
  export default {
      name:"Home",
      data(){
          return {
            banner_list:[],
            article_list:[],
            interval: 2000,
          }
      },

      methods:{
        get_banner_list(){
          this.$axios.get(`${this.$settings.host}/home/banner/list/`)
            .then((res)=>{
              this.banner_list = res.data;
            }).catch((error)=>{
          });
        },
        get_article_list(){
          // 请求获取推送文章列表
          let headers = {}
          if(this.token){
            headers={
              Authorization: "jwt " + this.token,
            }
          }
          this.$axios.get(`${this.$settings.host}/home/article/`,{
            headers:headers
          })
            .then((res)=>{
              this.article_list = res.data.results; // 分页返回的数据保存在result属性中
              // 本地记录,用户查看的数据
              this.history_article_list();
            }).catch((error)=>{
          });
        },
        history_article_list(){
          // todo 本地记录,用户查看的数据

        }
      },
      created() {
        this.get_banner_list();
        // this.get_nav_top_list();
        this.get_article_list();
        this.token = this.$settings.check_user_login(this,false);
      },


    components:{
        Header,
        Footer,
      }
  }
</script>

5.2 给予分页功能,实现点击更多

<template>
  <div id="home">
    <Header></Header>
    <div class="container">
      <div class="row">
        <div class="main">
          <!-- Banner -->
          <div class="banner">
            <el-carousel height="272px" indicator-position="none" :interval="interval">
              <el-carousel-item v-for="(banner_value,banner_index) in banner_list" :key="banner_index">

                <a :href="banner_value.link">
                  <img :src="banner_value.image" alt="">
                </a>


              </el-carousel-item>
            </el-carousel>
          </div>
          <div id="list-container">
            <!-- 文章列表模块 -->
            <ul class="note-list">
              <li class="" v-for="article in article_list">
                <div class="content">
                  <a class="title" target="_blank" href="">{{article.title}}</a>
                  <p class="abstract">{{article.content}}...</p>
                  <div class="meta">
                    <a class="nickname" target="_blank" href="">{{article.user.nickname}}</a>
                    <a target="_blank" href="">
                      <img src="/static/image/comment.svg" alt=""> {{article.comment_count}}
                    </a>
                    <span><img src="/static/image/like.svg" alt=""> {{article.like_count}}</span>
                    <span><img src="/static/image/like.svg" alt=""> {{article.collect_count}}</span>
                    <span><img src="/static/image/shang.svg" alt=""> {{article.reward_count}}</span>
                  </div>
                </div>
              </li>
<!--              <li class="have-img">-->
<!--                <a class="wrap-img" href="" target="_blank">-->
<!--&lt;!&ndash;                  <img class="img-blur-done" src="/static/image/10907624-107943365323e5b9.jpeg" />&ndash;&gt;-->
<!--                </a>-->
<!--                <div class="content">-->
<!--                  <a class="title" target="_blank" href="">“不耻下问”,正在毁掉你的人生</a>-->
<!--                  <p class="abstract">-->
<!--                    在过去,遇到不懂的问题,你不耻下问,找个人问问就行;在现在,如果你还这么干,多半会被认为是“搜商低”。 昨天,35岁的表姐把我拉黑了。 表姐是医...-->
<!--                  </p>-->
<!--                  <div class="meta">-->
<!--                    <span class="jsd-meta">-->
<!--                      <img src="/static/image/paid1.svg" alt=""> 6.7-->
<!--                    </span>-->
<!--                    <a class="nickname" target="_blank" href="">_飞鱼</a>-->
<!--                    <a target="_blank" href="">-->
<!--                      <img src="/static/image/comment.svg" alt=""> 33-->
<!--                    </a>-->
<!--                    <span><img src="/static/image/like.svg" alt=""> 113</span>-->
<!--                    <span><img src="/static/image/shang.svg" alt=""> 2</span>-->
<!--                  </div>-->
<!--                </div>-->
<!--              </li>-->
            </ul>
            <!-- 文章列表模块 -->
          </div>
        <a class="load-more" @click="read_more">阅读更多</a></div>
        <div class="aside">
          <!-- 推荐作者 -->
          <div class="recommended-author-wrap">
            <!---->
            <div class="recommended-authors">
              <div class="title">
                <span>推荐作者</span>
                <a class="page-change"><img class="icon-change" src="/static/image/exchange-rate.svg" alt="">换一批</a>
              </div>
              <ul class="list">
                <li>
                  <a href="" target="_blank" class="avatar">
                    <img src="/static/image/avatar.webp" />
                  </a>
                  <a class="follow" state="0"><img src="/static/image/follow.svg" alt="" />关注</a>
                  <a href="" target="_blank" class="name">董克平日记</a>
                  <p>写了807.1k字 · 2.5k喜欢</p>
                </li>
                <li>
                  <a href="" target="_blank" class="avatar">
                    <img src="/static/image/avatar.webp" />
                  </a>
                  <a class="follow" state="0"><img src="/static/image/follow.svg" alt="" />关注</a>
                  <a href="" target="_blank" class="name">董克平日记</a>
                  <p>写了807.1k字 · 2.5k喜欢</p>
                </li>

              </ul>
              <a href="" target="_blank" class="find-more">查看全部 ></a>
              <!---->
            </div>
          </div>
        </div>
      </div>
    </div>
    <Footer></Footer>
  </div>
</template>
<script>
  import Header from "./common/Header";
  import Footer from "./common/Footer";
  export default {
      name:"Home",
      data(){
          return {
            banner_list:[],
            article_list:[],
            interval: 2000,
            page: 1, // 当前推送数据的页数
          }
      },

      methods:{
        get_banner_list(){
          this.$axios.get(`${this.$settings.host}/home/banner/list/`)
            .then((res)=>{
              this.banner_list = res.data;
            }).catch((error)=>{
          });
        },
        get_article_list(){
          // 请求获取推送文章列表
          let headers = {}
          if(this.token){
            headers={
              Authorization: "jwt " + this.token,
            }
          }
          this.$axios.get(`${this.$settings.host}/home/article/`,{
            params:{
              p: this.page,
            },
            headers:headers
          })
            .then((res)=>{
              console.log(res.data.results);
              if(res.data.results.length > 0){
                this.article_list = this.article_list.concat(res.data.results); // 分页返回的数据保存在result属性中
                // 本地记录,用户查看的数据
                this.history_article_list();
              }
            }).catch((error)=>{

            });
        },
        read_more(){
          // 阅读更多
          this.page+=1;
          this.get_article_list();
        },
        history_article_list(){
          // todo 本地记录,用户查看的数据

        },
      },
      created() {
        this.get_banner_list();
        // this.get_nav_top_list();
        this.get_article_list();
        this.token = this.$settings.check_user_login(this,false);
      },
    components:{
        Header,
        Footer,
      }
  }
</script>

ajax防抖处理

<template>
  <div id="home">
    <Header></Header>
    <div class="container">
      <div class="row">
        <div class="main">
          <!-- Banner -->
          <div class="banner">
            <el-carousel height="272px" indicator-position="none" :interval="interval">
              <el-carousel-item v-for="(banner_value,banner_index) in banner_list" :key="banner_index">

                <a :href="banner_value.link">
                  <img :src="banner_value.image" alt="">
                </a>


              </el-carousel-item>
            </el-carousel>
          </div>
          <div id="list-container">
            <!-- 文章列表模块 -->
            <ul class="note-list">
              <li class="" v-for="article in article_list">
                <div class="content">
                  <a class="title" target="_blank" href="">{{article.title}}</a>
                  <p class="abstract">{{article.content}}...</p>
                  <div class="meta">
                    <a class="nickname" target="_blank" href="">{{article.user.nickname}}</a>
                    <a target="_blank" href="">
                      <img src="/static/image/comment.svg" alt=""> {{article.comment_count}}
                    </a>
                    <span><img src="/static/image/like.svg" alt=""> {{article.like_count}}</span>
                    <span><img src="/static/image/like.svg" alt=""> {{article.collect_count}}</span>
                    <span><img src="/static/image/shang.svg" alt=""> {{article.reward_count}}</span>
                  </div>
                </div>
              </li>
<!--              <li class="have-img">-->
<!--                <a class="wrap-img" href="" target="_blank">-->
<!--&lt;!&ndash;                  <img class="img-blur-done" src="/static/image/10907624-107943365323e5b9.jpeg" />&ndash;&gt;-->
<!--                </a>-->
<!--                <div class="content">-->
<!--                  <a class="title" target="_blank" href="">“不耻下问”,正在毁掉你的人生</a>-->
<!--                  <p class="abstract">-->
<!--                    在过去,遇到不懂的问题,你不耻下问,找个人问问就行;在现在,如果你还这么干,多半会被认为是“搜商低”。 昨天,35岁的表姐把我拉黑了。 表姐是医...-->
<!--                  </p>-->
<!--                  <div class="meta">-->
<!--                    <span class="jsd-meta">-->
<!--                      <img src="/static/image/paid1.svg" alt=""> 6.7-->
<!--                    </span>-->
<!--                    <a class="nickname" target="_blank" href="">_飞鱼</a>-->
<!--                    <a target="_blank" href="">-->
<!--                      <img src="/static/image/comment.svg" alt=""> 33-->
<!--                    </a>-->
<!--                    <span><img src="/static/image/like.svg" alt=""> 113</span>-->
<!--                    <span><img src="/static/image/shang.svg" alt=""> 2</span>-->
<!--                  </div>-->
<!--                </div>-->
<!--              </li>-->
            </ul>
            <!-- 文章列表模块 -->
          </div>
        <a class="load-more" v-if="has_more" @click="read_more">阅读更多</a></div>
        <div class="aside">
          <!-- 推荐作者 -->
          <div class="recommended-author-wrap">
            <!---->
            <div class="recommended-authors">
              <div class="title">
                <span>推荐作者</span>
                <a class="page-change"><img class="icon-change" src="/static/image/exchange-rate.svg" alt="">换一批</a>
              </div>
              <ul class="list">
                <li>
                  <a href="" target="_blank" class="avatar">
                    <img src="/static/image/avatar.webp" />
                  </a>
                  <a class="follow" state="0"><img src="/static/image/follow.svg" alt="" />关注</a>
                  <a href="" target="_blank" class="name">董克平日记</a>
                  <p>写了807.1k字 · 2.5k喜欢</p>
                </li>
                <li>
                  <a href="" target="_blank" class="avatar">
                    <img src="/static/image/avatar.webp" />
                  </a>
                  <a class="follow" state="0"><img src="/static/image/follow.svg" alt="" />关注</a>
                  <a href="" target="_blank" class="name">董克平日记</a>
                  <p>写了807.1k字 · 2.5k喜欢</p>
                </li>

              </ul>
              <a href="" target="_blank" class="find-more">查看全部 ></a>
              <!---->
            </div>
          </div>
        </div>
      </div>
    </div>
    <Footer></Footer>
  </div>
</template>
<script>
  import Header from "./common/Header";
  import Footer from "./common/Footer";
  export default {
      name:"Home",
      data(){
          return {
            banner_list:[],
            article_list:[],
            interval: 2000,
            page: 1, // 当前推送数据的页数
            is_send_ajax: false,
            has_more: true, // 阅读更多按钮的显示
          }
      },

      methods:{
        get_banner_list(){
          this.$axios.get(`${this.$settings.host}/home/banner/list/`)
            .then((res)=>{
              this.banner_list = res.data;
            }).catch((error)=>{
          });
        },
        get_article_list(){
          // 请求获取推送文章列表

          // 判断是否在ajax请求中
          if(this.is_send_ajax){
            return ; // 组织代码继续往下执行
          }

          let headers = {}
          if(this.token){
            headers={
              Authorization: "jwt " + this.token,
            }
          }

          // 进入ajax请求状态中
          this.is_send_ajax = true;

          this.$axios.get(`${this.$settings.host}/home/article/`,{
            params:{
              p: this.page,
            },
            headers:headers
          })
            .then((res)=>{
              console.log(res.data.results);
              if(res.data.results.length > 0){
                this.article_list = this.article_list.concat(res.data.results); // 分页返回的数据保存在result属性中
                // 本地记录,用户查看的数据
                this.history_article_list();
                this.is_send_ajax = false; // 退出请求状态
              }else{
                this.has_more = false; // 不再显示阅读更多
              }
            }).catch((error)=>{
              this.is_send_ajax = false; // 退出请求状态
              this.has_more = false; // 不再显示阅读更多
            });
        },
        read_more(){
          // 阅读更多
          if(this.is_send_ajax){
            return ;
          }
          this.page+=1;
          this.get_article_list();
        },
        history_article_list(){
          // todo 本地记录,用户查看的数据

        },
      },
      created() {
        this.get_banner_list();
        // this.get_nav_top_list();
        this.get_article_list();
        this.token = this.$settings.check_user_login(this,false);
      },
    components:{
        Header,
        Footer,
      }
  }
</script>

本地记录客户端接收过的推送数据

<template>
  <div id="home">
    <Header></Header>
    <div class="container">
      <div class="row">
        <div class="main">
          <!-- Banner -->
          <div class="banner">
            <el-carousel height="272px" indicator-position="none" :interval="interval">
              <el-carousel-item v-for="(banner_value,banner_index) in banner_list" :key="banner_index">

                <a :href="banner_value.link">
                  <img :src="banner_value.image" alt="">
                </a>


              </el-carousel-item>
            </el-carousel>
          </div>
          <div id="list-container">
            <!-- 文章列表模块 -->
            <ul class="note-list">
              <li class="" v-for="article in article_list">
                <div class="content">
                  <a class="title" target="_blank" href="">{{article.title}}</a>
                  <p class="abstract">{{article.content}}...</p>
                  <div class="meta">
                    <a class="nickname" target="_blank" href="">{{article.user.nickname}}</a>
                    <a target="_blank" href="">
                      <img src="/static/image/comment.svg" alt=""> {{article.comment_count}}
                    </a>
                    <span><img src="/static/image/like.svg" alt=""> {{article.like_count}}</span>
                    <span><img src="/static/image/like.svg" alt=""> {{article.collect_count}}</span>
                    <span><img src="/static/image/shang.svg" alt=""> {{article.reward_count}}</span>
                  </div>
                </div>
              </li>
            </ul>
            <!-- 文章列表模块 -->
          </div>
        <a class="load-more" v-if="has_more" @click="read_more">阅读更多</a></div>
        <div class="aside">
          <!-- 推荐作者 -->
          <div class="recommended-author-wrap">
            <!---->
            <div class="recommended-authors">
              <div class="title">
                <span>推荐作者</span>
                <a class="page-change"><img class="icon-change" src="/static/image/exchange-rate.svg" alt="">换一批</a>
              </div>
              <ul class="list">
                <li>
                  <a href="" target="_blank" class="avatar">
                    <img src="/static/image/avatar.webp" />
                  </a>
                  <a class="follow" state="0"><img src="/static/image/follow.svg" alt="" />关注</a>
                  <a href="" target="_blank" class="name">董克平日记</a>
                  <p>写了807.1k字 · 2.5k喜欢</p>
                </li>
                <li>
                  <a href="" target="_blank" class="avatar">
                    <img src="/static/image/avatar.webp" />
                  </a>
                  <a class="follow" state="0"><img src="/static/image/follow.svg" alt="" />关注</a>
                  <a href="" target="_blank" class="name">董克平日记</a>
                  <p>写了807.1k字 · 2.5k喜欢</p>
                </li>

              </ul>
              <a href="" target="_blank" class="find-more">查看全部 ></a>
              <!---->
            </div>
          </div>
        </div>
      </div>
    </div>
    <Footer></Footer>
  </div>
</template>
<script>
  import Header from "./common/Header";
  import Footer from "./common/Footer";
  export default {
      name:"Home",
      data(){
          return {
            banner_list:[],
            article_list:[],
            interval: 2000,
            page: 1, // 当前推送数据的页数
            is_send_ajax: false,
            has_more: true, // 阅读更多按钮的显示
          }
      },

      methods:{
        get_banner_list(){
          this.$axios.get(`${this.$settings.host}/home/banner/list/`)
            .then((res)=>{
              this.banner_list = res.data;
            }).catch((error)=>{
          });
        },
        get_article_list(){
          // 请求获取推送文章列表
          // 判断是否在ajax请求中
          if(this.is_send_ajax){
            return ; // 组织代码继续往下执行
          }

          let headers = {}
          if(this.token){
            headers.Authorization = "jwt " + this.token;
          }

          // 进入ajax请求状态中
          this.is_send_ajax = true;

          this.$axios.get(`${this.$settings.host}/home/article/`,{
            params:{
              p: this.page,
              visit: localStorage.visit_list || ""
            },
            headers:headers
          })
            .then((res)=>{
              console.log(res.data.results);
              if(res.data.results.length > 0){
                this.article_list = this.article_list.concat(res.data.results); // 分页返回的数据保存在result属性中
                // 本地记录,用户查看的数据
                this.history_article_list();
                this.is_send_ajax = false; // 退出请求状态
              }else{
                this.has_more = false; // 不再显示阅读更多
              }
            }).catch((error)=>{
              this.is_send_ajax = false; // 退出请求状态
              this.has_more = false; // 不再显示阅读更多
            });
        },
        read_more(){
          // 阅读更多
          if(this.is_send_ajax){
            return ;
          }
          this.page+=1;
          this.get_article_list();
        },
        history_article_list(){
          // 本地记录,用户查看的数据
          let visit_list = "";
          for(let article of this.article_list){
            visit_list+= ""+article.id+",";
          }
          localStorage.visit_list = visit_list;
        },
      },
      created() {
        this.get_banner_list();
        // this.get_nav_top_list();
        this.get_article_list();
        this.token = this.$settings.check_user_login(this,false);
      },
    components:{
        Header,
        Footer,
      }
  }
</script>

6.服务端推送文章

1. 判断用户是否登录
   1.1. 用户登录了
        1.1.1 用户已经关注了其他作者
          1.1.1.1 用户关注作者中,有足够的内容展示给用户
          1.1.1.2 用户关注作者中,没有足够内容展示给用户
        1.1.2 用户没有关注任何的作者
          1.1.1.1 根据浏览历史查找内容进行热度推荐
   1.2. 用户未登录
        1.1.3 根据浏览历史查找内容进行热度推荐

因为用户查看首页时, 显示的文章是不能重复的,所以接下来我们需要在每次推送文章给用户的时候,当用户点击阅读的时候则必须要进行记录:

6.1 实现用户和Feed内容的日志记录

用户和文章的推送日志

表格: user_message_log_table

第一主键 (第二主键) 属性列
user_id message_id is_push, is_read, is_like,is_reward, is_comment, created_time

在home/management/commands/tablestore.py,自定义命令中,新增创建推送日志数据表的命令:

from django.core.management import BaseCommand
from django.conf import settings
from tablestore.error import OTSServiceError
from tablestore import *
# class Command(BaseCommand):
#     help = """测试命令的帮助文档"""
#
#     def add_arguments(self,parser):
#         """参数设置"""
#         parser.add_argument("argument",nargs="*", help="必填参数的说明") # 位置参数
#         parser.add_argument("--option",'-p', default=None, help="可选参数的说明") # 选项参数
#
#     def handle(self, *args, **options):
#         """命令主方法
#         options: 参数列表
#         """
#         argument = options.get("argument") # 获取位置参数
#         option = options.get("option") # 获取位置参数
#
#         self.stdout.write("argument: %s" % argument)
#         self.stdout.write("option: %s" % option)
#
#         if option is None:
#             self.stdout.write("没有设置option选项参数")

class Command(BaseCommand):
    help = """表格存储命令必须接收而且只接收1个命令参数,如下:
            create  表示创建项目使用的表格
            delete  表示删除项目使用的表格
            """

    def add_arguments(self,parser):
        """参数设置"""
        parser.add_argument("argument",nargs="*", help="操作类型") # 位置参数

    def handle(self, *args, **options):
        """表格存储的初始化"""
        argument = options.get("argument")
        # print(argument)

        if len(argument) == 1:
            if argument[0] == "create":
                """创建表格"""
                self.create_table()
            elif argument[0] == "delete":
                """删除表格"""
                self.delete_table()
            else:
                self.stdout.write(self.help)
        else:
            self.stdout.write(self.help)

    @property
    def client(self):
        ots_client = OTSClient(
            settings.OTS_ENDPOINT, settings.OTS_ID, settings.OTS_SECRET, settings.OTS_INSTANCE
        )

        return ots_client

    def set_table(self, table_name, schema_of_primary_key, time_to_live=-1):
        """"""
        # 设置表的元信息
        table_meta = TableMeta(table_name, schema_of_primary_key)
        # 设置数据的有效型
        table_option = TableOptions(time_to_live=time_to_live, max_version=5)
        # 设置数据的预留读写吞吐量
        reserved_throughput = ReservedThroughput(CapacityUnit(0, 0))
        # 创建数据
        try:
            self.client.create_table(table_meta, table_option, reserved_throughput)
        except OTSServiceError as e:
            if e.get_error_code() == "OTSObjectAlreadyExist":
                print("数据表%s创建失败,当前数据表已经存在。" % table_name)
            else:
                print("数据表%s创建失败,远程服务端发生异常。" % table_name)
        else:
            self.stdout.write("创建表格%s完成" % table_name)

    def create_table(self):
        """创建表格"""
        # 存储库
        table_name = "user_message_table"
        schema_of_primary_key = [  # 主键列
            ('user_id', 'INTEGER'),
            ('sequence_id', 'INTEGER', PK_AUTO_INCR),
            ("sender_id", 'INTEGER'),
            ("message_id", 'INTEGER')
        ]

        self.set_table(table_name, schema_of_primary_key, time_to_live=7 * 86400)

        #  关系库
        table_name = "user_relation_table"
        # 主键列
        schema_of_primary_key = [
            ('user_id', 'INTEGER'),
            ("follow_user_id", 'INTEGER'),
        ]
        self.set_table(table_name, schema_of_primary_key)

        # 未读池
        table_name = "user_message_session_table"
        # 主键列
        schema_of_primary_key = [
            ('user_id', 'INTEGER'),
            ("last_sequence_id", 'INTEGER'),
        ]
        self.set_table(table_name, schema_of_primary_key)

        # 文章的推送日志
        table_name = "user_message_log_table"
        schema_of_primary_key = [ # 主键列
            ('user_id', 'INTEGER'),
            ("message_id",'INTEGER'),
        ]
        self.set_table(table_name, schema_of_primary_key)

    def delete_table(self):
        """删除表格"""
        table_list = self.client.list_table()
        for table in table_list:
            self.client.delete_table(table)
            self.stdout.write("删除%s完成" % table)

在首页获取推送内容时, 直接根据user_message_log_table近期记录过滤推送的文章内容,home.views,代码:

from renranapi.utils.ots import TableStore
from datetime import datetime
# 首页文章展示
class ArticleListAPIView(ListAPIView):
    serializer_class = ArticleListModelSerializer
    # 分页器
    pagination_class = HomeArticlePageNumberPagination

    def get_queryset(self):
        # 有效时间是7天
        now = datetime.fromtimestamp(datetime.now().timestamp() - 7 * 24 * 3600)

        query = Article.objects.filter(
            is_show=True,
            is_delete=False,
            is_public=True,
            created_time__gt=now
        )
        exclude_id_list = [] # 获取当前用户曾经阅读过的内容id列表

        if self.request.user.id:
            ts = TableStore()
            # 获取当前用户曾经阅读过的内容id
            exclude_id_list = ts.get_user_message_log(self.request.user.id)
            print(f"exclude_id_list={exclude_id_list}")
            if len(exclude_id_list) > 0:
                query = query.filter(~Q(id__in=exclude_id_list))

        data = query.order_by('orders', '-read_count', '-like_count', '-collect_count', '-comment_count',
                              '-reward_count', '-created_time')
        return data

renranapi.utils.ots,代码:

    def get_user_message_log(self,user_id):
        """获取用户近期的阅览记录"""
        data = []

        table_name = "user_message_log_table"
        # 范围查询的起始主键
        inclusive_start_primary_key = [
            ('user_id', user_id),
            ('message_id', INF_MIN)
        ]

        # 范围查询的结束主键
        exclusive_end_primary_key = [
            ('user_id', user_id),
            ('message_id', INF_MAX)
        ]

        # 查询所有列
        columns_to_get = []  # 表示返回所有列

        # 设置查询条件 where
        cond = CompositeColumnCondition(LogicalOperator.AND) # 声明一个逻辑运算符
        # add_sub_condition 给逻辑运算符添加子条件语句
        last_month = datetime.now().timestamp() - 31 * 24 * 3600
        cond.add_sub_condition(SingleColumnCondition("created_time", last_month, ComparatorType.GREATER_EQUAL))
        cond.add_sub_condition(SingleColumnCondition("is_read", True, ComparatorType.EQUAL))

        # 范围查询接口
        consumed, next_start_primary_key, row_list, next_token = self.client.get_range(
            table_name,  # 操作表明
            Direction.FORWARD,  # 范围的方向,字符串格式,取值包括'FORWARD'和'BACKWARD'。
            inclusive_start_primary_key, exclusive_end_primary_key,  # 取值范围
            columns_to_get,  # 返回字段列
            max_version=1,  # 返回版本数量
            column_filter = cond, # 过滤条件
        )

        data_list = []
        for row in row_list:
            data_list.append(int(row.primary_key[1][1]))  # (("user_id",1),("message_id",7))
        print(f"message_log_list={data_list}")
        return data_list

6.2 在用户点击阅读文章以后, 更新推送日志

客户端,Home.vue,代码:

                  <router-link class="title" :to="'/article/'+article.id">{{article.title}}</router-link>

服务端在获取文章详情时,更新推送日志,article.views,代码:

from renranapi.utils.ots import TableStore
from datetime import datetime
class ArticleDetailView(RetrieveAPIView):
    queryset = models.Article.objects.filter(is_delete=False,is_show=True)
    serializer_class = ArticleDetailModelSerializer

    def retrieve(self, request, *args, **kwargs):
        instance = self.get_object()
        serializer = self.get_serializer(instance)
        ret = serializer.data

        # 判断用户是否登录了
        print(request.user.id != ret["user"]["id"])
        if request.user.id:
            """已登录用户"""

            ts = TableStore()
            # 更新当前用户的推送日志
            ts.update_user_message_log(request.user.id, kwargs["pk"],filter=[
                ("is_read",True),
                ("created_time",datetime.now().timestamp())
            ])

            if request.user.id != ret["user"]["id"]:
                """非文章作者"""
                # 从关系库中获取当前访问者与作者之间的关注关系
                data = ts.get_one(
                    "user_relation_table",
                    [('user_id', ret["user"]["id"]), ('follow_user_id', request.user.id)])
                if data:
                    ret["is_focus"] = 2 # 登录已经关注了作者
                else:
                    ret["is_focus"] = 3 # 登录用户未关注作者
            else:
                ret["is_focus"] = 1 # 当前访问就是作者
        else:
            """未登录"""
            ret["is_focus"] = 0 # 游客
        return Response(ret)

renranapi.utils.ots,代码:

    def update_one(self,table_name,primary_key,attribute_columns,condition):
        """更新一条数据"""
        row = Row(primary_key, attribute_columns)
        consumed, return_row = self.client.update_row(table_name, row, condition)
        return return_row

    def update_user_message_log(self,user_id,message_id,filter):
        """更新用户的推送日志"""
        primary_key =  [ # 主键列
            ('user_id', int(user_id)),
            ("message_id",int(message_id)),
        ]
        attribute_columns = { 'PUT': filter }
        self.update_one("user_message_log_table",primary_key,attribute_columns,condition=None)

6.3 在首页中给用户展示关注作者推送的Feed内容

from datetime import datetime
from .serializers import ArticleListSeiralizer
from .paginations import ArticleListPagination
from article.models import Article
from renranapi.utils.ots import TableStore
from django.db.models import Q

# 首页文章展示
class ArticleListAPIView(ListAPIView):
    serializer_class = ArticleListModelSerializer
    # 分页器
    pagination_class = HomeArticlePageNumberPagination

    def get_queryset(self):
        # 有效时间是7天
        now = datetime.fromtimestamp(datetime.now().timestamp() - 7 * 24 * 3600)

        query = Article.objects.filter(
            is_show=True,
            is_delete=False,
            is_public=True,
            created_time__gt=now
        )
        exclude_id_list = [] # 获取当前用户曾经阅读过的内容id列表
        focus_id_list = [] # 获取关注feed推送文章id列表
        if self.request.user.id:
            ts = TableStore()

            # 获取当前用户曾经阅读过的内容id
            exclude_id_list = ts.get_user_message_log(self.request.user.id)
            print(f"exclude_id_list={exclude_id_list}")
            if len(exclude_id_list) > 0:
                query = query.filter(~Q(id__in=exclude_id_list))

            # 获取关注feed推送文章id
            focus_id_list = ts.get_user_feed(self.request.user.id)

            if len(focus_id_list) > 0:
                query = query.filter(id__in=focus_id_list)

        data = query.order_by('orders', '-read_count', '-like_count', '-collect_count', '-comment_count',
                              '-reward_count', '-created_time')
        return data


renranapi.utils.ots,代码:

    # 获取关注feed推送文章id列表
    def get_user_feed(self,user_id):

        table_name = "user_message_table"
        # 范围查询的起始主键
        inclusive_start_primary_key = [
            ('user_id', user_id),
            ('sequence_id', INF_MIN),
            ('sender_id', INF_MIN),
            ('message_id', INF_MIN),
        ]

        # 范围查询的结束主键
        exclusive_end_primary_key = [
            ('user_id', user_id),
            ('sequence_id', INF_MAX),
            ('sender_id', INF_MAX),
            ('message_id', INF_MAX),
        ]

        # 查询所有列
        columns_to_get = []  # 表示返回所有列

        # 范围查询接口
        consumed, next_start_primary_key, row_list, next_token = self.client.get_range(
            table_name,  # 操作表明
            Direction.FORWARD,  # 范围的方向,字符串格式,取值包括'FORWARD'和'BACKWARD'。
            inclusive_start_primary_key, exclusive_end_primary_key,  # 取值范围
            columns_to_get,  # 返回字段列
            max_version=1  # 返回版本数量
        )

        focus_list = []
        for row in row_list:
            focus_list.append(int(row.primary_key[-1][-1]))  # (("user_id",1),("follow_user_id",7))
        print(f"fans_list={focus_list}")
        focus_list = list(set(focus_list)) # 去重
        return focus_list

user_message_log_table表中, 用户和文章之间的关联形成一张巨型的矩阵.

文章1 文章2 文章3 文章4
用户1 1 0 1 0
用户2 1 1 0 0
用户3 0 1 1 1
用户4 1 1 0 0
用户5 1 0 0 0

现在推荐用户5去看文章: 文章2, 文章3

根据用户行为进行分析的基于物品的协同过滤 ItemCF

实现当前功能有2种思路:

  1. 获取当前用户的阅览记录,然后根据当前用户的阅览记录查找具有共同特征的用户群体。使用numpy实现矩阵进行过滤
pip install numpy
  1. 获取当前用户的阅览记录,然后根据当前用户的阅览记录查找具有共同特征的用户群体,基于字典排序进行权重推荐。

renran.utils.ots,代码:

    # 获取用户近期的阅览记录
    def get_user_message_log(self,user_id):
        """获取用户近期的阅览记录"""

        table_name = "user_message_log_table"
        # 范围查询的起始主键
        inclusive_start_primary_key = [
            ('user_id', user_id),
            ('message_id', INF_MIN)
        ]

        # 范围查询的结束主键
        exclusive_end_primary_key = [
            ('user_id', user_id),
            ('message_id', INF_MAX)
        ]

        # 查询所有列
        columns_to_get = []  # 表示返回所有列

        # 设置查询条件 where
        cond = CompositeColumnCondition(LogicalOperator.AND) # 声明一个逻辑运算符
        # add_sub_condition 给逻辑运算符添加子条件语句
        last_month = datetime.now().timestamp() - 31 * 24 * 3600
        cond.add_sub_condition(SingleColumnCondition("created_time", last_month, ComparatorType.GREATER_EQUAL))
        cond.add_sub_condition(SingleColumnCondition("is_read", True, ComparatorType.EQUAL))

        # 范围查询接口
        consumed, next_start_primary_key, row_list, next_token = self.client.get_range(
            table_name,  # 操作表明
            Direction.FORWARD,  # 范围的方向,字符串格式,取值包括'FORWARD'和'BACKWARD'。
            inclusive_start_primary_key, exclusive_end_primary_key,  # 取值范围
            columns_to_get,  # 返回字段列
            max_version=1,  # 返回版本数量
            column_filter = cond, # 过滤条件
        )

        data_list = []
        for row in row_list:
            data_list.append(int(row.primary_key[1][1]))  # (("user_id",1),("message_id",7))
        print(f"message_log_list={data_list}")
        return data_list

    # 更新用户的推送日志
    def update_user_message_log(self,user_id,message_id,filter):
        """更新用户的推送日志"""
        primary_key =  [ # 主键列
            ('user_id', int(user_id)),
            ("message_id",int(message_id)),
        ]
        attribute_columns = { 'PUT': filter }
        self.update_one("user_message_log_table",primary_key,attribute_columns,condition=None)

    # 获取关注feed推送文章id列表
    def get_user_feed(self,user_id):

        table_name = "user_message_table"
        # 范围查询的起始主键
        inclusive_start_primary_key = [
            ('user_id', user_id),
            ('sequence_id', INF_MIN),
            ('sender_id', INF_MIN),
            ('message_id', INF_MIN),
        ]

        # 范围查询的结束主键
        exclusive_end_primary_key = [
            ('user_id', user_id),
            ('sequence_id', INF_MAX),
            ('sender_id', INF_MAX),
            ('message_id', INF_MAX),
        ]

        # 查询所有列
        columns_to_get = []  # 表示返回所有列

        # 范围查询接口
        consumed, next_start_primary_key, row_list, next_token = self.client.get_range(
            table_name,  # 操作表明
            Direction.FORWARD,  # 范围的方向,字符串格式,取值包括'FORWARD'和'BACKWARD'。
            inclusive_start_primary_key, exclusive_end_primary_key,  # 取值范围
            columns_to_get,  # 返回字段列
            max_version=1  # 返回版本数量
        )

        focus_list = []
        for row in row_list:
            focus_list.append(int(row.primary_key[-1][-1]))  # (("user_id",1),("follow_user_id",7))
        print(f"fans_list={focus_list}")
        focus_list = list(set(focus_list)) # 去重

        # 当推送的feed流数据不足时,自动进行协同过滤推荐ItemCF
        if len(focus_list) < 10:
            message_history = self.get_message_by_itemcf(user_id)
            if len(message_history) > 0:
                focus_list = list(set(focus_list + message_history))

        return focus_list

    # 基于物品的协同过滤实现feed推送
    def get_message_by_itemcf(self,user_id):
        """基于物品的协同过滤实现feed推送"""

        # 1. 获取当前用户近期的浏览记录的文章列表
        message_list = self.get_user_message_log(user_id)

        # 2. 根据文章列表获取具有共同行为的用户列表
        user_list = self.get_user_by_history(message_list)

        # 3. 提取所有具有共同特征用户的浏览记录的文章列表
        message_history = self.get_message_by_user_list(user_list)
        return message_history

    # 根据文章列表获取具有共同行为的用户列表
    def get_user_by_history(self,message_list):
        """根据文章列表获取具有共同行为的用户列表"""
        table_name = "user_message_log_table"
        user_list = [] # 用户id列表

        for message_id in message_list:
            # 范围查询的起始主键
            inclusive_start_primary_key = [
                ('user_id', INF_MIN),
                ('message_id', message_id)
            ]

            # 范围查询的结束主键
            exclusive_end_primary_key = [
                ('user_id', INF_MAX),
                ('message_id', message_id)
            ]

            # 查询所有列
            columns_to_get = []  # 表示返回所有列

            # 设置查询条件 where
            cond = CompositeColumnCondition(LogicalOperator.AND)  # 声明一个逻辑运算符 and 与
            # add_sub_condition 给逻辑运算符添加子条件语句
            last_month = datetime.now().timestamp() - 31 * 24 * 3600
            # GREATER_EQUAL -- 大于等于
            cond.add_sub_condition(SingleColumnCondition("created_time", last_month, ComparatorType.GREATER_EQUAL))
            cond.add_sub_condition(SingleColumnCondition("is_read", True, ComparatorType.EQUAL))

            # 范围查询接口
            consumed, next_start_primary_key, row_list, next_token = self.client.get_range(
                table_name,  # 操作表明
                Direction.FORWARD,  # 范围的方向,字符串格式,取值包括'FORWARD'和'BACKWARD'。
                inclusive_start_primary_key, exclusive_end_primary_key,  # 取值范围
                columns_to_get,  # 返回字段列
                max_version=1,  # 返回版本数量
                column_filter=cond,  # 过滤条件
            )

            for row in row_list:
                user_list.append(int(row.primary_key[0][1]))  # (("user_id",1),("message_id",7))
        # 去重
        user_list = list(set(user_list))
        print(f"user_list={user_list}")

        return user_list

    # 提取所有具有共同特征用户的推送日志里面的文章列表
    def get_message_by_user_list(self,user_list):
        """提取所有具有共同特征用户的推送日志里面的文章列表"""
        data = []
        for user in user_list:
            message_list = self.get_user_message_log(user) # [1,2,3,4,1,3,4,1,4,5]
            data += message_list

        # 计算每个文章id出现的频率
        ret = {}
        for item in data:
            if item in ret:
                ret[item]+=1
            else:
                ret[item] = 1
        # 按值排序,降序排列,提取文章id
        message_list = sorted(ret.keys(), key=lambda key: ret[key], reverse=True)

        print('message_list>>',message_list)
        return message_list
原文地址:https://www.cnblogs.com/jia-shu/p/14677208.html