程序员社区项目

image-20240710162449654.png

开发模式

前后端分离,后端负责所有的设计、接口的定义,后端先行,前端协同,通过接口文档,采用apifox的文档进行对接。

敏捷开发,版本上线迭代,需求分析->功能设计->详细设计->编码实现。

开发工具

后端:IDEA

前端:VSCode

项目管理:giteazycode

包依赖管理:Maven3.6.0

数据库:Mysql5.7

数据库连接池和监控库:Druid

框架:Springboot 2.4.2

数据库图形化:Navicat

接口管理工具:APIPost7

Redis桌面工具:RedisDesktop

表建模:PDManager

原型设计:axure8

原型组件库: antdesign

代码生成器:easycode(idea的plugin市场)

一些插件:mybatis(类->dao->数据库),easycode(由数据库表生成相应代码), preconditions(参数校验)

node.js

架构设计

传统项目

[SpringMVC框架(详解)-CSDN博客](https://blog.csdn.net/H20031011/article/details/131511482?ops_request_misc=%7B%22request%5Fid%22%3A%22172145077916800222810035%22%2C%22scm%22%3A%2220140713.130102334..%22%7D&request_id=172145077916800222810035&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~sobaiduend~default-2-131511482-null-null.142^v100^pc_search_result_base8&utm_term=spring mvc架构&spm=1018.2226.3001.4187)

一般的mvc:model,view,controller

SpringMVC:controller(view+controller),service(业务逻辑),dao(数据库)

image-20240710175347652.png

image-20240720125604514.png

现有的架构

DDD架构-CSDN博客

[浅谈架构设计:MVC架构与DDD架构【开发实践】_ddd架构和mvc架构区别-CSDN博客](https://blog.csdn.net/qq_40656637/article/details/137344153?ops_request_misc=%7B%22request%5Fid%22%3A%22172145093416800184184571%22%2C%22scm%22%3A%2220140713.130102334.pc%5Fall.%22%7D&request_id=172145093416800184184571&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~first_rank_ecpm_v1~rank_v31_ecpm-1-137344153-null-null.142^v100^pc_search_result_base8&utm_term=spring mvc架构和ddd架构&spm=1018.2226.3001.4187)

ddd 架构

cbc3530efc1e41d6b6a94455d804d3d3[1].png

  • 用户接口层(User Interface ):负责向用户显示信息和解释用户指令(是DDD架构中的表现层)(表现层是视图层的超集,概念有所区别,知道最上层就是表现层即可)

  • 应用层(Application):很“薄”的一层,理论上不应该有业务规则或逻辑,主要面向用例和流程相关的操作。在应用层协调多个服务和领域对象完成服务的组合和编排,协作完成业务操作。此外,应用层也是微服务之间交互的通道,它可以调用其它微服务的应用服务,完成微服务之间服务的组合和编排

  • 领域层(Domain):是实现企业核心业务逻辑,通过各种校验手段保证业务的正确性。领域层主要体现领域模型的业务能力,它用来表达业务概念、业务状态和业务规则。领域层包含聚合根、实体、值对象、领域服务等领域模型中的领域对象

  • 基础层(Infrastructure):贯穿所有层,为其它层提供通用的技术和基础服务,包括第三方工具、驱动、消息中间件、网关、文件、缓存以及数据库等

  • 个人理解:将service层拆分为了应用层和领域层。其中应用层关注于用例和流程,不涉及业务规则或逻辑,通过组合和编排下层的领域层来完成业务操作。而领域层用于封装具体的业务规则或逻辑,拆分出来的领域层不再和具体流程关联,实现了高内聚和低耦合,还提高了领域层的可复用性。用户接口层和基础层则为原来的视图层和dao层的扩展,新增了部分职责功能。

例子:

  1. 电子商务领域
    • 实体:用户、产品、订单、支付记录。
    • 聚合:购物车、订单详情。
    • 领域服务:订单处理、库存管理、用户认证。
  2. 交通物流领域
    • 实体:司机、车辆、货物、运输任务。
    • 聚合:运输订单、车队管理。
    • 领域服务:路径规划、货物追踪、调度优化。

image-20240710175452746.png

  1. API(对外接口层):这一层负责定义对外提供的服务接口,通常用于与客户端或其他服务进行交互。

  2. Controller:在传统的MVC架构中,控制器用于处理用户的请求。在这里,它用于接收API层的请求,并将请求转换为应用层可以理解的格式。

  3. DTO(Data Transfer Object)

    代表数据传输对象的意思

    是一种设计模式之间传输数据的软件应用系统,数据传输目标往往是数据访问对象从数据库中检索数据

    数据传输对象与数据交互对象或数据访问对象之间的差异是一个以不具任何行为除了存储和检索的数据(访问和存取器)

    简而言之,就是接口之间传递的数据封装

    表里面有十几个字段:id,name,gender(M/F),age……

    页面需要展示三个字段:name,gender(男/女),age

    DTO由此产生,一是能提高数据传输的速度(减少了传输字段),二能隐藏后端表结构。

  4. BO(Business Object)

    代表业务对象的意思,Bo就是把业务逻辑封装为一个对象(注意是逻辑,业务逻辑),这个对象可以包括一个或多个其它的对象。通过调用Dao方法,结合Po或Vo进行业务操作。

    形象描述为一个对象的形为和动作,当然也有涉及到基它对象的一些形为和动作。比如处理一个人的业务逻辑,该人会睡觉,吃饭,工作,上班等等行为,还有可能和别人发关系的行为,处理这样的业务逻辑时,我们就可以针对BO去处理。

    再比如投保人是一个PO,被保险人是一个PO,险种信息也是一个PO等等,他们组合起来就是一张保单的BO。

  5. PO/DO: Persistent Object / Data Object,持久对象 / 数据对象。

    它跟持久层(通常是关系型数据库)的数据结构形成一一对应的映射关系,如果持久层是关系型数据库,那么,数据表中的每个字段(或若干个)就对应PO的一个(或若干个)属性。

  6. VO: View Object, 视图模型,展示层对象:

    对应页面显示(web页面/移动端H5/Native视图)的数据对象。

  7. Application层(应用层):这一层包含应用服务,它们协调领域对象来完成业务逻辑。它还包含一些业务逻辑的转换逻辑,如DTO到BO的转换。

  8. Interceptor:拦截器,用于在请求处理过程中进行一些前置或后置处理,例如日志记录、权限验证等。

  9. Application-MQ(消费者)/ Application-Job:这指的是应用层中处理消息队列消息的组件,或者定时任务的处理。

  10. Domain层(领域层):这是DDD中的核心层,包含业务逻辑和领域模型。领域层专注于业务规则和业务实体。

  11. Service:领域服务,执行领域逻辑但不自然属于任何实体或值对象的操作。

  12. Entity/PO(Persistent Object):持久化对象,通常与数据库存储相关,代表数据库中的记录。

  13. Mapper:数据访问对象,用于将领域对象映射到数据库表。

  14. Infra层(基础设施层):提供技术实现,如数据库访问、消息传递、外部服务调用等。

  15. RPC:远程过程调用,用于服务之间的通信。

  16. MG(生产者):指的是消息生成者,负责生成并发送消息到消息队列。

  17. Starter(启动层):指的是服务启动时需要自动执行的代码或配置。

  18. Aggressive(聚合层):聚合层,将多个领域对象聚合成一个更大的业务实体。

  19. Config:配置层,用于存储和访问配置信息。

  20. Dict(字典):指的是数据字典,用于存储一些固定的数据或映射关系。

  21. Common(公共层):包含整个应用中多个地方会用到的通用代码或工具。

  22. Enums:枚举,用于定义一组命名的常量。

  23. Utils:工具类,提供一些通用的辅助功能。

req->dto->do->bo->entity->po

UBWaSonlxTkZyGs[1].jpg

项目结构

image-20240717163452115.png

后端项目目录(backend)

  • asyncTool: 包含异步处理工具或库,用于处理异步任务。
  • doc: 存放项目文档,如API文档、技术规范等。
  • jc-club-auth: 认证服务,负责用户认证和授权。
  • jc-club-circle: 可能与社区圈子或用户组相关功能。
  • jc-club-common-starter: 通用启动器或工具类,提供项目通用功能。
  • jc-club-gateway: 网关服务,负责请求路由、负载均衡等。
  • jc-club-gen: 代码生成工具,可能用于快速生成项目代码。
  • jc-club-interview: 面试相关功能,可能包含面试题库或模拟面试。
  • jc-club-oss: 对象存储服务,用于管理文件存储。
  • jc-club-practice: 实践项目或示例代码。
  • jc-club-subject: 主题或课程相关功能,可能用于教育或培训。
  • jc-club-wx: 微信相关功能,可能包含微信公众号接口或小程序支持。

技术选型

[Spring和Spring Boot之间的区别(小结)_spring和springboot的区别-CSDN博客](https://blog.csdn.net/mengxin_chen/article/details/116240326?ops_request_misc=%7B%22request%5Fid%22%3A%22172145220016800227442776%22%2C%22scm%22%3A%2220140713.130102334..%22%7D&request_id=172145220016800227442776&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~top_positive~default-1-116240326-null-null.142^v100^pc_search_result_base8&utm_term=spring和spring boot区别&spm=1018.2226.3001.4187)

image-20240710163626898.png

服务器中间件

服务器采用的京东云 centos

Docker安装

1
2
3
4
5
6
7
yum install -y yum-utils device-mapper-persistent-data lvm2
yum-config-manager --add-repo http://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo
yum install docker-ce docker-ce-cli containerd.io -y
systemctl start docker
systemctl enable docker
docker version
docker images

Docker安装mysql

1
2
3
4
5
6
docker pull mysql:5.7 
docker images
mkdir -p /home/service/mysql/data
mkdir -p /home/service/mysql/conf
cd /home/service/mysql/conf
touch my.cnf

将以下内容粘入:

1
2
3
4
5
6
7
8
9
[mysqld]
user=mysql
character-set-server=utf8
default_authentication_plugin=mysql_native_password
default-time_zone = '+8:00'
[client]
default-character-set=utf8
[mysql]
default-character-set=utf8
1
2
3
4
5
6
7
docker run -p 3306:3306 --name mysql -v /home/service/mysql/logs:/logs -v /home/service/mysql/data:/mysql_data -e MYSQL_ROOT_PASSWORD=Wing1Q2W#E -d mysql:5.7
docker exec -it mysql bash
mysql -uroot -p

CREATE USER 'admin'@'%' IDENTIFIED BY 'Wing1Q2W#E';
GRANT ALL ON *.* TO 'admin'@'%';
flush privileges;

docker ps 查看启动状态。

navicat直接连接即可,云服务器需要开启防火墙。

Maven配置国内源

maven一定要放到Jenkins的数据挂载目录内,这样容器才能读到。参考开发工具选型里面的maaven包。

在maven的conf的setting的mirrors里面进行配置,配置后,Jenkins下载包会非常的快。

alimaven aliyun maven http://maven.aliyun.com/nexus/content/groups/public/ central

Docker安装Jenkins

机器上要有 jdk,服务器可以执行如下命令安装

1
yum install -y java-1.8.0-openjdk.x86_64

jenkins开始

1
2
3
4
docker search jenkins
docker pull jenkins/jenkins:2.414.2
docker run -d -u root -p 8080:8080 -p 50000:50000 -v /var/jenkins_home:/var/jenkins_home -v /etc/localtime:/etc/localtime --name jenkins jenkins/jenkins:2.414.2
docker start jenkins

image-20240710183915242.png

这样就是启动成功了。然后通过8080端口进行访问。访问的过程会很慢等待一下。服务器内存最好大点,内存小的容易启动不起来。

通过log来看一下密码:

1
docker logs 67166b666c76

image-20240710184049696.png

访问之后,输入上面的密码。

点击继续后,选择 按照推荐安装插件。然后继续等待。

image-20240710184132391.png

界面如下:

image-20240710184237761.png

新建任务

上面输入任务名称,下面选择构建自由风格

image-20240710184414482.png

选择源码管理,配置maven,注意:maven一定要放到Jenkins的数据挂载目录内,这样容器才能读到。

image-20240710184525082.png

配置ssh服务器

image-20240710184610325.png

image-20240710184703520.png

设置密码即可。

配置ssh分发

image-20240710184752937.png

配置shell脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
cp /var/jenkins_home/workspace/programmer-club-subject/programmer-club-subject/programmer-club-starter/target/programmer-club-starter.jar /var/jenkins_home/jar/
#!/bin/bash
APP_NAME=programmer-club-starter.jar
LOG_NAME=programmer-club-starter.log

pid=`ps -ef | grep $APP_NAME | grep -v grep|awk '{print $2}'`

function is_exist(){
pid=`ps -ef | grep $APP_NAME | grep -v grep|awk '{print $2}'`
if [ -z ${pid} ]; then
String="notExist"
echo $String
else
String="exist"
echo $String
fi
}

str=$(is_exist)
if [ ${str} = "exist" ]; then
echo " 检测到已经启动的程序,pid 是 ${pid} "
kill -9 $pid
else
echo " 程序没有启动了 "
echo "${APP_NAME} is not running"
fi

str=$(is_exist)
if [ ${str} = "exist" ]; then
echo "${APP_NAME} 已经启动了. pid=${pid} ."
else
source /etc/profile
BUILD_ID=dontKillMe
nohup java -Xms300m -Xmx300m -jar /var/jenkins_home/jar/$APP_NAME >$LOG_NAME 2>&1 &
echo "程序已重新启动..."
fi

yum安装JDK

1
yum install -y java-1.8.0-openjdk.x86_64

Docker安装minio,搭建自己的oss服务器

1
docker search minio

image-20240710185255132.png

1
2
3
4
5
6
7
8
9
10
docker pull minio/minio

docker run -p 9000:9000 -p 9090:9090 \
--name minio \
-d --restart=always \
-e "MINIO_ACCESS_KEY=minioadmin" \
-e "MINIO_SECRET_KEY=minioadmin" \
-v /mydata/minio/data:/data \
minio/minio server \
/data --console-address ":9090" -address ":9000"

启动后,访问机器ip+9090,进入minio的界面,输入用户名或密码后可以访问。

image-20240710185416658.png

Docker安装miniomc突破7天限制

1
2
3
4
5
6
7
8
9
10
11
12
13
docker pull minio/mc

docker run -it --entrypoint=/bin/sh minio/mc

mc config host add <ALIAS> <YOUR-S3-ENDPOINT> <YOUR-ACCESS-KEY> <YOUR-SECRET-KEY> [--api API-SIGNATURE]

mc config host add minio http://xxx.xx.xx.xxx:9000 GrVCPXySKgGoJiGgXmtv 0xlqSI9GXvnBOtp0GwUj5OshKNBk9JgwoexotbVV

mc ls minio

mc anonymous

mc anonymous set download minio/jichi

Docker查看运行容器启动命令

安装一个小工具 get_command_4_run_container

1
2
3
docker pull cucker/get_command_4_run_container
# 以nacos为例子
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock cucker/get_command_4_run_container nacos

看到如下的启动命令

1
2
3
4
5
6
7
8
9
10
11
12
docker run -d \ 
--name nacos \
--privileged \
--cgroupns host \
--env JVM_XMX=256m \
--env MODE=standalone \
--env JVM_XMS=256m \
-p 8848:8848/tcp \
-p 9848:9848/tcp \
--restart=always \
-w /home/nacos \
nacos/nacos-server

Docker安装nacos

1
2
docker search nacos
docker pull nacos/nacos-server

镜像拉完之后,启动脚本

1
2
3
4
5
6
7
8
9
10
11
12
docker run -d \
--name nacos \
--privileged \
--cgroupns host \
--env JVM_XMX=256m \
--env MODE=standalone \
--env JVM_XMS=256m \
-p 8848:8848/tcp \
-p 9848:9848/tcp \
--restart=always \
-w /home/nacos \
nacos/nacos-server

云服务器不要忘记打开防火墙端口。

访问 ip 地址+8848 /nacos 即可进入控制台

image-20240717151037534.png

nacos 的文档:https://nacos.io/zh-cn/docs/what-is-nacos.html

nacos 的架构原理:https://developer.aliyun.com/ebook/36?spm=a2c6h.20345107.ebook-index.18.152c2984fsi5ST

Docker安装Redis

1
2
docker search redis
docker pull redis

拉下镜像之后,点击下面地址选择自己需要的 redis 版本的配置文件

https://redis.io/docs/management/config/

image-20240717151249025.png

提前在服务器建立 /data/redis 文件夹,touch 文件redis.conf,也可以上面的直接复制

redis.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected-mode yes
port 6379
tcp-backlog 511
timeout 0
tcp-keepalive 300
daemonize no
pidfile /var/run/redis_6379.pid
loglevel notice
logfile ""
databases 16
always-show-logo no
set-proc-title yes
proc-title-template "{title} {listen-addr} {server-mode}"
locale-collate ""
...
1
2
启动命令
docker run -p 6379:6379 --name redis -v /data/redis/redis.conf:/etc/redis/redis.conf -v /data/redis/data:/data -d redis redis-server /etc/redis/redis.conf --appendonly yes

-p 6379:6379:把容器内的6379端口映射到宿主机6379端口

-v /data/redis/redis.conf:/etc/redis/redis.conf:把宿主机配置好的redis.conf放到容器内的这个位置中

-v /data/redis/data:/data:把redis持久化的数据在宿主机内显示,做数据备份

redis-server /etc/redis/redis.conf:这个是关键配置,让redis不是无配置启动,而是按照这个redis.conf的配置启动

–appendonly yes:redis启动后数据持久化

工具:Redis Desktop Manager

IDEA连接redis可以直接下载 plugin 的 redis 插件

Docker安装es

1
2
3
4
5
6
7
yum install -y yum-utils device-mapper-persistent-data lvm2

docker search elasticsearch

docker pull elasticsearch:7.3.1

docker run -d --name elasticsearch -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" -e ES_JAVA_OPTS="-Xms1024m -Xmx1024m" 3d3aa92f641f

启动成功之后,访问http://xxx.xx.xx.xxx:9200/

image-20240717151757675.png

看到这个就证明成功了!

插件:es-head

docker安装xxl-job

1
2
3
4
5
6
7
8
9
10
11
12
13
docker search xxl-job
docker pull xuxueli/xxl-job-admin:2.4.0

docker run -d \
-p 8088:8088\
-v /tool/xxl-job/logs:/data/applogs \
-v /tool/xxl-job/application.properties:/xxl-job/xxl-job-admin/src/main/resources/application.properties \
-e PARAMS="--server.port=8088\
--spring.datasource.url=jdbc:mysql://xxx.xx.xx.xxx:3306/xxl_job?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai \
--spring.datasource.username=root \
--spring.datasource.password=Wing1Q2W#E" \
--name xxl-job-admin \
xuxueli/xxl-job-admin:2.4.0

rocketmq安装

官网地址:https://rocketmq.apache.org/

安装包上传到 linux 的/soft 文件夹,没有此文件夹,先创建,不过在 es 的时候已经创建过了。

image-20240717152141331.png

1
2
3
4
5
6
7
8
9
10
yum install unzip  可以解压zip包的依赖
unzip rocketmq-all-4.8.0-bin-release.zip
cd rocketmq-all-4.8.0-bin-release
cd bin
vim runserver.sh
将其中的xmx,xms等进行修改256m,弄小一点,让服务器用
vim runbroker.sh
同理修改其中的xmx,xms等进行修改256m,弄小一点,让服务器用
nohup sh mqnamesrv &
tail -f ~/logs/rocketmqlogs/namesrv.log

启动broker

1
2
3
export NAMESRV_ADDR=localhost:9876
nohup sh mqbroker -n localhost:9876 &
tail -f ~/logs/rocketmqlogs/broker.log

发送消息

1
2
sh tools.sh org.apache.rocketmq.example.quickstart.Producer
sh tools.sh org.apache.rocketmq.example.quickstart.Consumer

如果发送消息报错,建立文件夹

1
2
3
4
cd ~/store
mkdir commitlog
cd commitlog
mkdir consumequeue

关闭

1
2
sh bin/mqshutdown broker
sh bin/mqshutdown namesrv

安装控制台

image-20240717152526211.png

更改端口和配置文件。

1
nohup java -Xms300m -Xmx300m -jar rocketmq-console.jar > console.log &

image-20240717152632964.png

第一部分

数据库表

数据库表建模JSON

刷题模块

image-20240717154028356.png

SQL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
DROP TABLE IF EXISTS `subject_radio`;
CREATE TABLE `subject_radio`
(
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`subject_id` bigint(20) DEFAULT NULL COMMENT '题目id',
`option_type` tinyint(4) DEFAULT NULL COMMENT 'a,b,c,d',
`option_content` varchar(128) DEFAULT NULL COMMENT '选项内容',
`is_correct` tinyint(2) DEFAULT NULL COMMENT '是否正确',
`created_by` varchar(32) DEFAULT NULL COMMENT '创建人',
`created_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(32) DEFAULT NULL COMMENT '修改人',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
`is_deleted` int(11) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='单选题信息表';

DROP TABLE IF EXISTS `subject_multiple`;
CREATE TABLE `subject_multiple`
(
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`subject_id` bigint(20) DEFAULT NULL COMMENT '题目id',
`option_type` bigint(4) DEFAULT NULL COMMENT '选项类型',
`option_content` varchar(64) DEFAULT NULL COMMENT '选项内容',
`is_correct` tinyint(2) DEFAULT NULL COMMENT '是否正确',
`created_by` varchar(32) DEFAULT NULL COMMENT '创建人',
`created_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(32) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`is_deleted` int(11) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='多选题信息表';

DROP TABLE IF EXISTS `subject_mapping`;
CREATE TABLE `subject_mapping`
(
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`subject_id` bigint(20) DEFAULT NULL COMMENT '题目id',
`category_id` bigint(20) DEFAULT NULL COMMENT '分类id',
`label_id` bigint(20) DEFAULT NULL COMMENT '标签id',
`created_by` varchar(32) DEFAULT NULL COMMENT '创建人',
`created_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(32) DEFAULT NULL COMMENT '修改人',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
`is_deleted` int(11) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=536 DEFAULT CHARSET=utf8 COMMENT='题目分类关系表';

DROP TABLE IF EXISTS `subject_liked`;
CREATE TABLE `subject_liked`
(
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`subject_id` bigint(20) DEFAULT NULL COMMENT '题目id',
`like_user_id` varchar(32) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '点赞人id',
`status` int(11) DEFAULT NULL COMMENT '点赞状态 1点赞 0不点赞',
`created_by` varchar(32) CHARACTER SET utf8 DEFAULT NULL COMMENT '创建人',
`created_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(32) CHARACTER SET utf8 DEFAULT NULL COMMENT '修改人',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
`is_deleted` int(11) DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_like` (`subject_id`,`like_user_id`) USING BTREE COMMENT '点赞唯一索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='题目点赞表';

DROP TABLE IF EXISTS `subject_label`;
CREATE TABLE `subject_label`
(
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`label_name` varchar(32) DEFAULT NULL COMMENT '标签分类',
`sort_num` int(11) DEFAULT NULL COMMENT '排序',
`category_id` varchar(50) DEFAULT NULL,
`created_by` varchar(32) DEFAULT NULL COMMENT '创建人',
`created_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(32) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`is_deleted` int(11) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=64 DEFAULT CHARSET=utf8 COMMENT='题目标签表';

DROP TABLE IF EXISTS `subject_judge`;
CREATE TABLE `subject_judge`
(
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`subject_id` bigint(20) DEFAULT NULL COMMENT '题目id',
`is_correct` tinyint(2) DEFAULT NULL COMMENT '是否正确',
`created_by` varchar(32) DEFAULT NULL COMMENT '创建人',
`created_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(32) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`is_deleted` int(11) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='判断题';

DROP TABLE IF EXISTS `subject_info`;
CREATE TABLE `subject_info`
(
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`subject_name` varchar(128) DEFAULT NULL COMMENT '题目名称',
`subject_difficult` tinyint(4) DEFAULT NULL COMMENT '题目难度',
`settle_name` varchar(32) DEFAULT NULL COMMENT '出题人名',
`subject_type` tinyint(4) DEFAULT NULL COMMENT '题目类型 1单选 2多选 3判断 4简答',
`subject_score` tinyint(4) DEFAULT NULL COMMENT '题目分数',
`subject_parse` varchar(512) DEFAULT NULL COMMENT '题目解析',
`created_by` varchar(32) DEFAULT NULL COMMENT '创建人',
`created_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(32) DEFAULT NULL COMMENT '修改人',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
`is_deleted` int(11) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=327 DEFAULT CHARSET=utf8 COMMENT='题目信息表';

DROP TABLE IF EXISTS `subject_category`;
CREATE TABLE `subject_category`
(
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`category_name` varchar(16) DEFAULT NULL COMMENT '分类名称',
`category_type` tinyint(2) DEFAULT NULL COMMENT '分类类型',
`image_url` varchar(64) DEFAULT NULL COMMENT '图标连接',
`parent_id` bigint(20) DEFAULT NULL COMMENT '父级id',
`created_by` varchar(32) DEFAULT NULL COMMENT '创建人',
`created_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(32) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`is_deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除 0: 未删除 1: 已删除',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8 COMMENT='题目分类';

DROP TABLE IF EXISTS `subject_brief`;
CREATE TABLE `subject_brief`
(
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`subject_id` int(20) DEFAULT NULL COMMENT '题目id',
`subject_answer` text COMMENT '题目答案',
`created_by` varchar(32) DEFAULT NULL COMMENT '创建人',
`created_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(32) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`is_deleted` int(11) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=280 DEFAULT CHARSET=utf8 COMMENT='简答题';

刷题模块数据模型

image-20240717162213876.png

image-20240717154416885.png
image-20240717154425945.png
image-20240717154441135.png
image-20240717162527674.png
image-20240717162448319.png
image-20240717154448206.png
image-20240717154500698.png

鉴权模块

1.刷题模块(微服务模块)

image-20240717163944712.png

starter类是用于放置整个项目的启动类的

产品功能模块

image-20240710164145589.png

研发功能模块拆分

image-20240710164949102.png

原型设计

axrue+antdesign的组件库

刷题首页

image-20240710165032544.png

题目详情

image-20240710165319312.png

分类模块

image-20240710174638222.png

分类的概念是面试题的大类。其中我们有两种概念:

  1. 一种是岗位分类,例如后端,前端,测试。
  2. 一种是岗位下细分的分类,比如后端下细分,框架,并发,集合等等。
新增分类

正常的业务逻辑,保证新增后,可以正常的插入数据库即可。

修改分类

crud

删除分类

crud

首页的分类

可以扩展做成做成缓存,不易变的数据,直接从redis查缓存。

缓存预热这种,启动项目之后,扔进去。

目前做成串行化的,二期可以优化,由前端先查询岗位大类,然后再根据大类查询小类。

标签详细设计

标签的概念是分类下的细分。标签是通用性的,独立的个体,与分类不进行强耦合,和题目相关。标签和分类是公用的,多个分类可以对应同一个标签。

新增标签

crud 直接看代码

修改标签

crud

删除标签

crud

标签查询

根据分类去查询标签,要通过题目信息的关联表来进行查询。详细看代码

以上功能涉及到 subject_label 表

7yCAHGEJegnk5NF[1].png

题目模块

image-20240710174839950.png

题目分为单选,多选,判断,简单,四种数据类型,在设计数据的时候,拆分成了题目的主表和其他对应的表来做。

新增题目

注意:采取工厂+策略的模式去做扩展,现在有四种题型,未来无论加多少种,都可以不用动主流程。

后期会结合es 做题目的查重。为搜索做准备。

修改题目

crud

删除题目

要注意删除主表的同时,也把其他的细分的数据表进行同步的处理。

题目列表

难度不大,就是个简单的分页的查询,分类、标签,难度这些其实都是入参的场景。

查标签,难度啊,出题人啊,等等,这些就直接查,不做join。

题目的详情

也做一下工厂+策略吧

此功能涉及如下数据表

image-20240710171403151.png

刷题模块代码的实现(jc-club-subject)

application层的SubjectController(应用层初探&SpringMVC集成)

jc-club-subject中的jc-club-application-controller中的controller模块下面建立SubjectController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.jingdianjichi.subject.application.controller;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;


/**
* 刷题controller
*/
@RestController
@RequestMapping("/subject")
public class SubjectController {
@GetMapping("/test")
public String test(){
return "hello world";
}
}

这里给出的测试代码如上。

jc-club-subject中的jc-club-starter包中有SubjectApplication.java作为启动类,并且需要修改pom.xml中的内容,让启动类能够访问到jc-club-application-controller中的代码内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//jc-club-starter的pom.xml,在starter中关于启动的配置是写在resources中的application.yml文件中的,包裹数据库连接,redis等等
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.4.2</version>
<exclusions>
<exclusion>
<artifactId>spring-boot-starter-logging</artifactId>
<groupId>org.springframework.boot</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.jingdianjichi</groupId>
<artifactId>jc-club-application-controller</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//SubjectApplication.java 启动类
package com.jingdianjichi.subject;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.ComponentScan;

/**
* 刷题微服务启动类
*
* @author: ChickenWing
* @date: 2023/10/1
*/
@SpringBootApplication
@ComponentScan("com.jingdianjichi")
@MapperScan("com.jingdianjichi.**.mapper")
@EnableFeignClients(basePackages = "com.jingdianjichi")
public class SubjectApplication {

public static void main(String[] args) {
SpringApplication.run(SubjectApplication.class);
}

}

记得maven install

mysql,druid,mybatis集成(infrastructure层)

彻底搞懂MyBaits中#{}和${}的区别_mybatis #{}-CSDN博客

jc-club-subject中的jc-club-infra包中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//pom.xml
<dependencies>
<!-- jdbcStarter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
<version>2.4.2</version>
</dependency>
<!-- druid连接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.22</version>
</dependency>
<!-- mysql -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.22</version>
</dependency>
<!-- mybatisplus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.0</version>
</dependency>
</dependencies>

subject_category这个模块

用IDEA自带是数据库工具去连上MySQL

image-20240720131302130.png

image-20240720131412700.png
联上数据库后,右键category表,然后Eazycode,选择目录,放在jc-club-infra包下的basic目录中,template选择mapper.xml.vm, dao.java.vm, entity.java.vm, service.java.vm, serviceImpl.java.vm(下面两张图是项目结束后完整的截图,这里就涉及到了mybatis)

image-20240720132232428.png

image-20240720132254642.png

https://blog.csdn.net/quest101/article/details/105624322?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522172154711316800226546866%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fall.%2522%257D&request_id=172154711316800226546866&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~first_rank_ecpm_v1~rank_v31_ecpm-1-105624322-null-null.142

[mybatis-plus关于@Mapper、@Repository、@MapperScan、xml文件的相关问题_mybatisplus repository-CSDN博客](https://blog.csdn.net/qq_41428418/article/details/132575881?ops_request_misc=&request_id=&biz_id=102&utm_term=mapperscan resulttype=&utm_medium=distribute.pc_search_result.none-task-blog-2allsobaiduweb~default-1-132575881.142^v100^pc_search_result_base8&spm=1018.2226.3001.4187)

继续在jc-club-starter模块的pom.xml中引入当前的jc-club-infra模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//jc-club-starter的pom.xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.4.2</version>
<exclusions>
<exclusion>
<artifactId>spring-boot-starter-logging</artifactId>
<groupId>org.springframework.boot</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.jingdianjichi</groupId>
<artifactId>jc-club-application-controller</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.jingdianjichi</groupId>
<artifactId>jc-club-infra</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>

jc-club-subject中的jc-club-starter中的resources中的application.yml中,定义了服务器和数据源的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
server:
port:3000
spring:
datasource:
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/jc-club?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&useSSL=true
type: com.alibaba.druid.pool.DruidDataSource
druid:
initial-size: 20
min-idle: 20
max-active: 100
max-wait: 60000
stat-view-servlet:
enabled: true
url-pattern: /druid/*
login-username: admin
login-password: 123456
filter:
stat:
enabled: true
slow-sql-millis: 2000
log-slow-sql: true
wall:
enabled: true
  • server: 定义了服务器的配置。

    • port: 指定了服务器运行的端口号,这里是 3000
  • spring: 包含了Spring框架的配置。

    • datasource: 定义了数据源的配置,用于数据库连接。
      • username: 数据库的用户名,这里是 root
      • password: 数据库的密码,这里是 123456
      • driver-class-name: MySQL数据库驱动的类名。
      • url: 数据库的连接URL,包括数据库地址、端口、数据库名以及一些连接参数。这里的URL表明连接到本地的MySQL服务器上的 jc-club 数据库,并且指定了时区、字符编码和SSL的使用。
      • type: 指定了数据源的类型,这里使用的是阿里巴巴的Druid连接池。
      • druid: Druid连接池的特定配置。
        • initial-size: 连接池的初始大小,这里是 20
        • min-idle: 连接池中最小的空闲连接数,这里是 20
        • max-active: 连接池中最大的活动连接数,这里是 100
        • max-wait: 连接池中获取连接的最大等待时间(毫秒),这里是 60000 毫秒(即60秒)。
        • stat-view-servlet: 用于Druid的监控页面。
          • enabled: 是否启用监控页面,这里是 true
          • url-pattern: 监控页面的URL模式。
          • login-username: 监控页面的登录用户名。
          • login-password: 监控页面的登录密码。
        • filter: 定义了Druid的过滤器配置。
          • stat: 用于统计的过滤器。
            • enabled: 是否启用统计过滤器,这里是 true
            • slow-sql-millis: 执行时间超过多少毫秒的SQL被认为是慢查询,这里是 2000 毫秒。
            • log-slow-sql: 是否记录慢查询的日志,这里是 true
          • wall: 用于防火墙的过滤器,用于防止SQL注入。
            • enabled: 是否启用防火墙过滤器,这里是 true

jc-club-subject中的jc-club-application-controller中的pom.xml中引入jc-club-infra模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>jc-club-application</artifactId>
<groupId>com.jingdianjichi</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>

<artifactId>jc-club-application-controller</artifactId>

<properties>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<maven.compiler.compilerVersion>1.8</maven.compiler.compilerVersion>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>


<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.4.2</version>
</dependency>
<dependency>
<groupId>com.jingdianjichi</groupId>
<artifactId>jc-club-infra</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>

</project>

jc-club-subject中的jc-club-application-controller中的SubjectController.java中新增一段对数据库的测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.jingdianjichi.subject.application.controller;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;


/**
* 刷题controller
*/
@RestController
@RequestMapping("/subject")
public class SubjectController {
@GetMapping("/test")
public String test(){
SubjectCategory subjectCategory = subjectCategoryService.queryById(1L);
return subjectCategory;
}
}

测试成功

image-20240721152600418.png

基于druid配置文件加密(infra中的工具类)

jc-club-subject中的jc-club-infra/basic中新建一个utils包,建一个用于数据库加密的DruidEncrypUtil.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package com.jingdianjichi.subject.infra.basic.utils;

import com.alibaba.druid.filter.config.ConfigTools;

import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;

public class DruidEncryptUtil {

private static String publicKey;

private static String privateKey;

static {
try {
String[] keyPair = ConfigTools.genKeyPair(512);
privateKey = keyPair[0]; //私钥
System.out.println("privateKey:" + privateKey);
publicKey = keyPair[1]; //公钥
System.out.println("publicKey:" + publicKey);
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
} catch (NoSuchProviderException e) {
e.printStackTrace();
}
}
//加密
public static String encrypt(String plainText) throws Exception {
String encrypt = ConfigTools.encrypt(privateKey, plainText);
System.out.println("encrypt:" + encrypt);
return encrypt;
}
//解密
public static String decrypt(String encryptText) throws Exception {
String decrypt = ConfigTools.decrypt(publicKey, encryptText);
System.out.println("decrypt:" + decrypt);
return decrypt;
}

public static void main(String[] args) throws Exception {
String encrypt = encrypt("123456");
System.out.println("encrypt:" + encrypt);
}

}

生成的公私钥和加密的密码

image-20240720154842241.png

jc-club-subject中的jc-club-starter中修改application.yml,在password、config和publicKey处进行修改:

  • config: 配置过滤器,这里启用了配置过滤器。

  • connectionProperties: 连接属性,这里配置了解密配置,config.decrypt=true 表示开启解密功能,config.decrypt.key=${publicKey} 表示使用配置的公钥属性进行解密。

  • publicKey: 定义了一个公钥,用于与 connectionProperties 中的 config.decrypt.key 配合,进行数据库密码的解密。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
server:
port:3000
spring:
datasource:
username: root
password: Me2Tw8jJlEU2C3ghYkBPPfauoyYKXOnb7iTsOHbISHU/mC1ol9OUvU3O9klxv1o5UEv49mErTSawnrw4zsG+5g==
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/jc-club?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&useSSL=true
type: com.alibaba.druid.pool.DruidDataSource
druid:
initial-size: 20
min-idle: 20
max-active: 100
max-wait: 60000
connectionProperties: config.decrypt=true;config.decrypt.key=${publicKey};
stat-view-servlet:
enabled: true
url-pattern: /druid/*
login-username: admin
login-password: 123456
filter:
stat:
enabled: true
slow-sql-millis: 2000
log-slow-sql: true
wall:
enabled: true
config:
enabled: true
publicKey: MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAIKZoTIyh/UEThK6nHOmxVlsSYM6o5qTle39c8NilMUjIln2P3bll86R0asiMLU2p2S81RRfARjIO1im8dNBvS8CAwEAAQ==

现在数据库可以通过加密后的密码连上了

分层架构业务开发

所有接口如图所示:

image-20240722125513009.png

题目分类(SubjectCategoryController.java)

这部分内容中,SujectCategoryController作为对题目类型涉及到get、post增删改查的入口,其中包含都在jc-club-application-controller中的SubjectCategoryServiceImplSubjectCategoryDomainServiceImpl。该controller通过jc-club-application-convert组件中SubjectCategoryDTOConverter的将DTO->BO/BO->DTO,再利用SubjectCategoryDomainServiceImpl组件的add方法,其中涉及到jc-club-domain-convert组件中的SubjectCategoryConverter将BO->Category,在通过SubjectCategoryServiceImpl调用infra层的SubjectCategoryConverter将Category类转化为DAO。

MapStruct转换器常见问题及其使用方式_@inheritinverseconfiguration-CSDN博客

这里的convert的实现是利用@Mapper注解到converter类的

MapStruct使用详解-CSDN博客

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import org.mapstruct.Mapper;

@Mapper
public interface SubjectCategoryDTOConverter {

SubjectCategoryDTOConverter INSTANCE = Mappers.getMapper(SubjectCategoryDTOConverter.class);

List<SubjectCategoryDTO> convertBoToCategoryDTOList(List<SubjectCategoryBO> subjectCategoryDTO);

SubjectCategoryBO convertDtoToCategoryBO(SubjectCategoryDTO subjectCategoryDTO);

SubjectCategoryDTO convertBoToCategoryDTO(SubjectCategoryBO subjectCategoryBO);

}

  1. 新增分类(POST: /subject/category/add

    请求body:

    1
    2
    3
    4
    5
    6
    {
    "categoryName":"后端"
    "categoryType":1,
    "parentId":0,
    "imgUrl":"http://image/123"
    }

    响应成功示例:

    1
    2
    3
    4
    5
    6
    7
    {
    "code":200,
    "message":"新增成功",
    "data":true,
    "reqUuid":"123123123.123123.123123",
    "success":true
    }

    新增jc-club-common包:

    1. Lombok是一个Java库,它通过注解的方式提供了一系列可以简化Java代码的工具,比如自动生成getter、setter、toString等方法。

    2. MapStruct是一个代码生成器,用于将Java方法的输入参数映射到输出参数,通常用于DTO(数据传输对象)和Entity(实体)之间的映射。

      DTO数据传输对象详解_dto撖寡情-CSDN博客

    3. mapstruct-processor,是MapStruct的注解处理器部分,用于在编译时生成映射代码。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    //pom.xml 注意lombok放在mapstruct前面是为了能够正常的拿到数据。如果项目中使用了lombok,那么需要在编译器指定他们的执行顺序,因为mapstrut底层是靠set/get赋值的,所以需要lombok先编译。
    <dependencies>
    <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.16</version>
    </dependency>
    <dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct</artifactId>
    <version>1.4.2.Final</version>
    </dependency>
    <dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct-processor</artifactId>
    <version>1.4.2.Final</version>
    </dependency>
    </dependencies>

    jc-club-infra中的entity下的SubjectCategory.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    package com.jingdianjichi.subject.infra.basic.entity;

    import lombok.Data;

    import java.util.Date;
    import java.io.Serializable;

    /**
    * 题目分类(SubjectCategory)实体类
    */
    @Data //导入了Lombok库中的@Data注解。@Data是一个便利的注解,它为类自动生成getter和setter方法、equals()、hashCode()和toString()方法。
    public class SubjectCategory implements Serializable { //序列化

    /**
    * 主键
    */
    private Long id;

    /**
    * 分类名称
    */
    private String categoryName;

    /**
    * 分类类型
    */
    private Integer categoryType;

    /**
    * 图标连接
    */
    private String imageUrl;

    /**
    * 父级id
    */
    private Long parentId;

    /**
    * 创建人
    */
    private String createdBy;

    /**
    * 创建时间
    */
    private Date createdTime;

    /**
    * 更新人
    */
    private String updateBy;

    /**
    * 更新时间
    */
    private Date updateTime;

    /**
    * 逻辑删除 0未删除 1已删除
    */
    private Integer isDeleted;

    }

    jc-club-infra中的mapper下的SubjectCategoryDao.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    package com.jingdianjichi.subject.infra.basic.mapper;

    import com.jingdianjichi.subject.infra.basic.entity.SubjectCategory;
    import org.apache.ibatis.annotations.Param;
    import java.util.List;

    /**
    * 题目分类(SubjectCategory)表数据库访问层
    */
    public interface SubjectCategoryDao {

    /**
    * 通过ID查询单条数据
    *
    * @param id 主键
    * @return 实例对象
    */
    SubjectCategory queryById(Long id);

    /**
    * 统计总行数
    *
    * @param subjectCategory 查询条件
    * @return 总行数
    */
    long count(SubjectCategory subjectCategory);

    /**
    * 新增数据
    *
    * @param subjectCategory 实例对象
    * @return 影响行数
    */
    int insert(SubjectCategory subjectCategory);

    /**
    * 批量新增数据(MyBatis原生foreach方法)
    *
    * @param entities List<SubjectCategory> 实例对象列表
    * @return 影响行数
    */
    int insertBatch(@Param("entities") List<SubjectCategory> entities);

    /**
    * 批量新增或按主键更新数据(MyBatis原生foreach方法)
    *
    * @param entities List<SubjectCategory> 实例对象列表
    * @return 影响行数
    * @throws org.springframework.jdbc.BadSqlGrammarException 入参是空List的时候会抛SQL语句错误的异常,请自行校验入参
    */
    int insertOrUpdateBatch(@Param("entities") List<SubjectCategory> entities);

    /**
    * 修改数据
    *
    * @param subjectCategory 实例对象
    * @return 影响行数
    */
    int update(SubjectCategory subjectCategory);

    /**
    * 通过主键删除数据
    *
    * @param id 主键
    * @return 影响行数
    */
    int deleteById(Long id);

    List<SubjectCategory> queryCategory(SubjectCategory subjectCategory);

    Integer querySubjectCount(Long id);

    }


    jc-club-infra中的service下的SubjectCategoryServiceImpl.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    package com.jingdianjichi.subject.infra.basic.service.impl;

    import com.alibaba.fastjson.JSON;
    import com.jingdianjichi.subject.infra.basic.entity.SubjectCategory;
    import com.jingdianjichi.subject.infra.basic.mapper.SubjectCategoryDao;
    import com.jingdianjichi.subject.infra.basic.service.SubjectCategoryService;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Service;

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

    /**
    * 题目分类(SubjectCategory)表服务实现类
    */
    @Service("subjectCategoryService") //Spring注解,用于声明这个类是一个服务组件,并且可以通过subjectCategoryService这个名称获取到这个服务的实例。
    @Slf4j //Lombok注解,自动为类生成一个日志对象(log),使用SLF4J日志框架。
    public class SubjectCategoryServiceImpl implements SubjectCategoryService {

    @Resource //Spring注解,用于自动装配(注入)SubjectCategoryDao类型的bean到subjectCategoryDao字段。
    private SubjectCategoryDao subjectCategoryDao;


    /**
    * 新增数据
    *
    * @param subjectCategory 实例对象
    * @return 实例对象
    */
    @Override
    public SubjectCategory insert(SubjectCategory subjectCategory) {
    if(log.isInfoEnabled()){
    log.info("SubjectCategoryController.add.subjectCategory:{}"
    , JSON.toJSONString(subjectCategory));
    }
    this.subjectCategoryDao.insert(subjectCategory);
    return subjectCategory;
    }

    @Override
    public SubjectCategory queryById(Long id) {
    return this.subjectCategoryDao.queryById(id);
    }

    /**
    * 修改数据
    *
    * @param subjectCategory 实例对象
    * @return 实例对象
    */
    @Override
    public int update(SubjectCategory subjectCategory) {
    return this.subjectCategoryDao.update(subjectCategory);
    }

    /**
    * 通过主键删除数据
    *
    * @param id 主键
    * @return 是否成功
    */
    @Override
    public boolean deleteById(Long id) {
    return this.subjectCategoryDao.deleteById(id) > 0;
    }

    @Override
    public List<SubjectCategory> queryCategory(SubjectCategory subjectCategory) {
    return this.subjectCategoryDao.queryCategory(subjectCategory);
    }

    @Override
    public Integer querySubjectCount(Long id) {
    return this.subjectCategoryDao.querySubjectCount(id);
    }
    }

    jc-club-domain中的pom.xml引入infra模块,便于jc-club-domain中的service/impl下的SubjectCategoryDomainServiceImpl.java来引用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
    <artifactId>jc-club-subject</artifactId>
    <groupId>com.jingdianjichi</groupId>
    <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>jc-club-domain</artifactId>
    <build>
    <plugins>
    <plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
    <source>8</source>
    <target>8</target>
    </configuration>
    </plugin>
    </plugins>
    </build>

    <dependencies>
    <dependency>
    <groupId>com.jingdianjichi</groupId>
    <artifactId>jc-club-infra</artifactId>
    <version>1.0-SNAPSHOT</version>
    </dependency>
    </dependencies>

    </project>

    jc-club-domain中的entity下的SubjectCategoryBO.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    package com.jingdianjichi.subject.domain.entity;

    import lombok.Data;

    import java.io.Serializable;
    import java.util.Date;
    import java.util.List;

    /**
    * 题目分类(SubjectCategory)实体类
    *
    * @author makejava
    * @since 2023-10-01 21:49:59
    */
    @Data
    public class SubjectCategoryBO implements Serializable {

    /**
    * 主键
    */
    private Long id;

    /**
    * 分类名称
    */
    private String categoryName;

    /**
    * 分类类型
    */
    private Integer categoryType;

    /**
    * 图标连接
    */
    private String imageUrl;

    /**
    * 父级id
    */
    private Long parentId;

    /**
    * 数量
    */
    private Integer count;

    /**
    * 标签bo数量
    */
    private List<SubjectLabelBO> labelBOList;

    }

    jc-club-domain中的service下的SubjectCategoryDomainService.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    package com.jingdianjichi.subject.domain.service;

    import com.jingdianjichi.subject.domain.entity.SubjectCategoryBO;
    import java.util.List;


    public interface SubjectCategoryDomainService {

    /**
    * 新增分类
    */
    void add(SubjectCategoryBO subjectCategoryBO);

    /**
    * 查询岗位大类
    */
    List<SubjectCategoryBO> queryCategory(SubjectCategoryBO subjectCategoryBO);

    /**
    * 更新分类
    */
    Boolean update(SubjectCategoryBO subjectCategoryBO);

    /**
    * 删除分类
    */
    Boolean delete(SubjectCategoryBO subjectCategoryBO);

    /**
    * 查询分类及标签
    */
    List<SubjectCategoryBO> queryCategoryAndLabel(SubjectCategoryBO subjectCategoryBO);

    }

    怎么将BO转为Category呢?这里需要在jc-club-domain中的convert包设置SubjectCategoryConverter.java,用到mapstruct。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    //SubjectCategoryConverter.java
    package com.jingdianjichi.subject.domain.convert;

    import com.jingdianjichi.subject.domain.entity.SubjectCategoryBO;
    import com.jingdianjichi.subject.infra.basic.entity.SubjectCategory;
    import org.mapstruct.Mapper;
    import org.mapstruct.factory.Mappers;

    import java.util.List;

    @Mapper
    public interface SubjectCategoryConverter {

    SubjectCategoryConverter INSTANCE = Mappers.getMapper(SubjectCategoryConverter.class);

    SubjectCategory convertBoToCategory(SubjectCategoryBO subjectCategoryBO);

    List<SubjectCategoryBO> convertBoToCategory(List<SubjectCategory> categoryList);

    }
    //此时`jc-club-domain`中的`service`下的`SubjectCategoryDomainServiceImpl.java`就可以调用这个了
    package com.jingdianjichi.subject.domain.service.impl;

    @Service
    public class SubjectCategoryDomainServiceImpl implements SubjectCategoryDomainService {

    @Resource
    private SubjectCategoryService subjectCategoryService;

    public void add(SubjectCategoryBO subjectCategoryBO) {
    SubjectCategory subjectCategory = SubjectCategoryConverter.INSTANCE
    .convertBoToCategory(subjectCategoryBO); //BO->SujectCategory
    subjectCategory.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
    subjectCategoryService.insert(subjectCategory);
    }
    }

    jc-club-subject中的jc-club-application-controllerpom.xml中新增jc-club-domain,目的是在创建SubjectCategoryController时能正常引入。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    //pom.xml
    <dependencies>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <version>2.4.2</version>
    </dependency>
    <dependency>
    <groupId>com.jingdianjichi</groupId>
    <artifactId>jc-club-infra</artifactId>
    <version>1.0-SNAPSHOT</version>
    </dependency>
    <dependency>
    <groupId>com.jingdianjichi</groupId>
    <artifactId>jc-club-domain</artifactId>
    <version>1.0-SNAPSHOT</version>
    </dependency>
    </dependencies>

    jc-club-subject中的jc-club-application-controllerSubjectCategoryController.java。这里会涉及到在jc-club-application-dto模块下的从BO到DTO的转换。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    //SubjectCategoryDTO.java BO->DTO
    package com.jingdianjichi.subject.application.dto;

    import lombok.Data;

    import java.io.Serializable;
    import java.util.List;

    /**
    * 题目分类
    *
    * @author: ChickenWing
    * @date: 2023/10/3
    */
    @Data
    public class SubjectCategoryDTO implements Serializable {

    /**
    * 主键
    */
    private Long id;

    /**
    * 分类名称
    */
    private String categoryName;

    /**
    * 分类类型
    */
    private Integer categoryType;

    /**
    * 图标连接
    */
    private String imageUrl;

    /**
    * 父级id
    */
    private Long parentId;

    /**
    * 数量
    */
    private Integer count;

    /**
    * 标签信息
    */
    private List<SubjectLabelDTO> labelDTOList;

    }
    //SubjectCategoryDTOConverter.java
    package com.jingdianjichi.subject.application.convert;

    import com.jingdianjichi.subject.application.dto.SubjectCategoryDTO;
    import com.jingdianjichi.subject.domain.entity.SubjectCategoryBO;
    import com.jingdianjichi.subject.infra.basic.entity.SubjectCategory;
    import org.mapstruct.Mapper;
    import org.mapstruct.factory.Mappers;

    import java.util.List;

    /**
    * 题目分类dto转换器
    */
    @Mapper
    public interface SubjectCategoryDTOConverter {

    SubjectCategoryDTOConverter INSTANCE = Mappers.getMapper(SubjectCategoryDTOConverter.class);

    List<SubjectCategoryDTO> convertBoToCategoryDTOList(List<SubjectCategoryBO> subjectCategoryDTO);

    SubjectCategoryBO convertDtoToCategoryBO(SubjectCategoryDTO subjectCategoryDTO);

    SubjectCategoryDTO convertBoToCategoryDTO(SubjectCategoryBO subjectCategoryBO);

    }


    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    //SubjectCategoryController.java
    @RestController
    @RequestMapping("/subject/category")
    @Slf4j
    public class SubjectCategoryController {

    @Resource
    private SubjectCategoryDomainService subjectCategoryDomainService;

    @GetMapping("/add")
    public Result<Boolean> add(@RequestBody SubjectCategoryDTO subjectCategoryDTO) {
    try{
    SubjectCategoryBO subjectCategoryBO=SubjectCategoryDTOConverter.INSTANCE.convertDtoToBO(subjectCategoryDTO);
    subjectDomainService.add(subjectCategoryBO);
    return Result.ok(true);
    }catch(Exception e){
    return Result.fail(false); //这里的返回结果在jc-club-common中的entity模块设计一个Result类来统一返回
    }
    }
    }

    由于会涉及到结果返回:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    //jc-club-common/entity Result.java
    package com.jingdianjichi.subject.common.entity;

    import com.jingdianjichi.subject.common.enums.ResultCodeEnum;
    import lombok.Data;

    @Data
    public class Result<T> {

    private Boolean success;

    private Integer code;

    private String message;

    private T data;

    public static Result ok(){
    Result result = new Result();
    result.setSuccess(true);
    result.setCode(ResultCodeEnum.SUCCESS.getCode());
    result.setMessage(ResultCodeEnum.SUCCESS.getDesc());
    return result;
    }

    public static <T> Result ok(T data){
    Result result = new Result();
    result.setSuccess(true);
    result.setCode(ResultCodeEnum.SUCCESS.getCode());
    result.setMessage(ResultCodeEnum.SUCCESS.getDesc());
    result.setData(data);
    return result;
    }

    public static Result fail(){
    Result result = new Result();
    result.setSuccess(false);
    result.setCode(ResultCodeEnum.FAIL.getCode());
    result.setMessage(ResultCodeEnum.FAIL.getDesc());
    return result;
    }

    public static <T> Result fail(T data){
    Result result = new Result();
    result.setSuccess(false);
    result.setCode(ResultCodeEnum.FAIL.getCode());
    result.setMessage(ResultCodeEnum.FAIL.getDesc());
    result.setData(data);
    return result;
    }

    }
    //jc-club-common/enums ResultCodeEnum.java
    package com.jingdianjichi.subject.common.enums;

    import lombok.Getter;

    @Getter
    public enum ResultCodeEnum {

    SUCCESS(200,"成功"),
    FAIL(500,"失败");

    public int code;

    public String desc;

    ResultCodeEnum(int code,String desc){
    this.code = code;
    this.desc = desc;
    }

    public static ResultCodeEnum getByCode(int codeVal){
    for(ResultCodeEnum resultCodeEnum : ResultCodeEnum.values()){
    if(resultCodeEnum.code == codeVal){
    return resultCodeEnum;
    }
    }
    return null;
    }

    }

    增加日志lo4j2和fastjson在jc-club-common中:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    //pom.xml
    <dependencies>
    <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.16</version>
    </dependency>
    <dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct</artifactId>
    <version>1.4.2.Final</version>
    </dependency>
    <dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct-processor</artifactId>
    <version>1.4.2.Final</version>
    </dependency>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-log4j2</artifactId>
    <version>2.4.2</version>
    </dependency>
    <dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.24</version>
    </dependency>
    </dependencies>

    对应的SubjectCategoryController.javaSubjectCategoryDomainServiceImpl.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    //SubjectCategoryController.java
    @RestController
    @RequestMapping("/subject/category")
    @Slf4j
    public class SubjectCategoryController {

    @Resource
    private SubjectCategoryDomainService subjectCategoryDomainService;

    @GetMapping("/add")
    public Result<Boolean> add(@RequestBody SubjectCategoryDTO subjectCategoryDTO) {
    try{
    if (log.isInfoEnabled()) {
    log.info("SubjectCategoryController.add.dto:{}", JSON.toJSONString(subjectCategoryDTO));
    }
    /*这里log.isInfoEnabled()的作用是如果不加log.isDebugEnabled()等
    进行预先判断,当系统loglevel设置高于Debug或Info或Trace时,虽然系统不会答应出这些级别的日志,但是每次还是会拼接
    参数字符串/序列化,影响系统的性能。*/
    SubjectCategoryBO subjectCategoryBO=SubjectCategoryDTOConverter.INSTANCE.convertDtoToBO(subjectCategoryDTO);
    subjectDomainService.add(subjectCategoryBO);
    return Result.ok(true);
    }catch(Exception e){
    return Result.fail(false); //这里的返回结果在jc-club-common中的entity模块设计一个Result类来统一返回
    }
    }
    }
    //SubjectCategoryDomainServiceImpl.java
    package com.jingdianjichi.subject.domain.service.impl;

    @Service
    @Slf4j
    public class SubjectCategoryDomainServiceImpl implements SubjectCategoryDomainService {

    @Resource
    private SubjectCategoryService subjectCategoryService;

    public void add(SubjectCategoryBO subjectCategoryBO) {
    if (log.isInfoEnabled()) {
    log.info("SubjectCategoryController.add.bo:{}", JSON.toJSONString(subjectCategoryBO));
    }
    SubjectCategory subjectCategory = SubjectCategoryConverter.INSTANCE
    .convertBoToCategory(subjectCategoryBO); //BO->SujectCategory
    subjectCategory.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
    subjectCategoryService.insert(subjectCategory);
    }
    }

    preconditions参数校验

    回到SubjectCategoryController.java,进行参数校验,在pom.xml中添加guava(注意参数校验放在controller)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    //jc-club-common/pom.xml
    <dependencies>
    <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.16</version>
    </dependency>
    <dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct</artifactId>
    <version>1.4.2.Final</version>
    </dependency>
    <dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct-processor</artifactId>
    <version>1.4.2.Final</version>
    </dependency>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-log4j2</artifactId>
    <version>2.4.2</version>
    </dependency>
    <dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.24</version>
    </dependency>
    <dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>19.0</version>
    </dependency>
    </dependencies>

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    //SubjectCategoryController.java
    @PostMapping("/add")
    public Result<Boolean> add(@RequestBody SubjectCategoryDTO subjectCategoryDTO) {
    try {
    if (log.isInfoEnabled()) {
    log.info("SubjectCategoryController.add.dto:{}", JSON.toJSONString(subjectCategoryDTO));
    }
    Preconditions.checkNotNull(subjectCategoryDTO.getCategoryType(), "分类类型不能为空");
    Preconditions.checkArgument(!StringUtils.isBlank(subjectCategoryDTO.getCategoryName()), "分类名称不能为空");
    Preconditions.checkNotNull(subjectCategoryDTO.getParentId(), "分类父级id不能为空");
    SubjectCategoryBO subjectCategoryBO = SubjectCategoryDTOConverter.INSTANCE.convertDtoToCategoryBO(subjectCategoryDTO);
    subjectCategoryDomainService.add(subjectCategoryBO);
    return Result.ok(true);
    } catch (Exception e) {
    log.error("SubjectCategoryController.add.error:{}", e.getMessage(), e);
    return Result.fail("新增分类失败");
    }
    }
刷题模块接口定义
  1. 新增分类
  2. 更新分类
  3. 查询分类
  4. 查询大类下分类
  5. 查询分类及标签(二期优化)
  6. 删除分类

image-20240710165032544.png

分类->标签

基本的->二次优化

数据库->缓存优化

  1. 新增分类:POST:/subject/category/add

    请求体:

    1
    2
    3
    4
    5
    6
    {
    "categoryName": "缓存",
    "parentId": 0,
    "categoryType": 1,
    "imageUrl": "https://image/category.icon"
    }

    响应成功示例:

    1
    2
    3
    4
    5
    6
    {
    "success": true,
    "code": 200,
    "message": "成功",
    "data": true
    }
  2. 查询分类:POST:/subject/category/queryPrimaryCategory

    请求体:

    1
    2
    3
    {
    "categoryType": 1
    }

    响应示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    {
    "success": true,
    "code": 200,
    "message": "成功",
    "data": [
    {
    "id": 1,
    "categoryName": "后端",
    "categoryType": 1,
    "imageUrl": "https://image/category.icon",
    "parentId": 0,
    "count": 65
    }
    ]
    }
  3. 查询大类下分类:POST:/subject/category/queryCategoryByPrimary

    请求体:

    1
    2
    3
    4
    {
    "parentId": 1,
    "categoryType": 2
    }

    响应示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    {
    "success": true,
    "code": 200,
    "message": "成功",
    "data": [
    {
    "id": 4,
    "categoryName": "框架",
    "categoryType": 2,
    "imageUrl": "http://image/123",
    "parentId": 1
    },
    {
    "id": 5,
    "categoryName": "并发",
    "categoryType": 2,
    "imageUrl": "http://image/123",
    "parentId": 1
    },
    {
    "id": 6,
    "categoryName": "jvm",
    "categoryType": 2,
    "imageUrl": "http://image/123",
    "parentId": 1
    }
    ]
    }
  4. 查询分类及标签(二期优化)

    请求体:

    1
    2
    3
    {
    "id": 1
    }

    响应示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    {
    "success": true,
    "code": 200,
    "message": "成功",
    "data": [
    {
    "id": 2,
    "categoryName": "缓存",
    "categoryType": 2,
    "imageUrl": "https://image/category.icon",
    "parentId": 1,
    "labelDTOList": [
    {
    "id": 1,
    "categoryId": 1,
    "labelName": "Redis",
    "sortNum": 1
    },
    {
    "id": 8,
    "categoryId": 1,
    "labelName": "集群",
    "sortNum": 1
    },
    {
    "id": 23,
    "categoryId": 1,
    "labelName": "实际应用",
    "sortNum": 1
    },
    {
    "id": 34,
    "categoryId": 1,
    "labelName": "多线程",
    "sortNum": 1
    },
    {
    "id": 44,
    "categoryId": 1,
    "labelName": "数据一致性",
    "sortNum": 1
    },
    {
    "id": 46,
    "categoryId": 1,
    "labelName": "分布式",
    "sortNum": 1
    },
    {
    "id": 47,
    "categoryId": 1,
    "labelName": "持久化",
    "sortNum": 1
    },
    {
    "id": 49,
    "categoryId": 1,
    "labelName": "事务",
    "sortNum": 1
    }
    ]
    },
    {
    "id": 3,
    "categoryName": "数据库",
    "categoryType": 2,
    "imageUrl": "https://image/category.icon",
    "parentId": 1,
    "labelDTOList": [
    {
    "id": 2,
    "categoryId": 1,
    "labelName": "进程",
    "sortNum": 1
    },
    {
    "id": 4,
    "categoryId": 1,
    "labelName": "Mysql",
    "sortNum": 1
    },
    {
    "id": 16,
    "categoryId": 1,
    "labelName": "索引",
    "sortNum": 1
    },
    {
    "id": 23,
    "categoryId": 1,
    "labelName": "实际应用",
    "sortNum": 1
    },
    {
    "id": 33,
    "categoryId": 1,
    "labelName": "存储引擎",
    "sortNum": 1
    },
    {
    "id": 44,
    "categoryId": 1,
    "labelName": "数据一致性",
    "sortNum": 1
    },
    {
    "id": 49,
    "categoryId": 1,
    "labelName": "事务",
    "sortNum": 1
    }
    ]
    },
    {
    "id": 4,
    "categoryName": "JavaSE",
    "categoryType": 2,
    "imageUrl": "https://image/category.icon",
    "parentId": 1,
    "labelDTOList": [
    {
    "id": 15,
    "categoryId": 1,
    "labelName": "基础",
    "sortNum": 1
    }
    ]
    },
    {
    "id": 5,
    "categoryName": "框架",
    "categoryType": 2,
    "imageUrl": "https://image/category.icon",
    "parentId": 1,
    "labelDTOList": [
    {
    "id": 38,
    "categoryId": 1,
    "labelName": "Spring",
    "sortNum": 1
    },
    {
    "id": 62,
    "categoryId": 1,
    "labelName": "SpringBoot",
    "sortNum": 1
    }
    ]
    },
    {
    "id": 6,
    "categoryName": "消息队列",
    "categoryType": 2,
    "imageUrl": "https://image/category.icon",
    "parentId": 1,
    "labelDTOList": [
    {
    "id": 15,
    "categoryId": 1,
    "labelName": "基础",
    "sortNum": 1
    },
    {
    "id": 23,
    "categoryId": 1,
    "labelName": "实际应用",
    "sortNum": 1
    }
    ]
    },
    {
    "id": 7,
    "categoryName": "代码管理工具",
    "categoryType": 2,
    "imageUrl": "https://image/category.icon",
    "parentId": 1,
    "labelDTOList": [
    {
    "id": 25,
    "categoryId": 1,
    "labelName": "Git",
    "sortNum": 1
    }
    ]
    },
    {
    "id": 9,
    "categoryName": "网络",
    "categoryType": 2,
    "imageUrl": "https://image/category.icon",
    "parentId": 1,
    "labelDTOList": [
    {
    "id": 15,
    "categoryId": 1,
    "labelName": "基础",
    "sortNum": 1
    }
    ]
    },
    {
    "id": 10,
    "categoryName": "操作系统",
    "categoryType": 2,
    "imageUrl": "https://image/category.icon",
    "parentId": 1,
    "labelDTOList": [
    {
    "id": 15,
    "categoryId": 1,
    "labelName": "基础",
    "sortNum": 1
    }
    ]
    },
    {
    "id": 11,
    "categoryName": "最佳实践",
    "categoryType": 2,
    "imageUrl": "https://image/category.icon",
    "parentId": 1,
    "labelDTOList": [
    {
    "id": 23,
    "categoryId": 1,
    "labelName": "实际应用",
    "sortNum": 1
    },
    {
    "id": 53,
    "categoryId": 1,
    "labelName": "Jvm",
    "sortNum": 1
    }
    ]
    }
    ]
    }

    这里优化后的其实是把大分类->小分类->标签全都查完了,是用的多线程去实现的

    数据查完之后,再通过遍历然后利用某些规则,前端就能很轻松的查到了

  5. 删除分类

    请求体:

    1
    2
    3
    {
    "id": 3
    }

    响应示例:

    1
    2
    3
    4
    5
    6
    {
    "success": true,
    "code": 200,
    "message": "成功",
    "data": true
    }
  6. 更新分类

    请求体:

    1
    2
    3
    4
    5
    6
    7
    {
    "id": 3,
    "categoryName": "Spring",
    "categoryType": 2,
    "parentId": 3,
    "imageUrl": "http://image/123"
    }

    响应示例:

    1
    2
    3
    4
    5
    6
    {
    "success": true,
    "code": 200,
    "message": "成功",
    "data": true
    }
题目列表及详情接口定义

image-20240710165032544.png

涉及到难度,创建时间,题目,点赞收藏评论,创建人

分页查询

  1. 查询题目列表(POST:/subject/category/querySubjectList)

    请求体:

    1
    2
    3
    4
    5
    6
    7
    {
    "pageIndex":1,
    "pageSize":10,
    "labelId":2,
    "categoryId":1,
    "difficulty":1
    }

    响应示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    {
    "code":200,
    "message":"查询成功",
    "data":{
    "total":100,
    "totalPage":20,
    "pageList":[
    {
    "subjectName":"SpringBoot的自动装配原理是什么?",
    "subjectId":1,
    "difficulty":1,
    "labelNames":[
    "并发",
    "集合"
    ]
    }
    ]
    }
    "reqUuid""1231231223",
    "success"true
    }
分类接口开发
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
//SubjectCategoryController.java
/**
* 查询岗位大类
*/
@PostMapping("/queryPrimaryCategory")
public Result<List<SubjectCategoryDTO>> queryPrimaryCategory(@RequestBody SubjectCategoryDTO subjectCategoryDTO) {
try {
SubjectCategoryBO subjectCategoryBO = SubjectCategoryDTOConverter.INSTANCE.
convertDtoToCategoryBO(subjectCategoryDTO);
List<SubjectCategoryBO> subjectCategoryBOList = subjectCategoryDomainService.queryCategory(subjectCategoryBO);
List<SubjectCategoryDTO> subjectCategoryDTOList = SubjectCategoryDTOConverter.INSTANCE.
convertBoToCategoryDTOList(subjectCategoryBOList);
return Result.ok(subjectCategoryDTOList);
} catch (Exception e) {
log.error("SubjectCategoryController.queryPrimaryCategory.error:{}", e.getMessage(), e);
return Result.fail("查询失败");
}

}
//domain包里 SubjectCategoryDomainService.java
public interface SubjectCategoryDomainService {

/**
* 新增分类
*/
void add(SubjectCategoryBO subjectCategoryBO);

/**
* 查询岗位大类
*/
List<SubjectCategoryBO> queryCategory(SubjectCategoryBO subjectCategoryBO);

}
//domain包里 SubjectCategoryDomainServiceImpl.java
@Override
public List<SubjectCategoryBO> queryCategory(SubjectCategoryBO subjectCategoryBO) {
SubjectCategory subjectCategory = SubjectCategoryConverter.INSTANCE
.convertBoToCategory(subjectCategoryBO);
subjectCategory.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
List<SubjectCategory> subjectCategoryList = subjectCategoryService.queryCategory(subjectCategory);
List<SubjectCategoryBO> boList = SubjectCategoryConverter.INSTANCE
.convertBoToCategory(subjectCategoryList);
if (log.isInfoEnabled()) {
log.info("SubjectCategoryController.queryPrimaryCategory.boList:{}",
JSON.toJSONString(boList));
}
boList.forEach(bo -> {
Integer subjectCount = subjectCategoryService.querySubjectCount(bo.getId());
bo.setCount(subjectCount);
});
return boList;
}

controller:用converter DTO->BO

domainservice: converter bo->category,用service带着category去查,用Dao去查(dao的接口+dao.xml,mapperscan能扫描到)数据库,返回List

controller:用converter bolist->dtolist

返回dtolist,用Result封装一下,查出来了

关于为什么能够用Result返回用postman可以查到,其实用了@RestController标识:[SpringBoot以及集成组件注解大全详解(一)——lomback && JPA_@componentscan是复合注解吗-CSDN博客](https://blog.csdn.net/qq_42133100/article/details/89084518?ops_request_misc=%7B%22request%5Fid%22%3A%22172154818816800186530805%22%2C%22scm%22%3A%2220140713.130102334.pc%5Fall.%22%7D&request_id=172154818816800186530805&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~first_rank_ecpm_v1~rank_v31_ecpm-1-89084518-null-null.142^v100^pc_search_result_base8&utm_term=lomback中@data RestController&spm=1018.2226.3001.4187)

[【MyBatis】Dao接口和Dao.xml文件如何建立连接-CSDN博客](https://blog.csdn.net/weixin_45156425/article/details/120956070?ops_request_misc=%7B%22request%5Fid%22%3A%22172154533316800207082850%22%2C%22scm%22%3A%2220140713.130102334.pc%5Fall.%22%7D&request_id=172154533316800207082850&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~first_rank_ecpm_v1~rank_v31_ecpm-1-120956070-null-null.142^v100^pc_search_result_base8&utm_term=mybatis和dao xml&spm=1018.2226.3001.4187)

image-20240721152600418.png

根据大类查询二级分类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
//更新SubjectCategoryDomainService.java
public interface SubjectCategoryDomainService {

/**
* 新增分类
*/
void add(SubjectCategoryBO subjectCategoryBO);

/**
* 查询岗位大类
*/
List<SubjectCategoryBO> queryCategory(SubjectCategoryBO subjectCategoryBO);
}
//SubjectCategoryController.java
/**
* 根据分类id查二级分类
*/
@PostMapping("/queryCategoryByPrimary")
public Result<List<SubjectCategoryDTO>> queryCategoryByPrimary(@RequestBody SubjectCategoryDTO subjectCategoryDTO) {
try {
if (log.isInfoEnabled()) {
log.info("SubjectCategoryController.queryCategoryByPrimary.dto:{}"
, JSON.toJSONString(subjectCategoryDTO));
}
Preconditions.checkNotNull(subjectCategoryDTO.getParentId(), "分类id不能为空");
SubjectCategoryBO subjectCategoryBO = SubjectCategoryDTOConverter.INSTANCE.
convertDtoToCategoryBO(subjectCategoryDTO);
List<SubjectCategoryBO> subjectCategoryBOList = subjectCategoryDomainService.queryCategory(subjectCategoryBO);
List<SubjectCategoryDTO> subjectCategoryDTOList = SubjectCategoryDTOConverter.INSTANCE.
convertBoToCategoryDTOList(subjectCategoryBOList);
return Result.ok(subjectCategoryDTOList);
} catch (Exception e) {
log.error("SubjectCategoryController.queryPrimaryCategory.error:{}", e.getMessage(), e);
return Result.fail("查询失败");
}
}

更新分类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* 更新分类
*/
@PostMapping("/update")
public Result<Boolean> update(@RequestBody SubjectCategoryDTO subjectCategoryDTO) {
try {
if (log.isInfoEnabled()) {
log.info("SubjectCategoryController.update.dto:{}", JSON.toJSONString(subjectCategoryDTO));
}
SubjectCategoryBO subjectCategoryBO = SubjectCategoryDTOConverter.INSTANCE.
convertDtoToCategoryBO(subjectCategoryDTO);
Boolean result = subjectCategoryDomainService.update(subjectCategoryBO);
return Result.ok(result);
} catch (Exception e) {
log.error("SubjectCategoryController.update.error:{}", e.getMessage(), e);
return Result.fail("更新分类失败");
}

}
//SubjectCategoryDomainService
Boolean update(SubjectCategoryBO subjectCategoryBO);
//SubjectCategoryDomainServiceImpl
@Override
public Boolean update(SubjectCategoryBO subjectCategoryBO) {
SubjectCategory subjectCategory = SubjectCategoryConverter.INSTANCE
.convertBoToCategory(subjectCategoryBO);
int count = subjectCategoryService.update(subjectCategory);
return count > 0;
}
//SubjectCategoryService
int update(SubjectCategory subjectCategory);
//SubjectCategoryServiceImpl
@Override
public int update(SubjectCategory subjectCategory) {
return this.subjectCategoryDao.update(subjectCategory);
}
//SubjectCategoryDao
int update(SubjectCategory subjectCategory);
//接下来就是通过SubjectCategoryD.xml和mybatis组件去进行数据库的操作

删除分类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
/**
* 删除分类
*/
@PostMapping("/delete")
public Result<Boolean> delete(@RequestBody SubjectCategoryDTO subjectCategoryDTO) {
try {
if (log.isInfoEnabled()) {
log.info("SubjectCategoryController.delete.dto:{}", JSON.toJSONString(subjectCategoryDTO));
}
SubjectCategoryBO subjectCategoryBO = SubjectCategoryDTOConverter.INSTANCE.
convertDtoToCategoryBO(subjectCategoryDTO);
Boolean result = subjectCategoryDomainService.delete(subjectCategoryBO);
return Result.ok(result);
} catch (Exception e) {
log.error("SubjectCategoryController.delete.error:{}", e.getMessage(), e);
return Result.fail("删除分类失败");
}

}
//SubjectCategoryDomainService
Boolean delete(SubjectCategoryBO subjectCategoryBO);
//SubjectCategoryDomainServiceImpl
@Override
public Boolean delete(SubjectCategoryBO subjectCategoryBO) {
SubjectCategory subjectCategory = SubjectCategoryConverter.INSTANCE
.convertBoToCategory(subjectCategoryBO);
subjectCategory.setIsDeleted(IsDeletedFlagEnum.DELETED.getCode()); //设置已经删除字段
int count = subjectCategoryService.update(subjectCategory);
return count > 0;
}
//IsDeletedFlagEnum 在common包下的enum中
@Getter
public enum IsDeletedFlagEnum {

DELETED(1,"已删除"),
UN_DELETED(0,"未删除");

public int code;

public String desc;

IsDeletedFlagEnum(int code, String desc){
this.code = code;
this.desc = desc;
}

public static IsDeletedFlagEnum getByCode(int codeVal){
for(IsDeletedFlagEnum resultCodeEnum : IsDeletedFlagEnum.values()){
if(resultCodeEnum.code == codeVal){
return resultCodeEnum;
}
}
return null;
}

}
//SubjectCategoryService
boolean deleteById(Long id);
//SubjectCategoryServiceImpl
@Override
public boolean deleteById(Long id) {
return this.subjectCategoryDao.deleteById(id) > 0;
}
//SubjectCategoryDao
int deleteById(Long id);
//接下来就是通过SubjectCategoryD.xml和mybatis组件去进行数据库的操作
题目标签接口定义
  1. 新增标签

    请求体:

    1
    2
    3
    4
    5
    {
    "labelName": "SpringMVC",
    "categoryId":1,
    "sortNum": 1
    }

    响应示例:

    1
    2
    3
    4
    5
    6
    {
    "success": true,
    "code": 200,
    "message": "成功",
    "data": true
    }
  2. 更新标签

    请求体:

    1
    2
    3
    4
    5
    {
    "id": 1,
    "labelName": "Spring",
    "sortNum": 10
    }

    响应示例:

    1
    2
    3
    4
    5
    6
    {
    "success": true,
    "code": 200,
    "message": "成功",
    "data": true
    }
  3. 删除标签

    请求体:

    1
    2
    3
    {
    "id": 1
    }

    响应示例:

    1
    2
    3
    4
    5
    6
    {
    "success": true,
    "code": 200,
    "message": "成功",
    "data": true
    }
  4. 根据分类查询标签

    请求体:

    1
    2
    3
    {
    "categoryId": 1
    }

    响应示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    {
    "success": true,
    "code": 200,
    "message": "成功",
    "data": [
    {
    "id": 2,
    "categoryId": 4,
    "labelName": "SpringBoot",
    "sortNum": 0
    },
    {
    "id": 3,
    "categoryId": 4,
    "labelName": "SpringMVC",
    "sortNum": 1
    }
    ]
    }
标签基础模块开发
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
//SubjectLabelController
public class SubjectLabelController {

@Resource
private SubjectLabelDomainService subjectLabelDomainService;

/**
* 新增标签
*/
@PostMapping("/add")
public Result<Boolean> add(@RequestBody SubjectLabelDTO subjectLabelDTO) {
try {
if (log.isInfoEnabled()) {
log.info("SubjectLabelController.add.dto:{}", JSON.toJSONString(subjectLabelDTO));
}
Preconditions.checkArgument(!StringUtils.isBlank(subjectLabelDTO.getLabelName()),
"标签名称不能为空");
SubjectLabelBO subjectLabelBO = SubjectLabelDTOConverter.INSTANCE.convertDtoToLabelBO(subjectLabelDTO);
Boolean result = subjectLabelDomainService.add(subjectLabelBO);
return Result.ok(result);
} catch (Exception e) {
log.error("SubjectLabelController.add.error:{}", e.getMessage(), e);
return Result.fail("新增标签失败");
}
}

/**
* 更新标签
*/
@PostMapping("/update")
public Result<Boolean> update(@RequestBody SubjectLabelDTO subjectLabelDTO) {
try {
if (log.isInfoEnabled()) {
log.info("SubjectLabelController.update.dto:{}", JSON.toJSONString(subjectLabelDTO));
}
Preconditions.checkNotNull(subjectLabelDTO.getId(), "标签id不能为空");
SubjectLabelBO subjectLabelBO = SubjectLabelDTOConverter.INSTANCE.convertDtoToLabelBO(subjectLabelDTO);
Boolean result = subjectLabelDomainService.update(subjectLabelBO);
return Result.ok(result);
} catch (Exception e) {
log.error("SubjectLabelController.update.error:{}", e.getMessage(), e);
return Result.fail("更新标签失败");
}
}

/**
* 删除标签
*/
@PostMapping("/delete")
public Result<Boolean> delete(@RequestBody SubjectLabelDTO subjectLabelDTO) {
try {
if (log.isInfoEnabled()) {
log.info("SubjectLabelController.delete.dto:{}", JSON.toJSONString(subjectLabelDTO));
}
Preconditions.checkNotNull(subjectLabelDTO.getId(), "标签id不能为空");
SubjectLabelBO subjectLabelBO = SubjectLabelDTOConverter.INSTANCE.convertDtoToLabelBO(subjectLabelDTO);
Boolean result = subjectLabelDomainService.delete(subjectLabelBO);
return Result.ok(result);
} catch (Exception e) {
log.error("SubjectLabelController.delete.error:{}", e.getMessage(), e);
return Result.fail("删除标签失败");
}
}


/**
* 查询分类下标签
*/
@PostMapping("/queryLabelByCategoryId")
public Result<List<SubjectLabelDTO>> queryLabelByCategoryId(@RequestBody SubjectLabelDTO subjectLabelDTO) {
try {
if (log.isInfoEnabled()) {
log.info("SubjectLabelController.queryLabelByCategoryId.dto:{}",
JSON.toJSONString(subjectLabelDTO));
}
Preconditions.checkNotNull(subjectLabelDTO.getCategoryId(), "分类id不能为空");
SubjectLabelBO subjectLabelBO = SubjectLabelDTOConverter.INSTANCE.convertDtoToLabelBO(subjectLabelDTO);
List<SubjectLabelBO> resultList = subjectLabelDomainService.queryLabelByCategoryId(subjectLabelBO);
List<SubjectLabelDTO> subjectLabelDTOS = SubjectLabelDTOConverter.INSTANCE.convertBOToLabelDTOList(resultList);
return Result.ok(subjectLabelDTOS);
} catch (Exception e) {
log.error("SubjectLabelController.queryLabelByCategoryId.error:{}", e.getMessage(), e);
return Result.fail("查询分类下标签失败");
}
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
//SubjectLabelDTOConverter
@Mapper
public interface SubjectLabelDTOConverter {

SubjectLabelDTOConverter INSTANCE = Mappers.getMapper(SubjectLabelDTOConverter.class);

SubjectLabelBO convertDtoToLabelBO(SubjectLabelDTO subjectLabelDTO);

List<SubjectLabelDTO> convertBOToLabelDTOList(List<SubjectLabelBO> boList);

}
//SubjectLabelConverter
@Mapper
public interface SubjectLabelConverter {

SubjectLabelConverter INSTANCE = Mappers.getMapper(SubjectLabelConverter.class);

SubjectLabel convertBoToLabel(SubjectLabelBO subjectLabelBO);

List<SubjectLabelBO> convertLabelToBoList(List<SubjectLabel> subjectLabelList);
}
//SubjectLabelDTO
@Data
public class SubjectLabelDTO implements Serializable {

/**
* 主键
*/
private Long id;

/**
* 分类id
*/
private Long categoryId;

/**
* 标签分类
*/
private String labelName;
/**
* 排序
*/
private Integer sortNum;

}
//SubjectLabelBO
@Data
public class SubjectLabelBO implements Serializable {

/**
* 主键
*/
private Long id;

/**
* 标签分类
*/
private String labelName;

/**
* 排序
*/
private Integer sortNum;

/**
* 分类id
*/
private Long categoryId;

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
//SubjectLabelDomainService
public interface SubjectLabelDomainService {

/**
* 新增标签
*/
Boolean add(SubjectLabelBO subjectLabelBO);

/**
* 更新标签
*/
Boolean update(SubjectLabelBO subjectLabelBO);

/**
* 删除标签
*/
Boolean delete(SubjectLabelBO subjectLabelBO);

/**
* 查询分类下标签
*/
List<SubjectLabelBO> queryLabelByCategoryId(SubjectLabelBO subjectLabelBO);

}
//SubjectLabelDomainServiceImpl
@Service
@Slf4j
public class SubjectLabelDomainServiceImpl implements SubjectLabelDomainService {

@Resource
private SubjectLabelService subjectLabelService;

@Resource
private SubjectMappingService subjectMappingService;

@Resource
private SubjectCategoryService subjectCategoryService;

@Override
public Boolean add(SubjectLabelBO subjectLabelBO) {
if (log.isInfoEnabled()) {
log.info("SubjectLabelDomainServiceImpl.add.bo:{}", JSON.toJSONString(subjectLabelBO));
}
SubjectLabel subjectLabel = SubjectLabelConverter.INSTANCE
.convertBoToLabel(subjectLabelBO);
subjectLabel.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
int count = subjectLabelService.insert(subjectLabel);
return count > 0;
}

@Override
public Boolean update(SubjectLabelBO subjectLabelBO) {
if (log.isInfoEnabled()) {
log.info("SubjectLabelDomainServiceImpl.update.bo:{}", JSON.toJSONString(subjectLabelBO));
}
SubjectLabel subjectLabel = SubjectLabelConverter.INSTANCE
.convertBoToLabel(subjectLabelBO);
int count = subjectLabelService.update(subjectLabel);
return count > 0;
}

@Override
public Boolean delete(SubjectLabelBO subjectLabelBO) {
if (log.isInfoEnabled()) {
log.info("SubjectLabelDomainServiceImpl.update.bo:{}", JSON.toJSONString(subjectLabelBO));
}
SubjectLabel subjectLabel = SubjectLabelConverter.INSTANCE
.convertBoToLabel(subjectLabelBO);
subjectLabel.setIsDeleted(IsDeletedFlagEnum.DELETED.getCode());
int count = subjectLabelService.update(subjectLabel);
return count > 0;
}

@Override
public List<SubjectLabelBO> queryLabelByCategoryId(SubjectLabelBO subjectLabelBO) {
//如果当前分类是1级分类,则查询所有标签
SubjectCategory subjectCategory = subjectCategoryService.queryById(subjectLabelBO.getCategoryId());
if(CategoryTypeEnum.PRIMARY.getCode() == subjectCategory.getCategoryType()){
SubjectLabel subjectLabel = new SubjectLabel();
subjectLabel.setCategoryId(subjectLabelBO.getCategoryId());
List<SubjectLabel> labelList = subjectLabelService.queryByCondition(subjectLabel);
List<SubjectLabelBO> labelResultList = SubjectLabelConverter.INSTANCE.convertLabelToBoList(labelList);
return labelResultList;
}
Long categoryId = subjectLabelBO.getCategoryId();
SubjectMapping subjectMapping = new SubjectMapping();
subjectMapping.setCategoryId(categoryId);
subjectMapping.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
List<SubjectMapping> mappingList = subjectMappingService.queryLabelId(subjectMapping);
if (CollectionUtils.isEmpty(mappingList)) {
return Collections.emptyList();
}
//将mappingList转换为一个流(Stream),使用map操作对流中的每个元素(SubjectMapping对象)应用一个函数,使用collect操作将流中的元素汇总或归纳到一个新的集合中
List<Long> labelIdList = mappingList.stream().map(SubjectMapping::getLabelId).collect(Collectors.toList());
List<SubjectLabel> labelList = subjectLabelService.batchQueryById(labelIdList);
List<SubjectLabelBO> boList = new LinkedList<>();
labelList.forEach(label -> { //lamda表达式
SubjectLabelBO bo = new SubjectLabelBO();
bo.setId(label.getId());
bo.setLabelName(label.getLabelName());
bo.setCategoryId(categoryId);
bo.setSortNum(label.getSortNum());
boList.add(bo);
});
return boList;
}
}
//SubjectMappingService
public interface SubjectMappingService {

/**
* 通过ID查询单条数据
*
* @return 实例对象
*/
SubjectMapping queryById(int id);

/**
* 新增数据
*
* @param subjectMapping 实例对象
* @return 实例对象
*/
SubjectMapping insert(SubjectMapping subjectMapping);

/**
* 修改数据
*
* @param subjectMapping 实例对象
* @return 实例对象
*/
int update(SubjectMapping subjectMapping);

/**
* 通过主键删除数据
*
* @return 是否成功
*/
boolean deleteById(int id);

/**
* 查询标签id
*/
List<SubjectMapping> queryLabelId(SubjectMapping subjectMapping);

/**
* 批量插入
*/
void batchInsert(List<SubjectMapping> mappingList);

}
//SubjectMappingServiceImpl
@Service("subjectMappingService")
public class SubjectMappingServiceImpl implements SubjectMappingService {

@Resource
private SubjectMappingDao subjectMappingDao;

/**
* 通过ID查询单条数据
*
* @return 实例对象
*/
@Override
public SubjectMapping queryById(int id) {
return this.subjectMappingDao.queryById(id);
}

/**
* 新增数据
*
* @param subjectMapping 实例对象
* @return 实例对象
*/
@Override
public SubjectMapping insert(SubjectMapping subjectMapping) {
this.subjectMappingDao.insert(subjectMapping);
return subjectMapping;
}

/**
* 修改数据
*
* @param subjectMapping 实例对象
* @return 实例对象
*/
@Override
public int update(SubjectMapping subjectMapping) {
return this.subjectMappingDao.update(subjectMapping);
}

/**
* 通过主键删除数据
*
* @return 是否成功
*/
@Override
public boolean deleteById(int id) {
return this.subjectMappingDao.deleteById(id) > 0;
}

@Override
public List<SubjectMapping> queryLabelId(SubjectMapping subjectMapping) {
return this.subjectMappingDao.queryDistinctLabelId(subjectMapping);
}

@Override
public void batchInsert(List<SubjectMapping> mappingList) {
this.subjectMappingDao.insertBatch(mappingList);
}

}
标签业务改动

将一级分类和标签联系起来

image-20240728114457915.png

SubjectLabelDao.xml

image-20240728114229629.png

domain层也跟着改

题目模块接口定义
  1. 新增单选题目

    请求体:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    {
    "subjectName": "SpringBoot自动装配原理是什么?",
    "subjectDifficult": 1,
    "subjectType": 1,
    "subjectScore": 2,
    "subjectParse": "题目解析",
    "categoryIds": [
    4,
    5
    ],
    "labelIds": [
    2,
    3
    ],
    "optionList": [
    {
    "optionType": 1,
    "optionContent": "自动的",
    "isCorrect": 1
    },
    {
    "optionType": 2,
    "optionContent": "其实是用配置文件",
    "isCorrect": 0
    }
    ]
    }

    响应成功示例:

    1
    2
    3
    4
    5
    6
    {
    "success": true,
    "code": 200,
    "message": "成功",
    "data": true
    }
  2. 新增多选题目

    请求体:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    {
    "subjectName": "SpringBoot自动装配原理是什么?",
    "subjectDifficult": 1,
    "subjectType": 2,
    "subjectScore": 2,
    "subjectParse": "题目解析",
    "categoryIds": [
    4,
    5
    ],
    "labelIds": [
    2,
    3
    ],
    "optionList": [
    {
    "optionType": 1,
    "optionContent": "自动的",
    "isCorrect": 1
    },
    {
    "optionType": 2,
    "optionContent": "其实是用配置文件",
    "isCorrect": 1
    }
    ]
    }

    响应成功示例:

    1
    2
    3
    4
    5
    6
    {
    "success": true,
    "code": 200,
    "message": "成功",
    "data": true
    }
  3. 新增判断题目

    请求体:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    {
    "subjectName": "SpringBoot自动装配原理是什么?",
    "subjectDifficult": 1,
    "subjectType": 3,
    "subjectScore": 2,
    "subjectParse": "题目解析",
    "categoryIds": [
    4,
    5
    ],
    "labelIds": [
    2,
    3
    ],
    "optionList": [
    {
    "isCorrect": 1
    }
    ]
    }

    响应成功示例:

    1
    2
    3
    4
    5
    6
    {
    "success": true,
    "code": 200,
    "message": "成功",
    "data": true
    }
  4. 新增简答题目

    请求体:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    {
    "subjectName": "Mysql是个什么东西?",
    "subjectDifficult": 1,
    "subjectType": 4,
    "subjectScore": 2,
    "subjectParse": "题目解析2",
    "subjectAnswer": "Mysql是个数据库",
    "categoryIds": [
    5
    ],
    "labelIds": [
    11
    ]
    }

    响应示例:

    1
    2
    3
    4
    5
    6
    {
    "success": true,
    "code": 200,
    "message": "成功",
    "data": true
    }
  5. 查询题目列表

    请求体:

    1
    2
    3
    4
    5
    6
    7
    {
    "pageNo": 2,
    "pageSize": 10,
    "labelId": 1,
    "categoryId": 2,
    "subjectDifficult": 1
    }

    响应示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    {
    "success": true,
    "code": 200,
    "message": "成功",
    "data": {
    "pageNo": 1,
    "pageSize": 20,
    "total": 1,
    "totalPages": 1,
    "result": [
    {
    "pageNo": 1,
    "pageSize": 20,
    "id": 9,
    "subjectName": "SpringBoot自动装配原理是什么?",
    "subjectDifficult": 1,
    "subjectType": 4,
    "subjectScore": 2,
    "subjectParse": "题目解析"
    }
    ],
    "start": 1,
    "end": 20
    }
    }
  6. 查询题目列表

    请求体:

    1
    2
    3
    4
    5
    6
    7
    {
    "pageNo": 2,
    "pageSize": 10,
    "labelId": 1,
    "categoryId": 2,
    "subjectDifficult": 1
    }

    响应示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    {
    "success": true,
    "code": 200,
    "message": "成功",
    "data": {
    "pageNo": 1,
    "pageSize": 20,
    "total": 1,
    "totalPages": 1,
    "result": [
    {
    "pageNo": 1,
    "pageSize": 20,
    "id": 9,
    "subjectName": "SpringBoot自动装配原理是什么?",
    "subjectDifficult": 1,
    "subjectType": 4,
    "subjectScore": 2,
    "subjectParse": "题目解析"
    }
    ],
    "start": 1,
    "end": 20
    }
    }
  7. 查询题目详情

    请求体:

    1
    2
    3
    {
    "id": 100
    }

    响应体:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    {
    "success": true,
    "code": 200,
    "message": "成功",
    "data": {
    "pageNo": 1,
    "pageSize": 20,
    "id": 9,
    "subjectName": "SpringBoot自动装配原理是什么?",
    "subjectDifficult": 1,
    "subjectType": 4,
    "subjectScore": 2,
    "subjectParse": "题目解析",
    "subjectAnswer": "题目答案",
    "labelName": [
    "SpringBoot",
    "SpringMVC"
    ]
    }
    }
题目模块接口开发

题目模块接口开发是程序员社区项目中用于管理题目信息的核心部分。它涉及一系列接口,用于实现题目的新增、查询、更新和删除等操作。这些接口包括但不限于:

  • 新增单选、多选、判断和简答题目的接口,允许用户提交题目名称、难度、类型、分数、解析、所属分类和标签等信息。
  • 查询题目列表的接口,支持分页查询,并可根据分类、标签、难度等条件进行筛选。
  • 查询题目详情的接口,通过题目ID获取详细信息,包括题目类型、内容、选项、正确答案和解析等。

代码层面,题目模块接口开发使用了Spring Boot框架来构建RESTful API,并采用了DTO(Data Transfer Object)和BO(Business Object)模式来分离数据传输和业务逻辑。通过MapStruct库实现了DTO与BO之间的自动转换,以简化代码并减少重复性工作。此外,服务层(Service)和数据访问层(DAO)的分离确保了业务逻辑的清晰和数据访问的高效性。

在实现上,题目模块接口利用了Lombok库来自动生成getter、setter等方法,以及使用Spring框架的注解如@RestController@RequestMapping来简化路由配置。日志记录采用SLF4J与Log4j2,确保了日志的灵活性和可配置性。此外,通过使用Spring的事务管理@Transactional,保证了数据操作的原子性和一致性。

整体而言,题目模块接口开发为程序员社区项目提供了一个稳定、可扩展且易于维护的后端服务,以支持题目内容的管理与展示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
//SubjectInfoBO
@Data
public class SubjectInfoBO extends PageInfo implements Serializable {

/**
* 主键
*/
private Long id;
/**
* 题目名称
*/
private String subjectName;
/**
* 题目难度
*/
private Integer subjectDifficult;
/**
* 出题人名
*/
private String settleName;
/**
* 题目类型 1单选 2多选 3判断 4简答
*/
private Integer subjectType;
/**
* 题目分数
*/
private Integer subjectScore;
/**
* 题目解析
*/
private String subjectParse;

/**
* 题目答案
*/
private String subjectAnswer;

/**
* 分类id
*/
private List<Integer> categoryIds;

/**
* 标签id
*/
private List<Integer> labelIds;

/**
* 标签name
*/
private List<String> labelName;

/**
* 答案选项
*/
private List<SubjectAnswerBO> optionList;

private Long categoryId;

private Long labelId;

private String keyWord;


/**
* 创建人昵称
*/
private String createUser;

/**
* 创建人头像
*/
private String createUserAvatar;

/**
* 题目数量
*/
private Integer subjectCount;


/**
* 是否被当前用户点赞
*/
private Boolean liked;

/**
* 当前题目点赞的数量
*/
private Integer likedCount;

/**
* 下一题
*/
private Long nextSubjectId;

/**
* 上一题
*/
private Long lastSubjectId;

}
//PageResult
@Data
public class PageResult<T> implements Serializable {

private Integer pageNo = 1;

private Integer pageSize = 20;

private Integer total = 0;

private Integer totalPages = 0;

private List<T> result = Collections.emptyList();

private Integer start = 1;

private Integer end = 0;

public void setRecords(List<T> result) {
this.result = result;
if (result != null && result.size() > 0) {
setTotal(result.size());
}
}

public void setTotal(Integer total) {
this.total = total;
if (this.pageSize > 0) {
this.totalPages = (total / this.pageSize) + (total % this.pageSize == 0 ? 0 : 1);
} else {
this.totalPages = 0;
}
this.start = (this.pageSize > 0 ? (this.pageNo - 1) * this.pageSize : 0) + 1;
this.end = (this.start - 1 + this.pageSize * (this.pageNo > 0 ? 1 : 0));
}

public void setPageSize(Integer pageSize) {
this.pageSize = pageSize;
}

public void setPageNo(Integer pageNo) {
this.pageNo = pageNo;
}

}
//PageInfo
@Data
public class PageInfo implements Serializable {

private Integer pageNo = 1;

private Integer pageSize = 20;

public Integer getPageNo() {
if (pageNo == null || pageNo < 1) {
return 1;
}
return pageNo;
}

public Integer getPageSize() {
if (pageSize == null || pageSize < 1 || pageSize > Integer.MAX_VALUE) {
return 20;
}
return pageSize;
}


}
//SubjectAnswerBO
@Data
public class SubjectAnswerBO implements Serializable {

/**
* 答案选项标识
*/
private Integer optionType;

/**
* 答案
*/
private String optionContent;

/**
* 是否正确
*/
private Integer isCorrect;

}
//SubjectAnswerDTO
@Data
public class SubjectAnswerDTO implements Serializable {

/**
* 答案选项标识
*/
private Integer optionType;

/**
* 答案
*/
private String optionContent;

/**
* 是否正确
*/
private Integer isCorrect;

}
//SubjectOptionBO
@Data
public class SubjectOptionBO implements Serializable {

/**
* 题目答案
*/
private String subjectAnswer;

/**
* 答案选项
*/
private List<SubjectAnswerBO> optionList;

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
//SubjectInfo
@Data
public class SubjectInfo implements Serializable {
private static final long serialVersionUID = -71318372165220898L;
/**
* 主键
*/
private Long id;
/**
* 题目名称
*/
private String subjectName;
/**
* 题目难度
*/
private Integer subjectDifficult;
/**
* 出题人名
*/
private String settleName;
/**
* 题目类型 1单选 2多选 3判断 4简答
*/
private Integer subjectType;
/**
* 题目分数
*/
private Integer subjectScore;
/**
* 题目解析
*/
private String subjectParse;
/**
* 创建人
*/
private String createdBy;
/**
* 创建时间
*/
private Date createdTime;
/**
* 修改人
*/
private String updateBy;
/**
* 修改时间
*/
private Date updateTime;

private Integer isDeleted;

private Integer subjectCount;

}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
//SubjectInfoDTO
@Data
public class SubjectInfoDTO extends PageInfo implements Serializable {

/**
* 主键
*/
private Long id;
/**
* 题目名称
*/
private String subjectName;
/**
* 题目难度
*/
private Integer subjectDifficult;
/**
* 出题人名
*/
private String settleName;
/**
* 题目类型 1单选 2多选 3判断 4简答
*/
private Integer subjectType;
/**
* 题目分数
*/
private Integer subjectScore;
/**
* 题目解析
*/
private String subjectParse;

/**
* 题目答案
*/
private String subjectAnswer;

/**
* 分类id
*/
private List<Integer> categoryIds;

/**
* 标签id
*/
private List<Integer> labelIds;

/**
* 答案选项
*/
private List<SubjectAnswerDTO> optionList;

/**
* 标签name
*/
private List<String> labelName;

private Long categoryId;

private Long labelId;

private String keyWord;

/**
* 创建人昵称
*/
private String createUser;

/**
* 创建人头像
*/
private String createUserAvatar;

/**
* 题目数量
*/
private Integer subjectCount;

/**
* 是否被当前用户点赞
*/
private Boolean liked;

/**
* 当前题目点赞的数量
*/
private Integer likedCount;

/**
* 下一题
*/
private Long nextSubjectId;

/**
* 上一题
*/
private Long lastSubjectId;

}
//SubjectTypeHandler
public interface SubjectTypeHandler {

/**
* 枚举身份的识别
*/
SubjectInfoTypeEnum getHandlerType();

/**
* 实际的题目的插入
*/
void add(SubjectInfoBO subjectInfoBO);

/**
* 实际的题目的插入
*/
SubjectOptionBO query(int subjectId);

}
//BriefTypeHandler
@Component
public class BriefTypeHandler implements SubjectTypeHandler{

@Resource
private SubjectBriefService subjectBriefService;

@Override
public SubjectInfoTypeEnum getHandlerType() {
return SubjectInfoTypeEnum.BRIEF;
}

@Override
public void add(SubjectInfoBO subjectInfoBO) {
SubjectBrief subjectBrief = BriefSubjectConverter.INSTANCE.convertBoToEntity(subjectInfoBO);
subjectBrief.setSubjectId(subjectInfoBO.getId().intValue());
subjectBrief.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
subjectBriefService.insert(subjectBrief);
}

@Override
public SubjectOptionBO query(int subjectId) {
SubjectBrief subjectBrief = new SubjectBrief();
subjectBrief.setSubjectId(subjectId);
SubjectBrief result = subjectBriefService.queryByCondition(subjectBrief);
SubjectOptionBO subjectOptionBO = new SubjectOptionBO();
subjectOptionBO.setSubjectAnswer(result.getSubjectAnswer());
return subjectOptionBO;
}
}
//JudgeTypeHandler
@Component
public class JudgeTypeHandler implements SubjectTypeHandler{

@Resource
private SubjectJudgeService subjectJudgeService;

@Override
public SubjectInfoTypeEnum getHandlerType() {
return SubjectInfoTypeEnum.JUDGE;
}

@Override
public void add(SubjectInfoBO subjectInfoBO) {
//判断题目的插入
SubjectJudge subjectJudge = new SubjectJudge();
SubjectAnswerBO subjectAnswerBO = subjectInfoBO.getOptionList().get(0);
subjectJudge.setSubjectId(subjectInfoBO.getId());
subjectJudge.setIsCorrect(subjectAnswerBO.getIsCorrect());
subjectJudge.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
subjectJudgeService.insert(subjectJudge);
}

@Override
public SubjectOptionBO query(int subjectId) {
SubjectJudge subjectJudge = new SubjectJudge();
subjectJudge.setSubjectId(Long.valueOf(subjectId));
List<SubjectJudge> result = subjectJudgeService.queryByCondition(subjectJudge);
List<SubjectAnswerBO> subjectAnswerBOList = JudgeSubjectConverter.INSTANCE.convertEntityToBoList(result);
SubjectOptionBO subjectOptionBO = new SubjectOptionBO();
subjectOptionBO.setOptionList(subjectAnswerBOList);
return subjectOptionBO;
}
}
//MultipleTypeHandler
@Component
public class MultipleTypeHandler implements SubjectTypeHandler{

@Resource
private SubjectMultipleService subjectMultipleService;

@Override
public SubjectInfoTypeEnum getHandlerType() {
return SubjectInfoTypeEnum.MULTIPLE;
}

@Override
public void add(SubjectInfoBO subjectInfoBO) {
//多选题目的插入
List<SubjectMultiple> subjectMultipleList = new LinkedList<>();
subjectInfoBO.getOptionList().forEach(option -> {
SubjectMultiple subjectMultiple = MultipleSubjectConverter.INSTANCE.convertBoToEntity(option);
subjectMultiple.setSubjectId(subjectInfoBO.getId());
subjectMultiple.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
subjectMultipleList.add(subjectMultiple);
});
subjectMultipleService.batchInsert(subjectMultipleList);
}

@Override
public SubjectOptionBO query(int subjectId) {
SubjectMultiple subjectMultiple = new SubjectMultiple();
subjectMultiple.setSubjectId(Long.valueOf(subjectId));
List<SubjectMultiple> result = subjectMultipleService.queryByCondition(subjectMultiple);
List<SubjectAnswerBO> subjectAnswerBOList = MultipleSubjectConverter.INSTANCE.convertEntityToBoList(result);
SubjectOptionBO subjectOptionBO = new SubjectOptionBO();
subjectOptionBO.setOptionList(subjectAnswerBOList);
return subjectOptionBO;
}
}
//RadioTypeHandler
@Component
public class RadioTypeHandler implements SubjectTypeHandler {

@Resource
private SubjectRadioService subjectRadioService;

@Override
public SubjectInfoTypeEnum getHandlerType() {
return SubjectInfoTypeEnum.RADIO;
}

@Override
public void add(SubjectInfoBO subjectInfoBO) {
//单选题目的插入
List<SubjectRadio> subjectRadioList = new LinkedList<>();
subjectInfoBO.getOptionList().forEach(option -> {
SubjectRadio subjectRadio = RadioSubjectConverter.INSTANCE.convertBoToEntity(option);
subjectRadio.setSubjectId(subjectInfoBO.getId());
subjectRadio.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
subjectRadioList.add(subjectRadio);
});
subjectRadioService.batchInsert(subjectRadioList);
}

@Override
public SubjectOptionBO query(int subjectId) {
SubjectRadio subjectRadio = new SubjectRadio();
subjectRadio.setSubjectId(Long.valueOf(subjectId));
List<SubjectRadio> result = subjectRadioService.queryByCondition(subjectRadio);
List<SubjectAnswerBO> subjectAnswerBOList = RadioSubjectConverter.INSTANCE.convertEntityToBoList(result);
SubjectOptionBO subjectOptionBO = new SubjectOptionBO();
subjectOptionBO.setOptionList(subjectAnswerBOList);
return subjectOptionBO;
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
//SubjectController
@RestController
@Slf4j
@RequestMapping("/subject")
public class SubjectController {

@Resource
private SubjectInfoDomainService subjectInfoDomainService;

@Resource
private RocketMQTemplate rocketMQTemplate;

/**
* 新增题目
*/
@PostMapping("/add")
public Result<Boolean> add(@RequestBody SubjectInfoDTO subjectInfoDTO) {
try {
if (log.isInfoEnabled()) {
log.info("SubjectController.add.dto:{}", JSON.toJSONString(subjectInfoDTO));
}
Preconditions.checkArgument(!StringUtils.isBlank(subjectInfoDTO.getSubjectName()),
"题目名称不能为空");
Preconditions.checkNotNull(subjectInfoDTO.getSubjectDifficult(), "题目难度不能为空");
Preconditions.checkNotNull(subjectInfoDTO.getSubjectType(), "题目类型不能为空");
Preconditions.checkNotNull(subjectInfoDTO.getSubjectScore(), "题目分数不能为空");
Preconditions.checkArgument(!CollectionUtils.isEmpty(subjectInfoDTO.getCategoryIds())
, "分类id不能为空");
Preconditions.checkArgument(!CollectionUtils.isEmpty(subjectInfoDTO.getLabelIds())
, "标签id不能为空");

SubjectInfoBO subjectInfoBO = SubjectInfoDTOConverter.INSTANCE.convertDTOToBO(subjectInfoDTO); //转为BO
List<SubjectAnswerBO> subjectAnswerBOS =
SubjectAnswerDTOConverter.INSTANCE.convertListDTOToBO(subjectInfoDTO.getOptionList()); //单独添加回答
subjectInfoBO.setOptionList(subjectAnswerBOS);
subjectInfoDomainService.add(subjectInfoBO);
return Result.ok(true);
} catch (Exception e) {
log.error("SubjectCategoryController.add.error:{}", e.getMessage(), e);
return Result.fail("新增题目失败");
}
}

/**
* 查询题目列表
*/
@PostMapping("/getSubjectPage")
public Result<PageResult<SubjectInfoDTO>> getSubjectPage(@RequestBody SubjectInfoDTO subjectInfoDTO) {
try {
if (log.isInfoEnabled()) {
log.info("SubjectController.getSubjectPage.dto:{}", JSON.toJSONString(subjectInfoDTO));
}
Preconditions.checkNotNull(subjectInfoDTO.getCategoryId(), "分类id不能为空");
Preconditions.checkNotNull(subjectInfoDTO.getLabelId(), "标签id不能为空");
SubjectInfoBO subjectInfoBO = SubjectInfoDTOConverter.INSTANCE.convertDTOToBO(subjectInfoDTO);
subjectInfoBO.setPageNo(subjectInfoDTO.getPageNo()); //页码
subjectInfoBO.setPageSize(subjectInfoDTO.getPageSize()); //大小
PageResult<SubjectInfoBO> boPageResult = subjectInfoDomainService.getSubjectPage(subjectInfoBO);
return Result.ok(boPageResult);
} catch (Exception e) {
log.error("SubjectCategoryController.add.error:{}", e.getMessage(), e);
return Result.fail("分页查询题目失败");
}
}

/**
* 查询题目信息
*/
@PostMapping("/querySubjectInfo")
public Result<SubjectInfoDTO> querySubjectInfo(@RequestBody SubjectInfoDTO subjectInfoDTO) {
try {
if (log.isInfoEnabled()) {
log.info("SubjectController.querySubjectInfo.dto:{}", JSON.toJSONString(subjectInfoDTO));
}
Preconditions.checkNotNull(subjectInfoDTO.getId(), "题目id不能为空");
SubjectInfoBO subjectInfoBO = SubjectInfoDTOConverter.INSTANCE.convertDTOToBO(subjectInfoDTO);
SubjectInfoBO boResult = subjectInfoDomainService.querySubjectInfo(subjectInfoBO);
SubjectInfoDTO dto = SubjectInfoDTOConverter.INSTANCE.convertBOToDTO(boResult);
return Result.ok(dto);
} catch (Exception e) {
log.error("SubjectCategoryController.add.error:{}", e.getMessage(), e);
return Result.fail("查询题目详情失败");
}
}

/**
* 全文检索
*/
@PostMapping("/getSubjectPageBySearch")
public Result<PageResult<SubjectInfoEs>> getSubjectPageBySearch(@RequestBody SubjectInfoDTO subjectInfoDTO) {
try {
if (log.isInfoEnabled()) {
log.info("SubjectController.getSubjectPageBySearch.dto:{}", JSON.toJSONString(subjectInfoDTO));
}
Preconditions.checkArgument(StringUtils.isNotBlank(subjectInfoDTO.getKeyWord()), "关键词不能为空");
SubjectInfoBO subjectInfoBO = SubjectInfoDTOConverter.INSTANCE.convertDTOToBO(subjectInfoDTO);
subjectInfoBO.setPageNo(subjectInfoDTO.getPageNo());
subjectInfoBO.setPageSize(subjectInfoDTO.getPageSize());
PageResult<SubjectInfoEs> boPageResult = subjectInfoDomainService.getSubjectPageBySearch(subjectInfoBO);
return Result.ok(boPageResult);
} catch (Exception e) {
log.error("SubjectCategoryController.getSubjectPageBySearch.error:{}", e.getMessage(), e);
return Result.fail("全文检索失败");
}
}

/**
* 获取题目贡献榜
*/
@PostMapping("/getContributeList")
public Result<List<SubjectInfoDTO>> getContributeList() {
try {
List<SubjectInfoBO> boList = subjectInfoDomainService.getContributeList();
List<SubjectInfoDTO> dtoList = SubjectInfoDTOConverter.INSTANCE.convertBOToDTOList(boList);
return Result.ok(dtoList);
} catch (Exception e) {
log.error("SubjectCategoryController.getContributeList.error:{}", e.getMessage(), e);
return Result.fail("获取贡献榜失败");
}
}

/**
* 测试mq发送
*/
@PostMapping("/pushMessage")
public Result<Boolean> pushMessage(@Param("id") int id) {
rocketMQTemplate.convertAndSend("test-topic", "早上好" + id);
return Result.ok(true);
}


}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
//SubjectInfoDTOConverter
@Mapper
public interface SubjectInfoDTOConverter {

SubjectInfoDTOConverter INSTANCE = Mappers.getMapper(SubjectInfoDTOConverter.class);

SubjectInfoBO convertDTOToBO(SubjectInfoDTO subjectInfoDTO);

SubjectInfoDTO convertBOToDTO(SubjectInfoBO subjectInfoBO);

List<SubjectInfoDTO> convertBOToDTOList(List<SubjectInfoBO> subjectInfoBO);

}
//SubjectAnswerDTOConverter
@Mapper
public interface SubjectAnswerDTOConverter {

SubjectAnswerDTOConverter INSTANCE = Mappers.getMapper(SubjectAnswerDTOConverter.class);

SubjectAnswerBO convertDTOToBO(SubjectAnswerDTO subjectAnswerDTO);

List<SubjectAnswerBO> convertListDTOToBO(List<SubjectAnswerDTO> dtoList);

}
//SubjectInfoConverter
@Mapper
public interface SubjectInfoConverter {

SubjectInfoConverter INSTANCE = Mappers.getMapper(SubjectInfoConverter.class);

SubjectInfo convertBoToInfo(SubjectInfoBO subjectInfoBO);

SubjectInfoBO convertOptionToBo(SubjectOptionBO subjectOptionBO);

SubjectInfoBO convertOptionAndInfoToBo(SubjectOptionBO subjectOptionBO,SubjectInfo subjectInfo);

List<SubjectInfoBO> convertListInfoToBO(List<SubjectInfo> subjectInfoList);

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//SubjectInfoDomainService
public interface SubjectInfoDomainService {

/**
* 新增题目
*/
void add(SubjectInfoBO subjectInfoBO);

/**
* 分页查询
*/
PageResult<SubjectInfoBO> getSubjectPage(SubjectInfoBO subjectInfoBO);

/**
* 查询题目信息
*/
SubjectInfoBO querySubjectInfo(SubjectInfoBO subjectInfoBO);

/**
* 全文检索
*/
PageResult<SubjectInfoEs> getSubjectPageBySearch(SubjectInfoBO subjectInfoBO);

List<SubjectInfoBO> getContributeList();


}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
@Service
@Slf4j
public class SubjectInfoDomainServiceImpl implements SubjectInfoDomainService {

@Resource
private SubjectInfoService subjectInfoService;

@Resource
private SubjectMappingService subjectMappingService;

@Resource
private SubjectLabelService subjectLabelService;

@Resource
private SubjectTypeHandlerFactory subjectTypeHandlerFactory;

@Resource
private SubjectEsService subjectEsService;

@Resource
private SubjectLikedDomainService subjectLikedDomainService;

@Resource
private UserRpc userRpc;

@Resource
private RedisUtil redisUtil;

private static final String RANK_KEY = "subject_rank";

@Override
@Transactional(rollbackFor = Exception.class)
public void add(SubjectInfoBO subjectInfoBO) {
if (log.isInfoEnabled()) {
log.info("SubjectInfoDomainServiceImpl.add.bo:{}", JSON.toJSONString(subjectInfoBO));
}
SubjectInfo subjectInfo = SubjectInfoConverter.INSTANCE.convertBoToInfo(subjectInfoBO);
subjectInfo.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
subjectInfoService.insert(subjectInfo);
SubjectTypeHandler handler = subjectTypeHandlerFactory.getHandler(subjectInfo.getSubjectType()); //题目类型关系工厂
subjectInfoBO.setId(subjectInfo.getId());
handler.add(subjectInfoBO);
List<Integer> categoryIds = subjectInfoBO.getCategoryIds();
List<Integer> labelIds = subjectInfoBO.getLabelIds();
List<SubjectMapping> mappingList = new LinkedList<>();
categoryIds.forEach(categoryId -> { //category和label多对多的关系
labelIds.forEach(labelId -> {
SubjectMapping subjectMapping = new SubjectMapping();
subjectMapping.setSubjectId(subjectInfo.getId());
subjectMapping.setCategoryId(Long.valueOf(categoryId));
subjectMapping.setLabelId(Long.valueOf(labelId));
subjectMapping.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
mappingList.add(subjectMapping);
});
});
subjectMappingService.batchInsert(mappingList);
//同步到es
SubjectInfoEs subjectInfoEs = new SubjectInfoEs();
subjectInfoEs.setDocId(new IdWorkerUtil(1, 1, 1).nextId());
subjectInfoEs.setSubjectId(subjectInfo.getId());
subjectInfoEs.setSubjectAnswer(subjectInfoBO.getSubjectAnswer());
subjectInfoEs.setCreateTime(new Date().getTime());
subjectInfoEs.setCreateUser("Roger");
subjectInfoEs.setSubjectName(subjectInfo.getSubjectName());
subjectInfoEs.setSubjectType(subjectInfo.getSubjectType());
subjectEsService.insert(subjectInfoEs);
//redis放入zadd计入排行榜
redisUtil.addScore(RANK_KEY, LoginUtil.getLoginId(), 1);
}
//实现了分页查询逻辑,并且将查询到的数据和相关的标签信息封装到业务对象中,最后包装成一个分页结果返回。
@Override
public PageResult<SubjectInfoBO> getSubjectPage(SubjectInfoBO subjectInfoBO) {
PageResult<SubjectInfoBO> pageResult = new PageResult<>();
pageResult.setPageNo(subjectInfoBO.getPageNo());
pageResult.setPageSize(subjectInfoBO.getPageSize());
int start = (subjectInfoBO.getPageNo() - 1) * subjectInfoBO.getPageSize();
SubjectInfo subjectInfo = SubjectInfoConverter.INSTANCE.convertBoToInfo(subjectInfoBO);
int count = subjectInfoService.countByCondition(subjectInfo, subjectInfoBO.getCategoryId()
, subjectInfoBO.getLabelId()); //根据条件查询满足要求的题目总数。
if (count == 0) {
return pageResult;
}
List<SubjectInfo> subjectInfoList = subjectInfoService.queryPage(subjectInfo, subjectInfoBO.getCategoryId()
, subjectInfoBO.getLabelId(), start, subjectInfoBO.getPageSize());
List<SubjectInfoBO> subjectInfoBOS = SubjectInfoConverter.INSTANCE.convertListInfoToBO(subjectInfoList);
subjectInfoBOS.forEach(info -> {
SubjectMapping subjectMapping = new SubjectMapping();
subjectMapping.setSubjectId(info.getId());
List<SubjectMapping> mappingList = subjectMappingService.queryLabelId(subjectMapping);
List<Long> labelIds = mappingList.stream().map(SubjectMapping::getLabelId).collect(Collectors.toList());
List<SubjectLabel> labelList = subjectLabelService.batchQueryById(labelIds);
List<String> labelNames = labelList.stream().map(SubjectLabel::getLabelName).collect(Collectors.toList());
info.setLabelName(labelNames);
});
pageResult.setRecords(subjectInfoBOS);
pageResult.setTotal(count);
return pageResult;
}

@Override
public SubjectInfoBO querySubjectInfo(SubjectInfoBO subjectInfoBO) {
SubjectInfo subjectInfo = subjectInfoService.queryById(subjectInfoBO.getId());
SubjectTypeHandler handler = subjectTypeHandlerFactory.getHandler(subjectInfo.getSubjectType());
SubjectOptionBO optionBO = handler.query(subjectInfo.getId().intValue());
SubjectInfoBO bo = SubjectInfoConverter.INSTANCE.convertOptionAndInfoToBo(optionBO, subjectInfo);
SubjectMapping subjectMapping = new SubjectMapping();
subjectMapping.setSubjectId(subjectInfo.getId());
subjectMapping.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
List<SubjectMapping> mappingList = subjectMappingService.queryLabelId(subjectMapping);
List<Long> labelIdList = mappingList.stream().map(SubjectMapping::getLabelId).collect(Collectors.toList());
List<SubjectLabel> labelList = subjectLabelService.batchQueryById(labelIdList);
List<String> labelNameList = labelList.stream().map(SubjectLabel::getLabelName).collect(Collectors.toList());
bo.setLabelName(labelNameList);
bo.setLiked(subjectLikedDomainService.isLiked(subjectInfoBO.getId().toString(), LoginUtil.getLoginId()));
bo.setLikedCount(subjectLikedDomainService.getLikedCount(subjectInfoBO.getId().toString()));
assembleSubjectCursor(subjectInfoBO, bo);
return bo;
}

private void assembleSubjectCursor(SubjectInfoBO subjectInfoBO, SubjectInfoBO bo) {
Long categoryId = subjectInfoBO.getCategoryId();
Long labelId = subjectInfoBO.getLabelId();
Long subjectId = subjectInfoBO.getId();
if (Objects.isNull(categoryId) || Objects.isNull(labelId)) {
return;
}
Long nextSubjectId = subjectInfoService.querySubjectIdCursor(subjectId, categoryId, labelId, 1);
bo.setNextSubjectId(nextSubjectId);
Long lastSubjectId = subjectInfoService.querySubjectIdCursor(subjectId, categoryId, labelId, 0);
bo.setLastSubjectId(lastSubjectId);
}

@Override
public PageResult<SubjectInfoEs> getSubjectPageBySearch(SubjectInfoBO subjectInfoBO) {
SubjectInfoEs subjectInfoEs = new SubjectInfoEs();
subjectInfoEs.setPageNo(subjectInfoBO.getPageNo());
subjectInfoEs.setPageSize(subjectInfoBO.getPageSize());
subjectInfoEs.setKeyWord(subjectInfoBO.getKeyWord());
return subjectEsService.querySubjectList(subjectInfoEs);
}

@Override
public List<SubjectInfoBO> getContributeList() {
Set<ZSetOperations.TypedTuple<String>> typedTuples = redisUtil.rankWithScore(RANK_KEY, 0, 5);
if (log.isInfoEnabled()) {
log.info("getContributeList.typedTuples:{}", JSON.toJSONString(typedTuples));
}
if (CollectionUtils.isEmpty(typedTuples)) {
return Collections.emptyList();
}
List<SubjectInfoBO> boList = new LinkedList<>();
typedTuples.forEach((rank -> {
SubjectInfoBO subjectInfoBO = new SubjectInfoBO();
subjectInfoBO.setSubjectCount(rank.getScore().intValue());
UserInfo userInfo = userRpc.getUserInfo(rank.getValue());
subjectInfoBO.setCreateUser(userInfo.getNickName());
subjectInfoBO.setCreateUserAvatar(userInfo.getAvatar());
boList.add(subjectInfoBO);
}));
return boList;
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
@Service("subjectInfoService")
public class SubjectInfoServiceImpl implements SubjectInfoService {
@Resource
private SubjectInfoDao subjectInfoDao;

/**
* 通过ID查询单条数据
*
* @param id 主键
* @return 实例对象
*/
@Override
public SubjectInfo queryById(Long id) {
return this.subjectInfoDao.queryById(id);
}

/**
* 新增数据
*
* @param subjectInfo 实例对象
* @return 实例对象
*/
@Override
public SubjectInfo insert(SubjectInfo subjectInfo) {
this.subjectInfoDao.insert(subjectInfo);
return subjectInfo;
}

/**
* 修改数据
*
* @param subjectInfo 实例对象
* @return 实例对象
*/
@Override
public SubjectInfo update(SubjectInfo subjectInfo) {
this.subjectInfoDao.update(subjectInfo);
return this.queryById(subjectInfo.getId());
}

/**
* 通过主键删除数据
*
* @param id 主键
* @return 是否成功
*/
@Override
public boolean deleteById(Long id) {
return this.subjectInfoDao.deleteById(id) > 0;
}

@Override
public int countByCondition(SubjectInfo subjectInfo, Long categoryId, Long labelId) {
return this.subjectInfoDao.countByCondition(subjectInfo, categoryId, labelId);
}

@Override
public List<SubjectInfo> queryPage(SubjectInfo subjectInfo, Long categoryId, Long labelId, int start, Integer pageSize) {
return this.subjectInfoDao.queryPage(subjectInfo, categoryId, labelId, start, pageSize);
}

@Override
public List<SubjectInfo> getContributeCount() {
return this.subjectInfoDao.getContributeCount();
}

@Override
public Long querySubjectIdCursor(Long subjectId, Long categoryId, Long labelId, int cursor) {
return this.subjectInfoDao.querySubjectIdCursor(subjectId, categoryId, labelId, cursor);
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public interface SubjectInfoService {

/**
* 通过ID查询单条数据
*
* @param id 主键
* @return 实例对象
*/
SubjectInfo queryById(Long id);

/**
* 新增数据
*
* @param subjectInfo 实例对象
* @return 实例对象
*/
SubjectInfo insert(SubjectInfo subjectInfo);

/**
* 修改数据
*
* @param subjectInfo 实例对象
* @return 实例对象
*/
SubjectInfo update(SubjectInfo subjectInfo);

/**
* 通过主键删除数据
*
* @param id 主键
* @return 是否成功
*/
boolean deleteById(Long id);

int countByCondition(SubjectInfo subjectInfo, Long categoryId, Long labelId);

List<SubjectInfo> queryPage(SubjectInfo subjectInfo, Long categoryId, Long labelId, int start, Integer pageSize);

List<SubjectInfo> getContributeCount();

Long querySubjectIdCursor(Long subjectId, Long categoryId, Long labelId, int cursor);

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
public interface SubjectInfoDao {

/**
* 通过ID查询单条数据
*
* @param id 主键
* @return 实例对象
*/
SubjectInfo queryById(Long id);

/**
* 查询指定行数据
*
* @param subjectInfo 查询条件
* @return 对象列表
*/
List<SubjectInfo> queryAllByLimit(SubjectInfo subjectInfo);

/**
* 统计总行数
*
* @param subjectInfo 查询条件
* @return 总行数
*/
long count(SubjectInfo subjectInfo);

/**
* 新增数据
*
* @param subjectInfo 实例对象
* @return 影响行数
*/
int insert(SubjectInfo subjectInfo);

/**
* 批量新增数据(MyBatis原生foreach方法)
*
* @param entities List<SubjectInfo> 实例对象列表
* @return 影响行数
*/
int insertBatch(@Param("entities") List<SubjectInfo> entities);

/**
* 批量新增或按主键更新数据(MyBatis原生foreach方法)
*
* @param entities List<SubjectInfo> 实例对象列表
* @return 影响行数
* @throws org.springframework.jdbc.BadSqlGrammarException 入参是空List的时候会抛SQL语句错误的异常,请自行校验入参
*/
int insertOrUpdateBatch(@Param("entities") List<SubjectInfo> entities);

/**
* 修改数据
*
* @param subjectInfo 实例对象
* @return 影响行数
*/
int update(SubjectInfo subjectInfo);

/**
* 通过主键删除数据
*
* @param id 主键
* @return 影响行数
*/
int deleteById(Long id);

int countByCondition(@Param("subjectInfo") SubjectInfo subjectInfo,
@Param("categoryId") Long categoryId,
@Param("labelId") Long labelId);

List<SubjectInfo> queryPage(@Param("subjectInfo") SubjectInfo subjectInfo,
@Param("categoryId") Long categoryId,
@Param("labelId") Long labelId,
@Param("start") int start,
@Param("pageSize") Integer pageSize);

List<SubjectInfo> getContributeCount();

Long querySubjectIdCursor(@Param("subjectId") Long subjectId,
@Param("categoryId") Long categoryId,
@Param("labelId") Long labelId,
@Param("cursor") int cursor);

}

JacksonConverter(对于返回值的处理)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package com.jingdianjichi.subject.application.config;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.jingdianjichi.subject.application.interceptor.LoginInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;

import java.util.List;

/**
* mvc的全局处理
*
* @author: ChickenWing
* @date: 2023/10/7
*/
@Configuration
public class GlobalConfig extends WebMvcConfigurationSupport {

@Override
protected void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
super.configureMessageConverters(converters);
converters.add(mappingJackson2HttpMessageConverter());
}

@Override
protected void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.addPathPatterns("/**");
}

/**
* 自定义mappingJackson2HttpMessageConverter
* 目前实现:空值忽略,空字段可返回
*/
private MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); //这行代码配置ObjectMapper,使其在序列化时不会因空Bean而失败。
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); //这行代码设置序列化时只包含非空的字段。(即返回的json结果中不会含有值为Null的字段)
return new MappingJackson2HttpMessageConverter(objectMapper);
}


}

SQL拦截器自动翻译(mybatis提供的)

SqlStatementInterceptor 主要作用是监控MyBatis的SQL执行时间,并根据不同的执行时间记录不同级别的日志

MybatisPlusAllSqlLog这个类实现了InnerInterceptor接口,它是MyBatis-Plus框架提供的一个内部拦截器接口,用于拦截SQL的执行。这个类有两个主要的重写方法:

  • beforeQuery: 在查询执行前调用,记录SQL信息。
  • beforeUpdate: 在更新执行前调用,记录SQL信息。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
//SqlStatementInterceptor 主要作用是监控MyBatis的SQL执行时间,并根据不同的执行时间记录不同级别的日志
package com.jingdianjichi.subject.infra.config;

import org.apache.ibatis.cache.CacheKey;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Properties;


@Intercepts({
@Signature(type = Executor.class, method = "update", args = {MappedStatement.class,
Object.class}),
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class,
Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class})})
public class SqlStatementInterceptor implements Interceptor {

public static final Logger log = LoggerFactory.getLogger("sys-sql");

@Override
public Object intercept(Invocation invocation) throws Throwable {
long startTime = System.currentTimeMillis();
try {
return invocation.proceed();
} finally {
long timeConsuming = System.currentTimeMillis() - startTime;
log.info("执行SQL:{}ms", timeConsuming);
if (timeConsuming > 999 && timeConsuming < 5000) {
log.info("执行SQL大于1s:{}ms", timeConsuming);
} else if (timeConsuming >= 5000 && timeConsuming < 10000) {
log.info("执行SQL大于5s:{}ms", timeConsuming);
} else if (timeConsuming >= 10000) {
log.info("执行SQL大于10s:{}ms", timeConsuming);
}
}
}

@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}

@Override
public void setProperties(Properties properties) {

}
}
//MybatisPlusAllSqlLog
public class MybatisPlusAllSqlLog implements InnerInterceptor {
public static final Logger log = LoggerFactory.getLogger("sys-sql");

@Override
public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
logInfo(boundSql, ms, parameter);
}

@Override
public void beforeUpdate(Executor executor, MappedStatement ms, Object parameter) throws SQLException {
BoundSql boundSql = ms.getBoundSql(parameter);
logInfo(boundSql, ms, parameter);
}

private static void logInfo(BoundSql boundSql, MappedStatement ms, Object parameter) {
try {
log.info("parameter = " + parameter);
// 获取到节点的id,即sql语句的id
String sqlId = ms.getId();
log.info("sqlId = " + sqlId);
// 获取节点的配置
Configuration configuration = ms.getConfiguration();
// 获取到最终的sql语句
String sql = getSql(configuration, boundSql, sqlId);
log.info("完整的sql:{}", sql);
} catch (Exception e) {
log.error("异常:{}", e.getLocalizedMessage(), e);
}
}

// 封装了一下sql语句,使得结果返回完整xml路径下的sql语句节点id + sql语句
public static String getSql(Configuration configuration, BoundSql boundSql, String sqlId) {
return sqlId + ":" + showSql(configuration, boundSql);
}

// 进行?的替换
public static String showSql(Configuration configuration, BoundSql boundSql) {
// 获取参数
Object parameterObject = boundSql.getParameterObject();
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
// sql语句中多个空格都用一个空格代替
String sql = boundSql.getSql().replaceAll("[\\s]+", " ");
if (!CollectionUtils.isEmpty(parameterMappings) && parameterObject != null) {
// 获取类型处理器注册器,类型处理器的功能是进行java类型和数据库类型的转换
TypeHandlerRegistry typeHandlerRegistry = configuration.getTypeHandlerRegistry();
// 如果根据parameterObject.getClass()可以找到对应的类型,则替换
if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
sql = sql.replaceFirst("\\?",
Matcher.quoteReplacement(getParameterValue(parameterObject)));
} else {
// MetaObject主要是封装了originalObject对象,提供了get和set的方法用于获取和设置originalObject的属性值,主要支持对JavaBean、Collection、Map三种类型对象的操作
MetaObject metaObject = configuration.newMetaObject(parameterObject);
for (ParameterMapping parameterMapping : parameterMappings) {
String propertyName = parameterMapping.getProperty();
if (metaObject.hasGetter(propertyName)) {
Object obj = metaObject.getValue(propertyName);
sql = sql.replaceFirst("\\?",
Matcher.quoteReplacement(getParameterValue(obj)));
} else if (boundSql.hasAdditionalParameter(propertyName)) {
// 该分支是动态sql
Object obj = boundSql.getAdditionalParameter(propertyName);
sql = sql.replaceFirst("\\?",
Matcher.quoteReplacement(getParameterValue(obj)));
} else {
// 打印出缺失,提醒该参数缺失并防止错位
sql = sql.replaceFirst("\\?", "缺失");
}
}
}
}
return sql;
}

// 如果参数是String,则添加单引号, 如果是日期,则转换为时间格式器并加单引号; 对参数是null和不是null的情况作了处理
private static String getParameterValue(Object obj) {
String value;
if (obj instanceof String) {
value = "'" + obj.toString() + "'";
} else if (obj instanceof Date) {
DateFormat formatter = DateFormat.getDateTimeInstance(DateFormat.DEFAULT,
DateFormat.DEFAULT, Locale.CHINA);
value = "'" + formatter.format(new Date()) + "'";
} else {
if (obj != null) {
value = obj.toString();
} else {
value = "";
}
}
return value;
}

}

部署

传统部署形式

在starter模块的pom.xml中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//配置的版本等内容
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-boot.version>2.4.2</spring-boot.version>
<spring-cloud-alibaba.version>2021.1</spring-cloud-alibaba.version>
<spring-cloud.version>2020.0.6</spring-cloud.version>
</properties>
....
//打包的名字
<build>
<finalName>${project.artifactId}</finalName>
<!--打包成jar包时的名字-->
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>2.3.0.RELEASE</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

打包好Jar包,复制到服务器上,java -jar运行即可

但是这样还是比较复杂

CI/CD jenkins自动打包集成和部署

1
2
3
4
docker search jenkins
docker pull jenkins/jenkins:lts
docker run -d -u root -p 8080:8080 -p 50000:50000 -v /var/jenkins_home:/var/jenkins_home -v /etc/localtime:/etc/localtime --name jenkins jenkins/jenkins:lts
docker ps -a

然后配置一些密码,maven,拉一些包,配置pom.xml,配置路径,就可以打包好,写一个shell脚本然后运行就行了

[shell脚本语言(超全超详细)-CSDN博客](https://blog.csdn.net/weixin_43288201/article/details/105643692?ops_request_misc=&request_id=&biz_id=102&utm_term=shell 脚本 ^&utm_medium=distribute.pc_search_result.none-task-blog-2allsobaiduweb~default-5-105643692.142^v100^pc_search_result_base8&spm=1018.2226.3001.4187)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
cp /var/jenkins_home/workspace/programmer-club-subject/programmer-club-subject/programmer-club-starter/target/programmer-club-starter.jar /var/jenkins_home/jar/
#!/bin/bash
APP_NAME=programmer-club-starter.jar
LOG_NAME=programmer-club-starter.log

pid=`ps -ef | grep $APP_NAME | grep -v grep|awk '{print $2}'`

function is_exist(){
pid=`ps -ef | grep $APP_NAME | grep -v grep|awk '{print $2}'`
if [ -z ${pid} ]; then
String="notExist"
echo $String
else
String="exist"
echo $String
fi
}

str=$(is_exist)
if [ ${str} = "exist" ]; then
echo " 检测到已经启动的程序,pid 是 ${pid} "
kill -9 $pid
else
echo " 程序没有启动了 "
echo "${APP_NAME} is not running"
fi

str=$(is_exist)
if [ ${str} = "exist" ]; then
echo "${APP_NAME} 已经启动了. pid=${pid} ."
else
source /etc/profile
BUILD_ID=dontKillMe
nohup java -Xms300m -Xmx300m -jar /var/jenkins_home/jar/$APP_NAME >$LOG_NAME 2>&1 &
echo "程序已重新启动..."
fi

这个脚本中获取PID的命令:

  1. ps -ef:这个命令列出了当前系统上所有正在运行的进程。-e选项表示显示所有进程,-f选项表示显示完整格式。
  2. grep $APP_NAMEgrep命令用于搜索包含指定文本的行。在这里,它搜索包含APP_NAME变量值(即programmer-club-starter.jar)的行。
  3. grep -v grep:这个命令用于排除包含grep本身的行,-v选项表示显示不包含匹配文本的行。
  4. awk '{print $2}'awk是一个强大的文本处理工具。在这里,它用于打印每行的第二个字段,即进程ID(PID)。在Unix/Linux系统中,ps -ef命令的输出中,PID通常位于第二列。

2. OSS模块(jc-club-oss)

image-20240728193051763.png

OSS模块设计

image-20240717153600388.png

注意:考虑 oss 的扩展性和切换性。

目前对接的 minio,要考虑,如果作为公共的 oss 服务,如何切换到其他的阿里云 oss 或者对接京东云的 oss。作为基础的 oss 服务,切换等等动作,不应该要求业务方进行改造,以及对切换有感知。

image-20240717153706800.png

minio模块开发

controller->service->adaper(阿里/minio,适配器模式,不用工厂+策略的是因为工厂+策略的传入的参数之类的差不多,所以用适配器)->具体操作

minio安装部署及使用-CSDN博客

pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
 <dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<artifactId>spring-boot-starter-logging</artifactId>
<groupId>org.springframework.boot</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.2.0</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.16</version>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> //spingcloud的nacos
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
</dependencies>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//application.yml
server:
port: 4000
minio:
url: http://117.72.14.166:9000
accessKey: minioadmin
secretKey: minioadmin
storage:
service:
type:
minio
/*
- `server.port: 4000`:这行配置指定了应用程序运行的端口号为4000。

- `minio`部分:配置了MinIO服务的相关属性。
- `url`:MinIO服务的URL地址,这里是`http://117.72.14.166:9000`。
- `accessKey`和`secretKey`:访问MinIO服务的密钥和私钥,这里都设置为`minioadmin`。

- `storage.service.type.minio`:指定存储服务的类型为MinIO。
*/

Miniocofig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package com.jingdianjichi.oss.config;

import io.minio.MinioClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* minio配置管理
*
* @author: ChickenWing
* @date: 2023/10/11
*/
@Configuration
public class MinioConfig {

/**
* minioUrl
*/
@Value("${minio.url}")
private String url;

/**
* minio账户
*/
@Value("${minio.accessKey}")
private String accessKey;

/**
* minio密码
*/
@Value("${minio.secretKey}")
private String secretKey;

/**
* 构造minioClient
*/
@Bean
public MinioClient getMinioClient() {
return MinioClient.builder().endpoint(url).credentials(accessKey, secretKey).build();
}

}

MinioUtil.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
package com.jingdianjichi.oss.util;

import com.jingdianjichi.oss.entity.FileInfo;
import io.minio.*;
import io.minio.errors.*;
import io.minio.http.Method;
import io.minio.messages.Bucket;
import io.minio.messages.Item;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.io.IOException;
import java.io.InputStream;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.LinkedList;
import java.util.List;
import java.util.stream.Collectors;

/**
* minio文件操作工具
*
* @author: ChickenWing
* @date: 2023/10/11
*/
@Component
public class MinioUtil {

@Resource
private MinioClient minioClient;

/**
* 创建bucket桶
*/
public void createBucket(String bucket) throws Exception {
boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build());
if (!exists) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
}
}

/**
* 上传文件
*/
public void uploadFile(InputStream inputStream, String bucket, String objectName) throws Exception {
minioClient.putObject(PutObjectArgs.builder().bucket(bucket).object(objectName)
.stream(inputStream, -1, 5242889L).build());
}

/**
* 列出所有桶
*/
public List<String> getAllBucket() throws Exception {
List<Bucket> buckets = minioClient.listBuckets();
return buckets.stream().map(Bucket::name).collect(Collectors.toList());
}

/**
* 列出当前桶及文件
*/
public List<FileInfo> getAllFile(String bucket) throws Exception {
Iterable<Result<Item>> results = minioClient.listObjects(
ListObjectsArgs.builder().bucket(bucket).build());
List<FileInfo> fileInfoList = new LinkedList<>();
for (Result<Item> result : results) {
FileInfo fileInfo = new FileInfo();
Item item = result.get();
fileInfo.setFileName(item.objectName());
fileInfo.setDirectoryFlag(item.isDir());
fileInfo.setEtag(item.etag());
fileInfoList.add(fileInfo);
}
return fileInfoList;
}

/**
* 下载文件
*/
public InputStream downLoad(String bucket, String objectName) throws Exception {
return minioClient.getObject(
GetObjectArgs.builder().bucket(bucket).object(objectName).build()
);
}

/**
* 删除桶
*/
public void deleteBucket(String bucket) throws Exception {
minioClient.removeBucket(
RemoveBucketArgs.builder().bucket(bucket).build()
);
}

/**
* 删除文件
*/
public void deleteObject(String bucket, String objectName) throws Exception {
minioClient.removeObject(
RemoveObjectArgs.builder().bucket(bucket).object(objectName).build()
);
}

/**
* 获取文件url
*/
public String getPreviewFileUrl(String bucketName, String objectName) throws Exception{
GetPresignedObjectUrlArgs args = GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(bucketName).object(objectName).build();
return minioClient.getPresignedObjectUrl(args);
}

}

FileInfo.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class FileInfo {

private String fileName;

private Boolean directoryFlag;

private String etag;

public String getFileName() {
return fileName;
}

public void setFileName(String fileName) {
this.fileName = fileName;
}

public Boolean getDirectoryFlag() {
return directoryFlag;
}

public void setDirectoryFlag(Boolean directoryFlag) {
this.directoryFlag = directoryFlag;
}

public String getEtag() {
return etag;
}

public void setEtag(String etag) {
this.etag = etag;
}
}

FileController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@RestController
public class FileController {

@Resource
private FileService fileService;

@RequestMapping("/testGetAllBuckets")
public String testGetAllBuckets() throws Exception {
List<String> allBucket = fileService.getAllBucket();
return allBucket.get(0);
}

@RequestMapping("/getUrl")
public String getUrl(String bucketName, String objectName) throws Exception {
return fileService.getUrl(bucketName, objectName);
}

/**
* 上传文件
*/
@RequestMapping("/upload")
public Result upload(MultipartFile uploadFile, String bucket, String objectName) throws Exception {
String url = fileService.uploadFile(uploadFile, bucket, objectName);
return Result.ok(url);
}

}

fileService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Service
public class FileService {

private final StorageAdapter storageAdapter;

public FileService(StorageAdapter storageAdapter) {
this.storageAdapter = storageAdapter;
}

/**
* 列出所有桶
*/
public List<String> getAllBucket() {
return storageAdapter.getAllBucket();
}

/**
* 获取文件路径
*/
public String getUrl(String bucketName,String objectName) {
return storageAdapter.getUrl(bucketName,objectName);
}

/**
* 上传文件
*/
public String uploadFile(MultipartFile uploadFile, String bucket, String objectName){
storageAdapter.uploadFile(uploadFile,bucket,objectName);
objectName = objectName + "/" + uploadFile.getOriginalFilename();
return storageAdapter.getUrl(bucket, objectName);
}
}

StorageConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Configuration
@RefreshScope //实现nacos动态的配置刷新
public class StorageConfig {

@Value("${storage.service.type}") //这里的注解是从pom.xml中去读的
private String storageType;

@Bean
@RefreshScope //实现nacos动态的配置刷新
public StorageAdapter storageService() {
if ("minio".equals(storageType)) {
return new MinioStorageAdapter();
} else if ("aliyun".equals(storageType)) {
return new AliStorageAdapter();
} else {
throw new IllegalArgumentException("未找到对应的文件存储处理器");
}
}

}

StorageAdapter.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public interface StorageAdapter {

/**
* 创建bucket桶
*/
void createBucket(String bucket);

/**
* 上传文件
*/
void uploadFile(MultipartFile uploadFile, String bucket, String objectName);

/**
* 列出所有桶
*/
List<String> getAllBucket();

/**
* 列出当前桶及文件
*/
List<FileInfo> getAllFile(String bucket);

/**
* 下载文件
*/
InputStream downLoad(String bucket, String objectName);

/**
* 删除桶
*/
void deleteBucket(String bucket);

/**
* 删除文件
*/
void deleteObject(String bucket, String objectName);

String getUrl(String bucket, String objectName);


}

MinioStorageAdapter.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
package com.jingdianjichi.oss.adapter;

import com.jingdianjichi.oss.entity.FileInfo;
import com.jingdianjichi.oss.util.MinioUtil;
import lombok.SneakyThrows;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.multipart.MultipartFile;

import javax.annotation.Resource;
import java.io.InputStream;
import java.util.List;

/**
* minioIO存储适配器
*/
public class MinioStorageAdapter implements StorageAdapter {

@Resource
private MinioUtil minioUtil;

/**
* minioUrl
*/
@Value("${minio.url}")
private String url;

@Override
@SneakyThrows
public void createBucket(String bucket) {
minioUtil.createBucket(bucket);
}

@Override
@SneakyThrows
public void uploadFile(MultipartFile uploadFile, String bucket, String objectName) {
minioUtil.createBucket(bucket);
if (objectName != null) {
minioUtil.uploadFile(uploadFile.getInputStream(), bucket, objectName + "/" + uploadFile.getOriginalFilename());
} else {
minioUtil.uploadFile(uploadFile.getInputStream(), bucket, uploadFile.getOriginalFilename());
}
}

@Override
@SneakyThrows
public List<String> getAllBucket() {
return minioUtil.getAllBucket();
}

@Override
@SneakyThrows
public List<FileInfo> getAllFile(String bucket) {
return minioUtil.getAllFile(bucket);
}

@Override
@SneakyThrows
public InputStream downLoad(String bucket, String objectName) {
return minioUtil.downLoad(bucket, objectName);
}

@Override
@SneakyThrows
public void deleteBucket(String bucket) {
minioUtil.deleteBucket(bucket);
}

@Override
@SneakyThrows
public void deleteObject(String bucket, String objectName) {
minioUtil.deleteObject(bucket, objectName);
}

@Override
@SneakyThrows
public String getUrl(String bucket, String objectName) {
return url + "/" + bucket + "/" + objectName;
}

}

这里相当于做了个适配器,这里用的是minio的,如果要用阿里的服务就要切到另一套了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package com.jingdianjichi.oss.adapter;

import com.jingdianjichi.oss.entity.FileInfo;
import org.springframework.web.multipart.MultipartFile;

import java.io.InputStream;
import java.util.LinkedList;
import java.util.List;

/**
* 阿里云oss适配器
*/
public class AliStorageAdapter implements StorageAdapter {

@Override
public void createBucket(String bucket) {

}

@Override
public void uploadFile(MultipartFile uploadFile, String bucket, String objectName) {

}

@Override
public List<String> getAllBucket() {
List<String> bucketNameList = new LinkedList<>();
bucketNameList.add("aliyun");
return bucketNameList;
}

@Override
public List<FileInfo> getAllFile(String bucket) {
return null;
}

@Override
public InputStream downLoad(String bucket, String objectName) {
return null;
}

@Override
public void deleteBucket(String bucket) {

}

@Override
public void deleteObject(String bucket, String objectName) {

}

@Override
public String getUrl(String bucket, String objectName) {
return null;
}

}

OSS模块配合nacos实现动态切换

nacos 作为配置中心,可以实现动态配置,适用于比如动态数据源切换,动态切换 oss。

这里要配合RefreshScope注解去使用,实现动态刷新(minio->ali)。

一文带你理解@RefreshScope注解实现动态刷新原理-CSDN博客

集成naocs动态配置

配置

  1. jc-club-oss/pom.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    <dependencies>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
    <exclusion>
    <artifactId>spring-boot-starter-logging</artifactId>
    <groupId>org.springframework.boot</groupId>
    </exclusion>
    </exclusions>
    </dependency>
    <dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.2.0</version>
    </dependency>
    <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.16</version>
    </dependency>
    <dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
    </dependency>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-log4j2</artifactId>
    </dependency>
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-bootstrap</artifactId>
    </dependency>
    <dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    </dependency>
    </dependencies>
  2. jc-club-oss/src/main/java/com/jingdianjichi/oss/controller/FileController.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
     public class FileController{ 
    @Resource
    private FileService fileService;

    @NacosValue(value = "${storage.service.type}", autoRefreshed = true)
    private String storageType;

    @RequestMapping("/testGetAllBuckets")
    public String testGetAllBuckets() throws Exception {
    List<String> allBucket = fileService.getAllBucket();
    return allBucket.get(0);
    }

    @RequestMapping("/testNacos")
    public String testNacos() throws Exception {
    return storageType;
    }

    }

  3. jc-club-oss/src/main/resources/bootstrap.yml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    spring:
    application:
    name: jc-club-oss-dev
    profiles:
    active: dev
    cloud:
    nacos:
    config:
    server-addr: 117.72.14.166:8848
    prefix: ${spring.application.name}
    group: DEFAULT_GROUP
    namespace:
    file-extension: yaml
    discovery:
    enabled: true
    server-addr: 117.72.14.166:8848
    /*
    Spring 应用名称:

    application.name: jc-club-oss-dev:定义了Spring应用的名称为jc-club-oss-dev。
    激活的配置文件:

    profiles.active: dev:指定了激活的配置文件为dev,这意味着在启动时会加载application-dev.yaml或application-dev.properties等配置文件。
    Nacos 配置:

    cloud.nacos.config.server-addr: 117.72.14.166:8848:定义了Nacos配置服务器的地址,这里是117.72.14.166:8848。
    cloud.nacos.config.prefix: ${spring.application.name}:定义了配置的前缀,这里使用了${spring.application.name},即应用名称作为前缀。
    cloud.nacos.config.group: DEFAULT_GROUP:定义了配置的分组,这里是DEFAULT_GROUP。
    cloud.nacos.config.namespace:这里没有指定具体的命名空间,通常用于区分不同环境的配置。
    cloud.nacos.config.file-extension: yaml:指定了配置文件的扩展名,这里是yaml。
    Nacos 服务发现:

    cloud.nacos.discovery.enabled: true:启用了Nacos的服务发现功能。
    cloud.nacos.discovery.server-addr: 117.72.14.166:8848:定义了Nacos服务发现服务器的地址,与配置服务器地址一致。
    配置的作用
    配置管理:通过Nacos配置中心,应用可以动态地读取和更新配置,而不需要重启应用。这对于微服务架构中的配置管理非常有用。
    服务发现:通过Nacos服务注册中心,应用可以注册自身服务并发现其他服务,从而实现服务间的调用。
    使用场景
    在微服务架构中,使用Nacos进行配置管理和服务发现可以提高系统的可维护性和可扩展性。
    动态配置更新:应用可以根据Nacos配置中心的配置变化自动更新其配置,而不需要人工干预。*/

通过在nacos界面修改:

1
2
3
storage:
service:
type: minio/aliyun

即可通过**@Configuration**注解的Storage.java中的@Bean、@RefreshScope注解的storageService方法会创建适配器的对应的StorageAdapter,拿到minio/阿里云的oss服务。

@Configuration注解使用详解【记录】-CSDN博客

Spring 常用注解@Configuration,@Bean,@Component,@Service,@Controller,@Repository,@Entity的区分与学习_@entity @repository 区别-CSDN博客

3. 登录鉴权模块

image-20240728193217684.png

image-20240729100733585.png

技术选型

Sa-Token 是一个轻量级 Java 权限认证框架,主要解决:登录认证权限认证单点登录OAuth2.0分布式Session会话微服务网关鉴权 等一系列权限相关问题。

Sa-Token 旨在以简单、优雅的方式完成系统的权限认证部分,以登录认证为例,你只需要:

1
2
// 会话登录,参数填登录人的账号id 
StpUtil.login(10001);

无需实现任何接口,无需创建任何配置文件,只需要这一句静态代码的调用,便可以完成会话登录认证。

如果一个接口需要登录后才能访问,我们只需调用以下代码:

1
2
// 校验当前客户端是否已经登录,如果未登录则抛出 `NotLoginException` 异常
StpUtil.checkLogin();

在 Sa-Token 中,大多数功能都可以一行代码解决:

踢人下线:

1
2
// 将账号id为 10077 的会话踢下线 
StpUtil.kickout(10077);复制到剪贴板错误复制成功

权限认证:

1
2
3
4
5
6
// 注解鉴权:只有具备 `user:add` 权限的会话才可以进入方法
@SaCheckPermission("user:add")
public String insert(SysUser user) {
// ...
return "用户增加";
}

路由拦截鉴权:

1
2
3
4
5
6
7
8
9
10
// 根据路由划分模块,不同模块不同鉴权 
registry.addInterceptor(new SaInterceptor(handler -> {
SaRouter.match("/user/**", r -> StpUtil.checkPermission("user"));
SaRouter.match("/admin/**", r -> StpUtil.checkPermission("admin"));
SaRouter.match("/goods/**", r -> StpUtil.checkPermission("goods"));
SaRouter.match("/orders/**", r -> StpUtil.checkPermission("orders"));
SaRouter.match("/notice/**", r -> StpUtil.checkPermission("notice"));
// 更多模块...
})).addPathPatterns("/**");
9

当你受够 Shiro、SpringSecurity 等框架的三拜九叩之后,你就会明白,相对于这些传统老牌框架,Sa-Token 的 API 设计是多么的简单、优雅!

Sa-Token 功能一览

Sa-Token 目前主要五大功能模块:登录认证、权限认证、单点登录、OAuth2.0、微服务鉴权。

  • 登录认证 —— 单端登录、多端登录、同端互斥登录、七天内免登录。
  • 权限认证 —— 权限认证、角色认证、会话二级认证。
  • 踢人下线 —— 根据账号id踢人下线、根据Token值踢人下线。
  • 注解式鉴权 —— 优雅的将鉴权与业务代码分离。
  • 路由拦截式鉴权 —— 根据路由拦截鉴权,可适配 restful 模式。
  • Session会话 —— 全端共享Session,单端独享Session,自定义Session,方便的存取值。
  • 持久层扩展 —— 可集成 Redis,重启数据不丢失。
  • 前后台分离 —— APP、小程序等不支持 Cookie 的终端也可以轻松鉴权。
  • Token风格定制 —— 内置六种 Token 风格,还可:自定义 Token 生成策略。
  • 记住我模式 —— 适配 [记住我] 模式,重启浏览器免验证。
  • 二级认证 —— 在已登录的基础上再次认证,保证安全性。
  • 模拟他人账号 —— 实时操作任意用户状态数据。
  • 临时身份切换 —— 将会话身份临时切换为其它账号。
  • 同端互斥登录 —— 像QQ一样手机电脑同时在线,但是两个手机上互斥登录。
  • 账号封禁 —— 登录封禁、按照业务分类封禁、按照处罚阶梯封禁。
  • 密码加密 —— 提供基础加密算法,可快速 MD5、SHA1、SHA256、AES 加密。
  • 会话查询 —— 提供方便灵活的会话查询接口。
  • Http Basic认证 —— 一行代码接入 Http Basic、Digest 认证。
  • 全局侦听器 —— 在用户登陆、注销、被踢下线等关键性操作时进行一些AOP操作。
  • 全局过滤器 —— 方便的处理跨域,全局设置安全响应头等操作。
  • 多账号体系认证 —— 一个系统多套账号分开鉴权(比如商城的 User 表和 Admin 表)
  • 单点登录 —— 内置三种单点登录模式:同域、跨域、同Redis、跨Redis、前后端分离等架构都可以搞定。
  • 单点注销 —— 任意子系统内发起注销,即可全端下线。
  • OAuth2.0认证 —— 轻松搭建 OAuth2.0 服务,支持openid模式 。
  • 分布式会话 —— 提供共享数据中心分布式会话方案。
  • 微服务网关鉴权 —— 适配Gateway、ShenYu、Zuul等常见网关的路由拦截认证。
  • RPC调用鉴权 —— 网关转发鉴权,RPC调用鉴权,让服务调用不再裸奔
  • 临时Token认证 —— 解决短时间的 Token 授权问题。
  • 独立Redis —— 将权限缓存与业务缓存分离。
  • Quick快速登录认证 —— 为项目零代码注入一个登录页面。
  • 标签方言 —— 提供 Thymeleaf 标签方言集成包,提供 beetl 集成示例。
  • jwt集成 —— 提供三种模式的 jwt 集成方案,提供 token 扩展参数能力。
  • RPC调用状态传递 —— 提供 dubbo、grpc 等集成包,在RPC调用时登录状态不丢失。
  • 参数签名 —— 提供跨系统API调用签名校验模块,防参数篡改,防请求重放。
  • 自动续签 —— 提供两种Token过期策略,灵活搭配使用,还可自动续签。
  • 开箱即用 —— 提供SpringMVC、WebFlux、Solon 等常见框架集成包,开箱即用。
  • 最新技术栈 —— 适配最新技术栈:支持 SpringBoot 3.x,jdk 17。

功能结构图:

image-20240728132015039.png

鉴权设计-RBAC模型

RBAC 模型(role-based access control)

非常成熟的安全的模型概念,基于角色帮助我们把授权和用户的访问控制来做结合。

User(用户)用户就是指我们的系统使用者。

PerMission(权限)用户我们对系统的操作,访问哪些东西,可以操作写入操作等等。实际的例子,比如新增题目。

Role(角色)我们去把一组的权限,去做集合,就得到了角色。

核心思想其实就是把角色和权限做关联,实现整体的一个灵活访问,提高我们的系统的安全性和管理型。基于这个模型,我们的开发速度还有粒度的粗细也都是十分好控制的。

优点:

灵活,安全,简化管理。

三种RBAC模型:

  1. RBAC-0 模型

    用户和角色是一个多对多的关系,角色和权限也是一个多对多关系。

  2. RBAC-1 模型

    多了一个继承的概念。

    比如一个业务部门,经理,主管,营业员。主管的权限肯定不能大于经理,营业员不能大于主管。

    子角色的范围一定会小于父角色。

  3. RBAC-2 模型

    角色互斥,基数约束,先决条件等等。

    • 角色互斥:同一个用户,不能被分配到复制的角色,比如说,你是一个采购,那你就不能分配销售。
    • 基数约束:一个角色分配的用户数量是有限的。比如有一个公司的架构师,最多只能有三个。
    • 先决条件:你想获得架构师的角色,那你必然得先是一个资深工程师的角色。

权限

  • 他的含义其实是非常广泛的,可以是菜单,页面,字段,数据。
    • 菜单权限
    • 页面权限
    • 字段权限
    • 数据权限
    • 操作权限

用户组:

  • 平台的用户基数非常大,角色也非常的多,如果说我给每个用户都操作一下角色,就非常的麻烦。
  • 抽象一层组的概念,把同类的用户,放在一起,直接拥有相同的权限。
  • 非常有益于减少工作量,一些管理方面也非常合适。用户组抽象到实际中,其实就是部门啊,科室啊。

鉴权数据模型设计

4aaca7492044e3e4d3c9d55c9b05c39.jpg

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
-- ----------------------------
-- Table structure for auth_permission
-- ----------------------------
DROP TABLE IF EXISTS `auth_permission`;
CREATE TABLE `auth_permission`
(
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(64) DEFAULT NULL COMMENT '权限名称',
`parent_id` bigint(20) DEFAULT NULL COMMENT '父id',
`type` tinyint(4) DEFAULT NULL COMMENT '权限类型 0菜单 1操作',
`menu_url` varchar(255) DEFAULT NULL COMMENT '菜单路由',
`status` tinyint(2) DEFAULT NULL COMMENT '状态 0启用 1禁用',
`show` tinyint(2) DEFAULT NULL COMMENT '展示状态 0展示 1隐藏',
`icon` varchar(128) DEFAULT NULL COMMENT '图标',
`permission_key` varchar(64) DEFAULT NULL COMMENT '权限唯一标识',
`created_by` varchar(32) DEFAULT NULL COMMENT '创建人',
`created_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(32) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`is_deleted` int(11) DEFAULT '0' COMMENT '是否被删除 0为删除 1已删除',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;

-- ----------------------------
-- Records of auth_permission
-- ----------------------------
INSERT INTO `auth_permission`
VALUES ('1', '新增题目', '0', '1', 'ladiwd/www', '0', '0', 'http://1.png', 'subject:add1', 'oYA4HtwGJEsLio6pGrhx5Hzv9XD0',
'2024-02-28 03:20:38', '', '2023-11-12 16:17:12', '0');

-- ----------------------------
-- Table structure for auth_role
-- ----------------------------
DROP TABLE IF EXISTS `auth_role`;
CREATE TABLE `auth_role`
(
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`role_name` varchar(32) DEFAULT NULL COMMENT '角色名称',
`role_key` varchar(64) DEFAULT NULL COMMENT '角色唯一标识',
`created_by` varchar(32) DEFAULT NULL COMMENT '创建人',
`created_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(32) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`is_deleted` int(11) DEFAULT '0' COMMENT '是否被删除 0未删除 1已删除',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;

-- ----------------------------
-- Records of auth_role
-- ----------------------------
INSERT INTO `auth_role`
VALUES ('1', '管理员', 'admin_user', 'oYA4HtwGJEsLio6pGrhx5Hzv9XD0', '2024-02-28 03:20:44', '', '2023-11-12 16:16:07',
'0');
INSERT INTO `auth_role`
VALUES ('2', '普通用户', 'normal_user', 'oYA4HtwGJEsLio6pGrhx5Hzv9XD0', '2024-02-28 03:20:44', '', '2023-11-12 16:16:10',
'0');

-- ----------------------------
-- Table structure for auth_role_permission
-- ----------------------------
DROP TABLE IF EXISTS `auth_role_permission`;
CREATE TABLE `auth_role_permission`
(
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`role_id` bigint(20) DEFAULT NULL COMMENT '角色id',
`permission_id` bigint(20) DEFAULT NULL COMMENT '权限id',
`created_by` varchar(32) DEFAULT NULL COMMENT '创建人',
`created_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(32) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`is_deleted` int(11) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8 COMMENT='角色权限关联表';

-- ----------------------------
-- Records of auth_role_permission
-- ----------------------------

-- ----------------------------
-- Table structure for auth_user
-- ----------------------------
DROP TABLE IF EXISTS `auth_user`;
CREATE TABLE `auth_user`
(
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_name` varchar(32) DEFAULT NULL COMMENT '用户名称/账号',
`nick_name` varchar(32) DEFAULT NULL COMMENT '昵称',
`email` varchar(32) DEFAULT NULL COMMENT '邮箱',
`phone` varchar(32) DEFAULT NULL COMMENT '手机号',
`password` varchar(64) DEFAULT NULL COMMENT '密码',
`sex` tinyint(2) DEFAULT NULL COMMENT '性别',
`avatar` varchar(255) DEFAULT NULL COMMENT '头像',
`status` tinyint(2) DEFAULT NULL COMMENT '状态 0启用 1禁用',
`introduce` varchar(255) DEFAULT NULL COMMENT '个人介绍',
`ext_json` varchar(255) DEFAULT NULL COMMENT '特殊字段',
`created_by` varchar(32) DEFAULT NULL COMMENT '创建人',
`created_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(32) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`is_deleted` int(11) DEFAULT '0' COMMENT '是否被删除 0未删除 1已删除',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户信息表';

-- ----------------------------
-- Records of auth_user
-- ----------------------------

-- ----------------------------
-- Table structure for auth_user_role
-- ----------------------------
DROP TABLE IF EXISTS `auth_user_role`;
CREATE TABLE `auth_user_role`
(
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` bigint(20) DEFAULT NULL COMMENT '用户id',
`role_id` bigint(20) DEFAULT NULL COMMENT '角色id',
`created_by` varchar(32) DEFAULT NULL COMMENT '创建人',
`created_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(32) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`is_deleted` int(11) DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户角色表';

鉴权架构设计

jc-club-auth:这个服务承载了我们所有的基础数据源。他不管鉴权,只管数据相关的持久化操作以及业务操作,提供出各种各样的权限相关的接口。

nacos:将 auth 服务以及 subject 服务都注册到上面。内部进行调用,不对外暴露。通过 nacos 实现我们的服务发现。

gateway(网关):网关层会对外提供服务内部实现路由,鉴权。整体我们采取 token 的方式来与前端进行交互。由网关来决定当前用户是否可以操作到后面的业务逻辑。

image-20240728144400289.png

鉴权、路由等处理都由网关来做。

鉴权功能设计

  1. 用户基础模块

    • 新增用户
    • 修改用户
    • 删除用户
    • 用户启用
    • 用户禁用
    • 用户密码加密
  2. 角色基础模块

    • 新增角色
    • 修改角色
    • 删除角色
    • 角色与用户的关联
  3. 权限基础模块

    • 新增权限

    • 修改权限

    • 删除权限

    • 权限禁用与启用

    • 权限的展示与隐藏

    • 权限与角色关联

  4. 登录注册模块

    • 注册用户与验证
    1. 短信的方式,通过向手机号发送验证码,来实现用户的验证并登录(考虑的成本是短信的费用)

    2. 邮箱的注册登录。

      用户注册的时候,留一个邮箱,我们往邮箱里通过邮箱服务器发送一个链接,用户点击之后,实现一个激活,激活成功之后就完成了注册。(0 成本,坏处这种发送的邮件很容易进垃圾箱)

    3. 个人公众号模式(个人开发者无公司的,比较适合使用,0 成本)

      用户登录的时候,弹出我们的这个公众号的码。扫码后,用户输入我们提示的验证码。可以随机比如说 nadbuge,通过我们的公众号对接的回调。能拿到一定的信息,用户的 openId。进而记录用户的信息

    4. 企业的服务号(必须要有营业执照,自己玩的不上线的话,也可以用测试号)

      好处就是不仅打通了各种回调,而且还能拿到用户的信息。

    • 登录功能

    传统的 pc 形式,都是登录之后,写入 cookie。前端再次请求的时候,带着 cookie 一个身份识别就可以完成认证。

    坏处是什么?小程序呀,app 呀,其实是没有 cookie 这个概念的。

    单点登录(SSO)详解——超详细-CSDN博客

    为了更好的扩展,我们就直接选择 token的模式。token 放入 header 来实现用户身份的识别与鉴权。

    • 踢人下线

    发现风险用户,可以通过后台直接把用户踢掉,禁止其再访问,token 也可以直接置为失效的形式。

    • 集成 redis (保存token)

    如果说我们选择了 token,然后不做 token 的保存,服务重启呀,分布式微服务啊,数据是无法共享并且会产生丢失问题,所以用 redis 来存储一些信息,实现共享。

    • 自定义我们的 token 风格和前缀

      比如正常的 token 可能是 uuid,我们可以选择其他形式。

      然后就是 token 的前端的传递,也可以去定义前缀,固定前缀才生效。

    • 记住我

    当我们去勾选记住我的时候,下次登录就自动实现了。

    前后端分离,没有 token 的时候,必然会产生无法实现的问题,我们就选择在前端的 localstorage 来做。

  5. 网关统一鉴权

    校验权限,校验用户的角色等等的东西,就放在网关里面统一去做。

    不放在网关,导致每个微服务,全要引入的鉴权的框架,不断的去写重复的代码。

    数据的权限获取产生问题:

    1. 网关直接对接数据库,实现查询。
    2. redis 中获取数据,获取不到的时候还是要像第一种一样去数据库里查。
    3. redis 中获取缓存,没有的话,从 auth 服务里面获取相关的信息。
    4. 直接从 redis 读取。

auth模块配置

jc-club-auth-application/pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId> //用于 Spring Cloud 项目中,通过 Feign 客户端简化 HTTP 客户端的调用。
<version>3.0.7</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-loadbalancer</artifactId> //用于 Spring Cloud 项目中,提供客户端负载均衡功能。
<version>3.0.6</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId> //自动生成 getter、setter、构造函数等。
<version>1.18.16</version>
</dependency>
</dependencies>

jc-club-auth-application-controller/pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.4.2</version>
</dependency>
<!-- sa-token 依赖-->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot-starter</artifactId>
<version>1.37.0</version>
</dependency>

<dependency>
<groupId>com.jingdianjichi</groupId>
<artifactId>jc-club-auth-domain</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

<dependency>
<groupId>com.jingdianjichi</groupId>
<artifactId>jc-club-auth-api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>

jc-club-auth-starter/pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.4.2</version>
<exclusions>
<exclusion>
<artifactId>spring-boot-starter-logging</artifactId>
<groupId>org.springframework.boot</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.jingdianjichi</groupId>
<artifactId>jc-club-auth-infra</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.jingdianjichi</groupId>
<artifactId>jc-club-auth-application-controller</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> //nacos
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
</dependencies>

jc-club-auth-domain/pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<dependencies>
<dependency>
<groupId>com.jingdianjichi</groupId>
<artifactId>jc-club-auth-infra</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.jingdianjichi</groupId>
<artifactId>jc-club-auth-common</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.12.7</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.12.7</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.8.6</version>
</dependency>
</dependencies>

jc-club-auth-infra/pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<dependencies>
<!-- jdbcStarter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
<version>2.4.2</version>
</dependency>
<!-- druid连接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.22</version>
</dependency>
<!-- mysql -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.22</version>
</dependency>
<!-- mybatisplus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.0</version>
</dependency>
<dependency>
<groupId>com.jingdianjichi</groupId>
<artifactId>jc-club-auth-common</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>

登录(详情在用户模块)

注意openid就是username

Sa-token

前后端分离 (sa-token.cc)

在auth模块中的UserController.java中实现

jc-club-auth: UserController

1
2
3
4
5
6
7
8
9
10
@RequestMapping("doLogin")
public Result<SaTokenInfo> doLogin(@RequestParam("validCode") String validCode) {
try {
Preconditions.checkArgument(!StringUtils.isBlank(validCode), "验证码不能为空!");
return Result.ok(authUserDomainService.doLogin(validCode));
} catch (Exception e) {
log.error("UserController.doLogin.error:{}", e.getMessage(), e);
return Result.fail("用户登录失败");
}
}

jc-club-auth:AuthUserDomainServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public SaTokenInfo doLogin(String validCode) {
String loginKey = redisUtil.buildKey(LOGIN_PREFIX, validCode);
String openId = redisUtil.get(loginKey);
if (StringUtils.isBlank(openId)) {
return null;
}
AuthUserBO authUserBO = new AuthUserBO();
authUserBO.setUserName(openId);
this.register(authUserBO);
StpUtil.login(openId);
SaTokenInfo tokenInfo = StpUtil.getTokenInfo();
return tokenInfo;
}

微服务注册到nacos

阿里云脚手架用于组件/版本的选择兼容,非常方便: start.aliyun.com

oss服务->nacos

image-20240729102012839.png

pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> //构建 Web 应用程序所需的 Spring MVC 和 Tomcat。
<exclusions>
<exclusion>
<artifactId>spring-boot-starter-logging</artifactId> //不会包含默认的日志框架
<groupId>org.springframework.boot</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.minio</groupId> //用于与 Minio 对象存储服务进行交互的客户端库。
<artifactId>minio</artifactId>
<version>8.2.0</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId> //
<artifactId>lombok</artifactId> //减少样板代码,自动生成 getter、setter 等。
<version>1.18.16</version>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> //用于集成 Nacos 配置中心,提供配置管理功能。
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId> //替换默认的日志框架,使用 Log4j2 作为日志记录器。
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId> //用于 Spring Cloud 应用的引导类,帮助应用启动时加载配置(bootstrap.yaml)。
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> //用于集成 Nacos 服务发现,提供服务注册与发现功能。
</dependency>
</dependencies>

这里要用到bootstrap.yaml去在nacos中注册:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
spring:
application:
name: jc-club-oss-dev
profiles:
active: dev
cloud:
nacos:
config:
server-addr: 117.72.14.166:8848
prefix: ${spring.application.name}
group: DEFAULT_GROUP
namespace:
file-extension: yaml
discovery:
enabled: true
server-addr: 117.72.14.166:8848 //部署有nacos的云服务器的地址

启动程序就注册上去了

(gateway->nacos)Spring Cloud Gateway搭建及路由配置

pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<dependencies>
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.2.0</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.16</version>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-loadbalancer</artifactId>
</dependency>
</dependencies>

bootstrap.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
spring:
application:
name: jc-club-gateway-dev
profiles:
active: dev
cloud:
nacos:
config:
server-addr: 117.72.14.166:8848
prefix: ${spring.application.name}
group: DEFAULT_GROUP
namespace:
file-extension: yaml
discovery:
enabled: true
server-addr: 117.72.14.166:8848

application.yaml,用于路由转发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
server:
port: 5000
spring:
cloud:
gateway:
routes:
- id: oss
uri: lb://jc-club-oss-dev //使用 lb:// 前缀表示这是一个负载均衡器的调用。jc-club-oss-dev 是服务名,Spring Cloud Gateway 将通过服务发现找到对应的实例。
predicates:
- Path=/oss/** //路由匹配条件
filters:
- StripPrefix=1 //过滤器用于从请求路径中去除前缀。
- id: auth
uri: lb://jc-club-auth-dev
predicates:
- Path=/auth/**
filters:
- StripPrefix=1
- id: subject
uri: lb://jc-club-subject-dev
predicates:
- Path=/subject/**
filters:
- StripPrefix=1

运行及注册到nacos中,并且可以通过网关进行路由转发到对应的微服务上。

鉴权+刷题->nacos

auth:

starter/pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.4.2</version>
<exclusions>
<exclusion>
<artifactId>spring-boot-starter-logging</artifactId>
<groupId>org.springframework.boot</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.jingdianjichi</groupId>
<artifactId>jc-club-auth-infra</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.jingdianjichi</groupId>
<artifactId>jc-club-auth-application-controller</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
</dependencies>

bootstrap.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
spring:
application:
name: jc-club-gateway-dev
profiles:
active: dev
cloud:
nacos:
config:
server-addr: 117.72.14.166:8848
prefix: ${spring.application.name}
group: DEFAULT_GROUP
namespace:
file-extension: yaml
discovery:
enabled: true
server-addr: 117.72.14.166:8848

subject:

starter/pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.4.2</version>
<exclusions>
<exclusion>
<artifactId>spring-boot-starter-logging</artifactId>
<groupId>org.springframework.boot</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.jingdianjichi</groupId>
<artifactId>jc-club-application-controller</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.jingdianjichi</groupId>
<artifactId>jc-club-application-mq</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.jingdianjichi</groupId>
<artifactId>jc-club-infra</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
</dependencies>

bootstrap.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
spring:
application:
name: jc-club-subject-dev
profiles:
active: dev
cloud:
nacos:
config:
server-addr: 117.72.14.166:8848
prefix: ${spring.application.name}
group: DEFAULT_GROUP
namespace:
file-extension: yaml
discovery:
enabled: true
server-addr: 117.72.14.166:8848

Sa-Token集成Redis

集成 Redis (sa-token.cc)

redis.io/docs/management/config

gateway网关(sa-token)基于redis实现分布式会话鉴权

gateway 集成 redis 及 refactor 鉴权

pom文件写入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<dependencies>
<!-- 省略了之前的部分,下面的是新增的 -->
<!-- Sa-Token 权限认证(Reactor响应式集成), 在线文档:https://sa-token.cc -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-reactor-spring-boot-starter</artifactId>
<version>1.37.0</version>
</dependency>

<!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
<version>1.37.0</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>

<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.8.6</version>
</dependency>

</dependencies>

gateway的applicaiton.yaml,修改网关配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
server:
port: 5000
spring:
cloud:
gateway:
routes:
- id: oss
uri: lb://jc-club-oss-dev
predicates:
- Path=/oss/**
filters:
- StripPrefix=1
- id: auth
uri: lb://jc-club-auth-dev
predicates:
- Path=/auth/**
filters:
- StripPrefix=1
- id: subject
uri: lb://jc-club-subject-dev
predicates:
- Path=/subject/**
filters:
- StripPrefix=1
- id: practice
uri: lb://jc-club-practice-dev
predicates:
- Path=/practice/**
filters:
- StripPrefix=1
- id: circle
uri: lb://jc-club-circle
predicates:
- Path=/circle/**
filters:
- StripPrefix=1
- id: interview
uri: lb://jc-club-interview
predicates:
- Path=/interview/**
filters:
- StripPrefix=1
redis:
# Redis数据库索引(默认为0)
database: 1
# Redis服务器地址
host: 117.72.14.166
# Redis服务器连接端口
port: 6379
# Redis服务器连接密码(默认为空)
password: jichi1234
# 连接超时时间
timeout: 2s
lettuce:
pool:
# 连接池最大连接数
max-active: 200
# 连接池最大阻塞等待时间(使用负值表示没有限制)
max-wait: -1ms
# 连接池中的最大空闲连接
max-idle: 10
# 连接池中的最小空闲连接
min-idle: 0
sa-token:
# token 名称(同时也是 cookie 名称)
token-name: satoken
# token 有效期(单位:秒) 默认30天,-1 代表永久有效
timeout: 2592000
# token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
active-timeout: -1
# 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
is-concurrent: true
# 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token)
is-share: true
# token 风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128、tik)
token-style: random-32
# 是否输出操作日志
is-log: true
token-prefix: jichi

jc-club-auth-starter/application.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
 server:
port: 3011
spring:
datasource:
username: root
password: qvQP7MBvSkbyGzLzlRaPp9swmOmkqdVkVgBNPQF7pMlImathGYopQcWR2CuZMZAkL1xrDHwut9Hbr2TZ4qmr2Q==
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://117.72.14.166:3306/jc-club?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8&useSSL=false
type: com.alibaba.druid.pool.DruidDataSource
druid:
initial-size: 20
min-idle: 20
connectionProperties: config.decrypt=true;config.decrypt.key=${publicKey};
max-active: 100
max-wait: 60000
stat-view-servlet:
enabled: true
url-pattern: /druid/*
login-username: admin
login-password: 123456
filter:
stat:
enabled: true
slow-sql-millis: 2000
log-slow-sql: true
wall:
enabled: true
config:
enabled: true
redis:
# Redis数据库索引(默认为0)
database: 1
# Redis服务器地址
host: 117.72.14.166
# Redis服务器连接端口
port: 6379
# Redis服务器连接密码(默认为空)
password: jichi1234
# 连接超时时间
timeout: 2s
lettuce: #lettuce连接池
pool:
# 连接池最大连接数
max-active: 200
# 连接池最大阻塞等待时间(使用负值表示没有限制)
max-wait: -1ms
# 连接池中的最大空闲连接
max-idle: 10
# 连接池中的最小空闲连接
min-idle: 0
publicKey: MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAMJzo9TiSuOGAMR2Zma25lWdtR1oxq6RcZYnWE9vcYLNKxUOkBlvSfMrbS25KtlJi+hIzikfCoyTDB0VI5gB3Q8CAwEAAQ==
logging:
config: classpath:log4j2-spring.xml
sa-token:
# token 名称(同时也是 cookie 名称)
token-name: satoken
# token 有效期(单位:秒) 默认30天,-1 代表永久有效
timeout: 2592000
# token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
active-timeout: -1
# 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
is-concurrent: true
# 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token)
is-share: true
# token 风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128、tik)
token-style: random-32
# 是否输出操作日志
is-log: true
token-prefix: jichi

网关自定义权限接口扩展

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Component
public class StpInterfaceImpl implements StpInterface {

@Resource
private RedisUtil redisUtil;

private String authPermissionPrefix = "auth.permission";

private String authRolePrefix = "auth.role";

@Override
public List<String> getPermissionList(Object loginId, String loginType) {
return getAuth(loginId.toString(), authPermissionPrefix);
}

@Override
public List<String> getRoleList(Object loginId, String loginType) {
return getAuth(loginId.toString(), authRolePrefix);
}
}

jc-club-gateway:SaTokenConfigure,satoken配置。

这个配置类的主要作用是:

  • 拦截所有请求。
  • 根据请求的路径,执行不同的权限校验逻辑,确保只有具有相应权限的用户才能访问特定的资源。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Configuration
public class SaTokenConfigure {

@Bean
public SaReactorFilter getSaReactorFilter() {
return new SaReactorFilter()
// 拦截地址
.addInclude("/**")
// 鉴权方法:每次访问进入
.setAuth(obj -> {
System.out.println("-------- 前端访问path:" + SaHolder.getRequest().getRequestPath());
// 登录校验 -- 拦截所有路由,并排除/user/doLogin 用于开放登录
SaRouter.match("/auth/**", "/auth/user/doLogin", r -> StpUtil.checkRole("admin"));
SaRouter.match("/oss/**", r -> StpUtil.checkLogin());
SaRouter.match("/subject/subject/add", r -> StpUtil.checkPermission("subject:add"));
SaRouter.match("/subject/**", r -> StpUtil.checkLogin());
})
;
}
}

网关全局异常处理(sa-token mono)

Sa-token 提供的示例,适用于单体项目的全局异常捕获。我们选择了微服务架构,则就要变为通过网关来进行全局异常的处理,我们希望,权限发生异常的时候,可以统一做 401 的返回,前端进行跳转登录。

image-20240729142433511.png

GatewayExceptionHandler.java

这个全局异常处理器的主要作用是:

  • 捕获并处理网关中的异常。
  • 根据异常类型返回不同的状态码和消息。
  • 将异常信息序列化为 JSON 格式,并设置正确的 Content-Type 发送回客户端。

深入学习ErrorWebExceptionHandler-CSDN博客

Flux、Mono、Reactor 实战(史上最全)_reactor mono-CSDN博客

[响应式编程二Mono,Flux简单介绍_flux mono-CSDN博客](https://blog.csdn.net/lsdstone/article/details/134983206?ops_request_misc=&request_id=&biz_id=102&utm_term=mono java响应式&utm_medium=distribute.pc_search_result.none-task-blog-2allsobaiduweb~default-2-134983206.142^v100^pc_search_result_base8&spm=1018.2226.3001.4187)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Component
public class GatewayExceptionHandler implements ErrorWebExceptionHandler {

private ObjectMapper objectMapper = new ObjectMapper(); //创建了一个ObjectMapper实例,用于将Java对象转换为JSON格式。

@Override
public Mono<Void> handle(ServerWebExchange serverWebExchange, Throwable throwable) { //处理异常的方法,返回一个Mono<Void>类型,表示异步操作的完成。
ServerHttpRequest request = serverWebExchange.getRequest();
ServerHttpResponse response = serverWebExchange.getResponse();
Integer code = 200;
String message = "";
if (throwable instanceof SaTokenException) {
code = 401;
message = "用户无权限";
throwable.printStackTrace();
} else {
code = 500;
message = "系统繁忙";
throwable.printStackTrace();
}
Result result = Result.fail(code, message);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
return response.writeWith(Mono.fromSupplier(() -> {
/*使用response.writeWith方法,结合Mono.fromSupplier来异步写入响应体。
在Mono.fromSupplier中:
使用objectMapper.writeValueAsBytes(result)将Result对象序列化为JSON格式的字节数组。
使用DataBufferFactory.wrap(bytes)将字节数组包装成数据缓冲区,以便写入响应体*/
DataBufferFactory dataBufferFactory = response.bufferFactory();
byte[] bytes = null;
try {
bytes = objectMapper.writeValueAsBytes(result); //json格式字节数组
} catch (JsonProcessingException e) {
e.printStackTrace();
}
return dataBufferFactory.wrap(bytes); //包装成数据缓冲区
}));
}

}

ResultCodeEnum.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Getter
public enum ResultCodeEnum {

SUCCESS(200,"成功"),
FAIL(500,"失败");

public int code;

public String desc;

ResultCodeEnum(int code, String desc){
this.code = code;
this.desc = desc;
}

public static ResultCodeEnum getByCode(int codeVal){
for(ResultCodeEnum resultCodeEnum : ResultCodeEnum.values()){
if(resultCodeEnum.code == codeVal){
return resultCodeEnum;
}
}
return null;
}

}

Result.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@Data
public class Result<T> {

private Boolean success;

private Integer code;

private String message;

private T data;

public static Result ok() {
Result result = new Result();
result.setSuccess(true);
result.setCode(ResultCodeEnum.SUCCESS.getCode());
result.setMessage(ResultCodeEnum.SUCCESS.getDesc());
return result;
}

public static <T> Result ok(T data) {
Result result = new Result();
result.setSuccess(true);
result.setCode(ResultCodeEnum.SUCCESS.getCode());
result.setMessage(ResultCodeEnum.SUCCESS.getDesc());
result.setData(data);
return result;
}

public static Result fail() {
Result result = new Result();
result.setSuccess(false);
result.setCode(ResultCodeEnum.FAIL.getCode());
result.setMessage(ResultCodeEnum.FAIL.getDesc());
return result;
}

public static <T> Result fail(T data) {
Result result = new Result();
result.setSuccess(false);
result.setCode(ResultCodeEnum.FAIL.getCode());
result.setMessage(ResultCodeEnum.FAIL.getDesc());
result.setData(data);
return result;
}

public static Result fail(Integer code,String message) {
Result result = new Result();
result.setSuccess(false);
result.setCode(code);
result.setMessage(message);
return result;
}

}

gateway实现redis权限数据拉取(gateway)

RedisTemplate->RedisConfig(重写序列化,@Bean创建RedisTemplate bean)->RedisUtil(封装对redis的操作,具体是用redistemplate来操作的)

为什么重写redistemplate?

  • 这里不重新他的一个序列化会造成一个乱码的问题,重写了RedisTemplate:
    • objectMapper->Jackson2jsonRedisSerializer->redisTemplate,注意@Bean注入

网关读取权限内容有三种形式。

1、网关层直接与数据库交互

2、网关层与 redis 进行交互

3、网关层与 redis 进行交互,redis 没有,则通过 feign 调用 auth 服务获取。

选择第二种形式,完全信任缓存,同时引出数据库与缓存数据一致性的方案。

pom.xml

1
2
3
4
5
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.8.6</version>
</dependency>

自定义获取权限改为从 redis:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package com.jingdianjichi.club.gateway.auth;

import cn.dev33.satoken.stp.StpInterface;
import com.alibaba.cloud.commons.lang.StringUtils;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.jingdianjichi.club.gateway.entity.AuthPermission;
import com.jingdianjichi.club.gateway.entity.AuthRole;
import com.jingdianjichi.club.gateway.redis.RedisUtil;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.stream.Collectors;

@Component
public class StpInterfaceImpl implements StpInterface {

@Resource
private RedisUtil redisUtil;

private String authPermissionPrefix = "auth.permission";

private String authRolePrefix = "auth.role";

@Override
public List<String> getPermissionList(Object loginId, String loginType) {
return getAuth(loginId.toString(), authPermissionPrefix);
}

@Override
public List<String> getRoleList(Object loginId, String loginType) {
return getAuth(loginId.toString(), authRolePrefix);
}

private List<String> getAuth(String loginId, String prefix) {
String authKey = redisUtil.buildKey(prefix, loginId.toString());
String authValue = redisUtil.get(authKey);
if (StringUtils.isBlank(authValue)) { //若空返回空
return Collections.emptyList();
}
List<String> authList = new LinkedList<>();
if (authRolePrefix.equals(prefix)) {
List<AuthRole> roleList = new Gson().fromJson(authValue, new TypeToken<List<AuthRole>>() {
}.getType());
authList = roleList.stream().map(AuthRole::getRoleKey).collect(Collectors.toList());
} else if (authPermissionPrefix.equals(prefix)) {
List<AuthPermission> permissionList = new Gson().fromJson(authValue, new TypeToken<List<AuthPermission>>() {
}.getType());
authList = permissionList.stream().map(AuthPermission::getPermissionKey).collect(Collectors.toList());
}
return authList;
}

}

RedisTemplate 重写优化:原生 redis 的 template 的序列化器会产生乱码问题,重写改为 jackson。

Spring date-redis中RedisTemplate的Jackson序列化设置_redistemplate序列化时date带有类型信息-CSDN博客

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package com.jingdianjichi.club.gateway.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {
/*总结来说,通过在RedisConfig类中使用@Bean注解定义redisTemplate方法,Spring容器会在启动时创建并注册这个RedisTemplate Bean。之后,应用程序的其他部分可以通过@Autowired或@Resource注解来自动获取并使用这个Bean。*/
@Bean
public RedisTemplate<String,Object> redisTemplate(RedisConnectionFactory redisConnectionFactory){
RedisTemplate<String,Object> redisTemplate = new RedisTemplate<>();
RedisSerializer<String> redisSerializer = new StringRedisSerializer();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(redisSerializer);
redisTemplate.setHashKeySerializer(redisSerializer);
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer());
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer());
return redisTemplate;
}

private Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer(){
Jackson2JsonRedisSerializer<Object> jsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); //设置 ObjectMapper 的访问级别,使其能够序列化所有字段,无论它们是否有 public 访问权限。
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,false);
objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
jsonRedisSerializer.setObjectMapper(objectMapper); //将配置好的 ObjectMapper 设置到 Jackson2JsonRedisSerializer 中。
return jsonRedisSerializer;
}

}

RedisUtil 的封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
package com.jingdianjichi.club.gateway.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
* RedisUtil工具类
*
* @author: ChickenWing
* @date: 2023/1/15
*/
@Component
@Slf4j
public class RedisUtil {

@Resource
private RedisTemplate redisTemplate; //通过DI拿到被RedisConfig改后的RedisTemplate

private static final String CACHE_KEY_SEPARATOR = ".";

/**
* 构建缓存key
*/
public String buildKey(String... strObjs) {
return Stream.of(strObjs).collect(Collectors.joining(CACHE_KEY_SEPARATOR));
}

/**
* 是否存在key
*/
public boolean exist(String key) {
return redisTemplate.hasKey(key);
}

/**
* 删除key
*/
public boolean del(String key) {
return redisTemplate.delete(key);
}

public void set(String key, String value) {
redisTemplate.opsForValue().set(key, value);
}

public boolean setNx(String key, String value, Long time, TimeUnit timeUnit) {
return redisTemplate.opsForValue().setIfAbsent(key, value, time, timeUnit);
}

public String get(String key) {
return (String) redisTemplate.opsForValue().get(key);
}

public Boolean zAdd(String key, String value, Long score) {
return redisTemplate.opsForZSet().add(key, value, Double.valueOf(String.valueOf(score)));
}

public Long countZset(String key) {
return redisTemplate.opsForZSet().size(key);
}

public Set<String> rangeZset(String key, long start, long end) {
return redisTemplate.opsForZSet().range(key, start, end);
}

public Long removeZset(String key, Object value) {
return redisTemplate.opsForZSet().remove(key, value);
}

public void removeZsetList(String key, Set<String> value) {
value.stream().forEach((val) -> redisTemplate.opsForZSet().remove(key, val));
}

public Double score(String key, Object value) {
return redisTemplate.opsForZSet().score(key, value);
}

public Set<String> rangeByScore(String key, long start, long end) {
return redisTemplate.opsForZSet().rangeByScore(key, Double.valueOf(String.valueOf(start)), Double.valueOf(String.valueOf(end)));
}

public Object addScore(String key, Object obj, double score) {
return redisTemplate.opsForZSet().incrementScore(key, obj, score);
}

public Object rank(String key, Object obj) {
return redisTemplate.opsForZSet().rank(key, obj);
}


}

用户模块开发(auth_user)

依然有从DTO->BO->entity的converter,此部分忽略掉描述

controller包
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
@RestController
@RequestMapping("/user/")
@Slf4j
public class UserController {

@Resource
private AuthUserDomainService authUserDomainService;

/**
* 用户注册
*/
@RequestMapping("register")
public Result<Boolean> register(@RequestBody AuthUserDTO authUserDTO) {
try {
if (log.isInfoEnabled()) {
log.info("UserController.register.dto:{}", JSON.toJSONString(authUserDTO));
}
checkUserInfo(authUserDTO);
AuthUserBO authUserBO = AuthUserDTOConverter.INSTANCE.convertDTOToBO(authUserDTO);
return Result.ok(authUserDomainService.register(authUserBO));
} catch (Exception e) {
log.error("UserController.register.error:{}", e.getMessage(), e);
return Result.fail("注册用户失败");
}
}

/**
* 修改用户信息
*/
@RequestMapping("update")
public Result<Boolean> update(@RequestBody AuthUserDTO authUserDTO) {
try {
if (log.isInfoEnabled()) {
log.info("UserController.update.dto:{}", JSON.toJSONString(authUserDTO));
}
checkUserInfo(authUserDTO);
AuthUserBO authUserBO = AuthUserDTOConverter.INSTANCE.convertDTOToBO(authUserDTO);
return Result.ok(authUserDomainService.update(authUserBO));
} catch (Exception e) {
log.error("UserController.update.error:{}", e.getMessage(), e);
return Result.fail("更新用户信息失败");
}
}

/**
* 获取用户信息
*/
@RequestMapping("getUserInfo")
public Result<AuthUserDTO> getUserInfo(@RequestBody AuthUserDTO authUserDTO) {
try {
if (log.isInfoEnabled()) {
log.info("UserController.getUserInfo.dto:{}", JSON.toJSONString(authUserDTO));
}
Preconditions.checkArgument(!StringUtils.isBlank(authUserDTO.getUserName()), "用户名不能为空");
AuthUserBO authUserBO = AuthUserDTOConverter.INSTANCE.convertDTOToBO(authUserDTO);
AuthUserBO userInfo = authUserDomainService.getUserInfo(authUserBO);
return Result.ok(AuthUserDTOConverter.INSTANCE.convertBOToDTO(userInfo));
} catch (Exception e) {
log.error("UserController.update.error:{}", e.getMessage(), e);
return Result.fail("更新用户信息失败");
}
}

/**
* 批量获取用户信息
*/
@RequestMapping("listByIds")
public Result<List<AuthUserDTO>> listUserInfoByIds(@RequestBody List<String> userNameList) {
try {
if (log.isInfoEnabled()) {
log.info("UserController.listUserInfoByIds.dto:{}", JSON.toJSONString(userNameList));
}
Preconditions.checkArgument(!CollectionUtils.isEmpty(userNameList), "id集合不能为空");
List<AuthUserBO> userInfos = authUserDomainService.listUserInfoByIds(userNameList);
return Result.ok(AuthUserDTOConverter.INSTANCE.convertBOToDTO(userInfos));
} catch (Exception e) {
log.error("UserController.listUserInfoByIds.error:{}", e.getMessage(), e);
return Result.fail("批量获取用户信息失败");
}
}


/**
* 用户退出
*/
@RequestMapping("logOut")
public Result logOut(@RequestParam String userName) {
try {
log.info("UserController.logOut.userName:{}", userName);
Preconditions.checkArgument(!StringUtils.isBlank(userName), "用户名不能为空");
StpUtil.logout(userName);
return Result.ok();
} catch (Exception e) {
log.error("UserController.logOut.error:{}", e.getMessage(), e);
return Result.fail("用户登出失败");
}
}

/**
* 删除用户
*/
@RequestMapping("delete")
public Result<Boolean> delete(@RequestBody AuthUserDTO authUserDTO) {
try {
if (log.isInfoEnabled()) {
log.info("UserController.delete.dto:{}", JSON.toJSONString(authUserDTO));
}
AuthUserBO authUserBO = AuthUserDTOConverter.INSTANCE.convertDTOToBO(authUserDTO);
return Result.ok(authUserDomainService.update(authUserBO));
} catch (Exception e) {
log.error("UserController.update.error:{}", e.getMessage(), e);
return Result.fail("删除用户信息失败");
}
}

private void checkUserInfo(@RequestBody AuthUserDTO authUserDTO) {
Preconditions.checkArgument(!StringUtils.isBlank(authUserDTO.getUserName()), "用户名不能为空");
}

/**
* 用户启用/禁用
*/
@RequestMapping("changeStatus")
public Result<Boolean> changeStatus(@RequestBody AuthUserDTO authUserDTO) {
try {
if (log.isInfoEnabled()) {
log.info("UserController.changeStatus.dto:{}", JSON.toJSONString(authUserDTO));
}
Preconditions.checkNotNull(authUserDTO.getStatus(), "用户状态不能为空");
AuthUserBO authUserBO = AuthUserDTOConverter.INSTANCE.convertDTOToBO(authUserDTO);
return Result.ok(authUserDomainService.update(authUserBO));
} catch (Exception e) {
log.error("UserController.changeStatus.error:{}", e.getMessage(), e);
return Result.fail("启用/禁用用户信息失败");
}
}

@RequestMapping("doLogin")
public Result<SaTokenInfo> doLogin(@RequestParam("validCode") String validCode) {
try {
Preconditions.checkArgument(!StringUtils.isBlank(validCode), "验证码不能为空!");
return Result.ok(authUserDomainService.doLogin(validCode));
} catch (Exception e) {
log.error("UserController.doLogin.error:{}", e.getMessage(), e);
return Result.fail("用户登录失败");
}
}

// 查询登录状态,浏览器访问: http://localhost:8081/user/isLogin
@RequestMapping("isLogin")
public String isLogin() {
return "当前会话是否登录:" + StpUtil.isLogin();
}

}
domain包

AuthUserDomainServiceImpl.java(用户注册、更新、删除、登录和信息查询,redis)

  1. 资源注入
  • @Resource:用于自动注入Spring管理的Bean,如各种服务(Service)和RedisUtil
  1. 成员变量
  • 定义了用于构建Redis键的前缀、盐值(salt)、登录验证码前缀(LOGIN_PREFIX)。
  1. 注册方法
  • @Override:覆盖接口中定义的方法。
  • @SneakyThrows:使用Lombok注解来隐藏抛出的异常。
  • @Transactional:声明事务支持,指定异常回滚。
  • public Boolean register(AuthUserBO authUserBO):注册用户的方法。
  1. 注册逻辑
  • 检查用户是否存在。
  • 密码加密存储。(md5+salt)
  • 设置默认头像和昵称。
  • 插入用户数据到数据库。
  • 建立用户与角色的关联。
  • 将角色和权限信息存储到Redis。
  1. 更新和删除方法
  • public Boolean update(AuthUserBO authUserBO):更新用户信息的方法。
  • public Boolean delete(AuthUserBO authUserBO):逻辑删除用户的方法,同时更新Redis中的缓存。
  1. 登录和获取用户信息方法
  • public SaTokenInfo doLogin(String validCode):处理用户登录的方法,使用Sa-Token进行认证。
  • public AuthUserBO getUserInfo(AuthUserBO authUserBO):根据用户名获取用户信息的方法。
  1. 批量获取用户信息方法
  • public List<AuthUserBO> listUserInfoByIds(List<String> userNameList):根据用户ID列表批量获取用户信息的方法。
  1. 事务和异常处理
  • 注册、更新和删除方法使用@Transactional注解,确保操作的原子性。
  • 使用@SneakyThrows来处理可能抛出的异常,避免显式声明异常。
  1. 日志记录
  • log变量用于记录日志信息。
  1. 缓存操作
  • 使用RedisUtil进行Redis的读写操作,如存储用户的角色和权限信息。
  1. 密码安全
  • 使用SaSecureUtil.md5BySalt方法对用户密码进行MD5加盐加密。
  1. 默认资源
  • 为新用户设置了默认的头像和昵称。
  1. 服务交互
  • 通过调用AuthUserServiceAuthUserRoleService等的方法,实现业务逻辑。

这个AuthUserDomainServiceImpl类通过实现AuthUserDomainService接口,提供了用户注册、更新、删除、登录和信息查询等服务,同时与Redis缓存进行交互,以提高系统的响应速度和性能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143


@Service
@Slf4j
public class AuthUserDomainServiceImpl implements AuthUserDomainService {

@Resource
private AuthUserService authUserService;

@Resource
private AuthUserRoleService authUserRoleService;

@Resource
private AuthPermissionService authPermissionService;

@Resource
private AuthRolePermissionService authRolePermissionService;

@Resource
private AuthRoleService authRoleService;

private String salt = "chicken";

@Resource
private RedisUtil redisUtil;

private String authPermissionPrefix = "auth.permission";

private String authRolePrefix = "auth.role";

private static final String LOGIN_PREFIX = "loginCode";

@Override
@SneakyThrows
@Transactional(rollbackFor = Exception.class)
public Boolean register(AuthUserBO authUserBO) {
//校验用户是否存在
AuthUser existAuthUser = new AuthUser();
existAuthUser.setUserName(authUserBO.getUserName());
List<AuthUser> existUser = authUserService.queryByCondition(existAuthUser);
if (existUser.size() > 0) {
return true;
}
AuthUser authUser = AuthUserBOConverter.INSTANCE.convertBOToEntity(authUserBO);
if (StringUtils.isNotBlank(authUser.getPassword())) {
authUser.setPassword(SaSecureUtil.md5BySalt(authUser.getPassword(), salt));
}
if (StringUtils.isBlank(authUser.getAvatar())) {
authUser.setAvatar("http://117.72.10.84:9000/user/icon/微信图片_20231203153718(1).png");
}
if (StringUtils.isBlank(authUser.getNickName())) {
authUser.setNickName("lzrj");
}
authUser.setStatus(AuthUserStatusEnum.OPEN.getCode());
authUser.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
Integer count = authUserService.insert(authUser);

//建立一个初步的角色的关联
AuthRole authRole = new AuthRole();
authRole.setRoleKey(AuthConstant.NORMAL_USER);
AuthRole roleResult = authRoleService.queryByCondition(authRole);
Long roleId = roleResult.getId();
Long userId = authUser.getId();
AuthUserRole authUserRole = new AuthUserRole();
authUserRole.setUserId(userId);
authUserRole.setRoleId(roleId);
authUserRole.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
authUserRoleService.insert(authUserRole);

String roleKey = redisUtil.buildKey(authRolePrefix, authUser.getUserName());
List<AuthRole> roleList = new LinkedList<>();
roleList.add(authRole);
redisUtil.set(roleKey, new Gson().toJson(roleList));

AuthRolePermission authRolePermission = new AuthRolePermission();
authRolePermission.setRoleId(roleId);
List<AuthRolePermission> rolePermissionList = authRolePermissionService.
queryByCondition(authRolePermission);

List<Long> permissionIdList = rolePermissionList.stream()
.map(AuthRolePermission::getPermissionId).collect(Collectors.toList());
//根据roleId查权限
List<AuthPermission> permissionList = authPermissionService.queryByRoleList(permissionIdList);
String permissionKey = redisUtil.buildKey(authPermissionPrefix, authUser.getUserName());
redisUtil.set(permissionKey, new Gson().toJson(permissionList));

return count > 0;
}

@Override
public Boolean update(AuthUserBO authUserBO) {
AuthUser authUser = AuthUserBOConverter.INSTANCE.convertBOToEntity(authUserBO);
Integer count = authUserService.updateByUserName(authUser);
return count > 0;
}

@Override
public Boolean delete(AuthUserBO authUserBO) {
AuthUser authUser = new AuthUser();
authUser.setId(authUserBO.getId());
authUser.setIsDeleted(IsDeletedFlagEnum.DELETED.getCode());
Integer count = authUserService.update(authUser);
//有任何的更新,都要与缓存进行同步的修改
return count > 0;
}

@Override
public SaTokenInfo doLogin(String validCode) {
String loginKey = redisUtil.buildKey(LOGIN_PREFIX, validCode);
String openId = redisUtil.get(loginKey);
if (StringUtils.isBlank(openId)) {
return null;
}
AuthUserBO authUserBO = new AuthUserBO();
authUserBO.setUserName(openId);
this.register(authUserBO);
StpUtil.login(openId);
SaTokenInfo tokenInfo = StpUtil.getTokenInfo();
return tokenInfo;
}

@Override
public AuthUserBO getUserInfo(AuthUserBO authUserBO) {
AuthUser authUser = new AuthUser();
authUser.setUserName(authUserBO.getUserName());
List<AuthUser> userList = authUserService.queryByCondition(authUser);
if (CollectionUtils.isEmpty(userList)) {
return new AuthUserBO();
}
AuthUser user = userList.get(0);
return AuthUserBOConverter.INSTANCE.convertEntityToBO(user);
}

@Override
public List<AuthUserBO> listUserInfoByIds(List<String> userNameList) {
List<AuthUser> userList = authUserService.listUserInfoByIds(userNameList);
if (CollectionUtils.isEmpty(userList)) {
return Collections.emptyList();
}
return AuthUserBOConverter.INSTANCE.convertEntityToBO(userList);
}

}
infra层

4aaca7492044e3e4d3c9d55c9b05c39.jpg

对这五张表的增删查改

常见加密&密码加密

前言

数据库如果说存储明文的密码是非常的危险的,一旦被攻击啊,或者数据泄漏,用户的信息疯狂的暴露出去,黑客什么都能干,这是非常不行,所以我们要做加密,让黑客即使拿到了密码信息, 也不知道原始的密码,就登录不成功。

加密的方式

  1. 摘要加密

    md5,sha1,sha256

    摘要主要就是哈希值,通过我们的散列的算法。摘要的概念主要是验证完整性和唯一性,不管我们的密码是多长啊,或者多复杂的啊,得到的值都是固定长度。

    摘要加密有一定的风险。123456 用 md5 加密。他其实是固定的,大家也可以到一些网站有反解密。

  2. 对称加密

    我们约定了一个密钥。这个密钥一定要好好保存,不能泄漏,一旦泄漏就可以进行想你想的解密了。

    加密的过程:密码+密钥 生成

    解密的过程:密文+密钥 反解

    密钥一定一定要做好其中的保存。

    常见的对称加密的算法:AES,DES,3DESC,SM4

  3. 非对称加密

    一个公钥,一个私钥。

    公钥去加密,私钥去解密。

    私钥去加密,公钥去解密。

    常见的算法:RSA,ECC,国密的 SM2

    算法的时性能上,差一点,加密的数量没有对称加密快。

加盐?是做饭吗?

摘要算法比如 md5,光加密 123456,结果都是一样的,如果是破解的库里正好有这个 md5 就很容易知道逆向是 123456。来一手加盐。盐是随机的字符串,他来与原密码进行一波二次加密。这样获取到的很难破解出来。如果不加盐,简单密码很容易撞库的。

AuthUserDomainServiceImpl.java,satoken:md5+salt,这里的salt是一个字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Override
@SneakyThrows
@Transactional(rollbackFor = Exception.class)
public Boolean register(AuthUserBO authUserBO) {
//校验用户是否存在
AuthUser existAuthUser = new AuthUser();
existAuthUser.setUserName(authUserBO.getUserName());
List<AuthUser> existUser = authUserService.queryByCondition(existAuthUser);
if (existUser.size() > 0) {
return true;
}
AuthUser authUser = AuthUserBOConverter.INSTANCE.convertBOToEntity(authUserBO);
if (StringUtils.isNotBlank(authUser.getPassword())) {
authUser.setPassword(SaSecureUtil.md5BySalt(authUser.getPassword(), salt)); //satoken:md5+salt,这里的salt是一个字符串
}
if (StringUtils.isBlank(authUser.getAvatar())) {
authUser.setAvatar("http://117.72.10.84:9000/user/icon/微信图片_20231203153718(1).png");
}
......
}

角色模块开发(auth_role)

controller

RolePermissionController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package com.jingdianjichi.auth.application.controller;

import com.alibaba.fastjson.JSON;
import com.google.common.base.Preconditions;
import com.jingdianjichi.auth.application.convert.AuthRolePermissionDTOConverter;
import com.jingdianjichi.auth.application.dto.AuthRolePermissionDTO;
import com.jingdianjichi.auth.domain.entity.AuthRolePermissionBO;
import com.jingdianjichi.auth.domain.service.AuthRolePermissionDomainService;
import com.jingdianjichi.auth.entity.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

/**
* 角色权限controller
*
* @author: ChickenWing
* @date: 2023/11/2
*/
@RestController
@RequestMapping("/rolePermission/")
@Slf4j
public class RolePermissionController {

@Resource
private AuthRolePermissionDomainService authRolePermissionDomainService;

/**
* 新增角色权限关联关系
*/
@RequestMapping("add")
public Result<Boolean> add(@RequestBody AuthRolePermissionDTO authRolePermissionDTO) {
try {
if (log.isInfoEnabled()) {
log.info("RolePermissionController.add.dto:{}", JSON.toJSONString(authRolePermissionDTO));
}
Preconditions.checkArgument(!CollectionUtils.isEmpty(authRolePermissionDTO.getPermissionIdList()),"权限关联不能为空");
Preconditions.checkNotNull(authRolePermissionDTO.getRoleId(),"角色不能为空!");
AuthRolePermissionBO rolePermissionBO = AuthRolePermissionDTOConverter.INSTANCE.convertDTOToBO(authRolePermissionDTO);
return Result.ok(authRolePermissionDomainService.add(rolePermissionBO));
} catch (Exception e) {
log.error("PermissionController.add.error:{}", e.getMessage(), e);
return Result.fail("新增角色权限失败");
}
}

}

RoleController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
package com.jingdianjichi.auth.application.controller;

import com.alibaba.fastjson.JSON;
import com.google.common.base.Preconditions;
import com.jingdianjichi.auth.application.convert.AuthRoleDTOConverter;
import com.jingdianjichi.auth.application.dto.AuthRoleDTO;
import com.jingdianjichi.auth.domain.entity.AuthRoleBO;
import com.jingdianjichi.auth.domain.service.AuthRoleDomainService;
import com.jingdianjichi.auth.entity.Result;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

/**
* 角色controller
*
* @author: ChickenWing
* @date: 2023/11/2
*/
@RestController
@RequestMapping("/role/")
@Slf4j
public class RoleController {

@Resource
private AuthRoleDomainService authRoleDomainService;

/**
* 新增角色
*/
@RequestMapping("add")
public Result<Boolean> add(@RequestBody AuthRoleDTO authRoleDTO) {
try {
if (log.isInfoEnabled()) {
log.info("RoleController.add.dto:{}", JSON.toJSONString(authRoleDTO));
}
Preconditions.checkArgument(!StringUtils.isBlank(authRoleDTO.getRoleKey()), "角色key不能为空");
Preconditions.checkArgument(!StringUtils.isBlank(authRoleDTO.getRoleName()), "角色名称不能为空");
AuthRoleBO authRoleBO = AuthRoleDTOConverter.INSTANCE.convertDTOToBO(authRoleDTO);
return Result.ok(authRoleDomainService.add(authRoleBO));
} catch (Exception e) {
log.error("UserController.register.error:{}", e.getMessage(), e);
return Result.fail("新增角色失败");
}
}

/**
* 修改角色
*/
@RequestMapping("update")
public Result<Boolean> update(@RequestBody AuthRoleDTO authRoleDTO) {
try {
if (log.isInfoEnabled()) {
log.info("RoleController.update.dto:{}", JSON.toJSONString(authRoleDTO));
}
Preconditions.checkNotNull(authRoleDTO.getId(), "角色id不能为空");
AuthRoleBO authRoleBO = AuthRoleDTOConverter.INSTANCE.convertDTOToBO(authRoleDTO);
return Result.ok(authRoleDomainService.update(authRoleBO));
} catch (Exception e) {
log.error("RoleController.update.error:{}", e.getMessage(), e);
return Result.fail("更新角色信息失败");
}
}

/**
* 删除角色
*/
@RequestMapping("delete")
public Result<Boolean> delete(@RequestBody AuthRoleDTO authRoleDTO) {
try {
if (log.isInfoEnabled()) {
log.info("RoleController.delete.dto:{}", JSON.toJSONString(authRoleDTO));
}
AuthRoleBO authRoleBO = AuthRoleDTOConverter.INSTANCE.convertDTOToBO(authRoleDTO);
return Result.ok(authRoleDomainService.delete(authRoleBO));
} catch (Exception e) {
log.error("RoleController.delete.error:{}", e.getMessage(), e);
return Result.fail("删除角色信息失败");
}
}

}

PermissionController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
package com.jingdianjichi.auth.application.controller;

import com.alibaba.fastjson.JSON;
import com.google.common.base.Preconditions;
import com.jingdianjichi.auth.application.convert.AuthPermissionDTOConverter;
import com.jingdianjichi.auth.application.dto.AuthPermissionDTO;
import com.jingdianjichi.auth.domain.entity.AuthPermissionBO;
import com.jingdianjichi.auth.domain.service.AuthPermissionDomainService;
import com.jingdianjichi.auth.entity.Result;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

/**
* 权限controller
*
* @author: ChickenWing
* @date: 2023/11/2
*/
@RestController
@RequestMapping("/permission/")
@Slf4j
public class PermissionController {

@Resource
private AuthPermissionDomainService authPermissionDomainService;

/**
* 新增权限
*/
@RequestMapping("add")
public Result<Boolean> add(@RequestBody AuthPermissionDTO authPermissionDTO) {
try {
if (log.isInfoEnabled()) {
log.info("PermissionController.add.dto:{}", JSON.toJSONString(authPermissionDTO));
}
Preconditions.checkArgument(!StringUtils.isBlank(authPermissionDTO.getName()), "权限名称不能为空");
Preconditions.checkNotNull(authPermissionDTO.getParentId(), "权限父id不能为空");
AuthPermissionBO permissionBO = AuthPermissionDTOConverter.INSTANCE.convertDTOToBO(authPermissionDTO);
return Result.ok(authPermissionDomainService.add(permissionBO));
} catch (Exception e) {
log.error("PermissionController.add.error:{}", e.getMessage(), e);
return Result.fail("新增权限失败");
}
}

/**
* 修改权限
*/
@RequestMapping("update")
public Result<Boolean> update(@RequestBody AuthPermissionDTO authPermissionDTO) {
try {
if (log.isInfoEnabled()) {
log.info("PermissionController.update.dto:{}", JSON.toJSONString(authPermissionDTO));
}
Preconditions.checkNotNull(authPermissionDTO.getId(), "权限id不能为空");
AuthPermissionBO permissionBO = AuthPermissionDTOConverter.INSTANCE.convertDTOToBO(authPermissionDTO);
return Result.ok(authPermissionDomainService.update(permissionBO));
} catch (Exception e) {
log.error("PermissionController.update.error:{}", e.getMessage(), e);
return Result.fail("更新权限信息失败");
}
}

/**
* 删除权限
*/
@RequestMapping("delete")
public Result<Boolean> delete(@RequestBody AuthPermissionDTO authPermissionDTO) {
try {
if (log.isInfoEnabled()) {
log.info("PermissionController.delete.dto:{}", JSON.toJSONString(authPermissionDTO));
}
Preconditions.checkNotNull(authPermissionDTO.getId(), "权限id不能为空");
AuthPermissionBO permissionBO = AuthPermissionDTOConverter.INSTANCE.convertDTOToBO(authPermissionDTO);
return Result.ok(authPermissionDomainService.delete(permissionBO));
} catch (Exception e) {
log.error("PermissionController.delete.error:{}", e.getMessage(), e);
return Result.fail("删除权限信息失败");
}
}

/**
* 查询用户权限
*/
@RequestMapping("getPermission")
public Result<Boolean> getPermission(String userName) {
try {
log.info("PermissionController.getPermission.userName:{}",userName);
Preconditions.checkArgument(!StringUtils.isBlank(userName), "用户id不能为空");
return Result.ok(authPermissionDomainService.getPermission(userName));
} catch (Exception e) {
log.error("PermissionController.getPermission.error:{}", e.getMessage(), e);
return Result.fail("查询用户权限信息失败");
}
}

}
domain

AuthPermissionDomainServiceImpl.java(权限)

  1. 业务方法实现
  • @Override:覆盖接口中定义的方法。
  • public Boolean add(AuthPermissionBO authPermissionBO):添加权限的方法,将业务对象(BO)转换为实体对象(Entity),设置未删除标志,并插入数据库。
  • public Boolean update(AuthPermissionBO authPermissionBO):更新权限的方法,将BO转换为Entity,并更新数据库。
  • public Boolean delete(AuthPermissionBO authPermissionBO):逻辑删除权限的方法,更新删除标志。
  1. 权限获取方法
  • public List<String> getPermission(String userName)
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79

    :根据用户名获取权限列表的方法。

    - 使用`RedisUtil`构建权限的Redis键。
    - 从Redis中获取权限的JSON字符串。
    - 如果Redis中没有数据,则返回空列表。
    - 使用Gson反序列化JSON字符串为`AuthPermission`列表。
    - 从权限列表中提取权限键(Permission Key)。

    3. 日志记录

    - `log`变量用于记录日志信息。

    4. 缓存操作

    - 使用`RedisUtil`进行Redis的读写操作,如获取用户权限信息。

    5. 数据转换

    - 使用`AuthPermissionBOConverter`将业务对象(BO)转换为数据库实体(Entity)。

    6. 逻辑删除

    - 设置`IsDeletedFlagEnum.UN_DELETED.getCode()`和`IsDeletedFlagEnum.DELETED.getCode()`来标记记录的删除状态。

    这个`AuthPermissionDomainServiceImpl`类通过实现`AuthPermissionDomainService`接口,提供了权限的增删改以及根据用户名获取权限列表的服务。它利用了Redis缓存来提高获取权限列表的性能,并采用了逻辑删除的方式来管理权限数据。通过这种方式,应用程序可以灵活地进行权限控制和验证。

    ```java
    @Service
    @Slf4j
    public class AuthPermissionDomainServiceImpl implements AuthPermissionDomainService {

    @Resource
    private AuthPermissionService authPermissionService;

    @Resource
    private RedisUtil redisUtil;

    private String authPermissionPrefix = "auth.permission";

    @Override
    public Boolean add(AuthPermissionBO authPermissionBO) {
    AuthPermission authPermission = AuthPermissionBOConverter.INSTANCE.convertBOToEntity(authPermissionBO);
    authPermission.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
    Integer count = authPermissionService.insert(authPermission);
    return count > 0;
    }

    @Override
    public Boolean update(AuthPermissionBO authPermissionBO) {
    AuthPermission authPermission = AuthPermissionBOConverter.INSTANCE.convertBOToEntity(authPermissionBO);
    Integer count = authPermissionService.update(authPermission);
    return count > 0;
    }

    @Override
    public Boolean delete(AuthPermissionBO authPermissionBO) {
    AuthPermission authPermission = new AuthPermission();
    authPermission.setId(authPermissionBO.getId());
    authPermission.setIsDeleted(IsDeletedFlagEnum.DELETED.getCode());
    Integer count = authPermissionService.update(authPermission);
    return count > 0;
    }

    @Override
    public List<String> getPermission(String userName) {
    String permissionKey = redisUtil.buildKey(authPermissionPrefix, userName);
    String permissionValue = redisUtil.get(permissionKey);
    if (StringUtils.isBlank(permissionValue)) {
    return Collections.emptyList();
    }
    List<AuthPermission> permissionList = new Gson().fromJson(permissionValue,
    new TypeToken<List<AuthPermission>>() {
    }.getType());
    List<String> authList = permissionList.stream().map(AuthPermission::getPermissionKey).collect(Collectors.toList());
    return authList;
    }

    }

AuthRoleDomainServiceImpl(角色)

  1. 业务方法实现@Override:覆盖接口中定义的方法。
  2. 添加角色方法public Boolean add(AuthRoleBO authRoleBO):
    • 添加角色的方法。
      • 使用 AuthRoleBOConverter 将业务对象(BO)转换为实体对象(Entity)。
      • 设置角色未删除标志。
      • 调用 authRoleService 的 insert 方法将实体插入数据库。
      • 返回操作影响的行数是否大于0。
  3. 更新角色方法public Boolean update(AuthRoleBO authRoleBO):
    • 更新角色的方法。
      • 类似于添加方法,但调用 update 方法更新数据库中的实体。
  4. 删除角色方法public Boolean delete(AuthRoleBO authRoleBO):
    • 逻辑删除角色的方法。
      • 创建一个新的 AuthRole 实体,设置ID和逻辑删除标志。
      • 调用 authRoleService 的 update 方法更新数据库中的实体。8.
  5. 日志记录log
    • 变量用于记录日志信息。
  6. 逻辑删除设置
    • IsDeletedFlagEnum.UN_DELETED.getCode() 和 IsDeletedFlagEnum.DELETED.getCode() 来标记记录的删除状态。

这个 AuthRoleDomainServiceImpl 类通过实现 AuthRoleDomainService 接口,提供了角色的增删改服务。它利用了逻辑删除的方式来管理角色数据,通过这种方式,应用程序可以灵活地进行角色管理和权限分配。注意,代码中没有显示具体的日志输出语句,但 @Slf4j 注解会在类中添加日志变量,可以在方法中使用 log 进行日志记录。此外,@SneakyThrows 注解未在此代码片段中使用,如果存在,它通常用于隐藏方法抛出的异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Service
@Slf4j
public class AuthRoleDomainServiceImpl implements AuthRoleDomainService {

@Resource
private AuthRoleService authRoleService;

@Override
public Boolean add(AuthRoleBO authRoleBO) {
AuthRole authRole = AuthRoleBOConverter.INSTANCE.convertBOToEntity(authRoleBO);
authRole.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
Integer count = authRoleService.insert(authRole);
return count > 0;
}

@Override
public Boolean update(AuthRoleBO authRoleBO) {
AuthRole authRole = AuthRoleBOConverter.INSTANCE.convertBOToEntity(authRoleBO);
Integer count = authRoleService.update(authRole);
return count > 0;
}

@Override
public Boolean delete(AuthRoleBO authRoleBO) {
AuthRole authRole = new AuthRole();
authRole.setId(authRoleBO.getId());
authRole.setIsDeleted(IsDeletedFlagEnum.DELETED.getCode());
Integer count = authRoleService.update(authRole);
return count > 0;
}

}

AuthRolePermissionDomainServiceImpl.java(角色权限关联)

添加角色权限关联方法

  • public Boolean add(AuthRolePermissionBO authRolePermissionBO)
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38

    :添加角色与权限关联的方法。

    - 首先创建一个`AuthRolePermission`列表,用于存储多个角色权限关联对象。
    - 通过`authRolePermissionBO`获取角色ID。
    - 遍历`authRolePermissionBO`中的权限ID列表。
    - 对于每个权限ID,创建一个新的`AuthRolePermission`实体,设置角色ID、权限ID和未删除标志。
    - 将创建的实体添加到列表中。
    - 使用`authRolePermissionService`的`batchInsert`方法批量插入关联数据到数据库。
    - 返回操作影响的行数是否大于0。

    这个`AuthRolePermissionDomainServiceImpl`类通过实现`AuthRolePermissionDomainService`接口,提供了添加角色与权限关联的服务。它允许一个角色与多个权限进行关联,通过批量插入的方式提高数据存储的效率。使用逻辑删除来管理角色权限关联数据,使得数据不会从数据库中真正删除,便于进行数据恢复或审计。注意,代码中没有提供删除或更新角色权限关联的实现,这可能是因为这些功能要么不需要实现,要么在其他部分的代码中实现。

    ```java
    @Service
    @Slf4j
    public class AuthRolePermissionDomainServiceImpl implements AuthRolePermissionDomainService {

    @Resource
    private AuthRolePermissionService authRolePermissionService;

    @Override
    public Boolean add(AuthRolePermissionBO authRolePermissionBO) {
    List<AuthRolePermission> rolePermissionList = new LinkedList<>();
    Long roleId = authRolePermissionBO.getRoleId();
    authRolePermissionBO.getPermissionIdList().forEach(permissionId -> {
    AuthRolePermission authRolePermission = new AuthRolePermission();
    authRolePermission.setRoleId(roleId);
    authRolePermission.setPermissionId(permissionId);
    authRolePermission.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
    rolePermissionList.add(authRolePermission);
    });
    int count = authRolePermissionService.batchInsert(rolePermissionList);
    return count > 0;
    }


    }
infra层

4aaca7492044e3e4d3c9d55c9b05c39.jpg

用户角色关联(user_role,这里主要是infra层的东西)

controller(UserController的注册模块)

其实就是UserController,在进行register等相关操作时会进行与默认角色的关联

domain

AuthUserRoleDomainServiceImpl.java

  1. 用户存在性检查:首先检查要注册的用户是否已存在。如果存在,则返回true
  2. 用户BO转换:将AuthUserBO(业务对象)转换为AuthUser实体。
  3. 密码加密:如果用户密码不为空,则使用MD5加盐的方式加密密码。
  4. 默认头像和昵称:如果用户没有提供头像或昵称,则设置默认值。
  5. 用户状态设置:设置用户状态为开启(AuthUserStatusEnum.OPEN)和未删除(IsDeletedFlagEnum.UN_DELETED)。
  6. 用户插入数据库:将用户实体插入数据库,并检查插入操作是否成功。
  7. 角色关联:为新用户分配一个默认角色(普通用户),并将角色与用户关联。
  8. Redis缓存角色信息:使用Redis缓存用户的角色信息,以便快速检索。
  9. 权限查询与缓存:查询角色拥有的权限,并将权限信息缓存到Redis。
  10. 事务管理:使用@Transactional注解确保方法在出现异常时可以回滚。(也可以用TransactionnalTemplate)
  11. 异常处理:使用@SneakyThrows注解来重新抛出检查型异常。
  12. 返回结果:如果用户插入成功,则返回true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
@Override
@SneakyThrows
@Transactional(rollbackFor = Exception.class) //事务
public Boolean register(AuthUserBO authUserBO) {
//校验用户是否存在
AuthUser existAuthUser = new AuthUser();
existAuthUser.setUserName(authUserBO.getUserName());
List<AuthUser> existUser = authUserService.queryByCondition(existAuthUser);
if (existUser.size() > 0) {
return true;
}
AuthUser authUser = AuthUserBOConverter.INSTANCE.convertBOToEntity(authUserBO);
if (StringUtils.isNotBlank(authUser.getPassword())) {
authUser.setPassword(SaSecureUtil.md5BySalt(authUser.getPassword(), salt));
}
if (StringUtils.isBlank(authUser.getAvatar())) {
authUser.setAvatar("http://117.72.10.84:9000/user/icon/微信图片_20231203153718(1).png");
}
if (StringUtils.isBlank(authUser.getNickName())) {
authUser.setNickName("lzrj");
}
authUser.setStatus(AuthUserStatusEnum.OPEN.getCode());
authUser.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
Integer count = authUserService.insert(authUser);

//建立一个初步的角色的关联
AuthRole authRole = new AuthRole();
authRole.setRoleKey(AuthConstant.NORMAL_USER);
AuthRole roleResult = authRoleService.queryByCondition(authRole);
Long roleId = roleResult.getId();
Long userId = authUser.getId();
AuthUserRole authUserRole = new AuthUserRole();
authUserRole.setUserId(userId);
authUserRole.setRoleId(roleId);
authUserRole.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
authUserRoleService.insert(authUserRole);

String roleKey = redisUtil.buildKey(authRolePrefix, authUser.getUserName());
List<AuthRole> roleList = new LinkedList<>();
roleList.add(authRole);
redisUtil.set(roleKey, new Gson().toJson(roleList));

AuthRolePermission authRolePermission = new AuthRolePermission();
authRolePermission.setRoleId(roleId);
List<AuthRolePermission> rolePermissionList = authRolePermissionService.
queryByCondition(authRolePermission);

List<Long> permissionIdList = rolePermissionList.stream()
.map(AuthRolePermission::getPermissionId).collect(Collectors.toList());
//根据roleId查权限
List<AuthPermission> permissionList = authPermissionService.queryByRoleList(permissionIdList);
String permissionKey = redisUtil.buildKey(authPermissionPrefix, authUser.getUserName());
redisUtil.set(permissionKey, new Gson().toJson(permissionList));

return count > 0;
}

为什么这里遇到@Transactional注解:Spring——事务注解@Transactional【建议收藏】-CSDN博客

@Transactional 注解在 Java 应用程序中,尤其是在使用 Spring 框架时,是一个非常重要的特性。这个注解通常用于声明方法在执行时应该被视为一个事务的边界。以下是使用 @Transactional 注解的一些主要原因:

  1. 确保数据一致性:在涉及数据库操作的方法中,@Transactional 确保方法执行过程中的所有数据库操作要么全部成功,要么在遇到异常时全部撤销,以保持数据的一致性。
  2. 简化代码:使用 @Transactional 注解可以避免在每个数据库操作后手动管理事务的开始和提交,简化了代码。
  3. 声明式事务管理:Spring 支持声明式事务管理,@Transactional 注解就是这一概念的实现之一,它允许将事务管理逻辑从业务逻辑代码中分离出来。
  4. 回滚策略:通过 @Transactional 注解,可以定义哪些异常会导致事务回滚。在您提供的代码中,rollbackFor = Exception.class 表示如果抛出任何类型的异常,事务都会回滚。
  5. 支持嵌套事务:当一个事务方法调用另一个带有 @Transactional 注解的方法时,Spring 会处理这些方法之间的事务嵌套。
  6. 提高性能:Spring 事务管理器可以针对不同的事务策略进行优化,比如懒加载事务、使用适当的隔离级别等,以提高应用程序性能。
  7. 可伸缩性:随着应用程序的扩展,@Transactional 注解可以很容易地应用于新的方法或类,而不需要对现有代码进行大量修改。

在您的代码示例中,@Transactional 注解应用于注册用户的方法上,这意味着从检查用户是否存在到用户信息写入数据库、角色和权限信息缓存到 Redis 的整个过程被视为一个单一的事务。如果在这个过程的任何地方发生异常,整个操作将回滚,以确保用户信息和相关的角色、权限设置要么完全应用,要么完全不应用,避免数据不一致的问题。

auth-domain

1
2
3
4
5
6
7
8
9
10
package com.jingdianjichi.auth.domain.constants;

/**
* auth服务常量
*/
public class AuthConstant {

public static final String NORMAL_USER = "normal_user";

}
infra层

4aaca7492044e3e4d3c9d55c9b05c39.jpg

接下来回到gateway模块,在用户登录时做好与redis的交互就可以打通了。

权限模块开发(auth_permission)

controller层

PermissionController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@RestController
@RequestMapping("/permission/")
@Slf4j
public class PermissionController {

@Resource
private AuthPermissionDomainService authPermissionDomainService;

/**
* 新增权限
*/
@RequestMapping("add")
public Result<Boolean> add(@RequestBody AuthPermissionDTO authPermissionDTO) {
try {
if (log.isInfoEnabled()) {
log.info("PermissionController.add.dto:{}", JSON.toJSONString(authPermissionDTO));
}
Preconditions.checkArgument(!StringUtils.isBlank(authPermissionDTO.getName()), "权限名称不能为空");
Preconditions.checkNotNull(authPermissionDTO.getParentId(), "权限父id不能为空");
AuthPermissionBO permissionBO = AuthPermissionDTOConverter.INSTANCE.convertDTOToBO(authPermissionDTO);
return Result.ok(authPermissionDomainService.add(permissionBO));
} catch (Exception e) {
log.error("PermissionController.add.error:{}", e.getMessage(), e);
return Result.fail("新增权限失败");
}
}

/**
* 修改权限
*/
@RequestMapping("update")
public Result<Boolean> update(@RequestBody AuthPermissionDTO authPermissionDTO) {
try {
if (log.isInfoEnabled()) {
log.info("PermissionController.update.dto:{}", JSON.toJSONString(authPermissionDTO));
}
Preconditions.checkNotNull(authPermissionDTO.getId(), "权限id不能为空");
AuthPermissionBO permissionBO = AuthPermissionDTOConverter.INSTANCE.convertDTOToBO(authPermissionDTO);
return Result.ok(authPermissionDomainService.update(permissionBO));
} catch (Exception e) {
log.error("PermissionController.update.error:{}", e.getMessage(), e);
return Result.fail("更新权限信息失败");
}
}

/**
* 删除权限
*/
@RequestMapping("delete")
public Result<Boolean> delete(@RequestBody AuthPermissionDTO authPermissionDTO) {
try {
if (log.isInfoEnabled()) {
log.info("PermissionController.delete.dto:{}", JSON.toJSONString(authPermissionDTO));
}
Preconditions.checkNotNull(authPermissionDTO.getId(), "权限id不能为空");
AuthPermissionBO permissionBO = AuthPermissionDTOConverter.INSTANCE.convertDTOToBO(authPermissionDTO);
return Result.ok(authPermissionDomainService.delete(permissionBO));
} catch (Exception e) {
log.error("PermissionController.delete.error:{}", e.getMessage(), e);
return Result.fail("删除权限信息失败");
}
}

/**
* 查询用户权限
*/
@RequestMapping("getPermission")
public Result<Boolean> getPermission(String userName) {
try {
log.info("PermissionController.getPermission.userName:{}",userName);
Preconditions.checkArgument(!StringUtils.isBlank(userName), "用户id不能为空");
return Result.ok(authPermissionDomainService.getPermission(userName));
} catch (Exception e) {
log.error("PermissionController.getPermission.error:{}", e.getMessage(), e);
return Result.fail("查询用户权限信息失败");
}
}

}

AuthPermissionDTO.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Data
public class AuthPermissionDTO implements Serializable {

private Long id;

private String name;

private Long parentId;

private Integer type;

private String menuUrl;

private Integer status;

private Integer show;

private String icon;

private String permissionKey;
}

AuthPermissionDTOConverter.java

1
2
3
4
5
6
7
8
@Mapper
public interface AuthPermissionDTOConverter {

AuthPermissionDTOConverter INSTANCE = Mappers.getMapper(AuthPermissionDTOConverter.class);

AuthPermissionBO convertDTOToBO(AuthPermissionDTO authPermissionDTO);

}
domain层

AuthPermissionBOConverter.java

1
2
3
4
5
6
7
8
@Mapper
public interface AuthPermissionBOConverter {

AuthPermissionBOConverter INSTANCE = Mappers.getMapper(AuthPermissionBOConverter.class);

AuthPermission convertBOToEntity(AuthPermissionBO authPermissionBO);

}

AuthPermissionBO.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Data
public class AuthPermissionBO implements Serializable {

private Long id;

private String name;

private Long parentId;

private Integer type;

private String menuUrl;

private Integer status;

private Integer show;

private String icon;

private String permissionKey;
}

AuthPermissionService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@Service
@Slf4j
public class AuthPermissionDomainServiceImpl implements AuthPermissionDomainService {

@Resource
private AuthPermissionService authPermissionService;

@Resource
private RedisUtil redisUtil;

private String authPermissionPrefix = "auth.permission";

@Override
public Boolean add(AuthPermissionBO authPermissionBO) {
AuthPermission authPermission = AuthPermissionBOConverter.INSTANCE.convertBOToEntity(authPermissionBO);
authPermission.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
Integer count = authPermissionService.insert(authPermission);
return count > 0;
}

@Override
public Boolean update(AuthPermissionBO authPermissionBO) {
AuthPermission authPermission = AuthPermissionBOConverter.INSTANCE.convertBOToEntity(authPermissionBO);
Integer count = authPermissionService.update(authPermission);
return count > 0;
}

@Override
public Boolean delete(AuthPermissionBO authPermissionBO) {
AuthPermission authPermission = new AuthPermission();
authPermission.setId(authPermissionBO.getId());
authPermission.setIsDeleted(IsDeletedFlagEnum.DELETED.getCode());
Integer count = authPermissionService.update(authPermission);
return count > 0;
}

@Override
public List<String> getPermission(String userName) {
String permissionKey = redisUtil.buildKey(authPermissionPrefix, userName);
String permissionValue = redisUtil.get(permissionKey);
if (StringUtils.isBlank(permissionValue)) {
return Collections.emptyList();
}
List<AuthPermission> permissionList = new Gson().fromJson(permissionValue,
new TypeToken<List<AuthPermission>>() {
}.getType());
List<String> authList = permissionList.stream().map(AuthPermission::getPermissionKey).collect(Collectors.toList());
return authList;
}

}
infra层

AuthPermission.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
public class AuthPermission implements Serializable {
private static final long serialVersionUID = -56518358607843924L;

private Long id;

private String name;

private Long parentId;

private Integer type;

private String menuUrl;

private Integer status;

private Integer show;

private String icon;

private String permissionKey;
/**
* 创建人
*/
private String createdBy;
/**
* 创建时间
*/
private Date createdTime;
/**
* 更新人
*/
private String updateBy;
/**
* 更新时间
*/
private Date updateTime;

private Integer isDeleted;


public Long getId() {
return id;
}

public void setId(Long id) {
this.id = id;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public Long getParentId() {
return parentId;
}

public void setParentId(Long parentId) {
this.parentId = parentId;
}

public Integer getType() {
return type;
}

public void setType(Integer type) {
this.type = type;
}

public String getMenuUrl() {
return menuUrl;
}

public void setMenuUrl(String menuUrl) {
this.menuUrl = menuUrl;
}

public Integer getStatus() {
return status;
}

public void setStatus(Integer status) {
this.status = status;
}

public Integer getShow() {
return show;
}

public void setShow(Integer show) {
this.show = show;
}

public String getIcon() {
return icon;
}

public void setIcon(String icon) {
this.icon = icon;
}

public String getPermissionKey() {
return permissionKey;
}

public void setPermissionKey(String permissionKey) {
this.permissionKey = permissionKey;
}

public String getCreatedBy() {
return createdBy;
}

public void setCreatedBy(String createdBy) {
this.createdBy = createdBy;
}

public Date getCreatedTime() {
return createdTime;
}

public void setCreatedTime(Date createdTime) {
this.createdTime = createdTime;
}

public String getUpdateBy() {
return updateBy;
}

public void setUpdateBy(String updateBy) {
this.updateBy = updateBy;
}

public Date getUpdateTime() {
return updateTime;
}

public void setUpdateTime(Date updateTime) {
this.updateTime = updateTime;
}

public Integer getIsDeleted() {
return isDeleted;
}

public void setIsDeleted(Integer isDeleted) {
this.isDeleted = isDeleted;
}

}

AuthPermissionDao.xml

  • type, show:这些列名使用了反引号,因为 typeshow 是 MySQL 的保留字。在 SQL 中,保留字是具有特定意义的关键字,如果用作列名或表名,需要用反引号括起来,以避免语法错误。
1
2
3
4
5
<!--新增所有列-->
<insert id="insert" keyProperty="id" useGeneratedKeys="true">
insert into auth_permission(name, parent_id, `type`, menu_url, status, `show`, icon, permission_key, created_by, created_time, update_by, update_time, is_deleted)
values (#{name}, #{parentId}, #{type}, #{menuUrl}, #{status}, #{show}, #{icon}, #{permissionKey}, #{createdBy}, #{createdTime}, #{updateBy}, #{updateTime}, #{isDeleted})
</insert>

AuthPermissionServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@Service("authPermissionService")
public class AuthPermissionServiceImpl implements AuthPermissionService {
@Resource
private AuthPermissionDao authPermissionDao;

/**
* 通过ID查询单条数据
*
* @param id 主键
* @return 实例对象
*/
@Override
public AuthPermission queryById(Long id) {
return this.authPermissionDao.queryById(id);
}

/**
* 新增数据
*
* @param authPermission 实例对象
* @return 实例对象
*/
@Override
public int insert(AuthPermission authPermission) {
return this.authPermissionDao.insert(authPermission);
}

/**
* 修改数据
*
* @param authPermission 实例对象
* @return 实例对象
*/
@Override
public int update(AuthPermission authPermission) {
return this.authPermissionDao.update(authPermission);
}

/**
* 通过主键删除数据
*
* @param id 主键
* @return 是否成功
*/
@Override
public boolean deleteById(Long id) {
return this.authPermissionDao.deleteById(id) > 0;
}

@Override
public List<AuthPermission> queryByRoleList(List<Long> roleIdList) {
return this.authPermissionDao.queryByRoleList(roleIdList);
}
}

角色权限关联开发(auth_role_permission)

controller

RolePermissionController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@RestController
@RequestMapping("/rolePermission/")
@Slf4j
public class RolePermissionController {

@Resource
private AuthRolePermissionDomainService authRolePermissionDomainService;

/**
* 新增角色权限关联关系
*/
@RequestMapping("add")
public Result<Boolean> add(@RequestBody AuthRolePermissionDTO authRolePermissionDTO) {
try {
if (log.isInfoEnabled()) {
log.info("RolePermissionController.add.dto:{}", JSON.toJSONString(authRolePermissionDTO));
}
Preconditions.checkArgument(!CollectionUtils.isEmpty(authRolePermissionDTO.getPermissionIdList()),"权限关联不能为空");
Preconditions.checkNotNull(authRolePermissionDTO.getRoleId(),"角色不能为空!");
AuthRolePermissionBO rolePermissionBO = AuthRolePermissionDTOConverter.INSTANCE.convertDTOToBO(authRolePermissionDTO);
return Result.ok(authRolePermissionDomainService.add(rolePermissionBO));
} catch (Exception e) {
log.error("PermissionController.add.error:{}", e.getMessage(), e);
return Result.fail("新增角色权限失败");
}
}

}

AuthRolePermissionDTO.java

1
2
3
4
5
6
7
8
9
10
11
12
@Data
public class AuthRolePermissionDTO implements Serializable {
private static final long serialVersionUID = 459343371709166261L;

private Long id;

private Long roleId;

private Long permissionId;

private List<Long> permissionIdList;
}
domain层

AuthRolePermissionDomainServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
@Slf4j
public class AuthRolePermissionDomainServiceImpl implements AuthRolePermissionDomainService {

@Resource
private AuthRolePermissionService authRolePermissionService;

@Override
public Boolean add(AuthRolePermissionBO authRolePermissionBO) {
List<AuthRolePermission> rolePermissionList = new LinkedList<>();
Long roleId = authRolePermissionBO.getRoleId();
authRolePermissionBO.getPermissionIdList().forEach(permissionId -> {
AuthRolePermission authRolePermission = new AuthRolePermission();
authRolePermission.setRoleId(roleId);
authRolePermission.setPermissionId(permissionId);
authRolePermission.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
rolePermissionList.add(authRolePermission);
});
int count = authRolePermissionService.batchInsert(rolePermissionList);
return count > 0;
}


}

AuthRolePermissionBO.java

1
2
3
4
5
6
7
8
9
10
11
12
@Data
public class AuthRolePermissionBO implements Serializable {
private static final long serialVersionUID = 459343371709166261L;

private Long id;

private Long roleId;

private Long permissionId;

private List<Long> permissionIdList;
}
infra层

AuthRolePermissionServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
@Service("authRolePermissionService")
public class AuthRolePermissionServiceImpl implements AuthRolePermissionService {
@Resource
private AuthRolePermissionDao authRolePermissionDao;

/**
* 通过ID查询单条数据
*
* @param id 主键
* @return 实例对象
*/
@Override
public AuthRolePermission queryById(Long id) {
return this.authRolePermissionDao.queryById(id);
}

/**
* 新增数据
*
* @param authRolePermission 实例对象
* @return 实例对象
*/
@Override
public AuthRolePermission insert(AuthRolePermission authRolePermission) {
this.authRolePermissionDao.insert(authRolePermission);
return authRolePermission;
}

@Override
public int batchInsert(List<AuthRolePermission> authRolePermissionList) {
return this.authRolePermissionDao.insertBatch(authRolePermissionList);
}

/**
* 修改数据
*
* @param authRolePermission 实例对象
* @return 实例对象
*/
@Override
public AuthRolePermission update(AuthRolePermission authRolePermission) {
this.authRolePermissionDao.update(authRolePermission);
return this.queryById(authRolePermission.getId());
}

/**
* 通过主键删除数据
*
* @param id 主键
* @return 是否成功
*/
@Override
public boolean deleteById(Long id) {
return this.authRolePermissionDao.deleteById(id) > 0;
}

@Override
public List<AuthRolePermission> queryByCondition(AuthRolePermission authRolePermission) {
return this.authRolePermissionDao.queryAllByLimit(authRolePermission);
}

}

AuthRolePermission.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
public class AuthRolePermission implements Serializable {
private static final long serialVersionUID = 459343371709166261L;

private Long id;

private Long roleId;

private Long permissionId;
/**
* 创建人
*/
private String createdBy;
/**
* 创建时间
*/
private Date createdTime;
/**
* 更新人
*/
private String updateBy;
/**
* 更新时间
*/
private Date updateTime;

private Integer isDeleted;


public Long getId() {
return id;
}

public void setId(Long id) {
this.id = id;
}

public Long getRoleId() {
return roleId;
}

public void setRoleId(Long roleId) {
this.roleId = roleId;
}

public Long getPermissionId() {
return permissionId;
}

public void setPermissionId(Long permissionId) {
this.permissionId = permissionId;
}

public String getCreatedBy() {
return createdBy;
}

public void setCreatedBy(String createdBy) {
this.createdBy = createdBy;
}

public Date getCreatedTime() {
return createdTime;
}

public void setCreatedTime(Date createdTime) {
this.createdTime = createdTime;
}

public String getUpdateBy() {
return updateBy;
}

public void setUpdateBy(String updateBy) {
this.updateBy = updateBy;
}

public Date getUpdateTime() {
return updateTime;
}

public void setUpdateTime(Date updateTime) {
this.updateTime = updateTime;
}

public Integer getIsDeleted() {
return isDeleted;
}

public void setIsDeleted(Integer isDeleted) {
this.isDeleted = isDeleted;
}

}

缓存与数据一致性问题(延迟双删)

1699108963209-2ec4047e-03ee-4f39-99ed-9210e4efc363[1].jpeg

根据以上的流程没有问题,但是当数据变更的时候,如何把缓存变到最新,使我们下面要讨论的问题。

  1. 更新了数据库,再更新缓存

    假设数据库更新成功,缓存更新失败,在缓存失效和过期的时候,读取到的都是老数据缓存。

  2. 更新缓存,更新数据库

    缓存更新成功了,数据库更新失败,是不是读取的缓存的都是错误的。

以上两种,全都不推荐。

  1. 先删除缓存,再更新数据库

    有一定的使用量。即使数据库更新失败。缓存也可以会刷。

    存在的问题是什么?

    高并发情况下!!

    比如说有两个线程,一个是 A 线程,一个是 B 线程。

    A 线程把数据删了,正在更新数据库,这个时候 B 线程来了,发现缓存没了,又查数据,又放入缓存。缓存里面存的就一直是老数据了。

延迟双删::star:

扩展思路

  1. 消息队列补偿

    删除失败的缓存,作为消息打入 mq,mq 消费者进行监听,再次进行重试刷缓存。

  2. canal

    监听数据库的变化,做一个公共服务,专门来对接缓存刷新。优点业务解耦,业务太多冗余代码复杂度。

网关与auth微服务缓存打通

主要跟注册有关

AuthUserDomainServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
@Override
@SneakyThrows
@Transactional(rollbackFor = Exception.class)
public Boolean register(AuthUserBO authUserBO) {
//校验用户是否存在
AuthUser existAuthUser = new AuthUser();
existAuthUser.setUserName(authUserBO.getUserName());
List<AuthUser> existUser = authUserService.queryByCondition(existAuthUser);
if (existUser.size() > 0) {
return true;
}
AuthUser authUser = AuthUserBOConverter.INSTANCE.convertBOToEntity(authUserBO);
if (StringUtils.isNotBlank(authUser.getPassword())) {
authUser.setPassword(SaSecureUtil.md5BySalt(authUser.getPassword(), salt));
}
if (StringUtils.isBlank(authUser.getAvatar())) {
authUser.setAvatar("http://117.72.10.84:9000/user/icon/微信图片_20231203153718(1).png");
}
if (StringUtils.isBlank(authUser.getNickName())) {
authUser.setNickName("别名");
}
authUser.setStatus(AuthUserStatusEnum.OPEN.getCode());
authUser.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
Integer count = authUserService.insert(authUser);

//建立一个初步的角色的关联
AuthRole authRole = new AuthRole();
authRole.setRoleKey(AuthConstant.NORMAL_USER); //普通角色
AuthRole roleResult = authRoleService.queryByCondition(authRole); //查到对应的角色返回结果,这里主要是拿角色的id
Long roleId = roleResult.getId();
Long userId = authUser.getId();
AuthUserRole authUserRole = new AuthUserRole();
authUserRole.setUserId(userId); //用户角色 设置 userId
authUserRole.setRoleId(roleId); //用户角色 设置 roleId
authUserRole.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
authUserRoleService.insert(authUserRole);

String roleKey = redisUtil.buildKey(authRolePrefix, authUser.getUserName()); //key:角色前缀+用户名
List<AuthRole> roleList = new LinkedList<>();
roleList.add(authRole);
redisUtil.set(roleKey, new Gson().toJson(roleList)); //key:角色前缀+用户名 value:列表形式的role,在这里只有普通用户

AuthRolePermission authRolePermission = new AuthRolePermission();
authRolePermission.setRoleId(roleId); //给角色权限设置roleId
List<AuthRolePermission> rolePermissionList = authRolePermissionService.
queryByCondition(authRolePermission); //查询到权限list

List<Long> permissionIdList = rolePermissionList.stream() //拿到角色list对应的权限id list
.map(AuthRolePermission::getPermissionId).collect(Collectors.toList());
List<AuthPermission> permissionList = authPermissionService.queryByRoleList(permissionIdList);//根据权限id list查询权限本身list
String permissionKey = redisUtil.buildKey(authPermissionPrefix, authUser.getUserName());//key:权限前缀+用户名
redisUtil.set(permissionKey, new Gson().toJson(permissionList));

return count > 0;
}

AuthPermissionDao.java

1
List<AuthPermission> queryByRoleList(@Param("list") List<Long> roleIdList);

AuthPermissionDao.xml

1
2
3
4
5
6
7
<select id="queryByRoleList" resultMap="AuthPermissionMap">
select * from auth_permission
where id in
<foreach open="(" close=")" collection="list" item="id" separator=",">
#{id}
</foreach>
</select>

[mybatis中resultMap的理解_result map-CSDN博客](https://blog.csdn.net/u012843873/article/details/80198185?ops_request_misc=%7B%22request%5Fid%22%3A%22172259150816800178584486%22%2C%22scm%22%3A%2220140713.130102334..%22%7D&request_id=172259150816800178584486&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~baidu_landing_v2~default-1-80198185-null-null.142^v100^pc_search_result_base8&utm_term=mybatic resultmap&spm=1018.2226.3001.4187)

回到网关层

StpInterfaceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Component
public class StpInterfaceImpl implements StpInterface {

@Resource
private RedisUtil redisUtil;

private String authPermissionPrefix = "auth.permission";

private String authRolePrefix = "auth.role";

@Override
public List<String> getPermissionList(Object loginId, String loginType) {
return getAuth(loginId.toString(), authPermissionPrefix);
}

@Override
public List<String> getRoleList(Object loginId, String loginType) {
return getAuth(loginId.toString(), authRolePrefix);
}

private List<String> getAuth(String loginId, String prefix) { //根据register过程中创建的Redis key进行查询,包含角色和权限两种
String authKey = redisUtil.buildKey(prefix, loginId.toString());
String authValue = redisUtil.get(authKey);
if (StringUtils.isBlank(authValue)) {
return Collections.emptyList();
}
List<String> authList = new LinkedList<>();
if (authRolePrefix.equals(prefix)) { //角色List
List<AuthRole> roleList = new Gson().fromJson(authValue, new TypeToken<List<AuthRole>>() {
}.getType());
authList = roleList.stream().map(AuthRole::getRoleKey).collect(Collectors.toList());
} else if (authPermissionPrefix.equals(prefix)) { //权限List
List<AuthPermission> permissionList = new Gson().fromJson(authValue, new TypeToken<List<AuthPermission>>() {
}.getType());
authList = permissionList.stream().map(AuthPermission::getPermissionKey).collect(Collectors.toList());
}
return authList;
}

}

在登录后就会拿到token,在redis中存放有k v,保存有登录状态。在网关层会有拦截器根据路由进行对应的拦截,验证是否登录/有权限。

拿到token之后,就可以通过isLogin去验证是否登录了(在请求header中带有isLogin)

权限认证 (sa-token.cc)

登录开发(微信公众号,测试号)

全流程:扫码微信->微信发送消息到服务器,校验签名确保来自微信(get)->服务器再把消息进行包装, 通过前缀+验证码(一个随机数)作为redis key,fromUserName(openId)作为value存到redis,并把包含验证码的消息发送到微信->用户验证码(微信),从redis拿到openId(即用户名)->用satoken用openId进行登录,然后利用satoken返回一个token,后续登录就会带上这个token

这里用的是测试号,并不会生成真正的验证码到手机上,只是为了开发接口。

登录注册模块

  • 注册用户与验证

    1. 短信的方式,通过向手机号发送验证码,来实现用户的验证并登录(考虑的成本是短信的费用)

    2. 邮箱的注册登录。

      用户注册的时候,留一个邮箱,我们往邮箱里通过邮箱服务器发送一个链接,用户点击之后,实现一个激活,激活成功之后就完成了注册。(0 成本,坏处这种发送的邮件很容易进垃圾箱)

    3. 个人公众号模式(个人开发者无公司的,比较适合使用,0 成本)

      用户登录的时候,弹出我们的这个公众号的码。扫码后,用户输入我们提示的验证码。可以随机比如说 nadbuge,通过我们的公众号对接的回调。能拿到一定的信息,用户的 openId。进而记录用户的信息

    4. 企业的服务号(必须要有营业执照,自己玩的不上线的话,也可以用测试号)

      好处就是不仅打通了各种回调,而且还能拿到用户的信息。

  • 登录功能

    传统的 pc 形式,都是登录之后,写入 cookie。前端再次请求的时候,带着 cookie 一个身份识别就可以完成认证。

    坏处是什么?小程序呀,app 呀,其实是没有 cookie 这个概念的。

    单点登录(SSO)详解——超详细-CSDN博客

    为了更好的扩展,我们就直接选择 token的模式。token 放入 header 来实现用户身份的识别与鉴权。

  • 踢人下线

    发现风险用户,可以通过后台直接把用户踢掉,禁止其再访问,token 也可以直接置为失效的形式。

  • 集成 redis (保存token)

    如果说我们选择了 token,然后不做 token 的保存,服务重启呀,分布式微服务啊,数据是无法共享并且会产生丢失问题,所以用 redis 来存储一些信息,实现共享。

  • 自定义我们的 token 风格和前缀

    比如正常的 token 可能是 uuid,我们可以选择其他形式。

    然后就是 token 的前端的传递,也可以去定义前缀,固定前缀才生效。

  • 记住我

    当我们去勾选记住我的时候,下次登录就自动实现了。

    前后端分离,没有 token 的时候,必然会产生无法实现的问题,我们就选择在前端的 localstorage 来做。

登录流程

整体采取个人号的登录模式,选取某信号的 openId 作为用户的唯一标识!

image-20240803115222050.png

整体流程:

  1. 用户扫公众号码。然后发一条消息:验证码。

  2. 通过 api 回复一个随机的验证码。存入 redis

    • key: 前缀+验证码
    • value: fromUsername(也就是openId)
  3. 用户在验证码框输入之后,点击登录,进入我们的注册模块,同时关联角色和权限。就实现了网关的统一鉴权。

  4. 用户就可以进行操作,用户可以根据个人的 openId 来维护个人信息。

  5. 用户登录成功之后,返回 token,前端的所有请求都带着 token 就可以访问了。

服务设计
  1. 开一个新的服务,叫我们的 jc-club-wechat。专门用于对接微信的 api 和微信的消息的回调。

    • 回调:关注公众号,发送验证码
  2. 通过 nacos 注册中心来调用我们的 auth 服务,来实现用户的注册。

  3. 另一种扩展方案,wechat 和 auth 不直接交互。

    • 通过 mq 来做。wechat 接收回调后,反向发出 mq。自身的 auth 来订阅 mq 进行消费。
公众号开发文档

里面的例子只有python的没有java的

测试号地址:https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index

公众号开发文档:https://developers.weixin.qq.com/doc/offiaccount/Getting_Started/Overview.html

回调消息接入指南:https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html

接收公众号消息体文档:https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Receiving_standard_messages.html

image-20240803115835247.png

公众号验签开发

用户向公众号发送消息时,公众号方收到的消息发送者是一个OpenID,是使用用户微信号加密后的结果,每个用户对每个公众号有一个唯一的OpenID。

image-20240803120538428.png

  1. 填写服务器配置:

    登录微信公众平台官网后,在公众平台官网的开发-基本设置页面,勾选协议成为开发者,点击“修改配置”按钮,填写服务器地址(URL)、Token和EncodingAESKey,其中URL是开发者用来接收微信消息和事件的接口URL。Token可由开发者可以任意填写,用作生成签名(该Token会和接口URL中包含的Token进行比对,从而验证安全性)。EncodingAESKey由开发者手动填写或随机生成,将用作消息体加解密密钥。

    同时,开发者可选择消息加解密方式:明文模式、兼容模式和安全模式。模式的选择与服务器配置在提交后都会立即生效,请开发者谨慎填写及选择。加解密方式的默认状态为明文模式,选择兼容模式和安全模式需要提前配置好相关加解密代码,详情请参考消息体签名及加解密部分的文档

    image-20240803122144007.png

  2. 验证消息的确来自微信服务器

    开发者提交信息后,微信服务器将发送GET请求到填写的服务器地址URL上,GET请求携带参数如下表所示:

    image-20240803122337758.png

    CallBackController.java

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    @GetMapping("callback")
    public String callback(@RequestParam("signature") String signature,
    @RequestParam("timestamp") String timestamp,
    @RequestParam("nonce") String nonce,
    @RequestParam("echostr") String echostr) {
    log.info("get验签请求参数:signature:{},timestamp:{},nonce:{},echostr:{}",
    signature, timestamp, nonce, echostr);
    String shaStr = SHA1.getSHA1(token, timestamp, nonce, "");
    if (signature.equals(shaStr)) {
    return echostr;
    }
    return "unknown";
    }

    这里signature是微信根据传入的token、timestamp、nonce根据sha1计算生成的,是微信回调来的。

内网穿透(natapp)

不同电脑下载地址:https://natapp.cn/#download

内网穿透使用指南:

https://natapp.cn/article/natapp_newbie

windows 启动方式:

进到目录下,运行exe

1
start natapp -authtoken=xxxx

配置内网穿透:

image-20240803122710792.png

会生成一个公网的分配的地址,从本地地址->公网地址,实现穿透

监听用户行为&自动回复消息(消息事件监听+策略模式实现解耦)
controller

解释关键点

  • **MessageUtil.parseXml**:这是一个自定义方法,用于将 XML 格式的字符串解析成 Map<String, String>
  • **WxChatMsgHandlerwxChatMsgFactory**:WxChatMsgHandler 是一个处理微信消息的接口或类,wxChatMsgFactory 是一个工厂类,用于根据消息类型获取对应的消息处理器。
  • **msgTypeKey**:这个字符串键值用于标识不同的消息类型,例如 “text”、”image”、”event.subscribe” 等。

主要逻辑流程

  1. 接收并记录微信发送的消息。
  2. 将 XML 格式的消息解析成 Map
  3. 提取消息类型和事件类型。
  4. 构建消息类型键值,并根据键值从工厂中获取对应的消息处理器。
    • subscribe消息
    • text消息
  5. 使用消息处理器处理消息,返回处理结果。

CallBackController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
private static final String token = "adwidhaidwoaid";

@Resource
private WxChatMsgFactory wxChatMsgFactory;

@PostMapping(value = "callback", produces = "application/xml;charset=UTF-8")
public String callback(
@RequestBody String requestBody,
@RequestParam("signature") String signature,
@RequestParam("timestamp") String timestamp,
@RequestParam("nonce") String nonce,
@RequestParam(value = "msg_signature", required = false) String msgSignature) {
log.info("接收到微信消息:requestBody:{}", requestBody);
Map<String, String> messageMap = MessageUtil.parseXml(requestBody);
String msgType = messageMap.get("MsgType");
String event = messageMap.get("Event") == null ? "" : messageMap.get("Event");
log.info("msgType:{},event:{}", msgType, event);

StringBuilder sb = new StringBuilder();
sb.append(msgType);
if (!StringUtils.isEmpty(event)) {
sb.append(".");
sb.append(event);
}
String msgTypeKey = sb.toString();
WxChatMsgHandler wxChatMsgHandler = wxChatMsgFactory.getHandlerByMsgType(msgTypeKey);
if (Objects.isNull(wxChatMsgHandler)) {
return "unknown";
}
String replyContent = wxChatMsgHandler.dealMsg(messageMap);
log.info("replyContent:{}", replyContent);
return replyContent;
}
Utils

MessageUtil.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class MessageUtil {

/**
* 解析微信发来的请求(String->XML).
*
* @param msg 消息
* @return map
*/
public static Map<String, String> parseXml(final String msg) {
// 将解析结果存储在HashMap中
Map<String, String> map = new HashMap<String, String>();

// 从request中取得输入流
try (InputStream inputStream = new ByteArrayInputStream(msg.getBytes(StandardCharsets.UTF_8.name()))) {
// 读取输入流
SAXReader reader = new SAXReader(); //saxreader
Document document = reader.read(inputStream);
// 得到xml根元素
Element root = document.getRootElement();
// 得到根元素的所有子节点
List<Element> elementList = root.elements();

// 遍历所有子节点
for (Element e : elementList) {
map.put(e.getName(), e.getText());
}
} catch (Exception e) {
e.printStackTrace();
}

return map;
}

}
handler

WxChatMsgHandler.java

有两个类继承它:

  • SubscribeMsgHandler.java

  • ReceiveTextMsgHandler.java

1
2
3
4
5
6
7
public interface WxChatMsgHandler {

WxChatMsgTypeEnum getMsgType();

String dealMsg(Map<String, String> messageMap);

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
package com.jingdianjichi.wx.handler;

import com.jingdianjichi.wx.redis.RedisUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.TimeUnit;

@Component
@Slf4j
public class ReceiveTextMsgHandler implements WxChatMsgHandler {

private static final String KEY_WORD = "验证码";

private static final String LOGIN_PREFIX = "loginCode";

@Resource
private RedisUtil redisUtil;

@Override
public WxChatMsgTypeEnum getMsgType() {
return WxChatMsgTypeEnum.TEXT_MSG;
}

@Override
public String dealMsg(Map<String, String> messageMap) {
log.info("接收到文本消息事件");
String content = messageMap.get("Content");
if (!KEY_WORD.equals(content)) {
return "";
}
String fromUserName = messageMap.get("FromUserName");
String toUserName = messageMap.get("ToUserName");

Random random = new Random();
int num = random.nextInt(1000);
String numKey = redisUtil.buildKey(LOGIN_PREFIX, String.valueOf(num));
redisUtil.setNx(numKey, fromUserName, 5L, TimeUnit.MINUTES);
String numContent = "您当前的验证码是:" + num + "! 5分钟内有效";
String replyContent = "<xml>\n" +
" <ToUserName><![CDATA[" + fromUserName + "]]></ToUserName>\n" +
" <FromUserName><![CDATA[" + toUserName + "]]></FromUserName>\n" +
" <CreateTime>12345678</CreateTime>\n" +
" <MsgType><![CDATA[text]]></MsgType>\n" +
" <Content><![CDATA[" + numContent + "]]></Content>\n" +
"</xml>";

return replyContent;
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.jingdianjichi.wx.handler;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.Map;

@Component
@Slf4j
public class SubscribeMsgHandler implements WxChatMsgHandler {

@Override
public WxChatMsgTypeEnum getMsgType() {
return WxChatMsgTypeEnum.SUBSCRIBE;
}

@Override
public String dealMsg(Map<String, String> messageMap) {
log.info("触发用户关注事件!");
String fromUserName = messageMap.get("FromUserName");
String toUserName = messageMap.get("ToUserName");
String subscribeContent = "感谢您的关注";
String content = "<xml>\n" +
" <ToUserName><![CDATA[" + fromUserName + "]]></ToUserName>\n" +
" <FromUserName><![CDATA[" + toUserName + "]]></FromUserName>\n" +
" <CreateTime>12345678</CreateTime>\n" +
" <MsgType><![CDATA[text]]></MsgType>\n" +
" <Content><![CDATA[" + subscribeContent + "]]></Content>\n" +
"</xml>";
return content;
}

}
redis

这里也用到了RedisUtil.java,和gateway模块的一致,所以后续思路可以把这部分提取出来当成common模块的。

ReceiveTextMsgHandler.java

1
2
3
String numKey = redisUtil.buildKey(LOGIN_PREFIX, String.valueOf(num));
redisUtil.setNx(numKey, fromUserName, 5L, TimeUnit.MINUTES); //key为登录的前缀(字符串)+验证码(validCode)
String numContent = "您当前的验证码是:" + num + "! 5分钟内有效";
公众号登录验证码逻辑

UserController.java

1
2
3
4
5
6
7
8
9
10
@RequestMapping("doLogin")
public Result<SaTokenInfo> doLogin(@RequestParam("validCode") String validCode) {
try {
Preconditions.checkArgument(!StringUtils.isBlank(validCode), "验证码不能为空!");
return Result.ok(authUserDomainService.doLogin(validCode));
} catch (Exception e) {
log.error("UserController.doLogin.error:{}", e.getMessage(), e);
return Result.fail("用户登录失败");
}
}

AuthUserDomainServiceImpl.java

通过前缀+验证码->从redis拿到openId(即用户名),用satoken用openId进行登录,然后利用satoken返回一个token

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public SaTokenInfo doLogin(String validCode) {
String loginKey = redisUtil.buildKey(LOGIN_PREFIX, validCode);
String openId = redisUtil.get(loginKey);
if (StringUtils.isBlank(openId)) {
return null;
}
AuthUserBO authUserBO = new AuthUserBO();
authUserBO.setUserName(openId);
this.register(authUserBO); //校验是否存在,存在直接返回,不存在就注册
StpUtil.login(openId);
SaTokenInfo tokenInfo = StpUtil.getTokenInfo();
return tokenInfo;
}

第二部分

前后端部署

前端:

下好node.js,

1
pnpm install

下载好依赖。

image-20240803195320973.png

后端:

如果服务器没那么大空间,Jenkins跑起来有点困难,可以就配置好相关配置后install打好jar包,扔到服务器上:

1
nohup java -jar ***.jar

细节优化

1、分类和标签的性能优化,一次性查询出来,组装成树结构

2、去出题的按钮的权限交互。用户登录成功后,返回给前端,当前用户的相关权限。前端存到本地 localstorage 里面,进行按钮级别的前端交互。对于一些敏感的写操作,后端也应该提供一些权限接口

3、退出功能,token 失效的功能

4、个人信息页面的查询功能。

5、上传头像的功能。

6、每次注册的时候,相同的 openId 要做校验。

1. 避免重复注册

AuthUserDomainServiceImpl.java

注意这里调了register的接口:登录—>注册(如果已经注册了就返回,没有注册就注册)->返回satoken提供了的tokenInfo。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public SaTokenInfo doLogin(String validCode) {
String loginKey = redisUtil.buildKey(LOGIN_PREFIX, validCode);
String openId = redisUtil.get(loginKey);
if (StringUtils.isBlank(openId)) {
return null;
}
AuthUserBO authUserBO = new AuthUserBO();
authUserBO.setUserName(openId);
this.register(authUserBO);
StpUtil.login(openId);
SaTokenInfo tokenInfo = StpUtil.getTokenInfo();
return tokenInfo;
}

AuthUserDomainServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
@SneakyThrows
@Transactional(rollbackFor = Exception.class)
public Boolean register(AuthUserBO authUserBO) {
//校验用户是否存在
AuthUser existAuthUser = new AuthUser();
existAuthUser.setUserName(authUserBO.getUserName());
List<AuthUser> existUser = authUserService.queryByCondition(existAuthUser);
if (existUser.size() > 0) { //这里如果已经注册过了,就直接返回了
return true;
}
...
}

2.个人信息查询

UserController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RequestMapping("getUserInfo")
public Result<AuthUserDTO> getUserInfo(@RequestBody AuthUserDTO authUserDTO) {
try {
if (log.isInfoEnabled()) {
log.info("UserController.getUserInfo.dto:{}", JSON.toJSONString(authUserDTO));
}
Preconditions.checkArgument(!StringUtils.isBlank(authUserDTO.getUserName()), "用户名不能为空");
AuthUserBO authUserBO = AuthUserDTOConverter.INSTANCE.convertDTOToBO(authUserDTO);
AuthUserBO userInfo = authUserDomainService.getUserInfo(authUserBO);
return Result.ok(AuthUserDTOConverter.INSTANCE.convertBOToDTO(userInfo));
} catch (Exception e) {
log.error("UserController.update.error:{}", e.getMessage(), e);
return Result.fail("更新用户信息失败");
}
}

authUserDomainServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
@Override
public AuthUserBO getUserInfo(AuthUserBO authUserBO) {
AuthUser authUser = new AuthUser();
authUser.setUserName(authUserBO.getUserName());
List<AuthUser> userList = authUserService.queryByCondition(authUser);
if (CollectionUtils.isEmpty(userList)) {
return new AuthUserBO();
}
AuthUser user = userList.get(0);
return AuthUserBOConverter.INSTANCE.convertEntityToBO(user);
}

authUserServiceImpl.java

1
2
3
4
@Override
public List<AuthUser> queryByCondition(AuthUser authUser) {
return this.authUserDao.queryAllByLimit(authUser);
}

3.用户退出

UserController.java

1
2
3
4
5
6
7
8
9
10
11
12
@RequestMapping("logOut")
public Result logOut(@RequestParam String userName) {
try {
log.info("UserController.logOut.userName:{}", userName);
Preconditions.checkArgument(!StringUtils.isBlank(userName), "用户名不能为空");
StpUtil.logout(userName);
return Result.ok();
} catch (Exception e) {
log.error("UserController.logOut.error:{}", e.getMessage(), e);
return Result.fail("用户登出失败");
}
}

4. 用户上传头像

oss模块下的FileController.java

接口定义/upload 是一个 HTTP 请求映射,用于接收文件上传请求。

参数MultipartFile uploadFile 是上传的文件,String bucket 是桶的名称,String objectName 是对象名称(文件路径)。

调用服务:调用 fileService.uploadFile 方法上传文件,并获取文件的 URL。

返回结果:使用 Result.ok(url) 返回上传后的文件 URL。

1
2
3
4
5
@RequestMapping("/upload")
public Result upload(MultipartFile uploadFile, String bucket, String objectName) throws Exception {
String url = fileService.uploadFile(uploadFile, bucket, objectName);
return Result.ok(url);
}

fileService.java

调用适配器:调用 storageAdapter.uploadFile 方法,将文件上传到指定的桶和对象名称。

更新对象名称:将 objectName 更新为 objectName + "/" + uploadFile.getOriginalFilename(),这一步是为了构建文件的完整路径。

获取 URL:调用 storageAdapter.getUrl 方法,获取文件的访问 URL 并返回。

1
2
3
4
5
public String uploadFile(MultipartFile uploadFile, String bucket, String objectName){
storageAdapter.uploadFile(uploadFile,bucket,objectName);
objectName = objectName + "/" + uploadFile.getOriginalFilename();
return storageAdapter.getUrl(bucket, objectName);
}

MinioStorageAdapter.java

创建桶:调用 minioUtil.createBucket(bucket) 方法,确保桶存在。如果桶不存在则创建。

上传文件:根据 objectName 是否为空,决定文件上传的路径。如果 objectName 不为空,则将文件上传到 objectName + "/" + uploadFile.getOriginalFilename()。如果为空,则上传到 uploadFile.getOriginalFilename()

获取 URL:构建并返回文件的 URL。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
@SneakyThrows
public void uploadFile(MultipartFile uploadFile, String bucket, String objectName) {
minioUtil.createBucket(bucket);
if (objectName != null) {
minioUtil.uploadFile(uploadFile.getInputStream(), bucket, objectName + "/" + uploadFile.getOriginalFilename());
} else {
minioUtil.uploadFile(uploadFile.getInputStream(), bucket, uploadFile.getOriginalFilename());
}
}

@Override
@SneakyThrows
public String getUrl(String bucket, String objectName) {
return url + "/" + bucket + "/" + objectName;
}

5.分类题目数量更新

SubjectCategoryController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@PostMapping("/queryPrimaryCategory")
public Result<List<SubjectCategoryDTO>> queryPrimaryCategory(@RequestBody SubjectCategoryDTO subjectCategoryDTO) {
try {
SubjectCategoryBO subjectCategoryBO = SubjectCategoryDTOConverter.INSTANCE.
convertDtoToCategoryBO(subjectCategoryDTO);
List<SubjectCategoryBO> subjectCategoryBOList = subjectCategoryDomainService.queryCategory(subjectCategoryBO);
List<SubjectCategoryDTO> subjectCategoryDTOList = SubjectCategoryDTOConverter.INSTANCE.
convertBoToCategoryDTOList(subjectCategoryBOList);
return Result.ok(subjectCategoryDTOList);
} catch (Exception e) {
log.error("SubjectCategoryController.queryPrimaryCategory.error:{}", e.getMessage(), e);
return Result.fail("查询失败");
}

}

SubjectCategoryDomainServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
public List<SubjectCategoryBO> queryCategory(SubjectCategoryBO subjectCategoryBO) {
SubjectCategory subjectCategory = SubjectCategoryConverter.INSTANCE
.convertBoToCategory(subjectCategoryBO);
subjectCategory.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
List<SubjectCategory> subjectCategoryList = subjectCategoryService.queryCategory(subjectCategory);
List<SubjectCategoryBO> boList = SubjectCategoryConverter.INSTANCE
.convertBoToCategory(subjectCategoryList);
if (log.isInfoEnabled()) {
log.info("SubjectCategoryController.queryPrimaryCategory.boList:{}",
JSON.toJSONString(boList));
}
boList.forEach(bo -> {
Integer subjectCount = subjectCategoryService.querySubjectCount(bo.getId());
bo.setCount(subjectCount);
});
return boList;
}

SubjectCategoryServiceImpl.java

1
2
3
4
5
6
7
8
9
@Override
public List<SubjectCategory> queryCategory(SubjectCategory subjectCategory) {
return this.subjectCategoryDao.queryCategory(subjectCategory);
}

@Override
public Integer querySubjectCount(Long id) {
return this.subjectCategoryDao.querySubjectCount(id);
}

SubjectCategoryDao.xml

1
2
3
4
5
6
7
<select id="querySubjectCount" resultType="java.lang.Integer">
select count(distinct subject_id)
from subject_mapping a,
subject_label b
where a.label_id = b.id
and b.category_id = #{id}
</select>

6. 分类标签性能优化:star:

SubjectCategoryDomainServiceImpl.java核心是使用CompletableFuture和Java流来并行处理任务:

  1. 对每个category,启动一个异步任务来获取标签列表,并将任务结果以CompletableFuture的形式收集到列表中。
  2. 遍历completableFutureList,获取每个任务的结果,并将非空结果合并到map中。 【Java 8 新特性】Java CompletableFuture supplyAsync()详解_completablefuture.supplyasync-CSDN博客
  3. 通过这种方式,可以并行处理多个任务,提高了程序的效率。

SubjectCategoryController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* 查询分类及标签一次性
*/
@PostMapping("/queryCategoryAndLabel")
public Result<List<SubjectCategoryDTO>> queryCategoryAndLabel(@RequestBody SubjectCategoryDTO subjectCategoryDTO) {
try {
if (log.isInfoEnabled()) {
log.info("SubjectCategoryController.queryCategoryAndLabel.dto:{}"
, JSON.toJSONString(subjectCategoryDTO));
}
Preconditions.checkNotNull(subjectCategoryDTO.getId(), "分类id不能为空");
SubjectCategoryBO subjectCategoryBO = SubjectCategoryDTOConverter.INSTANCE.
convertDtoToCategoryBO(subjectCategoryDTO);
List<SubjectCategoryBO> subjectCategoryBOList = subjectCategoryDomainService.queryCategoryAndLabel(subjectCategoryBO); //查种类和标签
List<SubjectCategoryDTO> dtoList = new LinkedList<>();
subjectCategoryBOList.forEach(bo -> {
SubjectCategoryDTO dto = SubjectCategoryDTOConverter.INSTANCE.convertBoToCategoryDTO(bo);
List<SubjectLabelDTO> labelDTOList = SubjectLabelDTOConverter.INSTANCE.convertBOToLabelDTOList(bo.getLabelBOList());
dto.setLabelDTOList(labelDTOList);
dtoList.add(dto);
});
return Result.ok(dtoList);
} catch (Exception e) {
log.error("SubjectCategoryController.queryPrimaryCategory.error:{}", e.getMessage(), e);
return Result.fail("查询失败");
}
}

自定义线程工厂:

CustomNameThreadFactory.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 导入所需的类和接口。
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.commons.lang3.StringUtils;

public class CustomNameThreadFactory implements ThreadFactory {
// 用于生成线程池编号的静态原子类。
private static final AtomicInteger poolNumber = new AtomicInteger(1);
// 线程所属的线程组。
private final ThreadGroup group;
// 用于生成线程编号的原子类。
private final AtomicInteger threadNumber = new AtomicInteger(1);
// 线程名称的前缀。
private final String namePrefix;

// 构造函数,接受一个字符串参数来设置线程名称的前缀。
CustomNameThreadFactory(String name) {
SecurityManager s = System.getSecurityManager();
// 如果存在安全管理器,则使用安全管理器的线程组,否则使用当前线程的线程组。
group = (s != null) ? s.getThreadGroup() : Thread.currentThread().getThreadGroup();
// 如果传入的名称为空或空白,则默认使用 "pool" 作为名称。
if (StringUtils.isBlank(name)) {
name = "pool";
}
// 构造线程名称的前缀,包括线程池编号和 "-thread-"。
namePrefix = name + "-" + poolNumber.getAndIncrement() + "-thread-";
}

// 实现 ThreadFactory 接口的 newThread 方法,用于创建新的线程。
@Override
public Thread newThread(Runnable r) {
// 创建一个新的线程,使用 group 作为线程组,r 作为要执行的任务。
Thread t = new Thread(group, r,
// 线程名称由前缀、线程编号和递增后的编号组成。
namePrefix + threadNumber.getAndIncrement(),
0);
// 如果线程是守护线程,则将其设置为非守护线程。
if (t.isDaemon()){
t.setDaemon(false);
}
// 如果线程的优先级不是普通优先级,则将其设置为普通优先级。
if (t.getPriority() != Thread.NORM_PRIORITY){
t.setPriority(Thread.NORM_PRIORITY);
}
// 返回新创建的线程。
return t;
}
}

SubjectCategoryDomainServiceImpl.java

创建并初始化SubjectCategory对象

  • 设置ParentIdcategoryId
  • 设置IsDeleted为未删除状态。

查询类别列表

  • 调用subjectCategoryService.queryCategory方法查询类别列表。

日志记录

  • 记录查询结果的日志。

转换类别列表

  • 使用SubjectCategoryConverterSubjectCategory列表转换为SubjectCategoryBO列表。

并行处理标签列表

  • 为每个类别创建一个异步任务,调用getLabelBOList方法获取标签列表。
  • 使用CompletableFuture并行处理这些任务。

收集并合并结果

  • 等待所有异步任务完成,并将结果合并到一个Map<Long, List<SubjectLabelBO>>中。

设置标签列表

  • 为每个SubjectCategoryBO对象设置对应的标签列表。

返回结果

  • 返回包含标签列表的SubjectCategoryBO列表。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
@Resource
private ThreadPoolExecutor labelThreadPool;

@SneakyThrows
@Override
public List<SubjectCategoryBO> queryCategoryAndLabel(SubjectCategoryBO subjectCategoryBO) {
Long id = subjectCategoryBO.getId();
String cacheKey = "categoryAndLabel." + subjectCategoryBO.getId();
List<SubjectCategoryBO> subjectCategoryBOS = cacheUtil.getResult(cacheKey,
SubjectCategoryBO.class, (key) -> getSubjectCategoryBOS(id));
return subjectCategoryBOS;
}

private List<SubjectCategoryBO> getSubjectCategoryBOS(Long categoryId) {
SubjectCategory subjectCategory = new SubjectCategory();
subjectCategory.setParentId(categoryId);
subjectCategory.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
List<SubjectCategory> subjectCategoryList = subjectCategoryService.queryCategory(subjectCategory);
if (log.isInfoEnabled()) {
log.info("SubjectCategoryController.queryCategoryAndLabel.subjectCategoryList:{}",
JSON.toJSONString(subjectCategoryList));
}
List<SubjectCategoryBO> categoryBOList = SubjectCategoryConverter.INSTANCE.convertBoToCategory(subjectCategoryList);
Map<Long, List<SubjectLabelBO>> map = new HashMap<>();
List<CompletableFuture<Map<Long, List<SubjectLabelBO>>>> completableFutureList = categoryBOList.stream().map(category ->
CompletableFuture.supplyAsync(() -> getLabelBOList(category), labelThreadPool).collect(Collectors.toList()); //对于每个category,调用异步的CompletableFuture.supplyAsync()执行getLabelBOList方法和ThreadPoolExecutor作为Executor去多线程+异步的获取labelBOList
completableFutureList.forEach(future -> {
try {
Map<Long, List<SubjectLabelBO>> resultMap = future.get();
if (!MapUtils.isEmpty(resultMap)) {
map.putAll(resultMap);
}
} catch (Exception e) {
e.printStackTrace();
}
});
categoryBOList.forEach(categoryBO -> {
if (!CollectionUtils.isEmpty(map.get(categoryBO.getId()))) {
categoryBO.setLabelBOList(map.get(categoryBO.getId()));
}
});
return categoryBOList;
}
//通过Category查找对应的labelList
private Map<Long, List<SubjectLabelBO>> getLabelBOList(SubjectCategoryBO category) {
if (log.isInfoEnabled()) {
log.info("getLabelBOList:{}", JSON.toJSONString(category));
}
Map<Long, List<SubjectLabelBO>> labelMap = new HashMap<>();
SubjectMapping subjectMapping = new SubjectMapping();
subjectMapping.setCategoryId(category.getId());
List<SubjectMapping> mappingList = subjectMappingService.queryLabelId(subjectMapping);
if (CollectionUtils.isEmpty(mappingList)) {
return null;
}
List<Long> labelIdList = mappingList.stream().map(SubjectMapping::getLabelId).collect(Collectors.toList());
List<SubjectLabel> labelList = subjectLabelService.batchQueryById(labelIdList);
List<SubjectLabelBO> labelBOList = new LinkedList<>();
labelList.forEach(label -> {
SubjectLabelBO subjectLabelBO = new SubjectLabelBO();
subjectLabelBO.setId(label.getId());
subjectLabelBO.setLabelName(label.getLabelName());
subjectLabelBO.setCategoryId(label.getCategoryId());
subjectLabelBO.setSortNum(label.getSortNum());
labelBOList.add(subjectLabelBO);
});
labelMap.put(category.getId(), labelBOList);
return labelMap;
}

这段代码的核心是使用CompletableFuture和Java流来并行处理任务:

  1. 对每个category,启动一个异步任务来获取标签列表,并将任务结果以CompletableFuture的形式收集到列表中。
  2. 遍历completableFutureList,获取每个任务的结果,并将非空结果合并到map中。 【Java 8 新特性】Java CompletableFuture supplyAsync()详解_completablefuture.supplyasync-CSDN博客
  3. 通过这种方式,可以并行处理多个任务,提高了程序的效率。

7. 用户权限获取

AuthPermissionDomainServiceImpl.java

将存储在Redis中的权限数据转换为一个易于使用的权限键列表,以便在应用程序中进行权限检查。如果Redis中没有找到用户的权限数据,则返回一个空列表。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public List<String> getPermission(String userName) {
String permissionKey = redisUtil.buildKey(authPermissionPrefix, userName);
String permissionValue = redisUtil.get(permissionKey);
if (StringUtils.isBlank(permissionValue)) {
return Collections.emptyList();
}
List<AuthPermission> permissionList = new Gson().fromJson(permissionValue,
new TypeToken<List<AuthPermission>>() {
}.getType()); //使用Google的Gson库将权限字符串反序列化为AuthPermission对象的列表。TypeToken用于指定泛型类型。
List<String> authList = permissionList.stream().map(AuthPermission::getPermissionKey).collect(Collectors.toList());
return authList;
}

8. 利用minio/mc突破图片7天权限

93d2be17b7b8562a08d456c5484f684.jpg

有两个问题:

  1. minio上传头像只能保存7天
  2. 生成的url很长,需要简化
1
2
3
4
5
6
7
8
9
10
11
12
13
docker pull minio/mc

docker run -it --entrypoint=/bin/sh minio/mc

mc config host add <ALIAS> <YOUR-S3-ENDPOINT> <YOUR-ACCESS-KEY> <YOUR-SECRET-KEY> [--api API-SIGNATURE]

mc config host add minio http://xxx.xx.xx.xxx:9000 GrVCPXySKgGoJiGgXmtv 0xlqSI9GXvnBOtp0GwUj5OshKNBk9JgwoexotbVV

mc ls minio

mc anonymous //可以设置什么

mc anonymous set download minio/jichi

url: bucket+name

FileController.java

image-20240806154518945.png

MinioStorageAdapter.java

image-20240806154356089.png

FileController.java

image-20240806154533525.png

MinioStorageAdapter.java

image-20240806154622450.png

功能规划

搜索功能(完成)

全文检索,技术选型 es。

安装 es。

xxl-job 定时任务,去做一个数据同步,全量数据导入

es 全文检索,做高亮

点赞(完成)

自己点赞过的,这里肯定要有一个点赞过的 icon 的一个标识

后面的数量,意味着这道题目被多少个人点过赞。

如何去防刷点赞。疯狂的点赞,取消点赞。前端配合防抖,后端的点赞数量放到 redis 里面。数据库的持久化,可以通过定时任务来定时的刷新同步。

image-20240803193709660.png

我的点赞(完成)

展示,我们当前当过赞的所有的数据,来进行一波展示。

image-20240803193658610.png

收藏(完成)

image-20240803193734357.png

我的收藏(完成)

image-20240803193755102.png

纠错(完成)

纠错当用户发现题目有问题,错误的话,就可以通过这个方式,来进行反馈。

image-20240803193910438.png

快速刷题(完成)

image-20240803193936006.png

在这个位置去加一个上一题,下一题。

贡献榜(完成)

按照我的周维度,月维度,来做数据的存储。zset。和 redis 做大量的交互。

feign 的微服务间调用(完成)

会涉及到微服务之间的逻辑调用。这个就用 feign 了。

打通用户上下文(完成)

配合 threadlocal,基于 token 来实现用户信息的上下文传递。

二级缓存的使用(完成)

点赞里面。

用户上下文打通

链路流程:

image-20240812140358717.png

详细设计:

image-20240812140516313.png

Loginfilter(实现Globalfilter接口,通过filter拿到token,解析出loginId,然后传到后面的过滤链中)

->LoginInterceptor(实现HandlerInterceptor,检验loginId是否存在且非空,如果存在,将其保存到自定义的线程局部变量上下文LoginContextHolder中,通过InheritableThreadLocal来实现)

以上都不拦截doLogin操作

gateway网关自定义拦截header

LoginFilter.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 引入Spring组件注解,标识这是一个Spring组件
@Component
// 使用lombok的@Slf4j注解自动为类生成日志对象
@Slf4j
public class LoginFilter implements GlobalFilter {

// 实现GlobalFilter接口的filter方法,该方法会在请求被路由之前调用
@Override
// 使用@SneakyThrows注解来避免显式声明异常,简化代码
@SneakyThrows
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 获取请求和响应对象
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();

// 创建请求的构建器,用于修改请求头信息
ServerHttpRequest.Builder mutate = request.mutate();

// 获取请求的URL路径
String url = request.getURI().getPath();

// 记录请求的URL到日志
log.info("LoginFilter.filter.url:{}", url);

// 如果请求的URL是"/user/doLogin",则直接放行,不进行拦截
if (url.equals("/user/doLogin")) {
return chain.filter(exchange);
}

// 尝试获取当前请求的token信息,这里使用了SaToken框架
SaTokenInfo tokenInfo = StpUtil.getTokenInfo();

// 将token信息记录到日志中,这里使用了Gson库来将对象转换为JSON字符串
log.info("LoginFilter.filter.url:{}", new Gson().toJson(tokenInfo));

// 从token信息中获取登录用户的ID
String loginId = (String) tokenInfo.getLoginId();

// 将登录用户的ID添加到请求头中
mutate.header("loginId", loginId);

// 将修改后的请求和原始的响应以及过滤器链一起构建成一个新的ServerWebExchange对象
// 并调用chain.filter方法继续过滤链的执行
return chain.filter(exchange.mutate().request(mutate.build()).build());
}
}

通过filter拿到token,解析出loginId,然后传到后面的过滤链中。

基于threadLocal实现上下文传递

mvc的全局处理:GlobalConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 标记这个类是一个Spring配置类
@Configuration
public class GlobalConfig extends WebMvcConfigurationSupport {

// 覆盖configureMessageConverters方法来添加自定义的消息转换器
@Override
protected void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
// 首先调用父类的configureMessageConverters方法
super.configureMessageConverters(converters);
// 添加一个MappingJackson2HttpMessageConverter到转换器列表中
converters.add(mappingJackson2HttpMessageConverter());
}

// 覆盖addInterceptors方法来添加自定义的拦截器
@Override
protected void addInterceptors(InterceptorRegistry registry) {
// 添加一个自定义的拦截器LoginInterceptor
registry.addInterceptor(new LoginInterceptor())
// 拦截所有路径
.addPathPatterns("/**")
// 排除/user/doLogin路径,不对登录请求进行拦截
.excludePathPatterns("/user/doLogin");
}

/**
* 自定义的MappingJackson2HttpMessageConverter实现
* 目前实现的功能:
* - 忽略空值,即使对象的字段为null也不会在JSON中出现
* - 只序列化非空字段,空字段不会被序列化到JSON中
*/
private MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter() {
// 创建ObjectMapper实例
ObjectMapper objectMapper = new ObjectMapper();
// 配置ObjectMapper,当对象的字段为null时不抛出异常
objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
// 设置序列化时包含非空字段
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
// 创建MappingJackson2HttpMessageConverter并使用自定义的ObjectMapper
return new MappingJackson2HttpMessageConverter(objectMapper);
}
}

登录拦截器:LoginInterceptor.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class LoginInterceptor implements HandlerInterceptor {

// 在请求处理之前执行的方法
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 从请求头中获取loginId
String loginId = request.getHeader("loginId");

// 判断loginId是否存在且非空
if (StringUtils.isNotBlank(loginId)) {
// 如果存在,将其保存到自定义的线程局部变量上下文LoginContextHolder中
LoginContextHolder.set("loginId", loginId);
}

// 返回true表示继续执行拦截器链中的下一个拦截器或处理器
return true;
}

// 请求处理完成后执行的方法
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
// 从上下文中移除loginId,清理线程局部变量
LoginContextHolder.remove();
}

}

登录上下文对象: LoginContextHolder

InheritableThreadLocal详解-CSDN博客

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class LoginContextHolder {

// 使用InheritableThreadLocal来创建线程局部变量,允许子线程继承父线程的值
private static final InheritableThreadLocal<Map<String, Object>> THREAD_LOCAL =
new InheritableThreadLocal<>();

// 将键值对放入线程局部变量中
public static void set(String key, Object val) {
Map<String, Object> map = getThreadLocalMap();
map.put(key, val);
}

// 根据键从线程局部变量中获取值
public static Object get(String key){
Map<String, Object> threadLocalMap = getThreadLocalMap();
return threadLocalMap.get(key);
}

// 专门用于获取loginId的方法
public static String getLoginId(){
return (String) getThreadLocalMap().get("loginId");
}

// 清除线程局部变量中的所有数据
public static void remove(){
THREAD_LOCAL.remove();
}

// 获取线程局部变量中存储的Map,如果Map不存在,则创建一个新的Map
public static Map<String, Object> getThreadLocalMap() {
Map<String, Object> map = THREAD_LOCAL.get();
if (Objects.isNull(map)) {
map = new ConcurrentHashMap<>(); // 使用线程安全的ConcurrentHashMap
THREAD_LOCAL.set(map);
}
return map;
}

}

之后就可以根据这个上下文,封装一下,去拿loginId

1
2
3
4
5
6
7
8
public class LoginUtil {

public static String getLoginId() {
return LoginContextHolder.getLoginId();
}


}

微服务之间的Feign调用

微服务之间的调用

openfeign 是 spring cloud 搞出来的一个升级版,netflix 的 feign 这个不维护了。

openfeign 他就是声明式的 webservice 的客户端,使用 feign,编写调用更加的简单,主要打上注解就可以进行一个调用。

1
String responese = service.hello();

一行代码直接搞定。

feign 就帮助我们把 http 的调用编的非常的容易和方便,他整体的实现就是利用了 resttemplate 对 http 的一个封装。

feign 通过注解的方式配置之后,就可以完成接口的自动绑定,那我们调用 feign 的时候就像调接口一样,内置负载。内部封装了 ribbon。

实操:

首先,微服务之间要暴露出提供给其他服务的接口。在这里以auth包中的接口暴露给subject包的接口为例。

auth:

jc-club-auth-api包中的UserFeignService接口,这里注意@FeignClient注解后面的"jc-club-auth-dev"实则是在starter包中对应的bootstrap.yaml中的服务名称(这个东西是注册到nacos上面的)

1
2
3
4
5
6
7
8
9
10
11
@FeignClient("jc-club-auth-dev")
public interface UserFeignService {

// 调用该接口实则是调用的对应的domainService
@RequestMapping("/user/getUserInfo")
Result<AuthUserDTO> getUserInfo(@RequestBody AuthUserDTO authUserDTO);

@RequestMapping("/user/listByIds")
Result<List<AuthUserDTO>> listUserInfoByIds(@RequestBody List<String> userNameList);

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 对应的domainService
@Override
public AuthUserBO getUserInfo(AuthUserBO authUserBO) {
AuthUser authUser = new AuthUser();
authUser.setUserName(authUserBO.getUserName());
List<AuthUser> userList = authUserService.queryByCondition(authUser);
if (CollectionUtils.isEmpty(userList)) {
return new AuthUserBO();
}
AuthUser user = userList.get(0);
return AuthUserBOConverter.INSTANCE.convertEntityToBO(user);
}

@Override
public List<AuthUserBO> listUserInfoByIds(List<String> userNameList) {
List<AuthUser> userList = authUserService.listUserInfoByIds(userNameList);
if (CollectionUtils.isEmpty(userList)) {
return Collections.emptyList();
}
return AuthUserBOConverter.INSTANCE.convertEntityToBO(userList);
}

jc-club-auth-application-controller包中的pom.xml文件中,添加对该api包的依赖

1
2
3
4
5
<dependency>
<groupId>com.jingdianjichi</groupId>
<artifactId>jc-club-auth-api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

subject:

jc-club-subjectinfra包中的pom.xml中添加依赖

1
2
3
4
5
<dependency>
<groupId>com.jingdianjichi</groupId>
<artifactId>jc-club-auth-api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

jc-club-subjectinfra包中添加UserRpc.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Component
public class UserRpc {

//auth-api模块中的UserFeignService接口
@Resource
private UserFeignService userFeignService;

public UserInfo getUserInfo(String userName) {
AuthUserDTO authUserDTO = new AuthUserDTO();
authUserDTO.setUserName(userName);
//调用UserFeignService中暴露的接口
Result<AuthUserDTO> result = userFeignService.getUserInfo(authUserDTO);
UserInfo userInfo = new UserInfo();
if (!result.getSuccess()) {
return userInfo;
}
AuthUserDTO data = result.getData();
userInfo.setUserName(data.getUserName());
userInfo.setNickName(data.getNickName());
userInfo.setAvatar(data.getAvatar());
return userInfo;
}

}

进行测试,能拿到userInfo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@RequestMapping("/subject/category")
@Slf4j
public class TestFeignController {

@Resource
private UserRpc userRpc;

@GetMapping("testFeign")
public void testFeign() {
UserInfo userInfo = userRpc.getUserInfo("lzrj");
log.info("testFeign.userInfo:{}", userInfo);
}

}

openFeign拦截器实现用户上下文打通

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import feign.RequestInterceptor;
import feign.RequestTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import org.apache.commons.lang3.StringUtils;
import java.util.Objects;

// 将Feign拦截器注册为Spring组件
@Component
public class FeignRequestInterceptor implements RequestInterceptor {

// 实现apply方法,该方法将在Feign客户端发出请求之前被调用
@Override
public void apply(RequestTemplate requestTemplate) {
// 从RequestContextHolder获取当前的ServletRequestAttributes
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();

// 从ServletRequestAttributes中获取HttpServletRequest对象
HttpServletRequest request = requestAttributes.getRequest();

// 检查HttpServletRequest是否非空
if (Objects.nonNull(request)) {
// 从HttpServletRequest的请求头中获取loginId
String loginId = request.getHeader("loginId");

// 检查loginId是否非空且非仅包含空白字符
if (StringUtils.isNotBlank(loginId)) {
// 如果loginId存在,将其添加到Feign请求模板的请求头中
requestTemplate.header("loginId", loginId);
}
}
}
}
1
2
3
4
5
6
7
8
9
@Configuration
public class FeignConfiguration {

@Bean
public RequestInterceptor requestInterceptor(){
return new FeignRequestInterceptor();
}

}

通过FeignRequestInterceptor在调用微服务之前,就可以把相关请求的loginId封装到请求中,进行跨微服务的loginId的传递:

guava本地缓存(已经升级成Caffiene)

泛型+函数式编程

guava:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.collect.Lists;
import com.google.gson.JsonObject;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.springframework.stereotype.Component;

// 将CacheUtil类标记为Spring组件
@Component
public class CacheUtil<K, V> {

// 使用Guava的CacheBuilder构建一个本地缓存
private Cache<String, String> localCache =
CacheBuilder.newBuilder()
// 设置缓存最大容量为5000
.maximumSize(5000)
// 设置写入后10秒过期
.expireAfterWrite(10, TimeUnit.SECONDS)
.build();

// 根据缓存键获取缓存结果,如果缓存未命中则调用function获取数据并缓存结果
public List<V> getResult(String cacheKey, Class<V> clazz,
Function<String, List<V>> function) {
List<V> resultList = new ArrayList<>();
// 尝试从缓存中获取内容
String content = localCache.getIfPresent(cacheKey);
if (StringUtils.isNotBlank(content)) {
// 如果缓存中有数据,解析JSON字符串到List中
resultList = JSON.parseArray(content, clazz);
} else {
// 如果缓存未命中,调用function获取数据
resultList = function.apply(cacheKey);
// 如果获取的数据不为空,则将数据序列化为JSON字符串并缓存
if (!CollectionUtils.isEmpty(resultList)) {
localCache.put(cacheKey, JSON.toJSONString(resultList));
}
}
return resultList;
}

// 根据缓存键获取Map类型的缓存结果,如果缓存未命中则调用function获取数据并缓存结果
// 注意:此方法的实现目前为空,需要根据具体需求进行实现
public Map<K, V> getMapResult(String cacheKey, Class<V> clazz,
Function<String, Map<K, V>> function) {
return new HashMap<>();
}

}

在这用到了queryCategoryAndLabel

1
2
3
4
5
6
7
8
9
10
@SneakyThrows
@Override
public List<SubjectCategoryBO> queryCategoryAndLabel(SubjectCategoryBO subjectCategoryBO) {
Long id = subjectCategoryBO.getId();
String cacheKey = "categoryAndLabel." + subjectCategoryBO.getId();
// 调用本地缓存
List<SubjectCategoryBO> subjectCategoryBOS = cacheUtil.getResult(cacheKey,
SubjectCategoryBO.class, (key) -> getSubjectCategoryBOS(id)); //如果本地缓存没有 就调用getSubjectCategoryBOS
return subjectCategoryBOS;
}

全文检索功能

ElasticSearch从入门到精通,史上最全(持续更新,未完待续,每天一点点)_elasticsearch从入门到精通,史上最全-CSDN博客

功能设计

image-20240813172543714.png

技术选型:elasticsearch。

目的是,网站现在整体的题目预计会到好几百,方便快速的搜索到自己想看的内容。

实现形式:

  • 同步:新增题目->MYSQL->es

    image-20240813172750581.png

  • 异步:mysql 存储完后,发送 mq

    image-20240813172810340.png

  • 异步canal:监听 mysql 变更的 binlog,实现 es 的存储

    image-20240813172928624.png

实际操作

[通过HTTP的方式操作ES-CSDN博客](https://blog.csdn.net/sss294438204/article/details/122884953?ops_request_misc=%7B%22request%5Fid%22%3A%22172355571716800211536069%22%2C%22scm%22%3A%2220140713.130102334..%22%7D&request_id=172355571716800211536069&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~sobaiduend~default-2-122884953-null-null.142^v100^pc_search_result_base8&utm_term=es http&spm=1018.2226.3001.4187)

接口:

image-20240813212925046.png

es分词问题

原生的不太行,用这个

1
https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.3.1/elasticsearch-analysis-ik-7.3.1.zip

解压后,上传到服务器 的 ik 文件夹下面:

1
2
3
4
5
6
7
8
9
10
11
12
mkdir /soft/ik

进入容器内部
docker exec -it elasticsearch /bin/bash
cd plugins
mkdir ik

回到外部
docker cp /soft/ik/. 73438a827b55:/usr/share/elasticsearch/plugins/ik

重启es
docker restart 73438a827b55

编码: 试手

subject-infrapom.xml

1
2
3
4
5
6
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
<version>2.4.2</version>
</dependency>

TestFeignController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@GetMapping("testCreateIndex")
public void testCreateIndex() {
subjectEsService.createIndex();
}

@GetMapping("addDocs")
public void addDocs() {
subjectEsService.addDoc();
}

@GetMapping("find")
public void find() {
subjectEsService.find();
}

@GetMapping("search")
public void search() {
subjectEsService.search();
}

实体类:SubjectInfoEs.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Data
@AllArgsConstructor
@NoArgsConstructor
@Document(indexName = "subject_index", createIndex = false)
public class SubjectInfoEs {

@Field(type = FieldType.Long)
@Id
private Long id;

@Field(type = FieldType.Text, analyzer = "ik_smart")
private String subjectName;

@Field(type = FieldType.Text, analyzer = "ik_smart")
private String subjectAnswer;

@Field(type = FieldType.Keyword)
private String createUser;

@Field(type = FieldType.Date, index = false)
private Date createTime;

}

SubjectEsRepository.java,继承自ElasticsearchRepository。这个接口是Spring Data Elasticsearch的一部分,用于提供对Elasticsearch的访问和操作。

1
2
3
@Component
public interface SubjectEsRepository extends ElasticsearchRepository<SubjectInfoEs, Long> {
}

SubjectEsService.java

1
2
3
4
5
6
7
8
9
10
11
public interface SubjectEsService {

void createIndex();

void addDoc();

void find();

void search();

}

SubjectEsServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@Service
@Slf4j
public class SubjectEsServiceImpl implements SubjectEsService {

@Resource
private ElasticsearchRestTemplate elasticsearchRestTemplate;

@Resource
private SubjectEsRepository subjectEsRepository;

@Override
public void createIndex() {
IndexOperations indexOperations =
elasticsearchRestTemplate.indexOps(SubjectInfoEs.class);
indexOperations.create();
Document mapping = indexOperations.createMapping(SubjectInfoEs.class);
indexOperations.putMapping(mapping);
}

@Override
public void addDoc() {
List<SubjectInfoEs> list = new ArrayList<>();
list.add(new SubjectInfoEs(1L,"redis是什么","redis是一个缓存","鸡翅",new Date()));
list.add(new SubjectInfoEs(2L,"mysql是什么","mysql是数据库","鸡翅",new Date()));
subjectEsRepository.saveAll(list);
}

@Override
public void find() {
Iterable<SubjectInfoEs> all = subjectEsRepository.findAll();
for (SubjectInfoEs subjectInfoEs : all){
log.info("subjectInfoEs:{}",JSON.toJSONString(subjectInfoEs));
}
}

@Override
public void search() {
NativeSearchQuery nativeSearchQuery = new NativeSearchQueryBuilder()
.withQuery(QueryBuilders.matchQuery("subjectName","redis"))
.build();
SearchHits<SubjectInfoEs> search = elasticsearchRestTemplate.
search(nativeSearchQuery, SubjectInfoEs.class);
List<SearchHit<SubjectInfoEs>> searchHits = search.getSearchHits();
log.info("searchHits:{}", JSON.toJSONString(searchHits));
}

}

subject-starter中的application.yaml

1
2
3
elasticsearch:
rest:
uris:http://172.72.14.166:9200

编码:自定义封装es集群连接统一管理

希望的一个目的

  1. 有自己的封装好的工具
  2. 集群,索引等等都要兼容的配置的概念
  3. 不想用 data 的这种方式,不够扩展

image-20240814112513230.png

配置类:读取配置文件自定义的属性,支持集群,节点等等一些信息

  1. @Configuration + @ConfigurationProperties + @Data(必须提供set方法)
  2. @Configuration + @Value

集群类:集群的名称、集群的节点

索引类:集群名称、索引名称

封装的请求类:查询条件、查询字段、页数、条数、快照、快照缓存时间、排序字段、排序类型、高亮

封装的返回类:文档id(保证唯一)、所有跟restClient交互的封装成一个Map

自定义工具类:目的就是为了提供一个RestHighLevelClient,在原生client的基础上封装一些好用的api

整体基于 es 的原生的 client 来去做。

subject-infrapom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<dependency>
<groupId>org.elasticsearch</groupId>
<artifactId>elasticsearch</artifactId>
<version>7.5.2</version>
</dependency>
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-client</artifactId>
<version>7.5.2</version>
</dependency>
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>7.5.2</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId>
<version>4.4</version>
</dependency>

es集群类EsClusterConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Data
public class EsClusterConfig implements Serializable {

/**
* 集群名称
*/
private String name;

/**
* 集群节点
*/
private String nodes;

}

EsConfigProperties.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
@ConfigurationProperties(prefix = "es.cluster") //这部分是跟application.yaml中的对应
public class EsConfigProperties {

private List<EsClusterConfig> esConfigs = new ArrayList<>();

public List<EsClusterConfig> getEsConfigs() {
return esConfigs;
}

public void setEsConfigs(List<EsClusterConfig> esConfigs) {
this.esConfigs = esConfigs;
}
}

subject-starter中的application.yaml

1
2
3
4
5
es:
cluster:
esConfigs[0]:
name: 73438a827b55
nodes: 117.72.14.166:9200

EsIndexInfo.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Data
public class EsIndexInfo implements Serializable {

/**
* 集群名称
*/
private String clusterName;

/**
* 索引名称
*/
private String indexName;

}

EsSearchRequest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@Data
public class EsSearchRequest {

/**
* 查询条件
*/
private BoolQueryBuilder bq;

/**
* 查询字段
*/
private String[] fields;

/**
* 页数
*/
private int from;

/**
* 条数
*/
private int size;

/**
* 需要快照
*/
private Boolean needScroll;

/**
* 快照缓存时间
*/
private Long minutes;

/**
* 排序字段
*/
private String sortName;

/**
* 排序类型
*/
private SortOrder sortOrder;

/**
* 高亮builder
*/
private HighlightBuilder highlightBuilder;

}

EsSourceData.java

1
2
3
4
5
6
7
8
@Data
public class EsSourceData implements Serializable {

private String docId;

private Map<String, Object> data;

}

EsRestClient.java

这个类封装了与Elasticsearch集群交互的常见操作

  1. 类定义和日志记录:使用@Component注解表明这是一个Spring组件,使用@Slf4j来引入日志记录功能。
  2. 客户端映射clientMap是一个静态的HashMap,用于存储不同Elasticsearch集群的RestHighLevelClient实例。
  3. 配置属性注入:通过@Resource注解注入EsConfigProperties,这是一个配置属性类,用于获取Elasticsearch集群的配置信息。
  4. 请求选项:定义了一个静态的RequestOptions对象COMMON_OPTIONS,用于后续的请求。
  5. 初始化方法initialize方法在组件初始化时被调用,用于根据配置创建和初始化RestHighLevelClient实例。
  6. 创建客户端方法initRestClient是一个私有方法,用于根据给定的集群配置创建RestHighLevelClient实例。
  7. 获取客户端方法getClient是一个静态方法,用于根据集群名称获取对应的RestHighLevelClient实例。
  8. 文档操作:类中定义了一系列的静态方法,用于执行Elasticsearch中的文档操作,如插入(insertDoc)、更新(updateDoc)、批量更新(batchUpdateDoc)、删除(deletedeleteDoc)、检查文档是否存在(isExistDocById)、获取文档(getDocById)等。
  9. 搜索功能searchWithTermQuery方法用于执行基于布尔查询构建器的搜索请求,并支持高亮显示、排序和滚动(scroll)。
  10. 批量插入batchInsertDoc方法用于批量插入文档。
  11. 更新查询updateByQuery方法允许执行基于查询的更新操作。
  12. 分词功能getAnalyze方法提供了一个分词功能,可以对输入的文本进行分词。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
@Component
@Slf4j
public class EsRestClient {

public static Map<String, RestHighLevelClient> clientMap = new HashMap<>();

@Resource
private EsConfigProperties esConfigProperties;

private static final RequestOptions COMMON_OPTIONS;

static {
RequestOptions.Builder builder = RequestOptions.DEFAULT.toBuilder();
COMMON_OPTIONS = builder.build();
}

@PostConstruct
public void initialize() {
List<EsClusterConfig> esConfigs = esConfigProperties.getEsConfigs();
for (EsClusterConfig esConfig : esConfigs) {
log.info("initialize.config.name:{},node:{}", esConfig.getName(), esConfig.getNodes());
RestHighLevelClient restHighLevelClient = initRestClient(esConfig);
if (restHighLevelClient != null) {
clientMap.put(esConfig.getName(), restHighLevelClient);
} else {
log.error("config.name:{},node:{}.initError", esConfig.getName(), esConfig.getNodes());
}
}
}

private RestHighLevelClient initRestClient(EsClusterConfig esClusterConfig) {
String[] ipPortArr = esClusterConfig.getNodes().split(",");
List<HttpHost> httpHostList = new ArrayList<>(ipPortArr.length);
for (String ipPort : ipPortArr) {
String[] ipPortInfo = ipPort.split(":");
if (ipPortInfo.length == 2) {
HttpHost httpHost = new HttpHost(ipPortInfo[0], NumberUtils.toInt(ipPortInfo[1]));
httpHostList.add(httpHost);
}
}
HttpHost[] httpHosts = new HttpHost[httpHostList.size()];
httpHostList.toArray(httpHosts);

RestClientBuilder builder = RestClient.builder(httpHosts);
RestHighLevelClient restHighLevelClient = new RestHighLevelClient(builder);
return restHighLevelClient;
}
//返回集群名称对应的RestHighLevelClient
private static RestHighLevelClient getClient(String clusterName) {
return clientMap.get(clusterName);
}
//新增文档
public static boolean insertDoc(EsIndexInfo esIndexInfo, EsSourceData esSourceData) {
try {
IndexRequest indexRequest = new IndexRequest(esIndexInfo.getIndexName());
indexRequest.source(esSourceData.getData());
indexRequest.id(esSourceData.getDocId());
getClient(esIndexInfo.getClusterName()).index(indexRequest, COMMON_OPTIONS);
return true;
} catch (Exception e) {
log.error("insertDoc.exception:{}", e.getMessage(), e);
}
return false;
}

public static boolean updateDoc(EsIndexInfo esIndexInfo, EsSourceData esSourceData) {
try {
UpdateRequest updateRequest = new UpdateRequest();
updateRequest.index(esIndexInfo.getIndexName());
updateRequest.id(esSourceData.getDocId());
updateRequest.doc(esSourceData.getData());
getClient(esIndexInfo.getClusterName()).update(updateRequest, COMMON_OPTIONS);
return true;
} catch (Exception e) {
log.error("updateDoc.exception:{}", e.getMessage(), e);
}
return false;
}

public static boolean batchUpdateDoc(EsIndexInfo esIndexInfo,
List<EsSourceData> esSourceDataList) {
try {
boolean flag = false;
BulkRequest bulkRequest = new BulkRequest();
for (EsSourceData esSourceData : esSourceDataList) {
String docId = esSourceData.getDocId();
if (StringUtils.isNotBlank(docId)) {
UpdateRequest updateRequest = new UpdateRequest();
updateRequest.index(esIndexInfo.getIndexName());
updateRequest.id(esSourceData.getDocId());
updateRequest.doc(esSourceData.getData());
bulkRequest.add(updateRequest);
flag = true;
}
}

if (flag) {
BulkResponse bulk = getClient(esIndexInfo.getClusterName()).bulk(bulkRequest, COMMON_OPTIONS);
if (bulk.hasFailures()) {
return false;
}
}

return true;
} catch (Exception e) {
log.error("batchUpdateDoc.exception:{}", e.getMessage(), e);
}
return false;
}

public static boolean delete(EsIndexInfo esIndexInfo) {
try {
DeleteByQueryRequest deleteByQueryRequest =
new DeleteByQueryRequest(esIndexInfo.getIndexName());
deleteByQueryRequest.setQuery(QueryBuilders.matchAllQuery());
BulkByScrollResponse response = getClient(esIndexInfo.getClusterName()).deleteByQuery(
deleteByQueryRequest, COMMON_OPTIONS
);
long deleted = response.getDeleted();
log.info("deleted.size:{}", deleted);
return true;
} catch (Exception e) {
log.error("delete.exception:{}", e.getMessage(), e);
}
return false;
}

public static boolean deleteDoc(EsIndexInfo esIndexInfo, String docId) {
try {
DeleteRequest deleteRequest = new DeleteRequest(esIndexInfo.getIndexName());
deleteRequest.id(docId);
DeleteResponse response = getClient(esIndexInfo.getClusterName()).delete(deleteRequest, COMMON_OPTIONS);
log.info("deleteDoc.response:{}", JSON.toJSONString(response));
return true;
} catch (Exception e) {
log.error("deleteDoc.exception:{}", e.getMessage(), e);
}
return false;
}

public static boolean isExistDocById(EsIndexInfo esIndexInfo, String docId) {
try {
GetRequest getRequest = new GetRequest(esIndexInfo.getIndexName());
getRequest.id(docId);
return getClient(esIndexInfo.getClusterName()).exists(getRequest, COMMON_OPTIONS);
} catch (Exception e) {
log.error("isExistDocById.exception:{}", e.getMessage(), e);
}
return false;
}

public static Map<String, Object> getDocById(EsIndexInfo esIndexInfo, String docId) {
try {
GetRequest getRequest = new GetRequest(esIndexInfo.getIndexName());
getRequest.id(docId);
GetResponse response = getClient(esIndexInfo.getClusterName()).get(getRequest, COMMON_OPTIONS);
Map<String, Object> source = response.getSource();
return source;
} catch (Exception e) {
log.error("isExistDocById.exception:{}", e.getMessage(), e);
}
return null;
}

public static Map<String, Object> getDocById(EsIndexInfo esIndexInfo, String docId,
String[] fields) {
try {
GetRequest getRequest = new GetRequest(esIndexInfo.getIndexName());
getRequest.id(docId);
FetchSourceContext fetchSourceContext = new FetchSourceContext(true, fields, null);
getRequest.fetchSourceContext(fetchSourceContext);
GetResponse response = getClient(esIndexInfo.getClusterName()).get(getRequest, COMMON_OPTIONS);
Map<String, Object> source = response.getSource();
return source;
} catch (Exception e) {
log.error("isExistDocById.exception:{}", e.getMessage(), e);
}
return null;
}

//执行Elasticsearch搜索,支持复杂的查询条件、字段选择、高亮显示、排序和滚动搜索。
public static SearchResponse searchWithTermQuery(EsIndexInfo esIndexInfo,
EsSearchRequest esSearchRequest) {
try {
BoolQueryBuilder bq = esSearchRequest.getBq();
String[] fields = esSearchRequest.getFields();
int from = esSearchRequest.getFrom();
int size = esSearchRequest.getSize();
Long minutes = esSearchRequest.getMinutes();
Boolean needScroll = esSearchRequest.getNeedScroll();
String sortName = esSearchRequest.getSortName();
SortOrder sortOrder = esSearchRequest.getSortOrder();

SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.query(bq);
searchSourceBuilder.fetchSource(fields, null).from(from).size(size);

if (Objects.nonNull(esSearchRequest.getHighlightBuilder())) {
searchSourceBuilder.highlighter(esSearchRequest.getHighlightBuilder());
}

if (StringUtils.isNotBlank(sortName)) {
searchSourceBuilder.sort(sortName);
}

searchSourceBuilder.sort(new ScoreSortBuilder().order(SortOrder.DESC));

SearchRequest searchRequest = new SearchRequest();
searchRequest.searchType(SearchType.DEFAULT);
searchRequest.indices(esIndexInfo.getIndexName());
searchRequest.source(searchSourceBuilder);
if (needScroll) {
Scroll scroll = new Scroll(TimeValue.timeValueMinutes(minutes));
searchRequest.scroll(scroll);
}
SearchResponse search = getClient(esIndexInfo.getClusterName()).search(searchRequest, COMMON_OPTIONS);
return search;
} catch (Exception e) {
log.error("searchWithTermQuery.exception:{}", e.getMessage(), e);
}
return null;
}

public static boolean batchInsertDoc(EsIndexInfo esIndexInfo, List<EsSourceData> esSourceDataList) {
if (log.isInfoEnabled()) {
log.info("批量新增ES:" + esSourceDataList.size());
log.info("indexName:" + esIndexInfo.getIndexName());
}
try {
boolean flag = false;
BulkRequest bulkRequest = new BulkRequest();

for (EsSourceData source : esSourceDataList) {
String docId = source.getDocId();
if (StringUtils.isNotBlank(docId)) {
IndexRequest indexRequest = new IndexRequest(esIndexInfo.getIndexName());
indexRequest.id(docId);
indexRequest.source(source.getData());
bulkRequest.add(indexRequest);
flag = true;
}
}


if (flag) {
BulkResponse response = getClient(esIndexInfo.getClusterName()).bulk(bulkRequest, COMMON_OPTIONS);
if (response.hasFailures()) {
return false;
}
}
} catch (Exception e) {
log.error("batchInsertDoc.error", e);
}

return true;
}

public static boolean updateByQuery(EsIndexInfo esIndexInfo, QueryBuilder queryBuilder, Script script, int batchSize) {
if (log.isInfoEnabled()) {
log.info("updateByQuery.indexName:" + esIndexInfo.getIndexName());
}
try {
UpdateByQueryRequest updateByQueryRequest = new UpdateByQueryRequest(esIndexInfo.getIndexName());
updateByQueryRequest.setQuery(queryBuilder);
updateByQueryRequest.setScript(script);
updateByQueryRequest.setBatchSize(batchSize);
updateByQueryRequest.setAbortOnVersionConflict(false);
BulkByScrollResponse response = getClient(esIndexInfo.getClusterName()).updateByQuery(updateByQueryRequest, RequestOptions.DEFAULT);
List<BulkItemResponse.Failure> failures = response.getBulkFailures();
} catch (Exception e) {
log.error("updateByQuery.error", e);
}
return true;
}

/**
* 分词方法
*/
public static List<String> getAnalyze(EsIndexInfo esIndexInfo, String text) throws Exception {
List<String> list = new ArrayList<String>();
Request request = new Request("GET", "_analyze");
JSONObject entity = new JSONObject();
entity.put("analyzer", "ik_smart");
entity.put("text", text);
request.setJsonEntity(entity.toJSONString());
Response response = getClient(esIndexInfo.getClusterName()).getLowLevelClient().performRequest(request);
JSONObject tokens = JSONObject.parseObject(EntityUtils.toString(response.getEntity()));
JSONArray arrays = tokens.getJSONArray("tokens");
for (int i = 0; i < arrays.size(); i++) {
JSONObject obj = JSON.parseObject(arrays.getString(i));
list.add(obj.getString("token"));
}
return list;
}


}

新增题目同步到es+带高亮的网站全文搜索

subject-infraSubjectInfoEs.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Data
public class SubjectInfoEs extends PageInfo implements Serializable {

private Long subjectId;

private Long docId;

private String subjectName;

private String subjectAnswer;

private String createUser;

private Long createTime;

private Integer subjectType;

private String keyWord;
//相关性的分数,搜索的结果
private BigDecimal score;

}

subject-infraEsSubjectFields.java

负责与Elasticsearch(ES)的交互,包括数据的插入和查询。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class EsSubjectFields {

public static final String DOC_ID = "doc_id";

public static final String SUBJECT_ID = "subject_id";

public static final String SUBJECT_NAME = "subject_name";

public static final String SUBJECT_ANSWER = "subject_answer";

public static final String SUBJECT_TYPE = "subject_type";

public static final String CREATE_USER = "create_user";

public static final String CREATE_TIME = "create_time";

public static final String[] FIELD_QUERY = {
SUBJECT_ID, SUBJECT_NAME, SUBJECT_ANSWER, SUBJECT_TYPE, DOC_ID, CREATE_USER, CREATE_TIME
};

}

subject-infraSubjectEsService.java

1
2
3
4
5
6
7
public interface SubjectEsService {

boolean insert(SubjectInfoEs subjectInfoEs);
//做一个分页的
PageResult<SubjectInfoEs> querySubjectList(SubjectInfoEs subjectInfoEs);

}

SubjectEsServiceImpl.java

  1. 插入方法insert方法实现了将SubjectInfoEs对象转换为ES的文档并插入到ES中。它首先调用convert2EsSourceData方法将SubjectInfoEs对象转换为ES的源数据格式,然后使用EsRestClientinsertDoc方法执行插入操作。
  2. 转换方法convert2EsSourceData是一个私有方法,用于将SubjectInfoEs对象的属性转换为一个Map,这个Map将作为ES文档的数据部分。
  3. 查询方法querySubjectList方法实现了分页查询ES中的数据。它首先创建一个EsSearchRequest查询请求,然后使用EsRestClientsearchWithTermQuery方法执行查询,并将结果转换为PageResult<SubjectInfoEs>对象。
  4. 结果转换方法convertResult是一个私有方法,用于将ES查询结果的SearchHit转换为SubjectInfoEs对象。它还处理了高亮显示查询关键字的功能。
  5. 查询构建方法createSearchListQuery是一个私有方法,用于构建查询请求。它使用BoolQueryBuilder来构建查询条件,并设置了高亮显示的配置。
  6. 获取ES索引信息方法getEsIndexInfo是一个私有方法,用于获取ES的索引信息,包括集群名称和索引名称。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
@Service
@Slf4j
public class SubjectEsServiceImpl implements SubjectEsService {

@Override
public boolean insert(SubjectInfoEs subjectInfoEs) {
EsSourceData esSourceData = new EsSourceData();
Map<String, Object> data = convert2EsSourceData(subjectInfoEs);
esSourceData.setDocId(subjectInfoEs.getDocId().toString());
esSourceData.setData(data);
return EsRestClient.insertDoc(getEsIndexInfo(), esSourceData);
}

private Map<String, Object> convert2EsSourceData(SubjectInfoEs subjectInfoEs) {
Map<String, Object> data = new HashMap<>();
data.put(EsSubjectFields.SUBJECT_ID, subjectInfoEs.getSubjectId());
data.put(EsSubjectFields.DOC_ID, subjectInfoEs.getDocId());
data.put(EsSubjectFields.SUBJECT_NAME, subjectInfoEs.getSubjectName());
data.put(EsSubjectFields.SUBJECT_ANSWER, subjectInfoEs.getSubjectAnswer());
data.put(EsSubjectFields.SUBJECT_TYPE, subjectInfoEs.getSubjectType());
data.put(EsSubjectFields.CREATE_USER, subjectInfoEs.getCreateUser());
data.put(EsSubjectFields.CREATE_TIME, subjectInfoEs.getCreateTime());
return data;
}
//带高亮的网站全文搜索
@Override
public PageResult<SubjectInfoEs> querySubjectList(SubjectInfoEs req) {
PageResult<SubjectInfoEs> pageResult = new PageResult<>();
EsSearchRequest esSearchRequest = createSearchListQuery(req);
SearchResponse searchResponse = EsRestClient.searchWithTermQuery(getEsIndexInfo(), esSearchRequest);

List<SubjectInfoEs> subjectInfoEsList = new LinkedList<>();
SearchHits searchHits = searchResponse.getHits();
if (searchHits == null || searchHits.getHits() == null) {
pageResult.setPageNo(req.getPageNo());
pageResult.setPageSize(req.getPageSize());
pageResult.setRecords(subjectInfoEsList);
pageResult.setTotal(0);
return pageResult;
}
SearchHit[] hits = searchHits.getHits();
for (SearchHit hit : hits) {
SubjectInfoEs subjectInfoEs = convertResult(hit);
if (Objects.nonNull(subjectInfoEs)) {
subjectInfoEsList.add(subjectInfoEs);
}
}
pageResult.setPageNo(req.getPageNo());
pageResult.setPageSize(req.getPageSize());
pageResult.setRecords(subjectInfoEsList);
pageResult.setTotal(Long.valueOf(searchHits.getTotalHits().value).intValue());
return pageResult;
}

/**
* 将Elasticsearch搜索结果的SearchHit对象转换为SubjectInfoEs对象。
* @param hit Elasticsearch返回的搜索结果条目。
* @return 转换后的SubjectInfoEs对象,如果结果为空则返回null。
*/
private SubjectInfoEs convertResult(SearchHit hit) {
// 获取搜索结果的源数据映射。
Map<String, Object> sourceAsMap = hit.getSourceAsMap();
// 如果源数据映射为空,则返回null。
if (CollectionUtils.isEmpty(sourceAsMap)) {
return null;
}

// 创建SubjectInfoEs对象用于存储转换结果。
SubjectInfoEs result = new SubjectInfoEs();

// 从源数据映射中获取题目ID,并设置到result对象。
result.setSubjectId(MapUtils.getLong(sourceAsMap, EsSubjectFields.SUBJECT_ID));
// 从源数据映射中获取题目名称,并设置到result对象。
result.setSubjectName(MapUtils.getString(sourceAsMap, EsSubjectFields.SUBJECT_NAME));
// 从源数据映射中获取题目答案,并设置到result对象。
result.setSubjectAnswer(MapUtils.getString(sourceAsMap, EsSubjectFields.SUBJECT_ANSWER));
// 从源数据映射中获取文档ID,并设置到result对象。
result.setDocId(MapUtils.getLong(sourceAsMap, EsSubjectFields.DOC_ID));
// 从源数据映射中获取题目类型,并设置到result对象。
result.setSubjectType(MapUtils.getInteger(sourceAsMap, EsSubjectFields.SUBJECT_TYPE));

// 获取搜索结果的相关性分数,并转换为百分比形式,设置到result对象。
result.setScore(new BigDecimal(String.valueOf(hit.getScore()))
.multiply(new BigDecimal("100.00").setScale(2, RoundingMode.HALF_UP)));

// 获取高亮字段,处理题目名称的高亮显示。
Map<String, HighlightField> highlightFields = hit.getHighlightFields();
HighlightField subjectNameField = highlightFields.get(EsSubjectFields.SUBJECT_NAME);
if (Objects.nonNull(subjectNameField)) {
// 获取题目名称的高亮片段。
Text[] fragments = subjectNameField.getFragments();
StringBuilder subjectNameBuilder = new StringBuilder();
for (Text fragment : fragments) {
// 拼接高亮片段。
subjectNameBuilder.append(fragment);
}
// 设置处理后的高亮题目名称。
result.setSubjectName(subjectNameBuilder.toString());
}

// 处理题目答案的高亮显示。
HighlightField subjectAnswerField = highlightFields.get(EsSubjectFields.SUBJECT_ANSWER);
if (Objects.nonNull(subjectAnswerField)) {
Text[] fragments = subjectAnswerField.getFragments();
StringBuilder subjectAnswerBuilder = new StringBuilder();
for (Text fragment : fragments) {
subjectAnswerBuilder.append(fragment);
}
// 设置处理后的高亮题目答案。
result.setSubjectAnswer(subjectAnswerBuilder.toString());
}

// 返回填充好的SubjectInfoEs对象。
return result;
}

/**
* 创建一个用于查询题目列表的EsSearchRequest对象。
* @param req 包含查询条件的SubjectInfoEs对象。
* @return 配置好的EsSearchRequest对象。
*/
private EsSearchRequest createSearchListQuery(SubjectInfoEs req) {
// 创建EsSearchRequest对象用于存储搜索请求的配置。
EsSearchRequest esSearchRequest = new EsSearchRequest();

// 创建一个布尔查询构造器,用于组合多个查询条件。
BoolQueryBuilder bq = new BoolQueryBuilder();

// 创建一个匹配查询构造器,用于匹配题目名称字段。
MatchQueryBuilder subjectNameQueryBuilder =
QueryBuilders.matchQuery(EsSubjectFields.SUBJECT_NAME, req.getKeyWord());

// 将题目名称的匹配查询添加到布尔查询中,并设置提升因子为2,以提高相关性。
bq.should(subjectNameQueryBuilder);
subjectNameQueryBuilder.boost(2);

// 创建一个匹配查询构造器,用于匹配题目答案字段。
MatchQueryBuilder subjectAnswerQueryBuilder =
QueryBuilders.matchQuery(EsSubjectFields.SUBJECT_ANSWER, req.getKeyWord());

// 将题目答案的匹配查询添加到布尔查询中。
bq.should(subjectAnswerQueryBuilder);

// 创建一个匹配查询构造器,用于匹配题目类型字段,必须是简答题类型。
MatchQueryBuilder subjectTypeQueryBuilder =
QueryBuilders.matchQuery(EsSubjectFields.SUBJECT_TYPE, SubjectInfoTypeEnum.BRIEF.getCode());

// 必须匹配题目类型的查询添加到布尔查询中。
bq.must(subjectTypeQueryBuilder);

// 设置至少应该匹配的should子句的数量为1。
bq.minimumShouldMatch(1);

// 创建高亮显示构造器,设置高亮显示的前缀和后缀标签。
HighlightBuilder highlightBuilder = new HighlightBuilder().field("*").requireFieldMatch(false);
highlightBuilder.preTags("<span style = \"color:red\">");
highlightBuilder.postTags("</span>");

// 设置EsSearchRequest对象的布尔查询构造器。
esSearchRequest.setBq(bq);
// 设置高亮显示构造器。
esSearchRequest.setHighlightBuilder(highlightBuilder);
// 设置需要返回的字段。
esSearchRequest.setFields(EsSubjectFields.FIELD_QUERY);
// 设置从第几个文档开始返回结果,用于分页。
esSearchRequest.setFrom((req.getPageNo() - 1) * req.getPageSize());
// 设置每页返回的文档数量。
esSearchRequest.setSize(req.getPageSize());
// 设置不需要滚动搜索。
esSearchRequest.setNeedScroll(false);

// 返回配置好的搜索请求对象。
return esSearchRequest;
}

private EsIndexInfo getEsIndexInfo() {
EsIndexInfo esIndexInfo = new EsIndexInfo();
esIndexInfo.setClusterName("73438a827b55");
esIndexInfo.setIndexName("subject_index");
return esIndexInfo;
}
}

SubjectInfoDomainServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
@Transactional(rollbackFor = Exception.class)
public void add(SubjectInfoBO subjectInfoBO) {
......
//同步到es
SubjectInfoEs subjectInfoEs = new SubjectInfoEs();
subjectInfoEs.setDocId(new IdWorkerUtil(1, 1, 1).nextId());
subjectInfoEs.setSubjectId(subjectInfo.getId());
subjectInfoEs.setSubjectAnswer(subjectInfoBO.getSubjectAnswer());
subjectInfoEs.setCreateTime(new Date().getTime());
subjectInfoEs.setCreateUser("lzrj");
subjectInfoEs.setSubjectName(subjectInfo.getSubjectName());
subjectInfoEs.setSubjectType(subjectInfo.getSubjectType());
subjectEsService.insert(subjectInfoEs);

}

SubjectController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 全文检索
*/
@PostMapping("/getSubjectPageBySearch")
public Result<PageResult<SubjectInfoEs>> getSubjectPageBySearch(@RequestBody SubjectInfoDTO subjectInfoDTO) {
try {
if (log.isInfoEnabled()) {
log.info("SubjectController.getSubjectPageBySearch.dto:{}", JSON.toJSONString(subjectInfoDTO));
}
Preconditions.checkArgument(StringUtils.isNotBlank(subjectInfoDTO.getKeyWord()), "关键词不能为空");
SubjectInfoBO subjectInfoBO = SubjectInfoDTOConverter.INSTANCE.convertDTOToBO(subjectInfoDTO);
subjectInfoBO.setPageNo(subjectInfoDTO.getPageNo());
subjectInfoBO.setPageSize(subjectInfoDTO.getPageSize());
PageResult<SubjectInfoEs> boPageResult = subjectInfoDomainService.getSubjectPageBySearch(subjectInfoBO);
return Result.ok(boPageResult);
} catch (Exception e) {
log.error("SubjectCategoryController.getSubjectPageBySearch.error:{}", e.getMessage(), e);
return Result.fail("全文检索失败");
}
}

SubjectInfoDomainServiceImpl.java

1
2
3
4
5
6
7
8
@Override
public PageResult<SubjectInfoEs> getSubjectPageBySearch(SubjectInfoBO subjectInfoBO) {
SubjectInfoEs subjectInfoEs = new SubjectInfoEs();
subjectInfoEs.setPageNo(subjectInfoBO.getPageNo());
subjectInfoEs.setPageSize(subjectInfoBO.getPageSize());
subjectInfoEs.setKeyWord(subjectInfoBO.getKeyWord());
return subjectEsService.querySubjectList(subjectInfoEs);
}

手写Mybatis拦截器自动填充数据(方法级别拦截器)

LoginContextHolderLoginUtil都放在common层里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
/**
* 填充createBy, createTime等公共字段的拦截器
*/
@Component
@Slf4j
// 声明拦截器,指定要拦截的Executor类中的update方法,以及该方法的参数类型
@Intercepts({
@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
})
public class MybatisInterceptor implements Interceptor {

@Override
public Object intercept(Invocation invocation) throws Throwable {
// 获取拦截方法的参数
MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType();
Object parameter = invocation.getArgs()[1];

// 如果参数为空,直接执行原方法
if (parameter == null) {
return invocation.proceed();
}

// 获取当前登录用户的id
String loginId = LoginUtil.getLoginId();
// 如果没有登录用户,直接执行原方法
if (StringUtils.isBlank(loginId)) {
return invocation.proceed();
}

// 根据SqlCommandType是INSERT或UPDATE,调用不同的处理方法
if (SqlCommandType.INSERT == sqlCommandType || SqlCommandType.UPDATE == sqlCommandType) {
replaceEntityProperty(parameter, loginId, sqlCommandType);
}

// 执行原方法
return invocation.proceed();
}

/**
* 根据不同的参数类型,调用不同的属性替换方法
* @param parameter 拦截方法的参数对象
* @param loginId 当前登录用户的id
* @param sqlCommandType SQL命令类型
*/
private void replaceEntityProperty(Object parameter, String loginId, SqlCommandType sqlCommandType) {
if (parameter instanceof Map) {
replaceMap((Map) parameter, loginId, sqlCommandType);
} else {
replace(parameter, loginId, sqlCommandType);
}
}

/**
* 处理Map类型的参数
* @param parameter Map参数
* @param loginId 登录用户的id
* @param sqlCommandType SQL命令类型
*/
private void replaceMap(Map parameter, String loginId, SqlCommandType sqlCommandType) {
for (Object val : parameter.values()) {
replace(val, loginId, sqlCommandType);
}
}

/**
* 处理普通对象或集合类型的参数
* @param parameter 参数对象
* @param loginId 登录用户的id
* @param sqlCommandType SQL命令类型
*/
private void replace(Object parameter, String loginId, SqlCommandType sqlCommandType) {
if (SqlCommandType.INSERT == sqlCommandType) {
dealInsert(parameter, loginId);
} else {
dealUpdate(parameter, loginId);
}
}

/**
* 处理UPDATE操作的字段替换
* @param parameter 参数对象
* @param loginId 登录用户的id
*/
private void dealUpdate(Object parameter, String loginId) {
// 获取所有字段,包括继承的字段
Field[] fields = getAllFields(parameter);
for (Field field : fields) {
try {
field.setAccessible(true);
// 如果字段已经有值,则跳过
Object o = field.get(parameter);
if (Objects.nonNull(o)) {
field.setAccessible(false);
continue;
}
// 根据字段名设置不同的值
if ("updateBy".equals(field.getName())) {
field.set(parameter, loginId);
} else if ("updateTime".equals(field.getName())) {
field.set(parameter, new Date());
} else {
// 其他字段不处理
}
field.setAccessible(false);
} catch (Exception e) {
log.error("dealUpdate.error:{}", e.getMessage(), e);
}
}
}

/**
* 处理INSERT操作的字段替换
* @param parameter 参数对象
* @param loginId 登录用户的id
*/
private void dealInsert(Object parameter, String loginId) {
// 获取所有字段,包括继承的字段
Field[] fields = getAllFields(parameter);
for (Field field : fields) {
try {
field.setAccessible(true);
// 如果字段已经有值,则跳过
Object o = field.get(parameter);
if (Objects.nonNull(o)) {
field.setAccessible(false);
continue;
}
// 根据字段名设置不同的值
if ("isDeleted".equals(field.getName())) {
field.set(parameter, 0);
} else if ("createdBy".equals(field.getName())) {
field.set(parameter, loginId);
} else if ("createdTime".equals(field.getName())) {
field.set(parameter, new Date());
} else {
// 其他字段不处理
}
field.setAccessible(false);
} catch (Exception e) {
log.error("dealInsert.error:{}", e.getMessage(), e);
}
}
}

/**
* 递归获取对象所有字段,包括父类的字段
* @param object 对象
* @return 字段数组
*/
private Field[] getAllFields(Object object) {
Class<?> clazz = object.getClass();
List<Field> fieldList = new ArrayList<>();
while (clazz != null) {
// 添加当前类的所有字段到列表
fieldList.addAll(new ArrayList<>(Arrays.asList(clazz.getDeclaredFields())));
clazz = clazz.getSuperclass();
}
// 转换列表为数组
Field[] fields = new Field[fieldList.size()];
fieldList.toArray(fields);
return fields;
}

// MyBatis插件接口方法,实际使用时会增强目标对象
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}

// 用于接收MyBatis传入的属性,一般不使用
@Override
public void setProperties(Properties properties) {
}
}

题目排行榜功能设计

排行榜一般来说实时的,非实时的。

实时的方案

  1. 数据库统计

现在数据库里面的 createby 字段。用户的标识是唯一的,那我直接通过 group by 的形式统计 count。

select count(1),create_by from subject_info group by create_by limit 0,5;

数据量比较小,并发也比较小。这种方案是 ok 的。保证可以走到索引,返回速度快,不要产生慢 sql。

在数据库层面加一层缓存,接受一定的延时性。

  1. redis 的 sorted set

    有序集合,不允许重复的成员,然后每一个 key 都会包含一个 score 分数的概念。redis 根据分数可以帮助我们做从小到大,和从大到小的一个处理。

    有序集合的 key 不可重复,score 重复。

    它通过我们的一个哈希表来实现的,添加,删除,查找,复杂度 o(1) ,最大数量是 2的32 次方-1。

    1
    2
    3
    4
    5
    6
    7
    zadd

    zrange

    zincrby

    zscore

    这种的好处在于,完全不用和数据库做任何的交互,纯纯的通过缓存来做,速度非常快,要避免一些大 key 的问题。

非实时

定时任务 xxl-job

统计数据库的数据形式,帮助我们统计完成后,直接写入缓存。缓存的外部的交互展示。

传统数据库实现排行榜

SubjectController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 获取题目贡献榜
*/
@PostMapping("/getContributeList")
public Result<List<SubjectInfoDTO>> getContributeList() {
try {
List<SubjectInfoBO> boList = subjectInfoDomainService.getContributeList();
List<SubjectInfoDTO> dtoList = SubjectInfoDTOConverter.INSTANCE.convertBOToDTOList(boList);
return Result.ok(dtoList);
} catch (Exception e) {
log.error("SubjectCategoryController.getContributeList.error:{}", e.getMessage(), e);
return Result.fail("获取贡献榜失败");
}
}

SubjectDomainInfoServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
public List<SubjectInfoBO> getContributeList() {
List<SubjectInfo> subjectInfoList = subjectInfoService.getContributeCount();
if (CollectionUtils.isEmpty(subjectInfoList)) {
return Collections.emptyList();
}
List<SubjectInfoBO> boList = new LinkedList<>();
subjectInfoList.forEach((subjectInfo -> {
SubjectInfoBO subjectInfoBO = new SubjectInfoBO();
subjectInfoBO.setSubjectCount(subjectInfo.getSubjectCount());
//这里用到了UserRpc
UserInfo userInfo = userRpc.getUserInfo(subjectInfo.getCreatedBy());
subjectInfoBO.setCreateUser(userInfo.getNickName());
subjectInfoBO.setCreateUserAvatar(userInfo.getAvatar());
boList.add(subjectInfoBO);
}));
return boList;
}

UserRpc.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
public class UserRpc {

@Resource
private UserFeignService userFeignService;

public UserInfo getUserInfo(String userName) {
AuthUserDTO authUserDTO = new AuthUserDTO();
authUserDTO.setUserName(userName);
Result<AuthUserDTO> result = userFeignService.getUserInfo(authUserDTO);
UserInfo userInfo = new UserInfo();
if (!result.getSuccess()) {
return userInfo;
}
AuthUserDTO data = result.getData();
userInfo.setUserName(data.getUserName());
userInfo.setNickName(data.getNickName());
//新增 设置头像
userInfo.setAvatar(data.getAvatar());
return userInfo;
}

}

subjectInfoServiceImpl.java

1
2
3
4
@Override
public List<SubjectInfo> getContributeCount() {
return this.subjectInfoDao.getContributeCount();
}

SubjectInfoDao.xml

1
2
3
4
5
6
7
8
9
<select id="getContributeCount" resultType="com.jingdianjichi.subject.infra.basic.entity.SubjectInfo">
select count(1) as subjectCount,
created_by as createdBy
from subject_info
where is_deleted = 0
and created_by is not null
group by created_by
limit 0,5
</select>

基于redis的zset实现排行榜

subject-domain/pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.4.2</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.12.7</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.12.7</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.9.0</version>
</dependency>

subject-doamin/RedisUtil.java

1
2
3
4
public Set<ZSetOperations.TypedTuple<String>> rankWithScore(String key, long start, long end) {
Set<ZSetOperations.TypedTuple<String>> set = redisTemplate.opsForZSet().reverseRangeWithScores(key, start, end);
return set;
}

redis——Zset有序集合之reverseRangeWithScore函数使用_reverserangewithscores-CSDN博客

SubjectInfoDomainServiceImpl.java

排行榜接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private static final String RANK_KEY = "subject_rank";
@Override
public List<SubjectInfoBO> getContributeList() {
Set<ZSetOperations.TypedTuple<String>> typedTuples = redisUtil.rankWithScore(RANK_KEY, 0, 5);
if (log.isInfoEnabled()) {
log.info("getContributeList.typedTuples:{}", JSON.toJSONString(typedTuples));
}
if (CollectionUtils.isEmpty(typedTuples)) {
return Collections.emptyList();
}
List<SubjectInfoBO> boList = new LinkedList<>();
typedTuples.forEach((rank -> {
SubjectInfoBO subjectInfoBO = new SubjectInfoBO();
subjectInfoBO.setSubjectCount(rank.getScore().intValue());
// 这个是openId
UserInfo userInfo = userRpc.getUserInfo(rank.getValue());
subjectInfoBO.setCreateUser(userInfo.getNickName());
subjectInfoBO.setCreateUserAvatar(userInfo.getAvatar());
boList.add(subjectInfoBO);
}));
// 返回一个包含题目信息的list
return boList;
}

点赞和收藏

点赞和收藏功能设计

点赞与收藏的逻辑是非常一样的,我们这里就选取点赞功能来给大家做开发。

按照我们的程序员 club 的设计,点赞业务其实涉及几个方面:

  • 我们肯定要知道一个题目被多少人点过赞
  • 还要知道,每个人他点赞了哪些题目。

点赞的业务特性:频繁。用户一多,时时刻刻都在进行点赞啊,收藏啊等等处理,如果说我们采取传统的数据库的模式啊,这个交互量是非常大的,很难去抗住这个并发问题,所以我们采取 redis 的方式来做。

查询的数据交互,我们可以和 redis 直接来做,持久化的数据,通过数据库查询即可,这个数据如何去同步到数据库,我们就采取的定时任务 xxl-job 定期来刷数据。

image-20240824121946908.png

记录的时候三个关键信息,点赞的人,被点赞的题目,点赞的状态。

我们最终的数据结构就是 hash,string 类型。

  • hash,存到一个键里面,键里是一个 map,他又分为 hashkey 和 hashval。
    • 谁点赞了哪个题目+状态:hashkey,subjectId:userId,val 就存的是点赞的状态 1 是点赞 0 是不点赞。
    • 点赞数量:string 类型 key subjectId,val 即使我们的题目被点赞的数量。
    • 有没有点过赞,key存在说明点过(并非记录状态):key为string 类型, subjectId:userId。

表结构:

image-20240824123024005.png

新增点赞

直接操作 redis

存 hash,存数量,存点赞的人与题目的 key。

取消点赞

上面的反逻辑,数量会-1,hash 里面的状态会更新,点赞人与题目关联的 key 会被删除

查询当前题目被点赞的数量

直接与 redis 交互,读题目的被点赞数量的 key

查询当前题目被当前用户是否点过赞

直接查 redis 就可以了。

我的点赞

直接查数据库做分页逻辑的展示。

点赞功能开发

RedisUtil.java

1
2
3
4
// key可以用来区分这些不同的哈希表。hashKey 则是这些哈希表内部的键
public void putHash(String key, String hashKey, Object hashVal) {
redisTemplate.opsForHash().put(key, hashKey, hashVal);
}

SubjectLikedDomainServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 定义常量,用于作为Redis中存储主题点赞信息的哈希表的键
private static final String SUBJECT_LIKED_KEY = "subject.liked";

// 定义常量,用于作为Redis中存储主题点赞数量的键
private static final String SUBJECT_LIKED_COUNT_KEY = "subject.liked.count";

// 定义常量,用于作为Redis中存储主题点赞详细信息的键
private static final String SUBJECT_LIKED_DETAIL_KEY = "subject.liked.detail";

/**
* 添加点赞信息到Redis。
*
* @param subjectLikedBO 包含点赞操作的业务对象,其中包含主题ID、点赞用户ID和状态。
*/
public void add(SubjectLikedBO subjectLikedBO) {
// 从业务对象中获取主题ID
Long subjectId = subjectLikedBO.getSubjectId();
// 从业务对象中获取点赞用户的ID
String likeUserId = subjectLikedBO.getLikeUserId();
// 从业务对象中获取点赞状态
Integer status = subjectLikedBO.getStatus();

// 构建用于存储点赞状态的哈希表的字段名
String hashKey = buildSubjectLikedKey(subjectId.toString(), likeUserId);

// 将点赞状态存储到Redis哈希表中
redisUtil.putHash(SUBJECT_LIKED_KEY, hashKey, status);

// 构建存储点赞详细信息的键,格式为 "subject.liked.detail + 主题ID + . + 点赞用户ID"
String detailKey = SUBJECT_LIKED_DETAIL_KEY + "." + subjectId + "." + likeUserId;

// 构建存储点赞数量的键,格式为 "subject.liked.count + . + 主题ID"
String countKey = SUBJECT_LIKED_COUNT_KEY + "." + subjectId;

// 判断点赞状态是否为 "点赞"
if (SubjectLikedStatusEnum.LIKED.getCode() == status) {
// 如果是点赞,增加点赞数量
redisUtil.increment(countKey, 1);
// 存储点赞详细信息,这里假设点赞详细信息只存储为 "1"
redisUtil.set(detailKey, "1");
} else {
// 如果不是点赞状态,执行以下操作
Integer count = redisUtil.getInt(countKey);
// 如果点赞数量为null或小于等于0,则不执行任何操作
if (Objects.isNull(count) || count <= 0) {
return;
}
// 减少点赞数量
redisUtil.increment(countKey, -1);
// 删除点赞详细信息
redisUtil.del(detailKey);
}
}

题目详情增加点赞数据

SubjectLikedDomainServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public Boolean isLiked(String subjectId, String userId) {
String detailKey = SUBJECT_LIKED_DETAIL_KEY + "." + subjectId + "." + userId;
return redisUtil.exist(detailKey);
}

@Override
public Integer getLikedCount(String subjectId) {
String countKey = SUBJECT_LIKED_COUNT_KEY + "." + subjectId;
Integer count = redisUtil.getInt(countKey);
if (Objects.isNull(count) || count <= 0) {
return 0;
}
return redisUtil.getInt(countKey);
}

SubjectInfoDomainServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Override
public SubjectInfoBO querySubjectInfo(SubjectInfoBO subjectInfoBO) {
SubjectInfo subjectInfo = subjectInfoService.queryById(subjectInfoBO.getId());
SubjectTypeHandler handler = subjectTypeHandlerFactory.getHandler(subjectInfo.getSubjectType());
SubjectOptionBO optionBO = handler.query(subjectInfo.getId().intValue());
SubjectInfoBO bo = SubjectInfoConverter.INSTANCE.convertOptionAndInfoToBo(optionBO, subjectInfo);
SubjectMapping subjectMapping = new SubjectMapping();
subjectMapping.setSubjectId(subjectInfo.getId());
subjectMapping.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
List<SubjectMapping> mappingList = subjectMappingService.queryLabelId(subjectMapping);
List<Long> labelIdList = mappingList.stream().map(SubjectMapping::getLabelId).collect(Collectors.toList());
List<SubjectLabel> labelList = subjectLabelService.batchQueryById(labelIdList);
List<String> labelNameList = labelList.stream().map(SubjectLabel::getLabelName).collect(Collectors.toList());
bo.setLabelName(labelNameList);
bo.setLiked(subjectLikedDomainService.isLiked(subjectInfoBO.getId().toString(), LoginUtil.getLoginId()));
bo.setLikedCount(subjectLikedDomainService.getLikedCount(subjectInfoBO.getId().toString()));
assembleSubjectCursor(subjectInfoBO, bo);
return bo;
}

xxljob定时任务完成

XXL-JOB 是一个轻量级分布式任务调度平台,其核心设计目标是开发迅速、学习简单、轻量级、易扩展。现已开放源代码并接入多家公司线上产品线,开箱即用。

image-20240824155129452.png

分布式任务调度平台XXL-JOB (xuxueli.com)

特性

  • 1、简单:支持通过 Web 页面对任务进行 CRUD 操作,操作简单,一分钟上手;
  • 2、动态:支持动态修改任务状态、启动 / 停止任务,以及终止运行中任务,即时生效;
  • 3、调度中心 HA(中心式):调度采用中心式设计,“调度中心” 自研调度组件并支持集群部署,可保证调度中心 HA;
  • 4、执行器 HA(分布式):任务分布式执行,任务 “执行器” 支持集群部署,可保证任务执行 HA;
  • 5、注册中心:执行器会周期性自动注册任务,调度中心将会自动发现注册的任务并触发执行。同时,也支持手动录入执行器地址;
  • 6、弹性扩容缩容:一旦有新执行器机器上线或者下线,下次调度时将会重新分配任务;
  • 7、路由策略:执行器集群部署时提供丰富的路由策略,包括:第一个、最后一个、轮询、随机、一致性 HASH、最不经常使用、最近最久未使用、故障转移、忙碌转移等;
  • 8、故障转移:任务路由策略选择 “故障转移” 情况下,如果执行器集群中某一台机器故障,将会自动 Failover 切换到一台正常的执行器发送调度请求。
  • 9、阻塞处理策略:调度过于密集执行器来不及处理时的处理策略,策略包括:单机串行(默认)、丢弃后续调度、覆盖之前调度;
  • 10、任务超时控制:支持自定义任务超时时间,任务运行超时将会主动中断任务;
  • 11、任务失败重试:支持自定义任务失败重试次数,当任务失败时将会按照预设的失败重试次数主动进行重试;其中分片任务支持分片粒度的失败重试;
  • 12、任务失败告警;默认提供邮件方式失败告警,同时预留扩展接口,可方便的扩展短信、钉钉等告警方式;
  • 13、分片广播任务:执行器集群部署时,任务路由策略选择 “分片广播” 情况下,一次任务调度将会广播触发集群中所有执行器执行一次任务,可根据分片参数开发分片任务;
  • 14、动态分片:分片广播任务以执行器为维度进行分片,支持动态扩容执行器集群从而动态增加分片数量,协同进行业务处理;在进行大数据量业务操作时可显著提升任务处理能力和速度。
  • 15、事件触发:除了 “Cron 方式” 和 “任务依赖方式” 触发任务执行之外,支持基于事件的触发任务方式。调度中心提供触发任务单次执行的 API 服务,可根据业务事件灵活触发。
  • 16、任务进度监控:支持实时监控任务进度;
  • 17、Rolling 实时日志:支持在线查看调度结果,并且支持以 Rolling 方式实时查看执行器输出的完整的执行日志;
  • 18、GLUE:提供 Web IDE,支持在线开发任务逻辑代码,动态发布,实时编译生效,省略部署上线的过程。支持 30 个版本的历史版本回溯。
  • 19、脚本任务:支持以 GLUE 模式开发和运行脚本任务,包括 Shell、Python、NodeJS、PHP、PowerShell 等类型脚本;
  • 20、命令行任务:原生提供通用命令行任务 Handler(Bean 任务,”CommandJobHandler”);业务方只需要提供命令行即可;
  • 21、任务依赖:支持配置子任务依赖,当父任务执行结束且执行成功后将会主动触发一次子任务的执行,多个子任务用逗号分隔;
  • 22、一致性:“调度中心” 通过 DB 锁保证集群分布式调度的一致性,一次任务调度只会触发一次执行;
  • 23、自定义任务参数:支持在线配置调度任务入参,即时生效;
  • 24、调度线程池:调度系统多线程触发调度运行,确保调度精确执行,不被堵塞;
  • 25、数据加密:调度中心和执行器之间的通讯进行数据加密,提升调度信息安全性;
  • 26、邮件报警:任务失败时支持邮件报警,支持配置多邮件地址群发报警邮件;
  • 27、推送 maven 中央仓库:将会把最新稳定版推送到 maven 中央仓库,方便用户接入和使用;
  • 28、运行报表:支持实时查看运行数据,如任务数量、调度次数、执行器数量等;以及调度报表,如调度日期分布图,调度成功分布图等;
  • 29、全异步:任务调度流程全异步化设计实现,如异步调度、异步运行、异步回调等,有效对密集调度进行流量削峰,理论上支持任意时长任务的运行;
  • 30、跨语言:调度中心与执行器提供语言无关的 RESTful API 服务,第三方任意语言可据此对接调度中心或者实现执行器。除此之外,还提供了 “多任务模式” 和 “httpJobHandler” 等其他跨语言方案;
  • 31、国际化:调度中心支持国际化设置,提供中文、英文两种可选语言,默认为中文;
  • 32、容器化:提供官方 docker 镜像,并实时更新推送 dockerhub,进一步实现产品开箱即用;
  • 33、线程池隔离:调度线程池进行隔离拆分,慢任务自动降级进入 “Slow” 线程池,避免耗尽调度线程,提高系统稳定性;
  • 34、用户管理:支持在线管理系统用户,存在管理员、普通用户两种角色;
  • 35、权限控制:执行器维度进行权限控制,管理员拥有全量权限,普通用户需要分配执行器权限后才允许相关操作;

其实把东西配置好就行

subject-starter/application.yaml

1
2
3
4
5
6
7
8
9
10
11
12
xxl:
job:
admin:
addresses: http://127.0.0.1:8080/xxl-job-admin
accessToken: default_token
executor:
appname: jc-club-subjcet
address:
ip: 127.0.0.1
port: 9999
logpath: /data/applogs/xxl-job/jobhandler
logretentiondays: 30

XxlJobConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Value("${xxl.job.admin.addresses}")
private String adminAddresses;

@Value("${xxl.job.accessToken}")
private String accessToken;

@Value("${xxl.job.executor.appname}")
private String appname;

@Value("${xxl.job.executor.address}")
private String address;

@Value("${xxl.job.executor.ip}")
private String ip;

@Value("${xxl.job.executor.port}")
private int port;

@Value("${xxl.job.executor.logpath}")
private String logPath;

@Value("${xxl.job.executor.logretentiondays}")
private int logRetentionDays;

//配置执行器
@Bean
public XxlJobSpringExecutor xxlJobExecutor() {
logger.info(">>>>>>>>>>> xxl-job config init.");
XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
xxlJobSpringExecutor.setAppname(appname);
xxlJobSpringExecutor.setAddress(address);
xxlJobSpringExecutor.setIp(ip);
xxlJobSpringExecutor.setPort(port);
xxlJobSpringExecutor.setAccessToken(accessToken);
xxlJobSpringExecutor.setLogPath(logPath);
xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays);

return xxlJobSpringExecutor;
}

SyncLikedJob.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
@Slf4j
public class SyncLikedJob {

@Resource
private SubjectLikedDomainService subjectLikedDomainService;

/**
* 同步点赞数据任务
*/
@XxlJob("syncLikedJobHandler")
public void syncLikedJobHandler() throws Exception {
XxlJobHelper.log("syncLikedJobHandler.start");
try {
subjectLikedDomainService.syncLiked();
} catch (Exception e) {
XxlJobHelper.log("syncLikedJobHandler.error" + e.getMessage());
}
}

}

SubjectLikedDomainServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Override
public void syncLiked() {
//redisUtil.getHashAndDelete(SUBJECT_LIKED_KEY)从Redis中获取键为SUBJECT_LIKED_KEY的哈希表,并在获取后删除此哈希表。
Map<Object, Object> subjectLikedMap = redisUtil.getHashAndDelete(SUBJECT_LIKED_KEY);
if (log.isInfoEnabled()) {
log.info("syncLiked.subjectLikedMap:{}", JSON.toJSONString(subjectLikedMap));
}
if (MapUtils.isEmpty(subjectLikedMap)) {
return;
}
//批量同步到数据库
List<SubjectLiked> subjectLikedList = new LinkedList<>();
subjectLikedMap.forEach((key, val) -> {
SubjectLiked subjectLiked = new SubjectLiked();
String[] keyArr = key.toString().split(":");
String subjectId = keyArr[0];
String likedUser = keyArr[1];
subjectLiked.setSubjectId(Long.valueOf(subjectId));
subjectLiked.setLikeUserId(likedUser);
subjectLiked.setStatus(Integer.valueOf(val.toString()));
subjectLiked.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
subjectLikedList.add(subjectLiked);
});
subjectLikedService.batchInsertOrUpdate(subjectLikedList);
}

SubjectLikedServiceImpl.java

1
2
3
4
@Override
public void batchInsertOrUpdate(List<SubjectLiked> subjectLikedList) {
this.subjectLikedDao.batchInsertOrUpdate(subjectLikedList);
}

RedisUtil.java

  1. 定义Map:创建一个新的HashMap实例,用于存储从Redis中获取的键值对。
  2. 使用Cursor遍历哈希表:通过调用redisTemplate.opsForHash().scan(key, ScanOptions.NONE)获取一个Cursor,它可以用来遍历Redis哈希表中的所有条目。ScanOptions.NONE表示不使用任何扫描选项。
  3. 遍历Cursor:使用while循环遍历Cursor,直到没有更多的条目。
  4. 获取条目:在循环内部,使用cursor.next()获取当前的条目,它是一个Map.Entry对象。
  5. 提取键和值:从Map.Entry对象中提取键(hashKey)和值(value)。
  6. 将键值对放入Map:使用map.put(hashKey, value)将提取的键和值放入之前创建的Map中。
  7. 删除哈希表中的条目:使用redisTemplate.opsForHash().delete(key, hashKey)从Redis的哈希表中删除当前遍历到的键值对。
  8. 返回Map:遍历完成后,返回包含所有键值对的Map。
1
2
3
4
5
6
7
8
9
10
11
12
public Map<Object, Object> getHashAndDelete(String key) {
Map<Object, Object> map = new HashMap<>();
Cursor<Map.Entry<Object, Object>> cursor = redisTemplate.opsForHash().scan(key, ScanOptions.NONE);
while (cursor.hasNext()) {
Map.Entry<Object, Object> entry = cursor.next();
Object hashKey = entry.getKey();
Object value = entry.getValue();
map.put(hashKey, value);
redisTemplate.opsForHash().delete(key, hashKey);
}
return map;
}

我的点赞功能开发

SubjectLikedController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 查询我的点赞列表
*/
@PostMapping("/getSubjectLikedPage")
public Result<PageResult<SubjectLikedDTO>> getSubjectLikedPage(@RequestBody SubjectLikedDTO subjectLikedDTO) {
try {
if (log.isInfoEnabled()) {
log.info("SubjectController.getSubjectLikedPage.dto:{}", JSON.toJSONString(subjectLikedDTO));
}
SubjectLikedBO subjectLikedBO = SubjectLikedDTOConverter.INSTANCE.convertDTOToBO(subjectLikedDTO);
subjectLikedBO.setPageNo(subjectLikedDTO.getPageNo());
subjectLikedBO.setPageSize(subjectLikedDTO.getPageSize());
PageResult<SubjectLikedBO> boPageResult = subjectLikedDomainService.getSubjectLikedPage(subjectLikedBO);
return Result.ok(boPageResult);
} catch (Exception e) {
log.error("SubjectCategoryController.getSubjectLikedPage.error:{}", e.getMessage(), e);
return Result.fail("分页查询我的点赞失败");
}
}

SubjectLikedDomainServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Override
public PageResult<SubjectLikedBO> getSubjectLikedPage(SubjectLikedBO subjectLikedBO) {
// 创建一个分页结果对象
PageResult<SubjectLikedBO> pageResult = new PageResult<>();
// 设置当前页码和每页显示的记录数
pageResult.setPageNo(subjectLikedBO.getPageNo());
pageResult.setPageSize(subjectLikedBO.getPageSize());
// 计算查询的起始索引
int start = (subjectLikedBO.getPageNo() - 1) * subjectLikedBO.getPageSize();
// 将BO对象转换为Entity对象
SubjectLiked subjectLiked = SubjectLikedBOConverter.INSTANCE.convertBOToEntity(subjectLikedBO);
// 设置点赞用户的ID为当前登录用户的ID
subjectLiked.setLikeUserId(LoginUtil.getLoginId());
// 执行条件查询,获取点赞记录的总数
int count = subjectLikedService.countByCondition(subjectLiked);
// 如果没有记录,则直接返回空的分页结果
if (count == 0) {
return pageResult;
}
// 执行分页查询,获取点赞记录的列表
List<SubjectLiked> subjectLikedList = subjectLikedService.queryPage(subjectLiked, start,
subjectLikedBO.getPageSize());
// 将Entity列表转换为BO列表
List<SubjectLikedBO> subjectInfoBOS = SubjectLikedBOConverter.INSTANCE.convertListInfoToBO(subjectLikedList);
// 遍历BO列表,为每个点赞记录添加主题名称
subjectInfoBOS.forEach(info -> {
SubjectInfo subjectInfo = subjectInfoService.queryById(info.getSubjectId());
info.setSubjectName(subjectInfo.getSubjectName());
});
// 设置分页结果中的记录列表和总数
pageResult.setRecords(subjectInfoBOS);
pageResult.setTotal(count);
// 返回分页结果对象
return pageResult;
}

SubjectLikedServiceImpl.java

1
2
3
4
5
6
7
8
9
@Override
public int countByCondition(SubjectLiked subjectLiked) {
return this.subjectLikedDao.countByCondition(subjectLiked);
}
//分页查询
@Override
public List<SubjectLiked> queryPage(SubjectLiked subjectLiked, int start, Integer pageSize) {
return this.subjectLikedDao.queryPage(subjectLiked, start, pageSize);
}

快速刷题功能开发

SubjectInfoDTO.java

新增字段

1
2
3
4
5
6
7
8
9
/**
* 下一题
*/
private Long nextSubjectId;

/**
* 上一题
*/
private Long lastSubjectId;

SubjectInfoDomainService.java

这段代码的主要逻辑是:

  1. 根据题目ID查询题目实体。
  2. 根据题目类型获取相应的处理器,并使用它查询题目选项信息。
  3. 将题目选项和题目信息转换为业务对象BO。
  4. 查询题目的标签ID列表,并批量查询标签列表。
  5. 提取标签名称并设置到业务对象BO中。
  6. 设置是否已点赞的状态和点赞数量。
  7. 组装题目的上下文信息,包括上一个和下一个题目的ID。
  8. 返回封装好的业务对象BO。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@Override
public SubjectInfoBO querySubjectInfo(SubjectInfoBO subjectInfoBO) {
// 根据题目信息BO中的ID查询题目实体
SubjectInfo subjectInfo = subjectInfoService.queryById(subjectInfoBO.getId());
// 根据题目类型获取相应的处理器
SubjectTypeHandler handler = subjectTypeHandlerFactory.getHandler(subjectInfo.getSubjectType());
// 使用处理器查询题目选项信息
SubjectOptionBO optionBO = handler.query(subjectInfo.getId().intValue());
// 将题目选项和题目信息转换为业务对象BO
SubjectInfoBO bo = SubjectInfoConverter.INSTANCE.convertOptionAndInfoToBo(optionBO, subjectInfo);
// 创建题目映射对象,设置题目ID和未删除标志
SubjectMapping subjectMapping = new SubjectMapping();
subjectMapping.setSubjectId(subjectInfo.getId());
subjectMapping.setIsDeleted(IsDeletedFlagEnum.UN_DELETED.getCode());
// 查询题目标签ID列表
List<SubjectMapping> mappingList = subjectMappingService.queryLabelId(subjectMapping);
// 从映射列表中提取标签ID
List<Long> labelIdList = mappingList.stream().map(SubjectMapping::getLabelId).collect(Collectors.toList());
// 批量查询标签列表
List<SubjectLabel> labelList = subjectLabelService.batchQueryById(labelIdList);
// 从标签列表中提取标签名称
List<String> labelNameList = labelList.stream().map(SubjectLabel::getLabelName).collect(Collectors.toList());
// 设置标签名称到业务对象BO中
bo.setLabelName(labelNameList);
// 设置是否已点赞的状态
bo.setLiked(subjectLikedDomainService.isLiked(subjectInfoBO.getId().toString(), LoginUtil.getLoginId()));
// 设置点赞数量
bo.setLikedCount(subjectLikedDomainService.getLikedCount(subjectInfoBO.getId().toString()));
// 组装题目的上下文信息,如上一个和下一个题目的ID
assembleSubjectCursor(subjectInfoBO, bo);
// 返回封装好的业务对象BO
return bo;
}

// 私有方法,用于组装题目的上下文信息
private void assembleSubjectCursor(SubjectInfoBO subjectInfoBO, SubjectInfoBO bo) {
// 获取分类ID、标签ID和题目ID
Long categoryId = subjectInfoBO.getCategoryId();
Long labelId = subjectInfoBO.getLabelId();
Long subjectId = subjectInfoBO.getId();
// 如果分类ID或标签ID为空,则不进行上下文信息的组装
if (Objects.isNull(categoryId) || Objects.isNull(labelId)) {
return;
}
// 查询下一个题目的ID
Long nextSubjectId = subjectInfoService.querySubjectIdCursor(subjectId, categoryId, labelId, 1);
// 设置下一个题目的ID到业务对象BO中
bo.setNextSubjectId(nextSubjectId);
// 查询上一个题目的ID
Long lastSubjectId = subjectInfoService.querySubjectIdCursor(subjectId, categoryId, labelId, 0);
// 设置上一个题目的ID到业务对象BO中
bo.setLastSubjectId(lastSubjectId);
}

SubjectInfoDao.xml

querySubjectIdCursor对应的sql语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<select id="querySubjectIdCursor" resultType="java.lang.Long">
select a.id
from subject_info a,
subject_mapping b
where a.id = b.subject_id
and b.category_id = #{categoryId}
and b.label_id = #{labelId}
<if test="cursor !=null and cursor == 1">
and a.id > #{subjectId}
</if>
<if test="cursor !=null and cursor == 0">
and a.id &lt; #{subjectId}
</if>
limit 0,1
</select>