发布时间:2023-01-28 22:00
社交模块作为热点数据来说,可能会频繁改动字段,因此用Mysql是肯定不现实的,一般使用Redis。这里我以发表朋友圈动态为例,社交模块包括发表动态,点赞、评论、收藏、关注以及签到统计等模块,这里我简单实现了动态发表,点赞、评论这三个模块。
关注功能模块,使用Redis集合Set,一个人两个集合数据,定时更新到数据库
https://blog.csdn.net/INGNIGHT/article/details/107066022
https://www.cnblogs.com/linjiqin/p/12828315.html
点赞、收藏模块,Set(点赞视频、点赞人评论)和Hash(like::url =1或0)结构都比较合适
https://juejin.cn/post/6904816415912493069#heading-10
https://juejin.cn/post/6895185457110319118#heading-20
https://juejin.cn/post/6844903967168675847
评论模块,可以选择list,用list和zset存储id,其他存储内容
https://juejin.cn/post/6844903709374169102
https://blog.csdn.net/qq171563857/article/details/107406409
https://symonlin.github.io/2019/07/29/redis-1/
登录统计、签到,使用Redis的Bitmap
https://juejin.cn/post/6990152493099384869
数据库自行参考,可以考虑持久化到数据库。这里说一下我的设计思路:
动态分为视频动态和图片形式的动态,类似于抖音和微信朋友圈,该模块单独编写,需要信息从其他模块获取;评论为二级评论,后端包装后返回,评论可以点赞等操作;点赞优先经过Redis,若没有查询数据库
create database if not exists lamp_social;
use lamp_social;
-- 评论表
drop table if exists social_comment;
CREATE TABLE social_comment (
comment_id int(11) NOT NULL AUTO_INCREMENT COMMENT '评论表id',
owner_id int(11) NOT NULL COMMENT '文章或视频id',
user_id int(11) NOT NULL COMMENT '用户id',
content text COMMENT '评论内容',
star_num int(11) not null default 0 COMMENT '点赞数量',
p_comment_id int(11) NOT NULL DEFAULT 0 COMMENT '若父评论则为0,默认一级评论;子评论对应其相应的评论父Id',
state int(2) NOT NULL DEFAULT 0 COMMENT '默认0,表示未审核,1表示审核通过,2表示不通过',
type int(2) NOT NULL DEFAULT 0 COMMENT '评论类型,默认为0,可以是对人、对资源、对视频等,暂时不用',
create_time timestamp not null default CURRENT_TIMESTAMP comment '创建时间',
update_time timestamp not null default CURRENT_TIMESTAMP comment '修改时间',
deleted tinyint not null default 0 comment '数据删除位 0正常 1逻辑删除',
primary key(comment_id)
)AUTO_INCREMENT=1 ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评论表';
-- 个人动态表
drop table if exists social_dynamic;
CREATE TABLE social_dynamic (
dynamic_id int(11) NOT NULL AUTO_INCREMENT COMMENT '动态id',
dynamic_url varchar(5000) default '' COMMENT '视频地址,若是图片朋友圈,那么地址中间用|进行分隔',
user_id int(11) NOT NULL COMMENT '用户id',
content text COMMENT '朋友圈内容',
star_num int(11) NOT NULL default 0 COMMENT '点赞数量',
collection_num int(11) NOT NULL default 0 COMMENT '收藏数',
state int(2) NOT NULL DEFAULT 0 COMMENT '默认0,表示未审核,1表示审核通过,2表示不通过',
type int(2) NOT NULL DEFAULT 0 COMMENT '动态类型,默认为0,表示视频,1表示图片朋友圈,每个数字可以对应不同视频类型',
create_time timestamp not null default CURRENT_TIMESTAMP comment '创建时间',
update_time timestamp not null default CURRENT_TIMESTAMP comment '修改时间',
deleted tinyint not null default 0 comment '数据删除位 0正常 1逻辑删除',
primary key(dynamic_id)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个人动态表';
-- 用户表可以添加一个点赞数字段,可选
drop table if exists social_user;
CREATE TABLE social_user (
user_id int(11) NOT NULL comment '用户id',
school_id int(11) NOT NULL comment '学校id',
star_num int(11) NOT NULL default 0 COMMENT '点赞数量',
focus_num int(11) NOT NULL default 0 COMMENT '关注数量',
fan_num int(11) NOT NULL default 0 COMMENT '粉丝数量',
create_time timestamp not null default CURRENT_TIMESTAMP comment '创建时间',
update_time timestamp not null default CURRENT_TIMESTAMP comment '修改时间',
primary key(user_id)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户点赞表';
-- 用户点赞表
drop table if exists social_user_like_dynamic;
CREATE TABLE social_user_like_dynamic (
liked_id int(11) NOT NULL AUTO_INCREMENT COMMENT '主键id',
dynamic_id int(11) NOT NULL COMMENT '动态id',
user_id int(11) NOT NULL COMMENT '用户id',
state int(2) NOT NULL DEFAULT 0 COMMENT '默认0,表示点赞,1表示取消点赞',
create_time timestamp not null default CURRENT_TIMESTAMP comment '创建时间',
update_time timestamp not null default CURRENT_TIMESTAMP comment '修改时间',
primary key(liked_id)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户点赞表';
-- 用户收藏表
drop table if exists social_user_collect_dynamic;
CREATE TABLE social_user_collect_dynamic (
collection_id int(11) NOT NULL AUTO_INCREMENT COMMENT '主键id',
dynamic_id int(11) NOT NULL COMMENT '动态id',
user_id int(11) NOT NULL COMMENT '用户id',
state int(2) NOT NULL DEFAULT 0 COMMENT '默认0,表示收藏,1表示取消收藏',
create_time timestamp not null default CURRENT_TIMESTAMP comment '创建时间',
update_time timestamp not null default CURRENT_TIMESTAMP comment '修改时间',
primary key(collection_id)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户收藏表';
-- 用户关注与粉丝表
drop table if exists social_user_focus;
CREATE TABLE social_user_focus (
focus_id int(11) NOT NULL AUTO_INCREMENT COMMENT '主键id',
user_id int(11) NOT NULL COMMENT '用户id',
focus_user_id int(11) NOT NULL COMMENT '关注用户id',
state int(2) NOT NULL DEFAULT 0 COMMENT '默认0,表示关注,1表示取消关注',
create_time timestamp not null default CURRENT_TIMESTAMP comment '创建时间',
update_time timestamp not null default CURRENT_TIMESTAMP comment '修改时间',
primary key(focus_id)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户关注与粉丝表';
Feed流产品在我们手机APP中几乎无处不在,常见的Feed流比如微信朋友圈、新浪微博、今日头条等。对Feed流的定义,可以简单理解为只要大拇指不停地往下划手机屏幕,就有一条条的信息不断涌现出来。
大多数Feed流产品都包含两种Feed流,一种是基于算法推荐,另一种是基于关注(好友关系)。例如下图中的微博和知乎,顶栏的页卡都包含“关注”和“推荐”这两种。两种Feed流背后用到的技术差别会比较大(读扩散、写扩散)。
参考:https://cloud.tencent.com/developer/article/1744756
动态发布因为考虑到先缓存到Redis,在异步保存到MySql,因此动态主键使用Redis的自增函数,通过Redis生成MySql的动态主键;
对于动态数据的存储,我使用了list存储结构,新的数据从左边压入list,考虑到feed流查询,我还设置了一个伴生list列表,用来与动态同步存储主键值,首先通过lastid查询上一次浏览的值,查询list的index,在通过存储动态的列表返回一个列表;同时使用了读写锁,是为了保证原子性;
最后异步或定时检查列表长度,若过长可以从右边舍弃,或者设置列表过期时间,插入的时候重新刷新过期时间
mysql表字段,评论父表和字表存储在同一个数据表,根据p_comment_id
字段分辨,返回的时候先查询出总的list,在使用JDK8的Stream流形成树形结构返回。ORM映射使用了Fluent MyBatis ,树形结构格式转换;
首先创建转换工具类,这里先将对象转化为json,在通过解析json进行复制操作
import com.alibaba.fastjson.JSON;
import java.util.List;
/**
* 两个对象或集合同名属性赋值
*/
public class ObjectConversion {
/**
* 从List copy到List
* @param list List
* @param clazz B
* @return List
*/
public static <T> List<T> copy(List<?> list,Class<T> clazz){
String oldOb = JSON.toJSONString(list);
return JSON.parseArray(oldOb, clazz);
}
/**
* 从对象A copy到 对象B
* @param ob A
* @param clazz B.class
* @return B
*/
public static <T> T copy(Object ob,Class<T> clazz){
String oldOb = JSON.toJSONString(ob);
return JSON.parseObject(oldOb, clazz);
}
}
我的VO类,主要用将数据库的评论拼装返回前端
@Data
@Accessors(chain = true)
public class VideoCommentVO {
/**
* 评论表id
*/
private Integer commentId;
/**
* 创建时间
*/
private Date createTime;
/**
* 评论内容
*/
private String content;
/**
* 文章或视频id
*/
private Integer ownerId;
/**
* 若父评论则为0,默认一级评论;子评论对应其相应的评论父Id
*/
private Integer pCommentId;
/**
* 点赞数量
*/
private Integer starNum;
/**
* 用户id
*/
private Integer userId;
/**
* 孩子
*/
private List<VideoCommentVO> child;
}
树形结构拼装,用了jdk8新特性
@Service
public class VideoCommentService {
@Autowired
CommentMapper commentMapper;
//就先二级评论吧
public List<VideoCommentVO> getVideoComment(Integer videoId) {
CommentQuery query = new CommentQuery()
.where().ownerId().eq(videoId).end()
.where().state().eq(0).end()
.where().deleted().eq(0).end();
List<CommentEntity> commentEntities = commentMapper.listEntity(query);
//列表拷贝
List<VideoCommentVO> videoCommentVOList = ObjectConversion.copy(commentEntities, VideoCommentVO.class);
//列表通过pcommentid进行分组
Map<Integer, List<VideoCommentVO>> collect = videoCommentVOList.stream().collect(Collectors.groupingBy(VideoCommentVO::getPCommentId));
//分组后遍历每一个数组设置孩子
videoCommentVOList.forEach(
videoComment->videoComment.setChild(collect.get(videoComment.getCommentId()))
);
System.out.println(videoCommentVOList);
//找出父结点并返回,排序默认从小到大
List<VideoCommentVO> result = videoCommentVOList.stream()
.filter(s -> s.getPCommentId().equals(0))
.sorted(Comparator.comparing(VideoCommentVO::getStarNum).reversed())
.collect(Collectors.toList());
return result;
}
}
如果遇到下面问题,回退版本号,我当时遇到了
// fastJson1.2.78版本会概率性出现该错误,回退到1.2.76即可
Comparison method violates its general contract
简单原理如上图所示,创建结点类,里面包含是否是敏感词结束符,以及一个HashMap,哈希里key值存储的是敏感词的一个词,value指向下一个结点(即指向下一个词),一个哈希表中可以存放多个值,比如赌博、赌黄这两个都是敏感词。
敏感词文件存在在resources
文件夹下,通过类加载器获取里面的敏感词。在springboot中,被@PostConstruct
修饰的方法会在服务器加载Servlet的时候运行,并且只会被服务器执行一次。PostConstruct
在构造函数之后执行,init()
方法之前执行。
/**
* 敏感词过滤器
*
* @author Shawn
* @date 2021年11月20日11:09
**/
@Component
public class SensitiveFilter {
private static final Logger logger = LoggerFactory.getLogger(SensitiveFilter.class);
/**
* 将敏感词替换成 ***
*/
private static final String REPLACEMENT = "***";
private final TreeNode rootNode = new TreeNode();
/**
* 被@PostConstruct修饰的方法会在服务器加载Servlet的时候运行,并且只会被服务器执行一次。PostConstruct在构造函数之后执行,init()方法之前执行。
* 初始化敏感词的结构树
*/
@PostConstruct
public void init(){
// 带资源的try语句,try块退出时,会自动调用res.close()方法,关闭资源。
try (
InputStream resourceAsStream = this.getClass().getClassLoader().getResourceAsStream("sensitive-words.txt");
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(resourceAsStream));
){
String keyword;
while((keyword=bufferedReader.readLine())!=null){
this.addKeyWord(keyword);
}
} catch(IOException e){
logger.error("资源文件加载失败 ==> {}",e.getMessage());
}
}
/**
* 将敏感词加入前缀树里
* @param keyword 敏感词
*/
private void addKeyWord(@NotNull String keyword){
TreeNode tempNode = rootNode;
for(int i = 0 ;i<keyword.length();i++){
char c = keyword.charAt(i);
// 进行空值判断,当多个敏感词首字母相同时,可以指向不同结点
TreeNode subNode = tempNode.getKeywordNode(c);
if(subNode==null){
subNode = new TreeNode();
tempNode.addKeywordNode(c,subNode);
}
// 指向下一个结点
tempNode=subNode;
}
// 结尾设置结束符
tempNode.setKeywordEnd(true);
}
/**
* 敏感词过滤器
* @param text 文本
* @return {@link String}
*/
public String filter(String text){
if(StringUtils.isBlank(text)){
return null;
}
// 规则树,用来匹配敏感词
TreeNode tempNode = rootNode;
// begin指针,指向文本中某个敏感词的第一位
int begin=0;
// end指针,指向文本中某个敏感词的最后一位
int end = 0;
StringBuilder sb = new StringBuilder();
while (end<text.length()){
char c = text.charAt(end);
// 跳过符号(防止敏感词混合符号,比如 ☆赌☆博)
if(this.isSymbol(c)){
// 若tempNode结点在开头,代表还没有匹配敏感字,这个特殊符号加入返回词,且 begin 指针指向下一个
if(tempNode == rootNode){
sb.append(c);
begin++;
}
// 无论符号在开头还是在中间,指针 end 都会向下走一步
end++;
continue;
}
// 检查子节点
tempNode = tempNode.getKeywordNode(c);
if(tempNode==null){
// 以指针 begin 开头的字符串不是敏感词
sb.append(text.charAt(begin));
// 进入下一位的判断
end++;
begin=end;
// 这里需要把规则树重新指向根节点
tempNode=rootNode;
}else if(tempNode.isKeyWordEnd()){
// 发现敏感词,将 begin~end 的字符串替换掉
sb.append(REPLACEMENT);
end++;
begin=end;
tempNode=rootNode;
}else{
// 敏感词匹配过程中
end++;
}
}
// 将最后一批字符计入结果(如果最后一次循环的字符串不是敏感词,上述的循环逻辑不会将其加入最终结果)
sb.append(text.substring(begin));
return sb.toString();
}
/**
* 是否是符号
*/
private boolean isSymbol(Character c){
// 0x2E80~0x9FFF 是东亚文字范围
return !CharUtils.isAsciiAlphanumeric(c) && (c < 0x2E80 || c > 0x9FFF);
}
/**
* 树节点
* 敏感词结点类,与hash表类似,所有敏感词形成一个hash表,每个词语的每个词形成一个链表,用map指向下一个
*/
private static class TreeNode{
// 关键词结束标识,默认是不是非结束结点
private boolean isKeywordEnd = false;
// map的key存储一个敏感词,value指向下一个敏感词结点
HashMap<Character, TreeNode> nodeMap = new HashMap<>();
// 返回是否本次词语结束
public boolean isKeyWordEnd(){
return isKeywordEnd;
}
// 设置是否是结束词
public void setKeywordEnd(boolean keywordEnd){
isKeywordEnd = keywordEnd;
}
// 添加敏感词,key表示字符
public void addKeywordNode(Character c, TreeNode treeNode){
nodeMap.put(c,treeNode);
}
// 获取当前词是否是敏感词,若没有在表中,则返回null
public TreeNode getKeywordNode(Character c){
return nodeMap.get(c);
}
}
}
考虑到点赞是字段频繁变动的,用Mysql肯定不合适,使用需要使用Redis内存数据库。这里以动态点赞为例子,点赞模块需要解决的几个问题