云岚到家前六章项目
项目概述
云岚到家项目是一个家政服务O2O平台,提供在线下单、抢单、派单、上门服务等业务,平台包括四个端:用户端(小程序)、服务端(app)、机构端、运营端,采用前后端分离开发模式,服务端包括订单、派单、抢单、支付、优惠券、秒杀等微服务,项目使用了MySQL、Redis、MQ、ES、等中间件,学完本项目将具备使用AI开发大型项目的实战能力。
学习收获
学习目标
1.掌握系统需求分析的流程与方法
2.掌握根据产品原型进行系统设计的方法
3.掌握使用AI进行三层架构的开发方法
4. 掌握分布式项目常见技术方案的设计与开发
5.掌握微服务架构常用中间件的原理与使用
6.掌握商品管理模块的设计与开发方法
7.掌握用户认证模块的的设计与开发方法
8.掌握门户模块的的设计与开发方法
9.掌握订单管理模块的设计与开发方法
10.掌握购物车模块的设计与开发方法
11.掌握支付模块的设计与开发方法
12.掌握优惠券模块的设计与开发方法
13.掌握秒杀抢购模块的设计与开发方法
14.掌握抢单模块的设计与开发方法
15.掌握派单调度模块的设计与开发方法
16.掌握售后服务模块的设计与开发方法
17.掌握搜索模块的设计与开发方法
18 能够独立进行电商等多行业项目的设计与开发
19.掌握线上故障处理的流程与常用方法
解决方案
1.缓存一致性解决方案
2.异构数据同步解决方案
3.MQ消息可靠性解决方案
4.基于ES实现全文检索解决方案
5.用户定位解决方案
6.用户认证解决方案
7.文件存储解决方案
8.支付服务设计方案
9.保证接口安全性解决方案
10.基于状态机的状态管理解决方案
11.订单快照解决方案
12.取消订单解决方案
13.关系数据库分库分表解决方案
14.海量数据分页查询优化方案
15.数据冷热分离优化方案
16.多线程任务处理解决方案
17.分布式任务调度解决方案
18.分布式锁解决方案
19.秒杀抢购业务解决方案
20.分布式事务控制解决方案
21.搜索附近业务技术方案
22.撮合匹配解决方案
23.系统缓存设计方案
配置要求
工欲善其事必先利其器。
个人电脑内存至少24G以上(为了更好的学习最好升级到32G)不符合条件的同学趁休息日升级电脑。
有条件的购买便携显示器进行分屏开发,开发效率杠杠滴,这是专业程序员的标配。
从下边的地址下载全部资料,按照本文档要求安装配置虚拟机及软件环境。
开发环境文件下载地址:
虚拟机:链接:https://pan.baidu.com/s/1VOWFqY7LucKgDu1yA_xM_Q?pwd=1234
本地maven仓库:链接:https://pan.baidu.com/s/1wqrnwApF_c99huvWcoZhCA 提取码:1234
1 开发工具版本
开发工具列表:
td {white-space:nowrap;border:0.5pt solid #dee0e3;font-size:10pt;font-style:normal;font-weight:normal;vertical-align:middle;word-break:normal;word-wrap:normal;}| 开发工具 | 版本号 | 安装位置 |
| IntelliJ-IDEA | 2021.x以上版本 | 个人电脑 |
| JDK | 11.x | 个人电脑 |
| Maven | 3.6.x以上版本 | 个人电脑 |
| Git | 2.37.x | 个人电脑 |
| VMware-workstation | 16.x或17.x | 个人电脑 |
| CentOS | 7.x | 虚拟机 |
| Docker | 18.09.0 | 虚拟机 |
| MySQL | 8.0.x | docker |
| Elasticsearch | 7.17.7 | docker |
| Kibana | 7.17.7 | docker |
| nacos | 2.4.0 | docker |
| rabbitmq | 3.8.26 | docker |
| redis | 6.2.7 | docker |
| xxl-job-admin | 2.3.1 | docker |
| nginx | 1.12.2 | docker |
| sentinel | 1.8.5 | docker |
| seata | 1.5.2 | docker |
| Canal | 1.15 | docker |
2 IDEA环境配置
安装IDEA,根据下边的步骤进行配置。
2.1 编码配置
2.2 自动导包设置
IDEA可以自动优化导入包,但是有多个同名的类调用不同的包,必须自己手动Alt+Enter设置, 下面可以通过设置来进行导包优化。
2.3 提示忽略大小写
IDEA代码提示默认是区分大小写的,设置为提示忽略大小写,编译我们后期的开发
2.4 设置 Java 编译级别
工程创建成功,点击Project Structure:
点击Project,设置SDK为11及Project language level,如下图:
3 Maven环境
3.1 安装Maven
下载maven3.8.6版本,下载链接如下:
https://dlcdn.apache.org/maven/maven-3/3.8.6/binaries/apache-maven-3.8.6-bin.zip
解压apache-maven-3.8.6-bin.zip到没有中文的目录下。
3.2 配置仓库
1、解压课程资料中的maven仓库下的repository.zip到本地硬盘
2、在Maven的conf目录中setting.xml文件中配置本地仓库的地址。
配置中央仓库位置:
在setting.xml文件中配置阿里云中央仓库地址。
3.3 IDEA中配置maven
在IDEA中配置maven:进入 File --> Settings --> Build --> Build Tools --> Maven
配置maven安装目录、setting.xml及本地仓库的位置。
4 配置虚拟机
4.1 导入虚拟机
项目中用到的一些服务端软件如:Nacos配置中心、Redis、RabbitMQ消息队列等通常会安装在企业局域网的服务器中,开发人员去远程连接它们。在教学中我们在自己的电脑上安装虚拟机,虚拟机代表了企业局域网中的服务器。
服务器操作系统使用Centos7,导入下发的虚拟机文件,也可以自行安装Centos7虚拟机。
1、导入虚拟机:
首先安装VMware-workstation 16.x 或 VMware-workstation 17.x 虚拟机软件(已安装vmware软件的同学不需要重新安装),可从课程资料“软件工具”目录获取安装程序(VMwareworkstation16.rar)。
1)设置网络
点击 “编辑--》虚拟网络编辑器”配置网络地址,地址须与下图一致。
设置子网IP:192.168.101.0,子网掩码:255.255.255.0。
2)导入虚拟机
解压老师提供的虚拟机文件,进入解压后的文件架,双击"CentOS 7 64 位.vmx" 文件,选择复制虚拟机。
对此虚拟机的设置建议至少8G内存、8核CPU,硬盘存储至少40G。
设置完成,启动虚拟机。
注意:虚拟机的IP地址为192.168.101.68,不用修改IP地址。
虚拟机启动成功如下界面:
注意:不用点击进行登录。
3)远程连接虚拟机
虚拟机启动成功,下边使用ssh客户端工具(FinalShell)远程 连接 虚拟机。
打开FinalShell软件,没有安装的可以从课程资料“软件工具”目录获取安装程序(finalshell_install.exe)。
通过下图所示建立新连接:
填写连接信息:
IP地址:192.168.101.68
账号与密码为:root/centos
如下图:
点击“确定”,创建连接成功,如下图:
双击连接信息,连接成功如下图:
执行 systemctl start docker 启动docker。
启动mysql:docker start mysql
启动redis:docker start redis
启动nacos:docker start nacos
以上软件如果已经启动不用重复启动。
查询docker容器:docker ps
如下图:
虚拟机已经安装了项目使用的软件,可通过docker ps -a查询全部安装的容器,具体在使用时再进行启动。
4.2 问题
win11打开.vmx文件报错:“未能启动虚拟机”
尝试解决方法:
-
打开虚拟机的存放路径
-
找到.vmx的配置文件
-
修改 virtualHW.version改为virtualHW.version = "18"
5 配置数据库环境
5.1 启动mysql容器
首先确保Docker容器中的MySQL8已经启动。
运行docker ps命令进行查看:
5.2 使用MySQL客户端连接数据库。
下边使用DataGrip 客户端连接数据库。
客户端工具不作限制,讲义中使用的是DataGrip ,安装程序从课程资料获取(datagrip-2022.2.2.win.zip)。
首先创建数据源
填写数据源信息
账号:root 密码:mysql
点击"Test Connection" 测试成功
在下发的虚拟机中MySQL数据库已经包括了项目使用的数据库。
下边通过客户端工具查询 jzo2o-foundations数据库的信息。
显示jzo2o-foundations数据库,如下图:
通过上图可知jzo2o-foundations数据库总共8张表。
5.3 创建 jzo2o-foundations数据库
如果没有使用下发的虚拟机则需要自行创建 jzo2o-foundations数据库。
首先创建 jzo2o-foundations数据库:
接下来导入数据:
从课程资料下的“sql脚本”目录拿到 jzo2o-foundations-init.sql,下边导入数据:
执行完成, 通过客户端工具查看jzo2o-foundations数据库信息,如下图:
jzo2o-foundations数据库共8张表。
6 配置Redis环境
6.1 启动redis容器
首先确保Docker容器中的redis已经启动,没有启动运行docker start redis启动redis。
运行docker ps命令进行查看:
6.2 使用客户端连接Redis
使用redis客户端程序 resp-2022.4.2.0.0.exe 连接redis。
客户端工具不作限制,讲义中使用的是resp-2022.4.2.0.0.exe,可从软件工具目录获取安装程序(resp-2022.4.2.0.0.exe)。
安装成功,新建一个连接:
配置连接参数:
注意:redis密码为:redis
测试连接成功如下图:
点击“确定”创建连接成功。
7 配置Nacos
7.1 检查配置
导入下发的虚拟机且成功启动nacos容器,如下图:
访问Nacos:http://192.168.101.68:8848/nacos进入nacos首页
在配置列表中找到"家政o2o"的命令空间可以正常查询到数据说明安装成功。
到此说明nacos服务可以正常使用。
如果不能正常显示可重启nacos,运行下边的命令:
docker restart nacos
7.2 自行安装
如果无法正常导入下发的虚拟机下边说明安装并配置nacos的步骤。
参考“第三方软件安装说明”文档安装Nacos。
安装nacos后需要创建命名空间并导入nacos的配置文件。
命名空间的id指定为:75a593f5-33e6-4c65-b2a0-18c403d20f63
然后在配置管理菜单选中“家政o2o”命名空间,导入“课程资料/nacos”下的“nacos_config_export_jzo2o.zip”
导入配置文件后注意修改数据库的IP地址、Redis的IP地址、RabbitMQ的IP地址等配置信息。
以下是部分配置文件:
数据库配置文件:shared-mysql.yaml
Redis配置文件:shared-redis-cluster.yaml
XXL-Job配置文件:shared-xxl-job.yaml
MQ配置文件:shared-rabbitmq.yaml
ES配置文件:shared-es.yaml
当前阶段需要修改shared-mysql.yaml、shared-redis-cluster.yaml中的IP地址为虚拟机的IP地址。
8 配置Git环境
8.1 安装Git
在个人电脑安装Git,使用常用软件工具目录中的Git-2.37.3-64-bit.exe。
也可以自行下载,地址:https://git-scm.com/ (windows版本:https://git-scm.com/download/win)
安装成功,在右键菜单出现Git菜单,如下图
配置git邮箱:
git config --global user.name "你的名字"
git config --global user.email "你的邮箱"
安装成功在IDEA中配置git的安装目录
8.2 注册Git平台账号
git仓库我们使用的是https://gitee.com/ 平台,首先需要注册gitee账号
注册成功登录gitee。
9 创建后端工程
9.1 创建jzo2o-framework
9.1.1 拉取jzo2o-framework工程
首先从git拉取项目的基础工程jzo2o-framework工程,本工程封装了项目所需要的一些基础组件,如:通用的工具类库、持久层MyBatis-Plus的通用功能抽取等。
课程提供的jzo2o-framework工程的仓库地址为:https://gitee.com/jzo2o-v2/jzo2o-framework.git ,大家需要登录自己gitee账号,Fork该仓库到自己的空间,Fork是将其它仓库的内容复制到自己仓库。
登录gitee,然后在浏览器输入https://gitee.com/jzo2o-v2/jzo2o-framework.git 仓库地址,点击"Fork",如下图:
然后选择目标空间进行复制,Fork即拷贝远程仓库https://gitee.com/jzo2o-v2/jzo2o-framework.git 的项目到自己的仓库。
点击“确定”复制成功,从下图可以看出源仓库的信息。
此时jzo2o-framework工程已复制到自己的仓库中,点击“克隆/下载”,复制仓库地址
然后从此Git仓库克隆jzo2o-framework到代码目录。
输入远程仓库地址,并选择本地代码目录:
在弹出窗口选择信任该项目。
接下来打开克隆成功的工程
有些idea无法识别maven工程需要手动操作,找到父工程的pom.xml
右键点击“Add as Maven Project”
操作成功后pom.xml文件前的图标已更改为“m”,表示已识别为maven工程。
注意配置工程使用的jdk
9.1.2 打包上传到maven仓库
在开发中一些公共组件会打包jar上传到公司的maven私服上,其它团队从私服下载jar包。
jzo2o-framework工程中包括了项目中用到一些封装组件,最后会发布为jar供其它项目使用,如果要发布到私服则执行mvn deploy命令,如果仅是在本地开发执行mvn install命令发布到本地仓库即可。
下边将jzo2o-framework打包后发布到本地仓库。
打开maven面板如下图,选择跳过测试,执行install命令。
打包成功观察日志如下:
9.2 创建jzo2o-gateway
9.2.1 拉取网关工程
jzo2o-gateway工程是项目的网关工程,使用Spring Cloud gateway实现,负责请求拦截、请求路由与负载均衡。
下边拉取网关工程,课程提供的git仓库地址:https://gitee.com/jzo2o-v2/jzo2o-gateway.git 。
操作步骤同jzo2o-framework工程,登录自己的gitee账号,Fork该仓库到自己的空间。
jzo2o-gateway创建完成需要注意nacos的ip地址:
9.2.2 运行网关工程
下边使用IDEA启动网关工程。
也可以进入nacos目录,双击运行:jzo2o-gateway-startup.bat启动网关,启动成功如下图:
9.3 创建jzo2o-foundations
运营基础服务工程jzo2o-foundations提供了运营端的接口支持,由于运营基础服务工程在课堂上我们需要开发维护,使用Git管理代码。
拉取jzo2o-foundations工程,本课程提供的git地址:https://gitee.com/jzo2o-v2/jzo2o-foundations.git,操作步骤同jzo2o-framework工程,登录自己的gitee账号,Fork该仓库到自己的空间。
工程创建成功如下图所示:
9.3.1 切换分支
接下来切换分支到“dev_01”,右键“dev_01”分支,点击Checkout
切换成功如下:
9.4 创建jzo2o-api
jzo2o-api工程提供了微服务内部接口的维护,用于微服务之间的远程调用。
拉取jzo2o-api工程,本课程提供的git地址:https://gitee.com/jzo2o-v2/jzo2o-api.git,操作步骤同jzo2o-framework工程,登录自己的gitee账号,Fork该仓库到自己的空间,从自己的空间拉取jzo2o-api工程,并切换到dev_01分支。
创建完成如下图:
和framework一样需要将api打包jar包上传到maven仓库被其它工程依赖。
接下来将API工程打包jar上传到maven仓库,如下图:
操作成功:
9.5 运行jzo2o-foundations
9.5.1 配置jzo2o-foundations
运行前配置nacos地址:
目前只使用了mysql和redis,需要保证mysql和redis启动成功,如下图:
进入nacos修改shared-redis-cluster.yaml中redis的地址:
修改shared-mysql.yaml中mysql数据库的地址。
9.5.2 运行jzo2o-foundations
开发中我们需要以debug方式启动:
启动成功:
9.6 创建jzo2o-publics
9.6.1 拉取jzo2o-publics
公共服务工程jzo2o-publics提供了地图定位、上传图片、验证码等公用服务,本课程提供的git仓库地址为:https://gitee.com/jzo2o-v2/jzo2o-publics.git ,操作步骤同jzo2o-framework工程,登录自己的gitee账号,Fork该仓库到自己的空间。
创建成功,如下图:
9.6.2 配置OSS
jzo2o-publics提供通用的上传图片接口,图片服务器使用阿里的OSS存储。
参考 “OSS配置文档” 开通阿里云OSS服务,并在nacos中配置OSS参数。
9.6.3 配置高德地图
jzo2o-publics提供通用的地图定位接口,地图定位服务使用高德地图。
参考 “高德地图web服务配置文档” 获取调用地理定位接口的key,并在nacos中配置高德地图key。
9.6.4 启动jzo2o-publics
启动前配置nacos地址:
通过IDEA启动
或通过双击启动脚本
10 配置前端环境
10.1 编译前端工程
10.1.1 安装nvm
我们面对的前端项目很多,比如A,B两个并行开发项目,nodejs依赖版本不一致。A项目需要v14.19.1老版本,B项目需要v16.15.0新版本,需要随时切换。
我们可以使用nvm工具灵活切换node.js版本
举例如下:
nvm全英文也叫node.js version management,是一个nodejs的版本管理工具。nvm和n都是node.js版本管理工具,为了解决node.js各种版本存在不兼容现象可以通过它可以安装和切换不同版本的node.js。
请大家自行上网查找nvm安装方法并自行安装。
10.1.2 编译前端工程
node版本切换到14.19.1,如下图:
如果没有14.19.1版本则需要通过nvm安装14.19.1版本的node,运行下的命令:
nvm install 14.19.1
从课程资料下的源码目录找到project-xzb-pc-admin-vue3-java.zip,并解压到project-xzb-pc-admin-vue3-java目录
通过cmd进入project-xzb-pc-admin-vue3-java目录运行下边的命令下载依赖包:
npm install || yarn
##或
cnpm install || yarn
运行效果如下图:
10.2 运行前端工程
10.2.1 运行前端工程
编译完成现在运行前端工程,运行前端工程之前需要启动以下后端服务:
jzo2o-gateway
jzo2o-foundations
接来来配置前端工程连接的网关地址:
通过cmd进入project-xzb-pc-admin-vue3-java目录,找到vite.config.ts文件,打开文件配置网关地址(默认连接本地网关,不需要修改):
如下图:
运行命令:npm run dev 启动前端工程
启动成功如下图:
运行成功自动进入http://localhost:3000
账号:xiaoyan
密码:888itcast.CN764%...
点击“登录”,进入运营管理端
10.2.2 服务管理功能验证
通过下边的功能验证判断开发环境是否搭建成功。
- 服务类型查询
进入服务类型管理,可以正常查询到数据说明配置成功。
如果不能正常查询到数据需要打开浏览器进入调试窗口,右键点击“检查”(讲义中使用的是chrome浏览器)
进入“网络”,找到请求查询服务类型接口的记录,如下图:
请求服务类型接口可以正常返回数据说明请求后端接口正常,如下图:
如果不能正常返回数据需要排查jzo2o-foundations工程是否正常启动成功,需要仔细阅读本文档配置jzo2o-foundations工程。
- 添加服务类型
- 启用服务类型
- 服务项查询
- 添加服务项
10.2.3 上传图片功能验证
通过下边的功能验证判断jzo2o-publics服务及OSS配置是否正确。
进入服务类型管理界面,点击“新建”测试上传图片,如下图:
选择一个图片进行上传,上传成功示例如下:
11 注意点
1、maven本地仓库地址一定要配置正确
2、运行安装文件时要以管理员模式运行。
3、关闭自己电脑的防火墙。
4、如果没有使用下发的虚拟机需要修改数据库的地址、nacos的地址。
修改数据库地址,进入nacos找到shared-mysql.yaml文件,修改数据库的ip、账号、密码,如下图所示。
5、如下警告可以忽略
环境部署好后在进行接口测试时发现控制台报下边的警告:
WARNING: An illegal reflective access operation has occurred
WARNING: Illegal reflective access by com.baomidou.mybatisplus.core.toolkit.SetAccessibleAction (file:/D:/develop/repository/com/baomidou/mybatis-plus-core/3.4.3/mybatis-plus-core-3.4.3.jar) to field java.lang.invoke.SerializedLambda.capturingClass
WARNING: Please consider reporting this to the maintainers of com.baomidou.mybatisplus.core.toolkit.SetAccessibleAction
WARNING: Use --illegal-access=warn to enable warnings of further illegal reflective access operations
WARNING: All illegal access operations will be denied in a future release
以上警告表示在运行时发生了非法反射操作,这是因为Java9以上版本限制了反射操作的范围,无法通过过反射访问或修改类的私有成员,而Mybatis-plus存在违反该规则的代码。
此警告不影响正常使用mybatis-plus,如果不想看到该警告信息可在启动程序时加入--illegal-access=warn
如下图:
以下软件已安装不用重新安装。
MySQL
拉取镜像:
docker pull mysql:8.0.26
创建以下目录:
sudo mkdir -p /usr/mysql/conf /usr/mysql/data /var/log/mysql
sudo chmod -R 777 /usr/mysql/ /var/log/mysql
创建配置文件
vim /usr/mysql/conf/my.cnf
添加以下内容到上述创建的配置文件中
[client]
#socket = /usr/mysql/mysqld.sock
default-character-set = utf8mb4
[mysqld]
#pid-file = /var/run/mysqld/mysqld.pid
#socket = /var/run/mysqld/mysqld.sock
#datadir = /var/lib/mysql
#socket = /usr/mysql/mysqld.sock
#pid-file = /usr/mysql/mysqld.pid
datadir = /usr/mysql/data
general_log = 1
general_log_file = /var/log/mysql/access.log
log-error = /var/log/mysql/error.log
character_set_server = utf8mb4
collation_server = utf8mb4_bin
secure-file-priv= NULL
#Disabling symbolic-links is recommended to prevent assorted security risks
#symbolic-links=0
#Custom config should go here
#!includedir /etc/mysql/conf.d/
创建启动容器
docker run -itd --name=mysql -v /usr/mysql/conf/my.cnf:/etc/mysql/my.cnf -v /usr/mysql/data:/usr/mysql/data -v /var/log/mysql:/var/log/mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=mysql mysql:8.0.26
说明: -v : 挂载宿主机目录和 docker容器中的目录,前面是宿主机目录,后面是容器内部目录 -d : 后台运行容器 -p 映射容器端口号和宿主机端口号 -e 环境参数,MYSQL_ROOT_PASSWORD设置root用户的密码
下边修改mysql的访问权限为所有客户端可以访问:
进入容器内部
sudo docker exec -it mysql /bin/bash
连接mysql
mysql -uroot -pmysql
使用mysql库
use mysql;
修改访问主机以及密码等,设置为所有主机可访问
ALTER USER 'root'@'%' IDENTIFIED WITH mysql_native_password BY 'mysql';
flush privileges;
Nacos
安装数据库
我们基于Docker来部署Nacos的注册中心,首先我们要准备MySQL数据库表,用来存储Nacos的数据。由于是Docker部署,所以大家需要将资料中的SQL文件导入到你Docker中的MySQL容器中:
最终表结构如下:
创建容器
然后,找到课前资料下的nacos文件夹和nacos.2.4.tar,全部上传到/root下:
nacos.2.4.tar是nacos的镜像文件,nacos文件夹下是nacos配置文件。
其中的nacos/custom.env文件中,注意:有一个MYSQL_SERVICE_HOST也就是mysql地址,需要修改为你自己的虚拟机IP地址:
然后,将课前资料中的nacos目录上传至虚拟机的/root目录。
首先导入nacos.tar镜像文件:
docker load -i nacos.2.4.tar
进入root目录,然后执行下面的docker命令:
docker run -d \
--name nacos \
--env-file ./nacos/custom.env \
-p 8848:8848 \
-p 9848:9848 \
-p 9849:9849 \
--restart=always \
nacos/nacos-server:v2.4.0.1
访问nacos
启动完成后,访问下面地址:http://192.168.101.68:8848/nacos/,注意将192.168.101.68替换为你自己的虚拟机IP地址。
进入首页:
注意:2.4版本开始nacos默认不进行认证直接进入首页,以前的版本首次访问会跳转到登录页,账号密码都是nacos
xxl-job
拉取镜像:
docker pull xuxueli/xxl-job-admin:2.3.1
创建目录:
/data/soft/xxl-job
/data/soft/xxl-job/applogs
创建配置文件:/data/soft/xxl-job/application.properties,内容如下:
### web
server.port=8080
server.servlet.context-path=/xxl-job-admin
### actuator
management.server.servlet.context-path=/actuator
management.health.mail.enabled=false
### resources
spring.mvc.servlet.load-on-startup=0
spring.mvc.static-path-pattern=/static/**
spring.resources.static-locations=classpath:/static/
### freemarker
spring.freemarker.templateLoaderPath=classpath:/templates/
spring.freemarker.suffix=.ftl
spring.freemarker.charset=UTF-8
spring.freemarker.request-context-attribute=request
spring.freemarker.settings.number_format=0.##########
### mybatis
mybatis.mapper-locations=classpath:/mybatis-mapper/*Mapper.xml
#mybatis.type-aliases-package=com.xxl.job.admin.core.model
### xxl-job, datasource
spring.datasource.url=jdbc:mysql://192.168.101.68:3306/xxl_job_2.3.1?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=mysql
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
### datasource-pool
spring.datasource.type=com.zaxxer.hikari.HikariDataSource
spring.datasource.hikari.minimum-idle=10
spring.datasource.hikari.maximum-pool-size=30
spring.datasource.hikari.auto-commit=true
spring.datasource.hikari.idle-timeout=30000
spring.datasource.hikari.pool-name=HikariCP
spring.datasource.hikari.max-lifetime=900000
spring.datasource.hikari.connection-timeout=10000
spring.datasource.hikari.connection-test-query=SELECT 1
spring.datasource.hikari.validation-timeout=1000
### xxl-job, email
spring.mail.host=smtp.qq.com
spring.mail.port=25
spring.mail.username=xxx@qq.com
spring.mail.from=xxx@qq.com
spring.mail.password=xxx
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.starttls.required=true
spring.mail.properties.mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory
### xxl-job, access token
xxl.job.accessToken=default_token
### xxl-job, i18n (default is zh_CN, and you can choose "zh_CN", "zh_TC" and "en")
xxl.job.i18n=zh_CN
## xxl-job, triggerpool max size
xxl.job.triggerpool.fast.max=200
xxl.job.triggerpool.slow.max=100
### xxl-job, log retention days
xxl.job.logretentiondays=30
创建数据库:xxl_job_2.3.1
导入xxl_job_2.3.1.sql,如下:
创建容器:
docker run -d -e \
--restart=always \
-v /data/soft/xxl-job/applogs:/data/applogs \
-v /data/soft/xxl-job/application.properties:/application.properties \
-p 8088:8080 \
--name xxl-job-admin \
xuxueli/xxl-job-admin:2.3.1
启动成功进入管理界面:
http://192.168.101.68:8088/xxl-job-admin
账号/密码:admin/123456
Elasticsearch7.17.7
拉取镜像
docker pull elasticsearch:7.17.7
创建文件夹:
mkdir -p /data/soft/es7.17.7/xzb
在/data/soft/es7.17.7/xzb下创建data目录并且修改权限为777
mkdir data
chmod 777 data
将课程资料下的"ES安装"目录中的 es.zip上传到/data/soft/es7.17.7/xzb下,并进行解压
unzip es.zip
解压成功如下图:
创建容器
docker run -d \
--name elasticsearch7.17.7 \
--restart always \
-p 9200:9200 \
-p 9300:9300 \
-e "discovery.type=single-node" \
-e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \
-v /data/soft/es7.17.7/xzb/data:/usr/share/elasticsearch/data \
-v /data/soft/es7.17.7/xzb/plugins:/usr/share/elasticsearch/plugins \
-v /data/soft/es7.17.7/xzb/config:/usr/share/elasticsearch/config \
elasticsearch:7.17.7
访问http://192.168.101.68:9200/,如下图说明启动成功:
kibana7.17.7
拉取镜像
docker pull kibana:7.17.7
创建容器:
注意修改es的地址
docker run --name kibana7.17.7 \
-e ELASTICSEARCH_HOSTS=http://192.168.101.68:9200 \
-p 5601:5601 \
-d kibana:7.17.7
下边启动容器,先保证Elasticsearch启动成功。
启动kibana容器成功,在浏览器输入地址访问:http://192.168.101.68:5601,进入DevTools,如下图:
执行:GET /_cat/indices?v 查询索引信息
创建项目所需要的索引
启动ES和kibana:
如果没有安装参考本文档 安装elasticsearch7.17.7 和 kibana7.17.7。
安装完成后进行启动:
docker start elasticsearch7.17.7
docker start kibana7.17.7
本项目共需创建下边三个索引结构:
首先通过下边的命令查询索引
GET /_cat/indices?v
创建orders_seize和serve_provider_info(已经存在无法重复创建)
PUT /serve_provider_info
{
"mappings" : {
"properties" : {
"acceptance_num" : {
"type" : "integer"
},
"city_code" : {
"type" : "keyword"
},
"evaluation_score" : {
"type" : "double"
},
"id" : {
"type" : "long"
},
"location" : {
"type" : "geo_point"
},
"pick_up" : {
"type" : "integer"
},
"serve_item_ids" : {
"type" : "long"
},
"serve_provider_type" : {
"type" : "integer"
},
"serve_times" : {
"type" : "integer"
},
"setting_status" : {
"type" : "long"
},
"settting_status" : {
"type" : "integer"
},
"skills" : {
"type" : "long"
}
}
}
}
如果需要修改索引结构需要删除重新创建:
DELETE 索引名
查询索引结构
GET /索引名/_mapping
下边继续创建其它索引:
创建:orders_seize (已经存在无法重复创建)
PUT /orders_seize
{
"mappings" : {
"properties" : {
"city_code" : {
"type" : "keyword"
},
"id" : {
"type" : "long"
},
"key_words" : {
"type" : "text",
"analyzer" : "ik_max_word",
"search_analyzer" : "ik_smart"
},
"location" : {
"type" : "geo_point"
},
"orders_amount" : {
"type" : "float"
},
"pur_num" : {
"type" : "integer"
},
"serve_address" : {
"type" : "text",
"index" : false
},
"serve_item_id" : {
"type" : "long"
},
"serve_item_img" : {
"type" : "text",
"fields" : {
"keyword" : {
"type" : "keyword",
"ignore_above" : 256
}
}
},
"serve_item_name" : {
"type" : "text",
"index" : false
},
"serve_start_time" : {
"type" : "text",
"index" : false
},
"serve_time" : {
"type" : "integer"
},
"serve_type_id" : {
"type" : "long"
},
"serve_type_name" : {
"type" : "text",
"index" : false
},
"total_amount" : {
"type" : "double"
}
}
}
}
创建serve_aggregation索引 (已经存在无法重复创建)
PUT /serve_aggregation
{
"mappings" : {
"properties" : {
"city_code" : {
"type" : "keyword"
},
"detail_img" : {
"type" : "text",
"index" : false
},
"hot_time_stamp" : {
"type" : "long"
},
"id" : {
"type" : "keyword"
},
"is_hot" : {
"type" : "short"
},
"price" : {
"type" : "double"
},
"serve_item_icon" : {
"type" : "text",
"index" : false
},
"serve_item_id" : {
"type" : "keyword"
},
"serve_item_img" : {
"type" : "text",
"index" : false
},
"serve_item_name" : {
"type" : "text",
"analyzer": "ik_max_word",
"search_analyzer":"ik_smart"
},
"serve_item_sort_num" : {
"type" : "short"
},
"serve_type_icon" : {
"type" : "text",
"index" : false
},
"serve_type_id" : {
"type" : "keyword"
},
"serve_type_img" : {
"type" : "text",
"index" : false
},
"serve_type_name" : {
"type" : "text",
"analyzer": "ik_max_word",
"search_analyzer":"ik_smart"
},
"serve_type_sort_num" : {
"type" : "short"
}
}
}
}
sentinel
安装
从课程资料--》开发环境配置目录找到“sentinel”目录获取sentinel.zip,并上传至/data/soft目录下
解压sentinel.zip:
unzip sentinel.zip
注意:
如果unzip命令不可用则需要安装unzip:
sudo yum install unzip
进入/data/soft/sentinel目录,如下所示:
注意修改docker-compose.yml中的nacos的地址及nacos的账号和密码:
version: "3.8"
services:
sentinel:
container_name: sentinel-dashboard
image: sentinel-dashboard:latest
ports:
- "8080:8080"
environment:
- "TZ=Asia/Shanghai"
# nacos访问地址+端口号
- SENTINEL_NACOS_SERVER_ADDR=192.168.101.68:8848
# nacos访问账号
- SENTINEL_NACOS_USERNAME=nacos
# nacos访问密码
- SENTINEL_NACOS_PASSWORD=nacos
# nacos访问命名空间
- SENTINEL_NACOS_NAMESPACE=75a593f5-33e6-4c65-b2a0-18c403d20f63
# sentinel dashboard平台登录账号
- SENTINEL_USERNAME=sentinel
# sentinel dashboard平台登录密码
- SENTINEL_PASSWORD=sentinel
# sentinel dashboard 访问端口号
- SENTINEL_PORT=8080
执行下边的命令创建镜像,如下:
docker build -t sentinel-dashboard .
下边启动容器:
docker-compose up -d
启动容器成功,通过docker ps命令查看容器:
接下来访问sentinel ,地址:http://192.168.101.68:8080/#/login
需要输入账号和密码,默认都是:sentinel
项目集成sentinel
- 添加nacos配置文件shared-sentinel.yaml,如下:
spring:
cloud:
sentinel:
transport:
# 供sentinel dashboard平台访问端口
port: 8719
# sentinel控制台
dashboard: 192.168.101.68:8080
#服务启动直接建立心跳连接
eager: true
注意:
1)、spring.cloud.sentinel.transport.port端口可以无需修改,如果多个微服务运行在同一个物理机时,同时都要占用8719端口,记得修改端口号,保证sentinel.transport.port端口不冲突,如果使用docker部署一定要将该端口开放
2)、spring.cloud.sentinel.transport.dashboard需要配置成自己sentinel-dashboard的访问地址
- 在项目中引入shared-sentinel.yaml配置文件
只需要在远程调用的调用方(客户端)配置sentinel即可,订单管理服务调用 客户管理服务,只需要在订单管理服务配置sentienl。
- 项目代码中添加依赖
<dependency>
<groupId>com.jzo2o</groupId>
<artifactId>jzo2o-sentinel</artifactId>
</dependency>
注意:
1)、hibernate-validator版本不要高于6,sentinel 1.8.5版本对于hibernate-validator 6以上版本存在不兼容问题。
2)、sentinel-dashboard注册中心和配置中心一定要和微服务保持一致,不然读不到熔断配置。
Seata
拉取镜像:
docker pull seataio/seata-server:1.5.2
创建目录:
mkdir -p /data/soft/seata-jzo2o/data /data/soft/seata-jzo2o/config
将配置resources.tar 上传到服务器解压到/data/soft/seata-jzo2o/config
解压resources.tar : tar xvf resources.tar
注意修改resources中application.yml中nacos的配置
server:
port: 7091
spring:
application:
name: seata-server
console:
user:
username: seata
password: seata
logging:
config: classpath:logback-spring.xml
file:
path: ${user.home}/logs/seata
seata:
config:
# support: nacos 、 consul 、 apollo 、 zk 、 etcd3
type: nacos
nacos:
server-addr: 192.168.101.68:8848
namespace: 75a593f5-33e6-4c65-b2a0-18c403d20f63
group: DEFAULT_GROUP
username: nacos
password: nacos
##if use MSE Nacos with auth, mutex with username/password attribute
#access-key: ""
#secret-key: ""
data-id: seata-server.properties
registry:
# support: nacos 、 eureka 、 redis 、 zk 、 consul 、 etcd3 、 sofa
type: nacos
#preferred-networks: 30.240.*
nacos:
application: seata-server
server-addr: 192.168.101.68:8848
group: DEFAULT_GROUP
namespace: 75a593f5-33e6-4c65-b2a0-18c403d20f63
cluster: default
username: nacos
password: nacos
##if use MSE Nacos with auth, mutex with username/password attribute
#access-key: ""
#secret-key: ""
server:
service-port: 8091 #If not configured, the default is '${server.port} + 1000'
max-commit-retry-timeout: -1
max-rollback-retry-timeout: -1
rollback-retry-timeout-unlock-enable: false
enableCheckAuth: true
retryDeadThreshold: 130000
xaerNotaRetryTimeout: 60000
recovery:
handle-all-session-period: 1000
undo:
log-save-days: 7
log-delete-period: 86400000
session:
branch-async-queue-size: 5000 #branch async remove queue size
enable-branch-async-remove: false #enable to asynchronous remove branchSession
metrics:
enabled: false
registry-type: compact
exporter-list: prometheus
exporter-prometheus-port: 9898
transport:
rpc-tc-request-timeout: 30000
enable-tc-server-batch-send-response: false
shutdown:
wait: 3
thread-factory:
boss-thread-prefix: NettyBoss
worker-thread-prefix: NettyServerNIOWorker
boss-thread-size: 1
security:
secretKey: SeataSecretKey0c382ef121d778043159209298fd40bf3850a017
tokenValidityInMilliseconds: 1800000
ignore:
urls: /,/**/*.css,/**/*.js,/**/*.html,/**/*.map,/**/*.svg,/**/*.png,/**/*.ico,/console-fe/public/**,/api/v1/auth/login
创建seata数据库,导入seata.sql
登录nacos配置seata-server.properties配置文件,内容如下:
store.mode = db
store.db.datasource = druid
store.db.dbType = mysql
store.db.driverClassName = com.mysql.cj.jdbc.Driver
store.db.url = jdbc:mysql://192.168.101.68:3306/seata?useUnicode=true&characterEncoding=utf8&autoReconnect=true&allowMultiQueries=true&useSSL=false
store.db.user = root
store.db.password = mysql
store.db.minConn = 5
store.db.maxConn = 100
store.db.globalTable = global_table
store.db.branchTable = branch_table
store.db.lockTable = lock_table
store.db.distributedLockTable = distributed_lock
store.db.queryLimit = 100
store.db.maxWait = 5000
#seata.tm.global_transaction_timeout = 60000
#seata.tm.beginTimeout = 5000
创建容器,seata端口号:8091(程序交互端口,根据情况进行修改),7091(管理端工具端口,根据情况进行修改)
docker run -d \
--name seata-server \
--restart always \
-p 8091:8091 \
-p 7091:7091 \
-v /data/soft/seata-jzo2o/config/resources:/seata-server/resources \
-e SEATA_IP=192.168.101.65 \
seataio/seata-server:1.5.2
测试,登录地址http://192.168.101.68:7091,账号和密码均为seata/seata,首次登录可能会慢稍等1-2分钟
注册阿里云账号
注册成功登录阿里云。
配置bucket
进入控制台:
搜索OSS
点击上图中控制台“对象存储OSS”,立即创建Bucket:
点击“立即创建”,填写bucket的信息,如下图:
注意:Bucket名称不能重复,除了Bucket外其它项目按下图填写
点击“确定”:
点击“进入Bucket”,到此Bucket创建成功
获取访问凭证
鼠标移动到右上角头像处,如下图:
点击“AccessKey管理”,如下图:
点击“创建AccessKey”,进行安装验证,如下图,选择手机号验证。
验证成功,创建AccessKey成功,如下图:
点击“复制”,将AccessKey ID和密钥保存好,稍后使用。
到此拿到了两个参数:accessKeyId和accessKeySecret。
重新进入OSS控制台,进入Bucket列表,如下图:
点击创建的Bucket
进入概览
复制Bucket名称此时拿到了bucket参数,如下图:
在本页面下方复制外网访问域名,此时拿到了endpoint参数。
到此我们共拿到了四个参数:
endpoint: 外网访问域名
accessKeyId: 访问key
accessKeySecret: 密钥
bucketName: 桶的名称
在nacos配置
进入nacos在jzo2o-publics.ymal中配置上边拿到四个参数:
官方参考地址:https://lbs.amap.com/api/webservice/guide/create-project/get-key
成为开发者并创建 key
登录控制台
登录 高德开放平台控制台,如果没有开发者账号,请注册开发者。
创建新应用
进入应用管理,创建新应用
创建 key
点开新应用,新应用中添加 key,服务平台选择 Web 服务。
获取 key
创建成功后,可获取 key。
到此我们获取高德地图接口key。
在nacos配置key
进入nacos配置jzo2o-publics.yml中高德地图key。
1后端
1.1 数据库
电商项目的数据库使用MySQL8,首先创建数据库“mall-plus”
从课程资料中找到“电商项目”目录中mall-plus.sql,创建表并导入数据。
共59张表
1.2 Redis
保证虚拟机中的Redis启动成功
1.3 MQ
保证虚拟机的RabbitMQ启动成功
1.4 Elasticsearch
保证虚拟机的Elasticsearch启动成功
1.5 部署工程
将“电商项目”目录中的mall-plus目录拷贝到代码目录,并使用IDEA打开mall-plus目录。
找到每个端的配置进行配置:
在application-dev.yml中主要配置:
-
数据库的IP、账号、密码等信息
-
redis的IP、密码等信息
-
RabbitMQ的IP、虚拟主机、账号、密码等信息
-
Elasticsearch的IP、端口等信息
配置完成,启动电商项目。
2 前端
2.1 安装nvm
我们面对的前端项目很多,比如A,B两个并行开发项目,nodejs依赖版本不一致。A项目需要v119.1老版本,B项目需要v16.15.0新版本,需要随时切换。
我们可以使用nvm工具灵活切换node.js版本
举例如下:
nvm全英文也叫node.js version management,是一个nodejs的版本管理工具。nvm和n都是node.js版本管理工具,为了解决node.js各种版本存在不兼容现象可以通过它可以安装和切换不同版本的node.js。
请大家自行上网查找nvm安装方法并自行安装。
2.2 运营端
node版本切换到116.0,如下图:
在“电商项目”目录下“mall-ui\manager”目录拷贝到代码目录,cmd进入此目录,运行"npm install || yarn"命令,安装依赖。
如下图:
启动运营端,运行:yarn run dev
如下图:
2.2 店铺端
在“电商项目”目录下“mall-ui\store”目录拷贝到代码目录,参考运营端运行店铺端。
2.3 小程序
2.3.1 申请小程序账号
开发小程序首先要申请小程序账号,参考官方文档:https://developers.weixin.qq.com/miniprogram/dev/framework/quickstart/getstart.html#申请账号
点击注册小程序(https://mp.weixin.qq.com/wxopen/waregister?action=step1)填写信息,如下图:
填写完成进入下一步:
进入邮箱激活小程序,进入下一步信息登记,选择“个人”
填写主体登记信息:
使用自己的个人微信扫描上图的二维码,如下图,点击“确定”
点击“确定”后提示“信息提交成功”,点击前往小程序。
进入小程序管理界面:
填写小程序信息,点击上图中小程序信息栏目的“去填写按钮”,如下图:
小程序名称一定谨慎填写,每年是有一定修改次数限制的。
注意:小程序名称不能重复
下边配置最关键的appid和密钥,通过左侧菜单找到开发管理菜单:
点击“开发管理”菜单进入下图界面,点击“开发设置”,如下图:
点击“生成” 弹出二维码,如下图:
使用自己的微信扫描二维码生成密钥成功,点击“复制”将密钥和AppID妥善保存,开发小程序要使用。
如果密钥丢失可以进行重置:
2.3.2 安装小程序开发工具
本部分内容可参考微信开发文档:https://developers.weixin.qq.com/ebook?action=get_post_info&docid=000e8842960070ab0086d162c5b80a
首先下载微信小程序开发工具。
用户端是基于微信小程序开发的,首先需要下载并安装微信开发者工具:
可以使用课程资料中提供的安装包或在线下载,点击下载(https://developers.weixin.qq.com/miniprogram/dev/devtools/stable.html)
安装成功创建快捷方式:
2.3.3 运行小程序
然后打开微信开发者工具,初次使用弹出身份确认,如下图,使用申请小程序账号时用的微信进行扫码。
扫码通过进入下边的界面:
进入添加小程序项目界面,如下图:
目录:选择小程序前端工程的 shop-uniapp\unpackage\dist\dev\mp-weixin目录。
AppID:填写申请小程序号获取的AppID。
选择不使用云服务。
如下图:(注意每人的目录位置可能不一样)
点击确定进入下边的界面:
小程序开发环境配置完成进行编译运行。
首先清除缓存
然后编译运行:
学习目标
入职公司进入项目团队的第一件事就是熟悉项目,本章以家政服务O2O项目为线索一起教大家如何去熟悉一个新项目,通过本章节的学习完成以下学习目标:
-
能够说出项目的业务模块
-
能够说出项目的业务流程
-
能够说出项目的技术架构
-
能够搭建项目的开发环境
-
能够根据需求熟悉项目的表结构和代码
1 如何熟悉一个新项目
进入一家新公司初次接触一个新项目应该从哪里入手呢?怎么做才能快速的去熟悉项目并顺利进入开发工作?
首先去熟悉项目大概情况,包括:项目的背景、项目模块、业务流程、项目架构等。
项目情况大概了解后就可以搭建开发环境。
然后去理解自己所分配的模块的需求及设计,熟悉项目现有的代码并进入开发。
1.1 项目的业务模块
了解项目的业务从项目背景开始。
目标:
能够说出项目整体业务流程
1)项目背景
2015年国家就提出了"互联网+"的行动计划,什么是"互联网+"?
“互联网+”简单的说就是“互联网+传统行业”,互联网为传统行业赋能,利用互联网具备的优势特点对传统行业进行优化升级转型,最终推动社会不断地向前发展。
互联网+的成功案例有很多,像滴滴出行、美团外卖和共享单车等平台将传统出行、餐饮和交通等行业与互联网连接,提供更便捷、经济高效的服务。
云岚到家项目是一个家政服务o2o平台,互联网+家政是继打车、外卖后的又一个风口,创业者众多,比如:58到家,天鹅到家等,o2o(Online To Offline)是将线下商务的机会与互联网的技术结合在一起,让互联网成为线下交易的前台,同时起到推广和成交的作用。
家政服务o2o市场前景非常好,中国社会的家庭小型化、人口老龄化进程加快,加之二胎政策、三孩政策的推行,创造了大量对于家政服务的潜在需求。艾媒咨询发布《2022-2023年中国家政服务行业发展剖析及行业投资机遇分析报告》显示,从2015年的2776亿元已提升至2021年的10149亿元,进入万亿级市场行列。
中国家政服务业从业人员数量规模逐年稳步增长
2)运营模式
我们调研了家政服务O2O平台的运营模式,如下:
C2B2C:
在家政 O2O(Online to Offline,线上到线下)领域中,"Consumer to Business to Consumer"(C2B2C)描述了一个商业模式,消费者不仅可以通过平台获取家政服务,还有机会成为服务提供者,平台做为中介提供家政服务的机会给服务提供者,并从中抽成实现盈利。在这个背景下,C2B2C 模式通常指的是:
-
消费者(Consumer):
- 最终的家庭用户,他们需要家政服务,例如清洁、保姆、维修等。
-
企业(Business):
- 在家政 O2O 中,企业通常是在线平台,提供家政服务的中介。平台通过在线渠道为消费者提供了查找、预订、支付等服务,同时也为家政服务提供者提供了工作机会。
-
消费者(家政服务提供者):
- 在 C2B2C 模式中,一些消费者也可以成为服务的提供者。这些个体可能是独立的家政服务专业人员,他们可以在家政 O2O 平台上注册,提供自己的服务,并被其他需要服务的消费者雇佣。
B2B2C:
代表着"Business to Business to Consumer",即企业到企业到消费者的模式。家政服务平台作为中间商,通过与各种家政服务提供商(家政服务公司)合作,为消费者提供多样化的家政服务选择。
B2B2C与C2B2C的区别是:B2B2C中服务提供者是家政服务中介公司,在C2B2C中是服务提供者是拥有服务技能的服务人员(散户)。
本项目结合了C2B2C和B2B2C模式,个人和家政服务中介都可以通过平台提供家政服务,如下图:
项目包括四个端:用户端(小程序)、服务端(app)、机构端(PC)、运营管理端(PC),四个端对应四类用户角色:
家政需求方:通过用户端小程序完成在线预约下单、支付、评价、投诉、退款等操作。
家政服务人员:通过服务端APP完成在线接单、结算等操作。
家政服务公司:通过机构端完成在线接单、派单、投诉处理、结算等操作。
平台方:通过管理端完成服务人员管理、机构管理、订单管理、财务管理等操作,一笔完成的订单,结算时按照分成比例平台进行抽成。
3) 项目模块
项目模块列表:
根据每个端去细化模块,项目业务模块图如下:
核心模块说明:
服务管理:对家政服务项目进行管理,最后在指定区域上架服务后用户可在当前区域购买。
下单支付:用户通过小程序完成下单支付,进入小程序首页查询服务,用户选择服务,下单并支付
抢单:服务人员和机构进行抢单。首先服务人员和机构设置接单范围、服务技能、开启抢单开关,然后进入抢单界面进行抢单。
派单调度:平台根据撮合匹配算法通过任务调度将订单和服务人员进行撮合匹配,促进成交。
订单管理:对订单的生命周期进行管理,包括创建订单、取消订单、删除订单、历史订单等。
服务人员管理:对服务人员的信息、认证等进行管理。
企业管理:对机构的信息、认证进行管理。
客户管理:对c端用户的信息、用户的状态等信息进行管理。
营销管理:对优惠券活动进行管理。
4) 项目业务流程
项目核心业务流程如下:
暂时无法在飞书文档外展示此内容
核心流程:
- 运营端在运营区域上架家政服务
比如:在北京上架 日常保洁、空调维修。
-
用户端通过定位区域获取当前区域的服务项目,选择家政服务,下单、支付
-
家政服务人员及家政服务公司(机构)通过平台抢单
-
家政服务人员现场服务,平台跟踪管理整个服务过程。
-
服务完成,用户评价、售后服务等。
5)界面原型展示
下边展示部分界面原型,按如下流程展示
-
运营端服务管理,服务上架
-
用户下单、支付(支付可以略过)
-
服务人员抢单
-
现场服务,开始服务、完成服务
-
用户评价
运营端统一管理服务项
运营人员通过运营端管理平台的服务项,设置运营城市开通的服务项。
设置城市开通的服务:
用户端购买服务
用户端通过小程序预约家政服务。
服务人员上门服务
服务人员进入app开启接单
选择接单范围
立即抢单:
抢单成功在规定时间上门服务
开始服务前上传照片
服务完成上传服务后的照片
服务完成,用户进行评价
6)小结
能够说出项目的业务领域:
你的项目是做什么业务的?
能够说出项目涉及几类用户角色
项目涉及哪些角色?
能够说出项目包括哪些业务模块
你的项目包括哪些模块?
能够说出项目整体业务流程:
说下你的项目的业务流程?
1.2 项目的架构
目标:能够用自己的话介绍自己的项目
1)项目架构
项目是基于Spring Cloud Alibaba框架构建的微服务项目,采用前后端分离模式进行开发,系统架构图如下:
用户层:
包括四个端:运营端(PC)、服务端(APP)、机构端(PC)、用户端(小程序)
负载层:
反向代理、负载均衡。
服务层:包括网关、业务微服务、基础服务。
业务微服务:包括运营基础服务、客户管理服务、订单管理服务、抢单服务、派单服务、支付服务等。
基础服务:Nacos(服务注册、配置中心)、XXL-JOB(任务调度)、RabbitMQ(消息队列)、Elasticsearch(全文检索)、Canal(数据同步)、Sentinel(熔断降级、限流)等。
数据层:
MySQL数据库存储:服务信息、区域信息、客户信息、订单信息、支付信息、抢单池、派单池、结算信息等。
分库分表:使用ShardingShphere-JDBC 进行分库分表。
消息队列:存储数据同步消息、各类异步消息等。
索引:服务信息、服务提供者信息、订单信息等。
缓存:服务信息、订单信息、服务单信息等。
2) 核心交互流程
下图是项目的核心交互流程,从下图找到核心交互流程是什么,以及参与的微服务有哪些,并用自己的话描述出来。
暂时无法在飞书文档外展示此内容
3) 小结
能够说出项目用的什么技术架构
你的项目用的什么架构?
能够用自己的话介绍自己的项目
说说你的项目吧?
在面试中被问的第一个问题就是项目介绍。怎么介绍自己的项目呢?
从以下几个方面进行项目介绍:
1、项目的背景,包括:是自研还是外包、什么业务、服务的客户群是谁、谁去运营等问题。
2、项目的业务模块、核心业务流程,可从项目每个端开始介绍。
3、项目的架构是什么
4、个人的工作职责,详细说明自己负责的模块,包括模块的设计,所用到的技术方案,以及所遇到的问题和解决方案。
一个例子:
我最近参与的项目是我们公司自研的家政服务o2o项目,项目包括四个端:用户端(小程序)、服务端(app)、机构端(PC)、运营管理端(PC),用户通过平台在线下单、支付,家政服务人员通过平台抢单,平台也会自动派单给服务人员,服务人员接到订单根据预约时间去现场服务,服务完成后用户进行在线评价,以及售后、退款等业务。 项目是基于Spring Cloud Alibaba框架构建的微服务项目,包括了运营基础服务、客户管理服务、订单管理服务、抢单服务、派单服务、优惠券服务、统计服务、评价服务等服务,系统还使用Redis、MQ、XXL-JOB、Elasticsearch、Canal、MongoDB等中间件。
我在这个项目中负责了xxx模块的设计与开发...
下边我说下xxx模块的具体设计吧....
1.3 作业
参考上边的方法介绍一下你做过的项目:
先写逐字稿,再录制语音,时长5分钟以内。
示例:https://www.bilibili.com/video/BV1j8411N7Bm?p=3&vd_source=81d4489ba9312103debc8ee843169f23
2 开发环境搭建
目标:理解搭建环境的步骤,参考文档搭建开发环境。
参考“家政o2o项目开发环境配置v1.0”搭建开发环境。
3 熟悉需求与设计
通常刚到企业中会分配一个独立的模块给你,这个模块一般会有一些业务逻辑但不会太复杂,下边以运营基础管理模块为例进行讲解。
3.1 熟悉需求
目标:阅读需求文档,理解基本概念和业务流程。
1)熟悉需求文档
参考“运营基础管理模块用户需求文档v1.0”熟悉需求。
如何去熟悉项目的需求?
根据产品原型、用户需求文档去熟悉项目的功能需求、业务流程,如果有测试账号可以登录系统通过体验系统的功能去熟悉项目的需求。
2)口述
下边的问题口述答案。
什么是服务?
什么是服务类型?
向区域添加服务流程是什么?
区域服务上架的流程是什么?
区域服务下架的流程是什么?
服务删除的流程是什么?
服务状态转化关系是什么?
3.2 熟悉设计
目标:
理解运营基本管理模块的表结构。
熟悉工程结构。
能够对接口进行断点调试。
1) 熟悉表结构
使用客户端连接数据库,根据需求去理解本模块的每一张表,
然后尝试梳理表之间的关系。
如下图:
serve_type:服务类型表
serve_item: 服务项表,存储了本平台的家政服务项目
每个服务项都有一个服务类型,一个服务类型下有多个服务项,服务类型与服务项是一对多关系。
region:区域表,存储运营地区信息,一般情况区域表行政级别是市。
serve: 服务表,存储了各个区域运营的服务及相关信息。
注意:这里不要把serve表简单理解为只是区域表和服务项表的中间关系表,因为如果是简单的关联关系表只需记录区域表和服务项表各自的主键Id即可,serve记录的是平台运营服务的信息,凡是与运营相关的信息都要记录在serve表,比如:运营价格。后期也可能会增加其它运营相关的字段。
region与serve_item是什么关系?
一个区域下可以设置多个服务项,一个服务项可以被多个区域设置,region与serve_item是多对多关系。
2) 熟悉工程结构
项目的工程列表如下:
首先熟悉jzo2o-foundations运营基础服务工程的结构,jzo2o-foundations工程结构如下图:
工程目录结构如下:
项目依赖:
<dependencies>
<!--服务发现-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!--配置管理-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!--开启Bootstrap配置文件的支持-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<!--jzo2o-mvc在jzo2o-framework下,针对web开发的依赖-->
<dependency>
<groupId>com.jzo2o</groupId>
<artifactId>jzo2o-mvc</artifactId>
</dependency>
<!--knife4j用于生成swagger文档-->
<dependency>
<groupId>com.jzo2o</groupId>
<artifactId>jzo2o-knife4j-web</artifactId>
</dependency>
<!-- <dependency>-->
<!-- <groupId>com.jzo2o</groupId>-->
<!-- <artifactId>jzo2o-es</artifactId>-->
<!-- </dependency>-->
<!--单元测试-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!--api接口,定义了服务之间的远程调用接口-->
<dependency>
<groupId>com.jzo2o</groupId>
<artifactId>jzo2o-api</artifactId>
</dependency>
<!--序列化工具库-->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>com.jzo2o</groupId>
<artifactId>jzo2o-redis</artifactId>
</dependency>
<!-- <dependency>-->
<!-- <groupId>com.jzo2o</groupId>-->
<!-- <artifactId>jzo2o-canal-sync</artifactId>-->
<!-- </dependency>-->
<!--jzo2o-mysql定义在jzo2o-framework工程,定义了持久层相关的依赖-->
<dependency>
<groupId>com.jzo2o</groupId>
<artifactId>jzo2o-mysql</artifactId>
</dependency>
<!-- <dependency>-->
<!-- <groupId>com.jzo2o</groupId>-->
<!-- <artifactId>jzo2o-xxl-job</artifactId>-->
<!-- </dependency>-->
</dependencies>
持久层:MySQL数据库,mybatis-plus框架,com.github.pagehelper分页组件
中间件:Redis、Elasticsearch、xxl-job
服务层:通过Spring进行事务控制,redisson分布式锁、Spring Cache缓存框架
web层: SpringMVC框架(基于SpringBoot开发)
web容器:undertow(Undertow 是一个采用 Java 开发的灵活的高性能 Web 服务器,红帽公司的开源产品)
高并发场景下undertow的性能更好。
3) 接口测试
搞清楚了项目的工程结构再通过一个具体的接口去读项目的代码。
具体可以把工程启动起来通过接口测试深入源码搞清楚执行流程。
启动jzo2o-foundations工程。
通过swagger接口文档去熟悉模块的接口,通过测试接口去理解接口的整个交互流程。
打开接口文档:http://localhost:11509/foundations/doc.html
通过左侧菜单找到“运营端-服务类型相关接口”,如下图:
点击“服务类型分页查询”打开接口文档,如下图:
找到接口地址:/foundations/operation/serve-type/page
/foundations:微服务的content-path路径
/operation:表示给运营端使用的接口
/serve-type/page:具体的接口地址
在jzo2o-foundations工程搜索“/serve-type”关键字
输入关键字如下图,找到了具体的接口类:
继续在ServeTypeController.java类中找/page接口,如下图:
下边debug运行工程,跟踪接口执行的流程:
首先打上断点,如下图:
debug运行jzo2o-foundations工程。
下边通过swagger接口文档测试该接口
进入“调试”窗口,点击“发送”
此时代码执行到断点处
进入service方法继续打断点跟踪:
跟踪请求参数:
放行继续执行,测试结束,观察响应结果
观察idea控制台输出的SQL执行语句
4) 小结
如何去熟悉系统的设计?
根据需求去熟悉本模块的核心表的结构,核心表搞清楚了再去熟悉其它表。
熟悉项目工程结构,搞清楚用的基础框架是开源的还是自研的,通过断点测试接口的执行流程阅读读项目的代码。
具体可以把工程启动起来通过接口测试深入源码搞清楚执行流程。
如何对现有接口进行断点调试?
运营基础工程用到哪些技术?
1 模块概述
运营服务管理模块为运营人员提供基础数据管理功能,包括:服务类型管理、服务项管理、区域管理、区域服务管理等子模块。
2 服务类型管理
2.1 界面原型
什么是服务?
服务即服务项目,本平台提供的家政服务项目,如:空调维修、电视维修、空调清洗、洗衣机清洗,这里都是服务项目,服务相当于电商系统中的商品。
什么是服务类型?
服务类型就是对服务项目的分类,如:空调维修和电视维修都属于家电维修分类,空调清洗和洗衣机清洗属于家电清洗分类,服务类型相当于电商系统中的商品分类。
服务类型管理模块是对服务类型的信息进行管理,对服务类型的管理流程如下:
1、进入服务类型列表
2、新建一个服务类型
内容如下:
1)服务类型名称。
2)排序字段,控制在页面上的显示顺序。
3)服务类型图标,在首页显示图标。
4)服务类型图片,在首页显示服务类型图片。
3、新建的服务类型启用后才可以在其下边添加服务项。
4、只有新建的服务类型方可删除,服务类型一旦启用将不可删除。
5、启用后的服务类型可以禁用。
注意:只有当服务类型下的服务项全部禁用才可以禁用服务类型。(禁用服务项的内容请参考服务项管理章节)
6、查看服务项将查看该服务类型下的所有服务项,此时进入服务项列表。
2.2 业务流程
1、添加服务类型
暂时无法在飞书文档外展示此内容
2、服务类型启用
服务类型当前状态为草稿或禁用状态方可启动。
暂时无法在飞书文档外展示此内容
3、服务类型禁用
服务类型当前状态为启用状态方可禁用。
暂时无法在飞书文档外展示此内容
4、服务类型删除
服务类型当前状态为草稿状态方可删除。
暂时无法在飞书文档外展示此内容
5、服务类型状态转化关系
暂时无法在飞书文档外展示此内容
新增一个服务类型初始状态为:草稿
启动服务类型:草稿--》启动 或 禁用--》启动
禁用服务类型:启动--》禁用
3 服务项管理
3.1 界面原型
服务项即服务项目,简称为服务,它是平台提供的家政服务项目,相当于电商项目中的商品。
对服务项管理的流程如下:
1、进入服务项管理列表
2、新建服务项
填写服务项的基本信息
内容如下:
1)服务名称
2)服务类型,服务项所属分类。
3)参考服务单价
此价格是参考价格,比如:室内清洁,每小时20元,20元是参考价格,每个地区可能不一样。
4)服务单位
价格的单位,比如:室内清洁,每小时20元,单位是每小时。
5)排序
排序字段,在页面上显示 的顺序。
6)服务图标
在首页显示的服务图标。
7)服务图片
在首页显示的服务图片。
填写服务项的描述、上传服务详情长图
内容如下:
1)服务描述
对服务项的详细描述信息。
2)服务详情长图
类似如下样式的图片
3)预览
点击预览对服务项的详情内容进行预览
预览的效果和手机上看到的一样。
4)填写完毕,点击保存。
服务项新建成功。
3、删除服务项
新建的服务项目可以删除,服务项启用后无法删除。
4、启用服务项
新创建的服务项启用后方可正式使用。
5、禁用服务项
服务项禁用后将不可在平台使用。
注意:当服务项没有在任何地区上架才可以禁用(在地区上架服务项的内容请参考区域服务管理章节)。
3.2 业务流程
1、添加服务项
暂时无法在飞书文档外展示此内容
2、服务项启用
暂时无法在飞书文档外展示此内容
3、服务项禁用
如果该服务项在某些区域正在运营将无法禁用,需要先将该服务项在所有区域下架方可禁用。
关于服务项在区域上架和下架的操作参见:区域服务管理模块。
暂时无法在飞书文档外展示此内容
4、服务项删除
暂时无法在飞书文档外展示此内容
5、服务项状态转化关系
暂时无法在飞书文档外展示此内容
4 区域管理
4.1 界面原型
本模块是对本项目所运营的地区信息进行管理。
1、进入区域管理列表
2、新建区域
内容如下:
地区选择:从城市列表中选择城市。平台是以城市为单位设置运营的地区。
区域负责人:填写负责人姓名
手机号码:填写负责人的电话
3、新创建的区域可以删除,区域启用后不允许删除
4、区域启用后表示该区域正式开始运营
区域存在上架的服务方可启用区域。
5、设置服务
在当前区域设置运营的服务(具体参考区域服务管理章节)
6、调度配置
设置本区域的调度规则,调度规则将在抢单和派单模块使用届时再讲解。
4.2 业务流程
1、添加区域
暂时无法在飞书文档外展示此内容
2、区域启用
暂时无法在飞书文档外展示此内容
3、区域禁用
暂时无法在飞书文档外展示此内容
4、区域删除
暂时无法在飞书文档外展示此内容
5、区域状态转化关系
暂时无法在飞书文档外展示此内容
5 区域服务管理
5.1 界面原型
区域服务管理是为运营地区设置要运营的服务项,不同地区所运营的服务项可能不同,比如:本平台在北京运营了老人陪护服务,而在郑州没有运营老人陪护服务。
1、进入区域列表,点击“设置服务”
2、进入设置服务页面
本页面展示了该区域当前正在运营及停止未运营的服务信息。
3、添加服务
进入添加服务界面,选择要添加的服务,点击“添加”
4、设置区域价格
区域价格即该服务在该区域的运营价格,输入修改后的价格。
区别服务的参考价格和运营价格:
服务的参考价格:在服务项管理模块进行设置,如下图:
本模块设置的价格是服务在某区域实际运营的价格。
问题:用户在某区域购买家政服务使用哪个价格计算?
5、上架
服务上架后用户可以在首页的服务列表点击该服务进行下单购买。
当区域服务的状态为草稿状态或下架状态时方可上架。
6、下架
服务下架后用户将不能对该服务下单。
当区域服务的状态为上架状态方可下架。
7、删除
当状态为草稿状态方可删除。
8、设置热门/取消热门
服务设置为热门将在首页显示,下图红框内显示的就是热门服务:
5.2 业务流程
1、向区域添加服务流程
暂时无法在飞书文档外展示此内容
2、区域服务上架
暂时无法在飞书文档外展示此内容
3、区域服务下架
暂时无法在飞书文档外展示此内容
4、服务删除
暂时无法在飞书文档外展示此内容
5、服务状态转化关系
暂时无法在飞书文档外展示此内容
学习目标
运营服务管理模块是我们在本项目学习的第一个模块,运营服务管理模块相当于商品管理,商品管理功能很通用,最典型的就是电商项目中的商品管理,家政项目中的家政服务项目相当于商品,凡是涉及消费用户的项目都包括商品管理模块,通过本章节的学习掌握商品管理模块的设计、开发细节。
能够对运营服务管理模块进行需求分析
能够说出运营服务管理模块的设计内容
能够设计开发区域服务子模块的接口
能够设计开发运营服务管理模块的其它接口
学习目标如下:
-
能够说出服务上架的流程
-
能够说出家政项目服务管理涉及的表有哪些
-
能够设计并开发区域服务查询接口
-
能够说出前后端联调步骤
-
能够设计并开发添加区域服务接口
-
能够设计并开发修改价格接口
-
能够设计并开发服务上架接口
-
能够设计并开发家政项目实战功能
-
能够说出电商项目商品管理涉及的表有哪些
-
能够说出电商项目商品发布的流程
-
能够设计并开发电商项目实战功能
家政项目实战:
删除区域服务
服务下架
设置热门服务
取消热门服务
启用区域功能完善
禁用区域功能完善
禁用服务项功能完善
代码环境
jzo2o-foundations:dev_01
1 前置知识
1.1 如何进行需求分析
1)如何进行需求分析?
在系统设计之前需要进行需求分析,需求分析就是根据用户所提的需求进行理解,它是软件开发周期中的关键环节,它直接影响到后续的设计、开发和测试阶段。需求分析阶段做好了能够减少后期修改的成本,提高系统的质量和用户的满意度。因此,在需求分析阶段投入足够的时间和资源是非常必要的。
需求分析通常从以下方面进行:
- 理解业务需求:与客户或产品经理沟通,确保你对业务流程有清晰的理解。
产品经理会召集我们一起开会讨论确定产品原型,我会根据产品原型去对每个功能进行分析。
-
梳理功能需求:将业务需求转化为具体的软件功能,划分模块,明确哪些是核心功能,哪些是辅助功能。
-
梳理用户操作事件流:在功能需求的基础上去梳理用户的操作流程。
-
分析对象属性:根据业务流程分析涉及到的对象有哪些及对象属性。
-
分析非功能性需求:比如性能要求、安全性、可维护性等。
2)产品原型
与客户沟通最好的方式就是通过界面原型进行沟通,因为界面原型客户看的懂,它是需求阶段最接近软件成品产物。
在使用界面原型进行沟通时,也需要确保技术术语和设计细节能够被非专业人士所理解。此外,应该保持原型的简洁性和易用性,避免过度复杂化导致信息传递失效。
除了通过界面原型进行沟通,还要与其他沟通方法(如会议讨论、文档说明等)相结合,以确保所有方面的需求都被准确无误地传达给项目团队。
产品原型网:https://www.axureshop.com/
3)分析功能列表
通常按不同的端进行分析,例如下表是云岚到家项目消费端的功能列表(部分功能):
4)模块化设计
模块化设计是在需求分析阶段对功能需求划分模块:
将相关的需求划分到一个模块中,模块下可以有子模块。
举例:
运营服务管理模块的定位是为对平台运营的服务进行管理,功能包括:服务分类管理、服务管理、区域管理等子模块。
运营服务管理是一个模块,其下又分为服务管理、区域管理等子模块,服务管理模块又包括了服务类型管理模块、服务项管理模块等多个子模块。
产品经理根据需求梳理模块清单:
根据每个端去细化模块,项目业务模块图如下:
5)细化需求
完善界面原型
和用户确定了项目范围,下一步就正式进入需求分析阶段,产品经理对界面原型进行完善,细化原型中的参数说明、对象属性、规则、约束、分析用户操作流程等。
举例:
用户操作流程
还需要细化用户的操作流程。
6)非功能性需求
除了功能需求还有非功能需求,比如性能要求、安全性、可维护性等这些也需要在需求阶段进行分析。
举例:
用户登录功能,为了保证安全性对密码加密传输、加密保存。
app首页为了提高动态数据的加载速度,保证首页3秒内加载完毕。
7)需求阶段产物
需求分析阶段的产物:
产品原型。
需求规格说明书。
AI: 给我一份需求规格说明书的例子,内容要足够详细
8)实战
阅读“运营服务管理模块用户需求文档v1.0”,进行区域服务管理子模块进行需求分析,要求如下:
-
列出功能列表,划分子模块
-
分析每个功能用户的操作流程,并写下来
-
分析对象属性,并写下来
举例:
功能:服务类型列表:
操作流程:
点击菜单进入服务类型列表
默认查询全部服务类型列表
通过上一页、下一页进行分页查询
对象属性:
查询列表字段包括:服务类型编号、服务类型名称 、服务类型图标、服务类型图片、排序、状态、更新时间。
举例:
功能:新建服务类型
操作流程:
点击菜单进入服务类型列表
点击“新建”打开新建服务类型窗口
填写“服务类型”、"排序" 信息。
上传服务类型图标、服务类型图片。
点击“保存”提交新建的服务类型信息
点击“取消”关闭窗口。
对象属性:
服务类型名称、排序、服务类型图标、服务类型图片。
9) 小结
你参与需求分析了吗?如何进行需求分析?
1.2 如何进行系统设计
1) 如何进行系统设计?
需求分析阶段完成是系统设计阶段,系统设计分为概要设计和详细设计。
AI:
软件的概要设计和详细设计分别要设计哪些内容
使用SpringBoot开发软件,在概要设计和详细设计阶段分别要设计哪些内容
概要设计是对系统模块、架构、数据库表、系统间的交互接口、外部接口等宏观层面进行设计。
详细设计是对具体类、方法、接口等微观层面的实现细节进行设计。
通常项目都会进行概要设计,详细设计阶段的设计内容根据公司的项目管理水平而不同。
我们在教学过程中将概要设计和详细设计统称为系统设计。
系统设计的核心通常包括以下几个方面:
- 系统模块设计
根据需求划分系统功能模块。
- 系统架构设计
确定基础框架、中间件等技术选型、外部接口和内部接口、工程结构等。
- 数据库设计
划分数据库,设计表,这项是中高级程序员的主要工作。
- 接口设计
在概要设计阶段主要设计外部接口及模块间的部分内部接口。
系统外部接口,比如:调用云平台提供的文件上传接口、微信小程序认证接口、用户定位接口等。
系统模块之间的内部接口,比如:订单模块如何获取商品管理模块的商品信息,订单模块如何获取支付结果。
在详细设计阶段根据需求设计具体的业务接口及模块间的内部接口,这项是中高级程序员的主要工作。
2) 系统架构设计
系统的业务模块划分好后就可以根据业务模块的划分进行系统架构设计:
-
定义应用架构:确定应用的结构(如单体应用、微服务架构)。
-
技术选型:选择合适的框架和技术栈,Spring Boot本身已经提供了很多开箱即用的功能,但你需要选择适合的数据库、缓存机制、消息队列等。
-
模块化:根据业务模块的划分将系统划分为不同的模块或服务,每个模块负责一部分功能,这有助于未来的扩展和维护。
-
设计内外部接口:分析所依赖的外部接口,分析系统内模块之间的交互接口。
举例:
云岚到家是微服务项目,根据业务模块去划分微服务。
技术选型:使用Spring Cloud Alibaba框架,数据库使用MySQL,还使用RabbitMQ、Redis、Sentinel、XXL-JOB、ShardingSphere-JDBC等中间件和框架。
单独设立第三方集成模块,用于和外部平台进行接口对接。
3) 数据库设计
根据需求分析及模块设计进行数据库设计:
-
微服务项目不同的模块设计不同的数据库
-
根据业务需求设计数据库表、及表结构。
-
考虑到数据的一致性和完整性,设计适当的约束条件。
-
性能优化:考虑索引、分区等技术以提高查询效率。
举例:
云岚到家是微服务项目,每个模块有自己独立的数据库
运营服务管理模块核心表如下:
serve_type:服务类型表
serve_item: 服务项表,存储了本平台的家政服务项目
每个服务项都有一个服务类型,一个服务类型下有多个服务项,服务类型与服务项是一对多关系。
region:区域表,存储运营地区信息,一般情况区域表行政级别是市。
serve: 服务表,存储了各个区域运营的服务及相关信息。
注意:这里不要把serve表简单理解为只是区域表和服务项表的中间关系表,因为如果是简单的关联关系表只需记录区域表和服务项表各自的主键Id即可,serve记录的是平台运营服务的信息,凡是与运营相关的信息都要记录在serve表,比如:运营价格。后期也可能会增加其它运营相关的字段。
region与serve_item是什么关系?
一个区域下可以设置多个服务项,一个服务项可以被多个区域设置,region与serve_item是多对多关系。
4) 接口设计
根据需求分析梳理功能列表、分析用户操作流程,然后再去设计外部接口及功能接口。
包括以下几个方面:
外部接口:是本项目与第三方系统之间的接口。
举例:在上传服务项图片时涉及外部接口,下图上传图片功能如果将图片保存到云平台则需要调用云平台的上传图片接口。
内部接口:是本项目模块与模块之间的接口定义
在概要设计阶段通常是根据业务流程和系统架构设计去分析,在详细设计(比如接口设计)时还会对内部接口进行分析、细化。
举例:
通用服务jzo2o-publics提供上传文件、微信认证、用户定位接口,其它微服务与jzo2o-publics服务交互,jzo2o-publics与外部系统交互。
暂时无法在飞书文档外展示此内容
功能接口:根据需求定义HTTP 功能接口。
设计RESTful API:用户操作一般伴随着接口请求,一般是根据用户操作事件流去设计接口,定义接口的URL、HTTP方法、请求参数和响应格式。
举例:根据新建服务类型的需求设计接口如下图:
5) 小结
你在进行设计时都设计哪些内容?
能够说出运营服务管理模块的设计内容?
2 运营服务管理
通过本模块的学习掌握家政项目运营服务管理模块的数据库、接口等设计内容,以及基于本项目架构进行开发的流程。
通过本模块的学习掌握针对一个新模块如何进行设计、开发的流程和方法,在拿到一个项目时知道如何下手。
2.1 区域服务管理子模块设计
1)模块定位
区域服务管理模块是运营服务管理的子模块,它是以城市区域为单位,对本区域运营的服务项进行管理。
只在区域上架服务后该区域的用户才可以在小程序对该家政服务下单。
2)数据表
区域服务管理模块最基本的是区域表和服务项表,然后是区域与服务项的关系表,此关系表存储了区域内所运营家政服务项目的信息。
数据表如下:
serve_type:服务类型表
serve_item: 服务项表,存储了本平台的家政服务项目
每个服务项都有一个服务类型,一个服务类型下有多个服务项,服务类型与服务项是一对多关系。
region:区域表,存储运营地区信息,一般情况区域表行政级别是市。
serve: 服务表,存储了各个区域运营的服务及相关信息。
注意:这里不要把serve表简单理解为只是区域表和服务项表的中间关系表,因为如果是简单的关联关系表只需记录区域表和服务项表各自的主键Id即可,serve记录的是平台运营服务的信息,凡是与运营相关的信息都要记录在serve表,比如:运营价格。后期也可能会增加其它运营相关的字段。
region与serve_item是什么关系?
一个区域下可以设置多个服务项,一个服务项可以被多个区域运营,region与serve_item是多对多关系。
3)生成模型类
数据库表设计完成就可以使用工具生成模型类、mapper接口、mapper映射文件等。
生成模型类的工具有很多:AI、MyBatis逆向工程、IDEA插件等。
下边是使用MybatisPlus插件自动生成代码的过程。
1、安装插件
2.重启IDEA,连接mysql
配置连接的数据库
输入连接字符串:jdbc:mysql://192.168.101.68:3306/jzo2o-foundations?useSSL=false&serverTimezone=UTC
账号和密码
3、配置代码生成规则
1)选择表:上图选择serve_type表。
2)设置生成代码的根目录:上图设置generator
3)设置包路径:上图设置为com.jzo2o.foundations
4)主键生成策略:根据表中主键的生成策略进行选择,支持的主键生成策略如下图:
本项目使用的MyBatisPlus版本支持前5个,对应MyBatisPlus源码如下:
暂时无法在飞书文档外展示此内容
AUTO:基于数据库的自增主键
NONE: 不设置id生成策略
INPUT:用户手工输入id
ASSIGN_ID:雪花算法生成id(可兼容数值型与字符串型)
ASSIGN_UUID:以UUID生成算法作为id生成策略
本项目使用ASSIGN_ID方式,雪花算法生成id。
5)勾选要生成的类及包路径(Entity、Mapper、Controller、Service、ServiceImpl)。
6)勾选是否生成lombok注解、restController注解、swagger注解等。
点击“check field”选择表中的字段。
点击“code generatro”生成代码。
生成成功在项目工程根目录有一个generator目录,里边为生成的代码,如下图:
对生成的代码根据自己的需求稍加修改,修改后拷贝到工程相应的包下即可。
4) 外部接口
在上传服务项图片时涉及外部接口,下图上传图片功能如果将图片保存到云平台则需要调用云平台的上传图片接口。
5)内部接口
根据本模块的定位,本模块去管理区域下运营的服务项目,那么本模块就要提供以下接口供其它模块调用:
查询某区域下运营的服务项目
查询服务项目的详细信息等
本模块有没有需要调用其它模块的接口呢?
在添加服务项界面中,上传服务项的图片会通过一个通用模块jzo2o-publics去将图片上传到OSS中,这里本模块与通用模块jzo2o-publics交互也是一个内部接口。
总之:分析内部接口要根据系统架构和业务流程去分析一些核心重要的接口,从两个方面去分析:
本模块提供接口供外部模块调用。
本模块调用外部模块的接口。
6)功能接口
根据用户的操作流程分析功能列表,区域服务管理子模块功能列表如下:
-
区域服务查询
-
添加区域服务
-
设置运营价格
-
删除区域下的服务项
-
设置热门服务
-
取消热门服务
-
上架服务
-
下架服务
根据以上功能列表结合界面原型设计 HTTP 功能接口。
7)小结
用自己的话介绍一下区域服务管理模块的设计内容?
1.2 查询区域服务
下边我们根据模块功能列表进行开发,首先进行接口设计,然后进行开发。
1.2.1 梳理接口
设计接口前先梳理本模块涉及哪些接口,根据梳理出的接口制定工作计划。
根据用户操作流程及功能列表,区域服务管理包括以下接口:
-
区域服务分页查询接口
-
区域服务新增接口
-
区域服务删除接口
-
区域服务价格修改接口
-
区域服务设置热门接口
-
区域服务取消热门接口
-
区域服务上架接口
-
区域服务下架接口
1.2.1 接口设计
下边设计区域服务分页查询接口,企业中前后分离开发通常由java程序员设计接口,设计完成提供接口文档给前端工程师。
如何设计一个接口?
首先明确该接口是一个前后端交互接口,该接口由前端通过HTTP协议进行调用,前端去调用接口需要知道以下内容:
-
HTTP请求方法
-
接口路径
-
请求参数类型
-
请求参数内容
-
响应结果类型
-
响应结果状态码
-
响应结果内容
所以我们设计接口需要设计这些内容。
HTTP请求方法
根据RESTful规范:
查询方法一般用GET,请求参数比较多可用POST
新增方法用POST
修改方法用PUT
删除方法用DELETE
本接口是一个分页查询接口,对查询请求通常我们用GET作为HTTP方法。
由于我们用的swagger去生成接口文档,所以在接口设计时需要定义Controller类和方法。
具体的代码实现可以找一些现有的类参考,参考com.jzo2o.foundations.controller.operation.ServeTypeController类,下边定义ServeController 类
代码如下:
暂时无法在飞书文档外展示此内容
@RestController("operationServeController") 指定名称operationServeController防止同类名的bean名称相同。
接口路径
定义为RESTful风格的路径。
接口路径为:/foundations/operation/serve/page
可以在类上边使用@RequestMapping指定该类中接口的路径的基础路径。
在方法中指定具体的路径。
完善controller如下:
如下:
暂时无法在飞书文档外展示此内容
@RequestMapping("/operation/serve") 指定本controller的根路径。
@GetMapping("/page") Get请求。
请求参数类型
常用的有:
json格式:
application/json,传递json格式字符串,当传递的参数是属于一个对象的属性时可用此格式,比如:新增、修改时通常传递的数据是某个对象的信息就可以使用此格式。
表单格式:
application/x-www-form-urlencoded,传递key/value串,就是在url后通过?和&进行拼接的参数,比如:
/foundations/operation/serve/page?pageNo=1&pageSize=10
当传递的参数比较杂且不属于某个特定的对象时使用此格式,本接口使用application/x-www-form-urlencoded格式。
请求参数内容
请求参数的内容根据需求文档和界面原型去识别。
对于查询类的接口常用的参数有:排序方式、排序字段、当前页码和每页显示记录数。
查询某个区域下的服务需要传递一个区域id。
对于分页查询的参数可以定义一个通用的类,如下是部分核心 代码:
暂时无法在飞书文档外展示此内容
定义ServePageQueryReqDTO 类,继承PageQueryDTO 类,代码如下:
暂时无法在飞书文档外展示此内容
我们发现ServePageQueryReqDTO 类中定义了分页查询区域下服务的参数。
完善controller方法:
暂时无法在飞书文档外展示此内容
注意:当请求参数格式为json需要在方法参数前加@RequestBody注解,这里请求参数类型为from表单格式不用添加此注解。
响应结果类型
常见的类型有:text/html、text/plain、application/json等。
本项目统一使用application/json
响应结果状态码
HTTP状态码是服务器返回给客户端的数字代码(三位数字),共分为五类:
1xx: 表示服务器接收到了客户端请求并正在处理
2xx: 表示成功状态码
3xx:表示重向定状态码
4xx:表示客户端错误状态码
5xx: 表示服务端错误状态码
当服务端处理成功返回200,其它表示失败。
响应结果内容
分页查询通用的响应内容有:数据列表、总页数、总记录数
数据列表中需要分析显示的具体字段,根据界面原型进行分析:
分页查询通用的响应内容如下:
pages 、total、 list 等是固定的。
list 中存储的是具体查询的数据(List结构)。
对于分页查询显示的通用信息用一个通用的类PageResult表示,如下是部分核心 代码:
暂时无法在飞书文档外展示此内容
具体的List数据需要定义ServeResDTO类,我们可以参考PO去定义ServeResDTO。
注意:有些信息在界面原型上是可以看的见的,比如:服务项名称,还有些在界面原型上看不见但是也需要作为结果响应给前端 ,比如:服务项的ID,当进行删除时就需要将此ID传入后端,但是界面 上却不显示。
暂时无法在飞书文档外展示此内容
接口设计完成
将Controller类中对应接口的方法编写完成就代表接口设计完成。
代码如下:
暂时无法在飞书文档外展示此内容
生成接口文档
我们通过swagger根据controller类最终生成接口文档,接口设计信息如下:
Java程序员可以使用接口文档测试接口,下边说明测试方法。
首先在controller方法打断点:
进入接口调试界面
输入请求参数:
点击“发送”,跟踪断点请求参数是否正确
跟踪响应内容
明明page(ServePageQueryReqDTO servePageQueryReqDTO)方法返回了null, 这里为什么会响应如下内容呢?
暂时无法在飞书文档外展示此内容
在jzo2o-mvc工程中有一个过滤器负责对接口的响应结构进行封装
封装的方法是在原有响应内容基础上加上msg、code、result、success等固定属性。
3) 小结
如何设计一个RESTful接口?
1.1.2 Mapper开发
目标:掌握开发持久层接口的过程。
1)生成基础代码
通常一个接口需求明确后从持久层开始开发。
对于一个新模块在设计完表会使用工具生成模型类、mapper接口、mapper映射文件等持久层文件,本项目使用MybatisPlus插件自动生成代码。
前边已介绍过MybatisPlus插件,在此不再赘述。
2)mapper开发
持久层基础代码生成完毕下边开发mapper接口,对于通用的CRUD接口由MybatisPlus提供。
对于需要自定义mapper接口的需求则需要自定义mapper接口及mapper映射文件,根据需求本接口要返回的数据包括多张表的数据,而MybatisPlus提供的CRUD是针对单表的,下边定义mapper实现多表关联查询。
先定义mapper接口:
mapper接口讲究通用性,根据数据处理最底层的需求去定义接口,本需求是根据区域查询服务列表,参数为区域id,方法返回值为服务列表。
暂时无法在飞书文档外展示此内容
再定义mapper映射:
暂时无法在飞书文档外展示此内容
3)单元测试
对mapper接口进行测试
暂时无法在飞书文档外展示此内容
断点调试:
4)小结
Mybatis-Plus有几种主键生成策略?
如何开发一个接口的持久层?
@Resource 和 @Autowired有什么区别?
1.1.3 Service开发
1) 定义service接口
service接口是提供controller调用,service接口的参数由controller传入通常controller方法将它的形参直接传入service方法,对于通用的service方法则需要定义更通用的参数,service方法的返回值类型通常可以直接定义为controller方法的返回值类型。
定义如下:
暂时无法在飞书文档外展示此内容
2)定义service实现类
这里我们使用的Mybatis-Plus提供的ServiceImpl类,如下代码,在此类中有baseMapper,baseMapper即是ServeMapper实例。
暂时无法在飞书文档外展示此内容
3)实现分页查询
如何实现分页查询?
我们可以参考现有service类实现方法。
分别阅读ServeTypeServiceImpl中的分页查询方法和ServeItemServiceImpl中的分页查询方法。
我们发现两个类中的分页查询方法不一样
ServeTypeServiceImpl的方法如下:
暂时无法在飞书文档外展示此内容
ServeTypeServiceImpl中的分页方法使用的是mybatis-plus自带的分页方法,通过selectPage方法实现分页。
ServeItemServiceImpl中的分页方法如下:
暂时无法在飞书文档外展示此内容
ServeItemServiceImpl中的分页方法使用的是com.github.pagehelper分页组件。
我们应该使用哪种方法呢?
第一种:mybatis-plus自带的分页方法,通过调用selectPage方法实现分页,适用于通过QueryWrapper拼装SQL。
第二种:pagehelper分页组件适用于自定义sql的分页查询。
这里我们自定义了查询当前区域服务的sql,所以适用第二种分页方法。
我们先照葫芦画瓢实现service:
暂时无法在飞书文档外展示此内容
4)单元测试
暂时无法在飞书文档外展示此内容
5)分页原理
使用com.github.pagehelper分页插件实现分页功能,下边介绍它的执行原理。
PageHelperUtils是项目封装的一个工具类,进入selectPage方法,调用PageHelper.startPage方法设置分页参数,通过一层一层进入源码,最终将分页参数设置到ThreadLocal
通过PageInterceptor拦截器拦截 MyBatis的Executor 的 query() 方法得到原始的sql语句,首先得到count总数,然后从newThreadLocal中取出分页参数,在原始sql语句中添加分页参数查询分页数据。
部分源码截图如下:
5)小结
项目的分页查询是怎么实现的?
1.1.4 Controller开发
1)完善controller方法
在controller方法中调用service方法进行业务处理。
得到service方法的返回值封装与客户端要的数据格式进行返回。
暂时无法在飞书文档外展示此内容
2)接口测试
重启服务,通过接口文档工具进行测试。
遇到问题在controller、service中打断点进行调试。
测试时注意请求参数填写是否正确,下边是测试结果
返回结果:
通过修改分页条件测试分页查询结果:
修改为一页显示2条记录,下边查询第一页:
再查询第二页:
3)单元测试
有些公司要求需要对三层bean进行单元测试,针对controller方法如何单元测试?
例用AI生成单元测试方法中我们发现有一个org.springframework.test.web.servlet.MockMvc的类,正是使用MockMvc实现对controller方法的单元测试。
上网查阅使用MockMvc对Spring Boot项目controller方法的测试方法,写出例子代码如下:
暂时无法在飞书文档外展示此内容
注意:本项目不要求对Controller方法进行单元测试,请自行学习MockMvc 。
4)小结
如何开发conroller方法?
1.1.5 前后端联调
前后端联调是一种类似生产环境的测试,通过前端请求后端接口测试业务功能。
1)启动前后端
启动foundations服务。
启动前端工程,在项目开发环境配置中我们已经将前端环境配置完成。
2)前后端联调
根据业务操作流程进行测试。
进入运营管理端,点击“区域管理”菜单,点击“设置服务”
区域服务列表如下图:
程序员在前后端测试时需要跟踪前后端交互数据判断精确判断测试结果及测试问题。
通过浏览器网络记录进行跟踪:
请求参数:
响应结果:
3) 小结
前后端联调都调些什么,怎么进行前后端联调?
1.2 添加区域服务
1.2.1 接口设计
1)接口分析
下边设计添加区域服务接口,重点设计:传入参数类型、参数内容、响应结果内容。
根据界面原型梳理操作流程:
首先进入某个区域的服务列表:
点击添加服务,如下图:
选择要添加到区域中的服务,点击“添加”按钮。
最终向serve表添加数据,表结构如下:
暂时无法在飞书文档外展示此内容
serve表存储了区域中开通的服务,根据字段信息思考数据来源:
| 字段名 | 含义 | 数据来源 |
| id | 服务id | 主键,自动生成 |
| serve_item_id | 服务项id | 接口传入 |
| region_id | 区域id | 接口传入 |
| city_code | 城市编码 | 根据区域id查询region表得到 |
| sale_status | 售卖状态 | 默认为草稿状态,数据库设置了默认值 |
| price | 价格 | 默认为服务项的价格,用户可修改 |
| is_hot | 是否为热门 | 默认为非热门,数据库设置了默认值 |
| hot_time_stamp | 更新为热门的时间戳 | 默认空 |
| create_time | 创建时间 | 数据库设置了默认值 |
| update_time | 更新时间 | 数据库设置了默认值 |
| create_by | 创建人 | 由framework的MyBatisAutoFillInterceptor自动处理 |
| update_by | 更新人 | 由framework的MyBatisAutoFillInterceptor自动处理 |
根据上边的分析,通过接口传入服务项id、区域id,支持多个服务传入,实现批量添加。
传入参数内容为数组,所以使用json格式。
传入参数内容包括:服务项id、区域id。
响应结果内容可为空,前端根据状态码判断是否添加成功。
接口设计信息:
接口路径:POST/foundations/operation/serve/batch
请求数据类型 application/json
2)接口定义
- 首先定义接收传入参数的模型类ServeUpsertReqDTO
暂时无法在飞书文档外展示此内容
- 编写controller方法,HTTP方法使用POST方式,代码如下:
暂时无法在飞书文档外展示此内容
- 重启服务,查看swagger文档如下:
3)小结
如何设计添加接口?
添加接口最终向数据库提交数据,根据界面原型结合表结构进行设计,先分析表中每个字段的数据来源,再分析接口传入参数内容,通常添加接口没有特殊要求可根据状态码判断是否添加成功。
1.2.2 接口开发
1)mapper
添加接口只向serve表添加数据所以使用mybatisplus提供的单表CRUD方法即可无需自定义mapper接口。
2)service
定义service接口:
暂时无法在飞书文档外展示此内容
定义service实现方法:
暂时无法在飞书文档外展示此内容
说明:
对于增、删、改类的接口通常会先进行入参校验,校验失败抛出异常,由异常处理器统一对异常进行处理。
我们抛出的异常是自定义异常类型,自定义的异常类型都继承了CommonException类型,在异常处理器中对此类型的异常进行处理。
异常处理器的源码在jzo2o-framework中,如下图:
通过@RestControllerAdvice注解加@ExceptionHandler注解实现,具体的原理是当controller抛出异常由DispatcherServlet统一拦截处理,再根据异常类型找到@ExceptionHandler标识方法去执行该方法进行异常处理。
3) controller
在controller方法中调用service接口添加区域服务。
暂时无法在飞书文档外展示此内容
4)小结
如何开发一个添加接口?
如何开发一个接口的service方法?
接口的异常处理怎么实现的?
1.2.3 测试
我们通过前后端联调测试添加区域服务接口。
1)启动前后端
启动foundations服务。
启动前端工程,在项目开发环境配置中我们已经将前端环境配置完成。
2)前后端联调
因为代码是我们写的,我们在前后端联调时除了测试成功结果还要测试失败结果。
下边我们先测试正常流程:
进入运营管理端,点击“区域管理”菜单,点击“设置服务”
区域服务列表如下图:
点击添加服务
点击添加服务,如下图:
添加成功查询区域服务列表是否存在已添加的服务。
如果列表没有显示已添加的服务则需要进行排查,可以进入数据库直接查询serve表查看是否添加成功,如果后台有错误日志需要根据错误日志进行排查。
下边测试异常流程:
我们测试服务重复添加的异常,由于前端控制无法选择已经添加的服务,我们可以开两个窗口,都停留在添加服务页面:
其中一个窗口添加成功后,另一个窗口也进行提交,此时跟踪前端拿到的响应如下:
暂时无法在飞书文档外展示此内容
此测试结果符合我们的预期。
1.3 修改价格
1.3.1 接口设计
1) 接口分析
根据界面原型设计传入参数类型、参数内容、响应结果内容。
进入修改界面
输入价格进行修改
传入参数内容:价格、区域服务ID(serve表主键) ,区域服务ID简称为服务ID
根据id更新serve表的价格字段。
传入参数类型:form表单格式
响应结果内容可为空,前端根据状态码判断是否添加成功。
接口设计信息如下:
接口路径:PUT/foundations/operation/serve/{id}
请求数据类型 application/x-www-form-urlencoded
2) 接口定义
编写controller方法,HTTP方法使用PUT方式,代码如下:
暂时无法在飞书文档外展示此内容
查看接口文档:
重启服务,查看swagger文档如下:
3)mapper
修改接口只向serve表更新数据所以使用mybatisplus提供的单表CRUD方法即可无需自定义mapper接口。
4)service
定义service接口:
暂时无法在飞书文档外展示此内容
定义service实现方法:
暂时无法在飞书文档外展示此内容
5) controller
在controller方法中调用service接口价格修改。
暂时无法在飞书文档外展示此内容
6)测试
略
7) 小结
如何设计修改接口?
修改接口最终向数据库提交数据,根据界面原型结合表结构进行设计,分析更新的数据有哪些,再分析更新数据的依据是什么,本接口是根据serve表的主键修改价格字段。
如果传入的要修改的参数比较多此时可用json格式,如果比较少可用form表单格式即可。通常添加、修改、删除接口没有特殊要求可根据状态码判断是否添加成功,有特殊要求则需要返回json数据。
1.4 服务上架
1.4.1 接口设计
1)接口分析
根据业务流程、界面原型进行设计。
在区域服务列表中点击“上架”,此服务在该区域将生效,用户即可对该服务进行下单。
业务流程如下:
暂时无法在飞书文档外展示此内容
在serve表有一个售卖状态sale_status,服务上架后将状态更改为“上架”,代码为2。
下边分析接口的传入参数类型、参数内容、响应结果内容。
传入参数内容:服务Id(serve表的主键),
传入参数类型:form表单格式
响应结果内容: 内容可为空,前端根据状态码判断是否添加成功。
注意:区分 服务id和服务项id,服务id即serve表的主键,服务项id即serve_item表的主键。
接口信息如下:
接口路径:PUT/foundations/operation/serve/onSale/{id}
请求数据类型 application/x-www-form-urlencoded
2) 接口定义
编写controller方法,HTTP方法使用PUT方式,代码如下:
暂时无法在飞书文档外展示此内容
查看接口文档:
重启服务,查看swagger文档如下:
1.4.2 接口开发
1)mapper
服务上架最终修改serve表的状态,所以使用mybatisplus提供的单表CRUD方法即可无需自定义mapper接口。
2)service
定义service接口:
暂时无法在飞书文档外展示此内容
定义service实现方法:
暂时无法在飞书文档外展示此内容
3) controller
在controller方法中调用service接口上架区域服务。
暂时无法在飞书文档外展示此内容
4)测试
略
1.5 运营服务管理模块其它接口
1.5.1 删除区域服务开发
参考需求文档开发区域服务删除接口。
接口文档如下:
1.5.2 服务下架开发
参考需求文档开发服务下架接口。
1.5.3 设置热门服务开发
要求:实现更新serve表的是否热门字段。
接口文档:
1.5.4 取消热门服务开发
要求:实现更新serve表的是否热门字段。
接口文档:
1.5.5 启用区域功能完善
参考业务流程图进行功能完善,如下图:
增加校验:区域下存在上架的服务方可启用。
1.5.6 禁用区域功能完善
参考业务流程图进行功能完善,如下图:
增加校验:区域下不存在上架的服务方可禁用。
1.5.7 禁用服务项功能完善
参考业务流程图进行功能完善,如下图:
增加校验:该服务没有在任何区域上架方可禁用。
学习目标
-
能说出家政项目认证模块有哪些认证方式
-
能够说出对接微信进行小程序认证的流程
-
能够说出OpenID与UnionID的区别
-
说出手机验证码认证的流程
-
能说出小程序端定位的流程
-
能够设计开发机构端注册接口
-
能够设计开发机构端忘记密码接口
-
能够设计开发实名认证模块
-
能够设计开发电商项目小程序认证接口
家政项目实战:
机构端注册功能开发
忘记密码功能开发
实名认证模块设计开发
代码环境
jzo2o-customer: dev_01
mall-plus: dev_01
1 认证模块
1.1 需求分析
1)基础概念
一般情况有用户交互的项目都有认证授权功能,首先我们要搞清楚两个概念:认证和授权。
认证: 就是校验用户的身份是否合法,常见的认证方式有账号密码登录、手机验证码登录等。
授权:则是该用户登录系统成功后当用户去点击菜单或操作数据时系统判断该用户是否有权限,有权限则允许继续操作,没有权限则拒绝访问。
2) 小程序认证流程
了解了认证和授权的概念,本节对小程序认证功能进行需求分析。
本项目包括四个端:用户端(小程序)、服务端(app)、机构端(PC)、运营管理端(PC).
分别对应四类用户角色:家政需求方即c端用户,家政服务人员、家政服务公司(机构)、平台运营人员。
用户端通过小程序使用平台,初次使用小程序会进行认证,如下图:
点击“快速登录”弹出服务条款窗口:
点击“同意”进行认证,系统与微信进行交互获取用户在小程序中的唯一标识openid。
注意:点击“同意”弹出获取位置信息,此信息表示要进行定位,定位功能稍后介绍,这里选择允许或拒绝都可以。
初次认证通过会自动注册用户信息到本平台。
下边是小程序的认证流程:
暂时无法在飞书文档外展示此内容
3)OpenID与UnionID
在上边认证流程中提到OpenID,它是什么呢?
AI:项目对接微信登录中unionid和openid的区别是什么
在微信的开放平台中,
openid和unionid都是用来唯一标识用户的ID,但是它们的应用场景和作用范围有所不同。
OpenID:
OpenID 是微信公众平台或小程序平台提供的一个用户身份标识。
每个用户的 OpenID 在同一个公众号/应用的体系内是唯一的。
开发者通过获取用户的 OpenID 可以与自己的应用系统中的用户进行关联,实现用户管理等功能。
OpenID 主要用于单一应用(如一个微信公众号或小程序)的用户身份识别。
UnionID:
UnionID 只有在用户将该应用绑定到微信开放平台之后才能获取。
在同一个微信开放平台下,每个用户的 UnionID 是唯一的,并且在该开放平台下的所有应用中通用。
使用 UnionID 的好处在于,当用户授权给多个与同一开放平台账号关联的应用时,这些应用可以共享这个 UnionID,从而可以方便地管理用户在不同应用中的数据和权限。
UnionID 通常用于需要跨应用(比如多个小程序、公众号等)同步用户信息的场景。
简单来说,如果你的应用只需要在单一的公众号或小程序内管理用户身份,那么使用 OpenID 就足够了。而如果你的应用涉及到多个不同的应用,并且希望在这些应用之间共享用户身份信息,则需要使用 UnionID。为了能够使用 UnionID,你需要将你的应用绑定到微信开放平台。
我们的项目是一个独立的小程序,我们使用OpenID即可。
4) 手机验证码认证
服务人员通过app登录采用手机验证码认证方式,输入手机号、发送验证码,验证码校验通过则认证通过,初次认证通过将自动注册服务人员信息。
如下图:
手机验证码认证流程如下:
暂时无法在飞书文档外展示此内容
5) 账号密码认证
机构端认证方式是账号密码认证方式,通过pc浏览器进入登录界面输入账号和密码登录系统,如下图:
机构端提供单独的注册页面,输入手机号,接收验证码进行注册,如下图:
管理端的认证方式也是账号密码方式,界面如下图:
管理端的账号由管理员在后台录入,不提供注册页面。
5)小结
本项目的认证方式有哪些?
1.2 小程序认证
1.2.1 测试小程序认证
1)参考官方流程
下边测试用户端小程序的认证流程,我们先参考微信官方提供的小程序登录流程先大概知道小程序认证流程需要几部分,如下图:
(文档地址:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html)
从图上可以看出小程序认证流程需要三部分:
小程序:即前端程序
开发者服务器:后端微服务程序。
微信接口服务:即微信服务器。
1.前端调用wx.login()获取登录凭证code
2.前端请求后端进行认证,发送code
3.后端请求微信获取openid,发送appid、app密钥、code参数,微信返回openid
4.后端生成认证成功凭证返回给前端。
5.前端存储用户认证成功凭证
2)申请小程序账号
开发小程序首先要申请小程序账号,参考官方文档:https://developers.weixin.qq.com/miniprogram/dev/framework/quickstart/getstart.html#申请账号
点击注册小程序(https://mp.weixin.qq.com/wxopen/waregister?action=step1)填写信息,如下图:
填写完成进入下一步:
进入邮箱激活小程序,进入下一步信息登记,选择“个人”
填写主体登记信息:
使用自己的个人微信扫描上图的二维码,如下图,点击“确定”
点击“确定”后提示“信息提交成功”,点击前往小程序。
进入小程序管理界面:
填写小程序信息,点击上图中小程序信息栏目的“去填写”按钮,如下图:
小程序名称一定谨慎填写,每年是有一定修改次数限制的。
注意:小程序名称不能重复
下边配置最关键的appid和密钥,通过左侧菜单找到开发管理菜单:
点击“开发管理”菜单进入下图界面,点击“开发设置”,如下图:
点击“生成” 弹出二维码,如下图:
使用自己的微信扫描二维码生成密钥成功,点击“复制”将密钥和AppID妥善保存,开发小程序要使用。
如果密钥丢失可以进行重置:
3)创建jzo2o-customer
小程序账号申请成功,下边部署配置后端程序。
客户管理工程jzo2o-customer提供了小程序认证接口支持。
jzo2o-customer通过jzo2o-publics请求微信获取openid。(jzo2o-publics在第一章环境配置中已完成创建)
下边拉取jzo2o-customer工程,本课程提供的jzo2o-customer工程git地址为:https://gitee.com/jzo2o-v2/jzo2o-customer.git,创建过程参考jzo2o-foundations工程,先fork再从自己仓库拉取代码。
创建成功,并切换到dev_01分支,如下图:
工程创建完成修改bootstrap-dev.yml配置文件:
接下来进入nacos修改jzo2o-publics.yaml中小程序的appid和密钥,如下图:
微服务工程配置好下边需要创建jzo2o-customer工程的数据库,从课程资料下的sql脚本目录找到jzo2o-customer-init.sql,执行脚本创建jzo2o-customer数据库。
启动jzo2o-customer工程,如下图:
小程序认证需要启动的微服务包括:网关jzo2o-gateway、客户管理jzo2o-customer、公共服务jzo2o-publics,将其它服务也正常启动。
启动这三个微服务成功,下边开始部署前端。
4) 部署前端
本部分内容可参考微信开发文档:https://developers.weixin.qq.com/ebook?action=get_post_info&docid=000e8842960070ab0086d162c5b80a
首先下载微信小程序开发工具,也可从课程资料中“小程序开发工具”获取安装程序。
用户端是基于微信小程序开发的,首先需要下载并安装微信开发者工具:
可以使用课程资料中提供的安装包或在线下载,点击下载(https://developers.weixin.qq.com/miniprogram/dev/devtools/stable.html)
安装成功创建快捷方式:
配置小程序开发环境
首先拷贝到课程资料下源码目录中的project-xzb-xcx-uniapp-java.zip到你的代码目录并解压到project-xzb-xcx-uniapp-java目录下。
然后打开微信开发者工具,初次使用弹出身份确认,如下图,使用申请小程序账号时用的微信进行扫码。
扫码通过,进入下边的界面。
如果首先进入小程序开发工具没有进入下图的界面而是自动打开其它项目,可以点击:项目--》关闭当前项目:
进入下图界面后,点击加号,选择小程序的目录。
选择小程序前端工程的 project-xzb-xcx-uniapp-java\unpackage\dist\dev\mp-weixin目录。
AppID:填写申请小程序号获取的AppID。
选择不使用云服务。
如下图:
点击确定进入下边的界面:
修改project-xzb-xcx-uniapp-java\unpackage\dist\dev\mp-weixin\utils\env.js 配置文件,指定后端网关的地址
设置代理
选择设置--》项目设置,在本地配置中选择不校验合法域名。
如果要选择其它目录可以关闭当前项目,重新选择。
5) 编译运行
小程序认证需要启动的微服务包括:网关jzo2o-gateway、客户管理jzo2o-customer、公共服务jzo2o-publics,保证这三个服务全部启动。
注意:保证jzo2o-publics服务配置高德地图key(参考:高德地图web服务配置文档)、微信的appid和app密钥。配置完成将jzo2o-publics服务重新启动。
小程序开发环境配置完成进行编译运行。
首先清除缓存
然后编译运行:
编译运行到登录界面:
点击“快速登录”按照前边讲的小程序认证流程进行操作,请求认证接口进行认证,进入调试器-->Network观察请求记录,如下图:
认证接口的地址是:/customer/open/login/common/user
此接口最终从微信拿到用户的openid(微信给用户分配的唯一标识),并将openid存储到数据库,认证通过生成token令牌返回给前端。
认证通过进入下边的界面:
6)真机调试(了解)
在开发环境还可以通过手机打开小程序进行测试,下边介绍具体的配置方法,注意此部分内容作为了解,正常开发使用上边介绍的通过微信开发工具进行测试,方便跟踪接口交互数据。
首先保证手机和PC在同一个网络,因为在手机上打开小程序需要访问PC上的微服务接口。
可以让手机和PC连接同一个热点,连接热点后查询无线网卡的IP,如下图:
192.168.137.1是我的测试环境,同时要保证手机的IP地址和192.168.137.1在同一个网段。
接下来配置网关地址
设置代理
然后点击预览
生成二维码后打开手机微信扫码将在手机上预览。
7) 小结
从配置环境到测试整个过程稍微繁琐,需要按文档的步骤逐个操作,每一步操作成功再进入下一步。
首先申请小程序账号,然后配置后端工程(网关jzo2o-gateway、客户管理jzo2o-customer、公共服务jzo2o-publics)、保证启动成功。
再部署前端工程,这里需要安装微信小程序开发工具等。
最后编译运行前端工程测试认证流程。
测试过程可进入调试界面跟踪前后端的交互数据。
1.2.2 阅读代码
下边通过阅读代码理解小程序认证的流程。
1)小程序认证流程
我们去开发整个小程序认证流程还先参考官方流程,如下图:
(文档地址:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html)
整个过程包括三部分:
小程序:即前端程序
开发者服务器:后端微服务程序。
微信接口服务:即微信服务器。
具体的流程如下:
1.前端调用wx.login()获取登录凭证code
2.前端请求后端进行认证,发送code
3.后端请求微信获取openid
4.后端生成认证成功凭证返回给前端。
根据官方的认证流程我们定义本项目小程序认证的交互流程:
customer工程提供认证接口,publics工程作为一个公共服务提供与微信通信的接口。
前端与cutomer交互不与publics交互。
暂时无法在飞书文档外展示此内容
2)阅读代码
下边根据认证流程阅读代码,我们以断点调试的方式跟踪接口交互过程。
提供的小程序认证接口
在jzo2o-customer服务中提供小程序认证接口。
在前端清除缓存后重新编译运行,点击快速登录,授权获取手机号,请求jzo2o-customer的普通用户登录接口,普通用户登录接口如下:
请求publics获取openid
jzo2o-customer服务会请求publics服务申请openid,publics服务与微信进行对接。
publics服务中提供获取openid接口如下:
publics请求微信获取openid
publics请求微信获取获取openid 接口如下:
接口描述:https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-login/code2Session.html
生成token
customer收到openid后查询数据库获取用户信息并生成token.
openid是微信用户在家政o2o平台的唯一标识,首先根据openid查询jzo2o-customer的common_user表,是否存在用户,如果不存在则自动注册用户信息,用户信息存储到jzo2o-customer数据库的common_user表中。
common_user表的结构如下:
暂时无法在飞书文档外展示此内容
认证通过生成用户token返回给前端。
token令牌的格式我们使用的是JWT格式,JWT是一种常用的令牌格式,它可以防篡改,关于JWT不明白的同学可以通过视频自学(https://www.bilibili.com/video/BV1j8411N7Bm?p=110&vd_source=81d4489ba9312103debc8ee843169f23)
在JWT令牌中存储了当前登录用户的信息(json),包括如下属性:
用户id: id,对应common_user表的主键。 用户名称:String name,对应common_user表的nickname字段。 用户头像:String avatar,对应common_user表的avatar字段。 用户类型:Integer userType,c端用户的用户类型代码为1,具体定义在com.jzo2o.common.constants.UserType中。
网关对token统一校验
登录成功后,用户访问小程序会携带token,所有请求到网关,如果是白名单则不校验token,否则需要校验token。
网关对token进行解析校验,token不合法直接返回失败信息,token合法解析出用户信息放在http的head中继续请求微服务。
代码如下:
我们可以进行断点调试:
如下图访问“我的地址”进行调试:
进入断点位置:
首先获取前端传入token,网关校验token并解析出用户信息。
解析token通过,将用户信息放入http头中,继续向微服务转发
接下来进入微服务,在微服务中解析http头信息中的用户信息,写入ThreadLocal方便应用程序使用。
源代码如下:
"我的地址"接口属于jzo2o-customer微服务,所以我们进入jzo2o-customer微服务,找到此类,在此类打断点。
仍然访问“我的地址”,进入断点,如下图:
用户放入ThreadLocal中,在controller及service方法中非常方便去获取当前用户信息。
我们找到“我的地址”接口的service方法并打上断点
重新 访问“我的地址”进入此断点
我们发现当前用户的id已经在微服务中成功获取。
3)小结
小程序认证流程是什么?
当前认证通过的用户信息保存到哪里了?
1.3 手机验证码认证
1.3.1 测试手机验证码认证
服务人员使用APP登录平台使用的是手机验证码认证方式,整个认证流程也需要部署前端、后端。
客户管理工程jzo2o-customer与公共服务jzo2o-publics提供手机验证码的接口,这两个服务在小程序认证时已经部署这里不再部署,我们只需要部署前端工程即可。
1)部署前端
服务端的前端工程需要使用 HBuilder 3.8.7 X 软件编译运行,从课程资料下的软件工具目录获取安装包HBuilderX.3.8.7.20230703.zip,也可以自行下载(https://www.dcloud.io/hbuilderx.html)。
安装成功快捷方式如下:
启动HBuilderX
下边从课程资料拷贝project-xzb-app-uniapp-java.zip到代码目录并解压,cmd进入project-xzb-app-uniapp-java目录运行 npm install || yarn 或 cnpm install || yarn 安装依赖包,如下图:
下边用HBuilderX打开project-xzb-app-uniapp-java目录
配置网关地址
配置完成,使用HBuilderX运行到浏览器
运行成功进入登录页面:
下边进入调试模式
选择布局方式:
打开Network调试窗口:
如果修改了文件需要重新编译运行在HBuilderX控制台右键
2)认证测试
下边测试手机验证码认证流程。
首先输入手机号,服务人员的信息存储在jzo2o-customer数据库的serve_provider表中,从表中找一个手机号录入
点击发送验证码,此时前端请求后端发送验证码,在开发环境我们从控制台获取验证码,稍后后带大家分析发送验证码的程序。
注意此时因为请求后端发送验证码我们观察在浏览器的Network窗口有一条记录,如下图,该请求必须响应状态为200方可正常发送验证。
从控制台获取刚才发送的验证码
将从控制台获取的验证码填入验证码输入框
点击登录进行认证,认证过程会先校验验证码是否正确,如果验证码正确再根据手机号查询serve_provider表是否存在相应记录且用户未被冻结,全部成功则认证通过。
认证通过进入首页。
1.3.2 阅读代码
1)手机验证码认证流程
customer工程提供认证接口,publics工程作为一个公共服务提供与发送验证码接口。
暂时无法在飞书文档外展示此内容
2) 阅读代码
找到具体的接口
前端请求publics服务发送验证码接口:publics/sms-code/send
代码如下:
具体发送验证码逻辑:
前端请求customer服务的认证接口:/customer/open/login/worker
代码如下:
机构和和服务人员认证接口是同一个,根据类型判断是机构还是服务人员,下图中红色箭头指向的代码是服务人员认证的方法。
customer服务请求publics服务校验验证码
publics服务提供校验验证码接口,如下图:
验证码证码逻辑
具体的验证码校验逻辑是先查询redis中的正确的验证码,再和用户输入的进行对比,如果不一致则说明输入错误,输入正确删除验证码。如下代码:
在使用redisTemplate时需要在工程中引入下边的依赖:
暂时无法在飞书文档外展示此内容
在jzo2o-redis中定义了redisTemplate的定义,如下图:
使用redisTemplate时只需要在bean里注入上图中定义的redisTemplate即可。
在测试验证码发送时可以打开redis进行跟踪,下图显示了存入redis中的验证码,注意观察key和value:
自动注册
校验验证码完成customer服务根据手机号查询数据库,如果用户冻结则认证失败,如果用户不存在则自动注册。
服务人员和机构都存储到serve_provider表,结果如下:
暂时无法在飞书文档外展示此内容
最后生成token返回给前端。
3)小结
手机验证码服务的实现方案?
为了方便验证码测试前端会自动填入123456,更改验证码发送接口固定生成的验证码为123456.
2 用户端定位
1) 用户端首页定位
用户端在小程序认证通过后会自动进行定位,也可以在首页手动定位,定位成功后用户进入首页会根据定位的城市查询该城市有哪些服务项目。
手动定位过程如下图:
点击下图箭头位置进行手动定位。
定位成功再次进入首页发现位置变为新地址,如下图:
2)高德地图配置
小程序端的定位是通过手机的定位模块进行定位,定位成功获取经纬度坐标,平台根据经纬度坐标请求地图服务获取经纬度坐标对应的具体位置。
小程序首先通过微信提供的方法拿到经纬度坐标,然后请求后端获取具体的位置,后端会请求高德地图根据经纬度获取具体的城市信息。
要测试用户端定位的流程首先需要在高德地图开通地图定位服务。
参考“高德地图web服务配置文档” 获取访问接口的key。
进入nacos配置jzo2o-publics.yml中高德地图key。
3) 测试定位
启动jzo2o-publics服务。
启动小程序,先清除缓存再进行编译
启动成功,点击“快速登录”,先同意服务条款,再允许获取位置信息,如下图:
点击“允许”,观察Network,请求定位接口/publics/map/regeo?location=,如下图:
/publics/map/regeo接口返回经纬度对应的城市信息。
完整内容如下:
暂时无法在飞书文档外展示此内容
在首页左上角发现定位到北京市,如下图:
在不同位置进行测试接口返回的位置信息会和讲义不一致,这个需要注意。
到此说明定位测试通过。
下边介绍一种虚拟定位的设置,这个在开发中经常使用,虚拟定位即不是按手机位置进行定位,比如:你在北京,想测试定位到郑州某个位置该如何操作呢?
在微信开发环境可以指定小程序虚拟定位。
首先打开虚拟定位,指定经纬度,如下图:
经纬度坐标可以使用高德地图的坐标拾取工具获取:
注册高德地图,进入https://lbs.amap.com/tools/picker,即可输入一个具体的位置拿到经纬度,将下图中获取的经纬度设置到上图的位置,清除缓存重新编译小程序即可按虚拟定位去定位。
4)用户端定位交互流程
暂时无法在飞书文档外展示此内容
-
小程序获取经纬度坐标
-
前端请求publics服务根据经纬度查询地理编码(cityCode)
-
publics服务调用高德地图接口根据经纬度查询地理编码
-
前端根据cityCode找到系统中对应的区域ID
5) 阅读代码
定位过程中小程序请求publics服务的接口查询经纬度对应的位置信息,调用/publics/map/regeo接口,下边阅读/publics/map/regeo接口。
接口文档:http://localhost:11503/publics/doc.html#/default/地图服务相关接口/getCityCodeByLocationUsingGET
通过mapService的getCityCodeByLocation方法调用高德地图的查询地理编码接口,如下图:
高德地图的查询地理编码接口定位如下图:
接口文档地址:https://lbs.amap.com/api/webservice/guide/api/georegeo,大家可自行阅读。
最终接口返回省、市、县、详细位置等信息,如下:
为什么要调用高德地图的查询地理编码接口呢?
在foundations数据库的region区域表中有一列是city_code,高德地图返回的详细信息中city_code与region区域表中city_code是一致的,如下图:
小程序拿到的是经纬度坐标,通过调用此接口就可以根据经纬度得到city_code从而关联到平台具体的区域。
region表的city_code从哪来?
先从从高德地图下载拿到全国的区域信息,包括了city_code(https://lbs.amap.com/api/webservice/download)
将下载得到AMap_adcode_citycode.xlsx文件处理为json文件由前端进行保存。
前端在添加区域时从该json文件中选择区域,如下图:
区域信息中包括了从高德地图拿到的city_code,添加一个区域将city_code保存到了region表中。
6)小结
如何开发小程序定位功能?
3 机构端(实战)
3.1 机构端账号密码认证测试
机构和管理端的认证方式都是账号密码认证方式,本作业限定为机构端账号密码认证,具体要求如下:
部署机构端前端并将认证流程测试通过
从课程资料的源码目录拷贝project-xzb-PC-vue3-java.zip到自己的代码目录,并解压到project-xzb-PC-vue3-java目录。
修改根目录的vite.config.ts文件中网关地址配置
修改后端地址
安装依赖包(如果已经安装依赖包则不用安装):
cmd进入project-xzb-PC-vue3-java目录运行 :npm install || yarn 或 cnpm install || yarn
安装依赖包完成运行:npm run dev 运行前端工程,如下图:
运行成功自动打开浏览器:http://localhost:6001/
前端默认的账号:15896123123,密码为:888itcast.CN764%...
机构信息存储在jzo2o-customer数据库的serve_provider表中,可从serve_provider表获取账号。
机构端账号密码认证接口请求customer服务的接口:
代码如下:
登录成功:
退出按钮如下:
3.2 完成机构注册功能开发
界面原型:
进入登录页面,点击“去注册”进入注册页面
接口定义如下:
接口地址:POST/customer/open/serve-provider/institution/register
设计须知
参考服务端自动注册的代码实现。
注意:机构端注册和服务端注册完成要向serve_provider表写入数据,具体查阅上图的方法。
密码加密方式:使用BCrypt方式,BCrypt是一种密码哈希函数,通常用于存储用户密码的安全性。它是基于 Blowfish 密码算法的一种单向哈希函数
测试方法:
暂时无法在飞书文档外展示此内容
根据上边的测试代码可知,BCrypt的使用方法如下:
用户输入密码,通过passwordEncoder.encode("输入的密码")得到哈希串,将哈希串存储到数据库。
用户登录校验密码,从数据库取出哈希串,连同用户输入的密码,调用下边的方法:
passwordEncoder.matches("用户输入的密码", "从数据库查询的密码哈希串");
3.3 完成忘记密码功能开发
界面原型:
进入登录页面,点击“忘记密码”进入找回密码页面
接口定义如下:
接口名称:机构登录密码重置接口
接口路径:POST/customer/agency/serve-provider/institution/resetPassword
设计须知:
首先校验验证码是否正确。
校验手机号是否存在数据库。
通过校验最后修改密码,密码的加密方式参考机构注册接口。
4 实名认证(实战)
4.1 需求分析
4.1.1 业务流程
暂时无法在飞书文档外展示此内容
4.1.2 服务端提交认证
使用一个新的手机号登录,登录成功自动进入下边的界面:
上传身份证件,点击“确认提交”。
提交后等待运营人员审核,当审核没有通过可以查看失败原因:
点击“认证失败”
点击“重新认证”重新填写认证信息。
运营人员审核通过后显示“已完成”
4.1.3 机构端提交认证
首先登录点击认证进入上传资质界面
点击“去认证”
填写信息点击“提交”,运营人员进行审核 。
可以查看认证结果
点击“认证失败”
点击“重新认证”再次上传资质信息。
当认证通过会显示“已完成”。
完成其它项目的设置即可进入首页
4.1.4 运营端审核认证
4.1.4.1 审核服务人员
审核服务人员认证列表:
数据说明:
审核状态,0:未审核,1:已审核
待审核:机构或服务人员提交了申请,后台人员没有提交审核结果(同意、驳回)
已审核:机构或服务人员提交了申请,后台人员已选择同意/驳回
认证状态:认证状态,1:认证中,2:认证成功,3: 认证失败
认证中:提交认证后的认证状态
认证通过:显示最新的认证申请,后台人员选择通过,显示认证通过
认证失败:显示最新的认证申请,后台人员选择驳回,显示认证失败
驳回:
驳回后审核状态为“已审核”,认证状态为“认证失败”
驳回后服务人员可以再次提交认证申请,运营人员在查询列表可以查到多条该用户的申请记录。
点击“通过”
点击“确定”认证成功
4.1.4.2 审核机构
运营人员审核机构:
驳回:
审核完成再查询审核记录,审核状态为已审核,状态为认证失败。
当机构端再次提交审核后再次查询企业认证信息,新增了一条认证信息。
审核通过:
审核通过后审核状态为已审核,认证状态为认证成功。
4.2 接口设计
4.2.1 服务端提交认证接口设计
4.2.1.1 服务端提交认证申请
接口路径:POST/customer/worker/worker-certification-audit
请求数据类型: application/json
请求参数:
示例:
暂时无法在飞书文档外展示此内容
响应参数:
4.2.1.2 查询最新的驳回原因
接口路径:GET/customer/worker/worker-certification-audit/rejectReason
请求数据类型: application/x-www-form-urlencoded
请求参数:
无
响应参数:
示例:
暂时无法在飞书文档外展示此内容
4.2.2 机构端提交认证接口设计
4.2.2.1 机构提交认证申请
接口路径:POST/customer/agency/agency-certification-audit
请求数据类型: application/json
请求参数:
示例:
暂时无法在飞书文档外展示此内容
响应参数:
4.2.2.2 查询最新的驳回原因
接口路径:GET/customer/agency/agency-certification-audit/rejectReason
请求数据类型: application/x-www-form-urlencoded
请求参数:
无
响应参数:
示例:
暂时无法在飞书文档外展示此内容
4.2.3 运营端审核认证接口设计
4.2.3.1 审核服务人员认证分页查询
接口路径:GET/customer/operation/worker-certification-audit/page
示例:
暂时无法在飞书文档外展示此内容
4.2.3.2 审核服务人员认证信息
接口路径:PUT/customer/operation/worker-certification-audit/audit/{id}
4.2.3.3 审核机构认证分页查询
接口路径:GET/customer/operation/agency-certification-audit/page
示例:
暂时无法在飞书文档外展示此内容
4.2.3.4 审核机构认证信息
接口路径:PUT/customer/operation/agency-certification-audit/audit/{id}
4.3 设计须知
系统已经存在以下认证相关的表:
agency_certification:机构认证状态表
worker_certification:服务人员认证状态表
根据需求可知现在要实现的功能是认证的申请与审核,参考上述两张表设计并创建符合需求的表。
5 我的账户设置(实战)
5.1 配置OSS
本模块在维护银行账户信息时需要上传银行卡照片,本项目的图片服务器使用阿里的OSS存储。
参考 “OSS配置文档” 开通阿里OSS服务,并在publics服务配置参数。
如果已配置则无需重复配置。
5.2 需求分析
5.2.1 服务端设置银行账户
服务端设置银行卡账户,每个服务人员设置一个银行卡账户。
首先点击“我的”
进入“我的”界面,点击“账户设置”
进入账户设置,如果已经设置了账户信息则直接显示在界面中。
录入账户信息:
点击“确认提交”。
再次进入“账户设置”显示设置的账户信息。
5.2.2 机构端设置银行账户
机构端登录进入首页,点击“账户设置”
打开账户设置界面
填写要求:
填写信息:
点击“提交”后再次进入“账户设置”显示机构的账户信息。
5.3 服务端设置银行账户接口设计
5.3.1 新增或更新银行账号信息接口
接口名称: 新增或更新银行账号信息
接口路径:POST/customer/worker/bank-account
请求类型:application/json
5.3.2 获取当前用户银行账号接口
接口名称:获取当前用户银行账号
接口功能:进入账户设置界面会先查询当前用户的账户信息,如果已经设置则显示在页面中。
接口路径:GET/customer/worker/bank-account/currentUserBankAccount
5.4 机构端设置银行账户接口设计
5.4.1 新增或更新银行账号信息接口
接口名称: 新增或更新银行账号信息
接口路径:POST/customer/agency/bank-account
请求类型:application/json
5.4.2 获取当前用户银行账号接口
接口名称:获取当前用户银行账号
接口功能:进入账户设置界面会先查询当前用户的账户信息,如果已经设置则显示在页面中。
接口路径:GET/customer/agency/bank-account/currentUserBankAccount
作业
实战完成要能针对以下问题进行流畅口述:
1、你负责模块的怎么设计的
介绍业务流程、表设计、接口设计
2、在开发中你遇到了什么问题
学习目标
-
能说出实现门户的常用技术方案
-
能说出小程序首页哪些信息要缓存
-
会使用Spring Cache的常用注解
-
能说出缓存穿透的解决方案
-
能说出缓存击穿的解决方案
-
能说出缓存雪崩的解决方案
-
能说出保证缓存一致性的方案
-
能够实现开通区域列表缓存
-
能够实现定时任务更新缓存
-
能够说出xxl-job工作原理
-
能够实现首页服务列表缓存
-
能够实现服务类型列表缓存
-
能够实现热门服务列表缓存
-
能够实现服务详情缓存
家政项目实战:
服务类型列表查询
热门服务列表查询
服务详情查询
代码环境
jzo2o-foundations:dev_02
1 门户技术方案
1)什么是门户
本节分析小程序首页的技术方案。
小程序首页界面原型如下图:
包括如下部分:
-
搜索栏
-
服务分类
-
热门服务项目
首页是用户进入这个小程序的入口,聚合很多的信息,包括内容导航、热点信息等。
类似的界面还有:
百度新闻:
新浪首页:
这种系统首页入口称为门户,是指一个网站或应用程序的主页,它是用户进入这个网站或系统的入口,主页上通常聚合很多的信息,包括内容导航、热点信息等,比如:门户网站的首页、新闻网站的首页、小程序的首页等。
常见的软件门户类型:
-
企业门户:这种类型的门户通常用于组织内部,为员工提供一个集中访问企业内部各种应用、数据和服务的地方。它可以包括人力资源信息、财务系统、项目管理工具等。
-
客户门户:面向外部客户的门户,允许客户登录并访问特定的服务,如账户管理、订单跟踪、技术支持等。
-
知识门户:这类门户主要用于知识管理和分享,可以包含文档库、论坛、博客等功能,帮助用户获取所需的信息和专业知识。
-
Web门户:这是最广为人知的一种门户形式,它通常提供新闻、电子邮件、搜索引擎等综合性服务,用户可以通过这个平台方便地访问网络上的多种资源。
2)常用的技术方案
实现门户功能用到哪些技术呢?
基于两个需求去分析
1.门户上的信息是动态的
门户上的信息会按照一定的时间周期去更新,比如一个新闻网站不可能一直显示一样的新闻。
2.门户作为入口其访问频率非常高
对于访问频率高的界面其加载速度是至关重要的,因为它直接影响用户的体验和留存率。一般来说门户网站的首页应该在2至3秒内加载完成,这被认为是一个合理的加载时间目标。
常见的两类门户是:web门户和移动应用门户。
我们针对这两类门户分析技术方案。
Web门户技术方案
web门户是最常见的门户类型,比如:新浪、百度新闻等,它们通过PC浏览器访问,用户可以通过桌面电脑、笔记本电脑、平板电脑和智能手机等设备访问。Web门户通常运行在Web浏览器上,用户可以通过输入网址或通过搜索引擎访问。
web门户是通过浏览器访问html网页,虽然html网页上的内容是动态的但是考虑门户作为入口其访问频率非常高所以就需要提高它的加载速度,如果网页上的数据是通过实时查询数据库得到是无法满足要求的,所以针对web门户提高性能的关键是如何提高html文件的访问性能,如何提高查询数据的性能。
1、将门户页面生成静态网页发布到CDN服务器。
纯静态网页通过Nginx加载要比去Tomcat加载快很多。
我们可以使用模板引擎技术将动态数据静态化生成html文件,并通过CDN分发到边缘服务器,可以提高访问效率。
Java模板引擎技术有很多,比如:freemarker、velocity等。
在课下参考入门视频自学: https://www.bilibili.com/video/BV1j8411N7Bm?p=93&vd_source=81d4489ba9312103debc8ee843169f23
什么是CDN?
CDN 是构建在数据网络上的一种分布式的内容分发网,旨在提高用户访问网站或应用时的性能。
下图中,通过CDN将内容分发到各个城市的CDN节点上,北京的网民请求北京的服务即可拿到资源,提高访问速度。
2、html文件上的静态资源比如:图片、视频、CSS、Js等也全部放到CDN服务。
3、html上的动态数据通过异步请求后端缓存服务器加载,不要直接查询数据库,通过Redis缓存提高查询速度。
暂时无法在飞书文档外展示此内容
4、使用负载均衡,通过部署多个Nginx服务器共同提供服务,不仅保证系统的可用性,还可以提高系统的访问性能。
暂时无法在飞书文档外展示此内容
5、在前端也做一部分缓存。
不仅服务端可以做缓存,前端也可以做缓存,前端可以把缓存信息存储到
LocalStorage: 提供了持久化存储,可以存储大量数据
SessionStorage: 与 LocalStorage 类似,但数据只在当前会话中有效,当用户关闭标签页或浏览器时清空。
Cookie: 存储在用户计算机上的小型文本文件,可以在客户端和服务器之间传递数据
浏览器缓存:通过 HTTP 头部控制,比如:Cache-Control头部提供了更灵活的缓存控制选项,可以定义缓存的最大有效时间。
移动应用门户技术方案
移动应用门户是专为移动设备(如智能手机和平板电脑)设计的应用程序,比如:小程序、APP等,用户可以通过应用商店下载并安装。这些应用程序提供了更好的用户体验,通常具有更高的性能和交互性,可以直接从设备主屏幕启动。
对于移动应用提高访问效率方法通常有:
静态资源要走CDN服务器
对所有请求进行负载均衡
在前端及服务端缓存门户上显示的动态数据。
3)小结
根据上边的分析,对于Java程序员需要关注的是缓存服务的开发,主流的缓存服务器是Redis,所以我们接下来的工作重点是使用Redis为门户开发缓存服务接口。
实现一个门户用到的技术方案有哪些?
2 缓存技术方案
2.1 需求分析
目标:明确本项目门户有哪些信息需要缓存
1)界面原型
了解了门户的技术方案,下边通过门户界面原型分析本项目门户包括哪些部分。
本项目小程序门户首页如下图:
第1部分:城市选择
用户允许微信授权后,自动获取当前定位,点击地址进入城市选择页面,如下图:
已开通城市是指在区域管理中所有启用的区域信息。
第2部分:搜索
触发搜索框进入搜索主页面,如下图:
输入关键字搜索服务信息。
第3部分:首页服务列表
默认展示前两个服务分类,每个服务分类下取前4个服务项(根据后台排序规则显示,如排序相同则按照更新时间倒序排列)
点击一级分类进入【全部服务】页;点击服务项进入【服务项目详情页】
第4部分:热门服务列表
这里显示在区域服务界面设置热门服务的服务项。
第5部分:全部服务
点击首页服务列表的服务分类或直接点击“全部服务”进入全部服务界面,
全部服务界面,如下图:
在全部服务界面需要展示当前区域下的服务分类,点击服务分类查询分类下的服务。
第6部分 服务详情
点击服务名称进入服务详情页面:
2)缓存需求
根据门户的技术方案,为了提供效率门户显示的信息从缓存查询,根据上边的界面原型分析有哪些信息需要缓存?
1.定位界面上的已开通区域列表
2.服务搜索
通过Elasticsearch查询,稍后单独分析
3.首页服务列表
4.热门服务列表
5.服务信息
点击某一个服务打开服务详情页面,如下图:
服务的详细信息一部分是服务介绍的图片和内容等,一部分是区域服务信息(比如价格)。
6.服务分类
在全部服务界面左侧显示服务分类
服务分类下的服务项通过Elasticsearch去查询,稍后单独分析。
3)小结
经过分析要明确有哪些信息需要缓存。
1、首页服务列表,包括两个服务分类及每个分类下的四个服务项。
2、热门服务列表
3、服务类型列表
4、开通城市列表
5、服务详细信息,内容包括服务项信息、服务信息。
2.2 Spring Cache入门
2.2.1 入门程序
目标:学会使用SpringCache查询缓存注解并理解它的原理
1)Redis访问工具
本项目使用Redis存储缓存数据,如何通过Java去访问Redis?
常用的有Jedis和Lettuce两个访问redis的客户端库,其中Lettuce的性能和并发性要好一些,Spring Boot 默认使用的是 Lettuce 作为 Redis 的客户端。
本项目集成了Spring data redis框架,在项目中可以通过RedisTemplate访问Redis,RedisTemplate提供了方便访问redis的模板方法。
RedisTemplate和Lettuce 是什么关系?
RedisTemplate 进行 Redis 操作时,实际上是通过 Lettuce 客户端与 Redis 服务器进行通信。
本项目也集成了Spring Cache,Spring Cache是spring的缓存框架,可以集成各种缓存中间件,比如:EhCache、Caffeine、redis。
Spring Cache最终也是通过Lettuce 去访问redis 。
使用Spring Cache的方法很简单,只需要在方法上添加注解即可实现将方法返回数据存入缓存,以及清理缓存等注解的使用。
RedisTemplate适用于灵活操作redis的场景,通过RedisTemplate的API灵活访问Redis。
这两种访问 redis的方法在本项目都有使用。
2)Spring Cache基本介绍
Spring Cache是Spring提供的一个缓存框架,基于AOP原理,实现了基于注解的缓存功能,只需要简单地加一个注解就能实现缓存功能,对业务代码的侵入性很小。
基于SpringBoot使用Spring Cache非常简单,首先加入依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
<version>2.7.10</version>
</dependency>
本项目已在jzo2o-framework下的jzo2o-redis工程引入此依赖,其它服务只需要引入jzo2o-redis的依赖即可。
简单认识它的常用注解:
@EnableCaching:开启缓存注解功能
@Cacheable:查询数据时缓存,将方法的返回值进行缓存。 @CacheEvict:用于删除缓存,将一条或多条数据从缓存中删除。 @CachePut:用于更新缓存,将方法的返回值放到缓存中 @Caching:组合多个缓存注解; @CacheConfig:统一配置@Cacheable中的value值
3)切换分支
本节使用jzo2o-foundations工程的dev_02分支,课程会下发dev_02分支的代码
jzo2o-foundations_dev02.zip,大家在开发前先在本地创建了一个新分支dev_02并切换到dev_02,然后将下发的代码覆盖dev_02下的代码。
在jzo2o-foundations工程引入jzo2o-redis依赖
<dependency>
<groupId>com.jzo2o</groupId>
<artifactId>jzo2o-redis</artifactId>
</dependency>
在jzo2o-foundations工程的bootstrap.yml中引入redis的配置文件,如下图:
在nacos配置shared-redis-cluster.yaml,开发环境使用redis单机,配置文件如下:
注意配置redis的IP地址、端口和密码。
4)查询数据时缓存
下边使用Cacheable注解实现查询服务信息时对服务信息进行缓存,它的执行流程是:第一次查询服务信息缓存中没有该服务的信息此时去查询数据库,查询数据库拿到服务信息并进行缓存,第二次再去查询该服务信息发现缓存中有该服务的信息则直接查询缓存不再去数据库查询。
流程如下:
暂时无法在飞书文档外展示此内容
首先在工程启动类中添加@EnableCaching注解,它表示开启Spring cache缓存组件。
下边实现对区域服务信息查询时进行缓存。
首先找到区域服务信息的service,为了不和原来的getById(Serializable id)查询方法混淆,单独定义查询区域服务信息缓存的方法,如下:
在IServeService接口中定义如下接口:
/**
* 查询区域服务信息并进行缓存
* @param id 对应serve表的主键
* @return 区域服务信息
*/
Serve queryServeByIdCache(Long id);
在接口实现类中定义如下方法:
@Override
public Serve queryServeByIdCache(Long id) {
return getById(id);
}
此时该方法还是查询数据库。
下边在方法中添加Cacheable注解:
// @Cacheable(value = "JZ_CACHE:SERVE_RECORD",key = "#id")
@Cacheable(value = RedisConstants.CacheName.SERVE,key = "#id")
public Serve queryServeByIdCache(Long id) {
return getById(id);
}
Cacheable注解配置的两项参数说明:
value:缓存的名称,缓存名称作为缓存key的前缀。
key: 缓存key,支持SpEL表达式,上述代码表示取参数id的值作为key
最终缓存key为:缓存名称+“::”+key,例如:上述代码id为123,最终的key为:JZ_CACHE:SERVE_RECORD::123
SpEL(Spring Expression Language)是一种在 Spring 框架中用于处理字符串表达式的强大工具,它可以实现获取对象的属性,调用对象的方法操作。
keyGenerator:指定一个自定义的键生成器(实现 org.springframework.cache.interceptor.KeyGenerator 接口的类),用于生成缓存的键。与 key 属性互斥,二者只能选其一。
5)测试
对queryServeByIdCache方法进行测试,编写单元测试方法,如下:
package com.jzo2o.foundations.service;
import com.jzo2o.common.model.PageResult;
import com.jzo2o.foundations.model.domain.Serve;
import com.jzo2o.foundations.model.dto.request.ServePageQueryReqDTO;
import com.jzo2o.foundations.model.dto.response.*;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.util.Assert;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.util.List;
@SpringBootTest
@Slf4j
class IServeServiceTest {
//区域服务查询
@Test
public void test_queryServeByIdCache(){
Serve serve = serveService.queryServeByIdCache(1693815623867506689L);
Assert.notNull(serve,"服务为空");
}
...
运行测试方法,观察redis,数据缓存成功,如下图:
缓存key:JZ_CACHE:SERVE_RECORD::1693815623867506689
缓存value:serve表的记录
缓存过期时间:-1,表示永不过期
仔细核实内容是否符合我们的预期结果。
虽然数据被成功缓存,如果想调整缓存过期时间怎么做呢?
在@Cacheable注解中有一个属性为cacheManager,表示缓存管理器,通过缓存管理器可以设置缓存过期时间。
所有缓存相关的基础类都在jzo2o-redis工程,在jzo2o-redis工程定义spring cache需要的缓存管理器,如下:
上图中共包括三个缓存管理器:
缓存时间为30分钟、一天、永久,分别对应的bean的名称为:cacheManager30Minutes、cacheManagerOneDay、cacheManagerForever。
下边我们在@Cacheable注解中指定缓存管理器为cacheManagerOneDay,即缓存时间为一天。
@Cacheable(value = RedisConstants.CacheName.SERVE,key="#id",cacheManager = RedisConstants.CacheManager.ONE_DAY)
public Serve queryServeByIdCache(Long id) {
return getById(id);
}
重新运行单元测试方法,我们发现缓存的过期时间没有改变,这是为什么?
原因是根据前边的缓存流程:
先查询缓存,如果缓存存在则直接查询缓存返回数据,不再向缓存存储 数据。
暂时无法在飞书文档外展示此内容
所以我们需要删除缓存,重新运行测试方法:
测试通过,观察redis中的缓存,过期时间已经改变,这说明我们设置的缓存管理器生效。
由于缓存时间加了随机数,缓存一天的时间为90000秒左右。
关于缓存时间加随机数的原因稍后讲解。
6)工作原理
Spring Cache是基于AOP原理,对添加注解@Cacheable的类生成代理对象,在方法执行前查看是否有缓存对应的数据,如果有直接返回数据,如果没有调用源方法获取数据返回,并缓存起来,下边跟踪Spring Cache的切面类CacheAspectSupport.java中的private Object execute(final CacheOperationInvoker invoker, Method method, CacheOperationContexts contexts)方法。
如下图:
下边打断点调试:
分别测试命中缓存和未命中缓存的情况。
第一次查询(redis中没有缓存)时执行方法查询数据库,第二次命中缓存直接查询redis不再执行方法。
7)小结
Spring Cache 用在项目哪里了?怎么用的?
Spring Cache @Cacheable 注解的工作原理?
2.2.2 测试Spring Cache
目标:学会使用@CacheEvict和@CachePut注解
在Spring Cache入门中使用了@Cacheable 注解,它实现的是查询时进行缓存。
下边测试另外两个常用 的注解,如下:
@CacheEvict:用于删除缓存,将一条或多条数据从缓存中删除。 @CachePut:用于更新缓存,将方法的返回值放到缓存中
其它注解在项目开发中用到时再进行讲解,也可以自行查阅资料测试。
1)测试@CachePut
CachePut注解实现的是将方法的返回值放到缓存中。
在服务上架后会将区域服务的信息写入缓存,服务下架会从缓存删除,下边我们实现服务上架将服务写入缓存。
找到服务上架的方法,在方法上添加@CachePut注解:
@Override
@Transactional
@CachePut(value = RedisConstants.CacheName.SERVE, key = "#id", cacheManager = RedisConstants.CacheManager.ONE_DAY)
public Serve onSale(Long id){
....
上边代码同样指定了缓存名称、缓存key及缓存管理器(缓存过期时间为一天)。
重启foundation服务通过界面操作上架:
测试成功查看redis是否写入缓存:
2)测试@CacheEvict
下边测试服务下架删除缓存。
找到服务下架的方法,添加@CacheEvict注解
@Override
@Transactional
@CacheEvict(value = RedisConstants.CacheName.SERVE, key = "#id")
public Serve offSale(Long id){
....
这里是删除缓存所以不用再指定缓存管理器。
重启foundation服务通过界面操作下架:
3)小结
Spring Cache有哪些常用的注解,都有什么用?
@EnableCaching:开启缓存注解功能
@Cacheable:查询数据时缓存,将方法的返回值进行缓存。 @CacheEvict:用于删除缓存,将一条或多条数据从缓存中删除。 @CachePut:用于更新缓存,将方法的返回值放到缓存中 @Caching:组合多个缓存注解;
2.3 高并发项目缓存常见问题复习
1)缓存穿透问题
什么是缓存穿透?如何解决缓存穿透?
什么是布隆过滤器?如何使用布隆过滤器?
本项目使用缓存空值或特殊值的方法去解决缓存穿透。
2)缓存击穿问题
什么是缓存击穿?如何解决缓存击穿?
本项目对热点数据定时预热,使用定时任务刷新缓存保证缓存永不过期,解决缓存穿透问题。
3) 缓存雪崩问题
什么是缓存雪崩?如何解决缓存雪崩?
本项目对key设置不同的过期时间解决缓存雪崩问题。
4)缓存不一致问题
如何保证缓存一致性?
3 小程序首页缓存实现
3.1 开通区域列表缓存实现
目标:
能说出项目中的缓存方案。
实现开通区域列表缓存(完成查询缓存、删除缓存)。
1)缓存方案分析
根据本项目门户的需求可知共有以下几块信息需要缓存,如下:
td {white-space:nowrap;border:0.5pt solid #dee0e3;font-size:10pt;font-style:normal;font-weight:normal;vertical-align:middle;word-break:normal;word-wrap:normal;}| 信息内容类型 | 缓存过期时间 | 缓存结构 | 缓存key | 缓存同步方案 |
| 开通区域列表 | 永久缓存 | String | JZ_CACHE::ACTIVE_REGIONS | 查询缓存:查询开通区域列表进行缓存 启用区域:删除开通区域缓存 禁用区域:删除开通区域及其它信息 由定时任务每天凌晨更新缓存 |
| 首页服务列表 | 永久缓存 | String | JZ_CACHE:SERVE_ICON::区域id | 查询缓存:初次查询进行缓存 禁用区域:删除本区域的首页服务列表缓存 由定时任务每天凌晨更新缓存 |
| 服务类型列表 | 永久缓存 | String | JZ_CACHE:SERVE_TYPE::区域id | 查询缓存:初次查询直接缓存 禁用区域:删除本区域的服务类型列表缓存 由定时任务每天凌晨更新缓存 |
| 热门服务列表 | 永久缓存 | String | JZ_CACHE:HOT_SERVE::区域id | 查询缓存:初次查询直接缓存 禁用区域:删除本区域的热门服务列表缓存 由定时任务每天凌晨更新缓存 |
| 服务项信息 | 缓存1天 | String | JZ_CACHE:SERVE_ITEM::服务项id | 启动:添加缓存 禁用:删除缓存 修改: 修改缓存 |
| 服务信息 | 缓存1天 | String | JZ_CACHE:SERVE_RECORD::服务id | 上架:添加缓存 下架:删除缓存 修改: 修改缓存 |
下边分析第一个开通区域列表的缓存方案:
查询缓存:查询已开通区域列表,如果没有缓存则查询数据库并将查询结果进行缓存,如果存在缓存则直接返回
启用区域:删除开通区域信息缓存(再次查询将缓存新的开通区域列表)。
禁用区域:删除开通区域信息缓存,删除该区域下的其它缓存信息,包括:首页服务列表,服务类型列表,热门服务列表。
定时任务:每天凌晨缓存已开通区域列表。
2)查询缓存实现
下边我们先实现开通区域列表查询缓存。
首先把测试环境准备好:
启动jzo2o-gateway
启动jzo2o-publics
启动jzo2o-customer(如果前端已经通过小程序认证可以不用启动)
启动jzo2o-foundations
打开小程序开发工具
打开小程序,点击首页左上角的地址进入服务地址城市选择页面,如下图:
在定位界面显示已开通城市列表,已开通城市是指在区域管理中所有启用的区域信息,如下图:
跟踪Network找到开通区域列表的URL:/foundations/consumer/region/activeRegionList
打开jzo2o-foundations工程,根据接口地址找到具体的代码:
找到接口:
package com.jzo2o.foundations.controller.consumer;
import com.jzo2o.api.foundations.dto.response.RegionSimpleResDTO;
import com.jzo2o.foundations.service.IRegionService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.List;
/**
* <p>
* 区域表 前端控制器
* </p>
*
* @author itcast
* @since 2023-07-03
*/
@RestController("consumerRegionController")
@RequestMapping("/consumer/region")
@Api(tags = "用户端 - 区域相关接口")
public class RegionController {
@Resource
private IRegionService regionService;
@GetMapping("/activeRegionList")
@ApiOperation("已开通服务区域列表")
public List<RegionSimpleResDTO> activeRegionList() {
return regionService.queryActiveRegionListCache();
}
}
找到service方法如下:
在service方法上添加Spring cache注解:
@Override
@Cacheable(value = RedisConstants.CacheName.JZ_CACHE, key = "'ACTIVE_REGIONS'", cacheManager = RedisConstants.CacheManager.FOREVER)
public List<RegionSimpleResDTO> queryActiveRegionListCache() {
return queryActiveRegionList();
}
说明:
key: 当key用一个固定字符串时需要在双引号中用单引号括起来,如下所示:
key = "'ACTIVE_REGIONS'"
cacheManager :RedisConstants.CacheManager.FOREVER设置了缓存永不过期。
重启jzo2o-foundations工程进行测试。
通过小程序访问定位界面,观察Network:
/foundations/consumer/region/activeRegionList接口请求成功。
观察redis成功缓存开通区域列表:
3)启用区域
启用一个新区域已经开通区域列表需要变更,该如何实现呢?
启用区域后删除已开通区域列表缓存,当去查询开通区域列表时重新缓存最新的开通区域列表。
可通过接口文档(http://localhost:11509/foundations/doc.html)找到启用区域的接口,如下:
接口的代码如下:
找到service代码,修改如下:
/**
* 区域管理
*
* @author itcast
* @create 2023/7/17 16:50
**/
@Service
public class RegionServiceImpl extends ServiceImpl<RegionMapper, Region> implements IRegionService {
/**
* 区域启用
*
* @param id 区域id
*/
@Override
@CacheEvict(value = RedisConstants.CacheName.JZ_CACHE, key = "'ACTIVE_REGIONS'")
public void active(Long id) {
......
稍后测试。
4)禁用区域
如果是禁用一个区域则需要删除开通区域列表缓存。
找到禁用区域的代码,修改如下:
/**
* 区域管理
*
* @author itcast
* @create 2023/7/17 16:50
**/
@Service
public class RegionServiceImpl extends ServiceImpl<RegionMapper, Region> implements IRegionService {
@Override
@Caching(evict = {
@CacheEvict(value = RedisConstants.CacheName.JZ_CACHE, key = "'ACTIVE_REGIONS'")
// todo:删除首页服务列表缓存
})
public void deactivate(Long id) {
......
禁用区域除了删除开通区域列表还需要删除首页服务列表、热门服务列表等,所以这里使用@Caching注解。
5)测试
下边进行测试:
首先重启foundations服务。
启动运营管理(前端)进行测试。
测试禁用区域,找一个区域将区域下边服务全部下架,然后禁用该区域
最初有两个开通区域:
将郑州区域禁用,进入redis查询JZ_CACHE::ACTIVE_REGIONS缓存已经删除。
使用小程序进入定位界面查询开通区域列表,观察redis,缓存了最新的开通区域列表
测试启用区域,找一个区域添加服务并上架然后启用这个区域。
下边启用区域“郑州”,启动用区域后再次查询开通区域列表,redis缓存了最新的开通区域列表:
6)小结
项目哪里进行了缓存,缓存方案是什么?
项目中如何保证缓存的一致性?
3.2 定时任务更新缓存
3.2.1 配置XXL-JOB环境
对于开通区域列表的缓存数据需要由定时任务每天凌晨更新缓存,我们使用xxl-job实现。
1) 启动xxl-job
执行docker start xxl-job-admin 启动xxl-job
访问:http://192.168.101.68:8088/xxl-job-admin/
账号和密码:admin/123456
2) 执行器
1.添加执行器依赖
本项目在framework中定义了jzo2o-xxl-job工程,它对执行器bean执行了定义:
所以在需要使用xxl-job的微服务中需要引入下边的依赖,在jzo2o-foundations服务中引入下边的依赖:
<dependency>
<groupId>com.jzo2o</groupId>
<artifactId>jzo2o-xxl-job</artifactId>
</dependency>
2.配置xxl-job
接下来进入nacos,配置shared-xxl-job.yaml:
说明:
address:调度中心的地址
appName:执行器名称,为spring.application.name表示微服务的名称(在bootstrap.yml中配置)
port:执行器端口号,通过xxl-job.port配置
在jzo2o-foundations.yaml中配置执行器的端口:
在jzo2o-foundations中加载shared-xxl-job.yaml:
3. 下边进入调度中心添加执行器
进入调度中心,进入执行器管理界面,如下图:
点击新增,填写执行器信息
AppName:执行名称,在shared-xxl-job.yaml中指定执行器名称就是微服务的应用名。
名称:取一个中文名称。
注册方式:自动注册,只要执行器和调度中心连通执行器会自动注册到调度中心
机器地址:自动注册时不用填写。
找到应用名:jzo2o-foundations,如下图:
添加成功:
启动jzo2o-foundations,查看jzo2o-foundations的控制台:
>>>>>>>>>>> xxl-job remoting server start success, nettype = class com.xxl.job.core.server.EmbedServer, port = 11603 说明执行器启动成功。
稍等片刻进入 xxl-job调度中心,进入执行器管理界面,执行器注册成功:
点击“查看(1)”,查看执行器的地址,如下图:
3.2.2 定义缓存更新任务
根据本节的目标,使用xxl-job定时更新开通区域列表的缓存。
1)编写任务方法
下边编写更新开通区域列表缓存的任务方法:
先删除开通区域的缓存,再查询开通区域列表进行缓存。
package com.jzo2o.foundations.handler;
import com.jzo2o.foundations.constants.RedisConstants;
import com.jzo2o.foundations.service.IRegionService;
import com.jzo2o.foundations.service.IServeService;
import com.xxl.job.core.handler.annotation.XxlJob;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
/**
* springCache缓存同步任务
*
* @author itcast
* @create 2023/8/15 18:14
**/
@Slf4j
@Component
public class SpringCacheSyncHandler {
@Resource
private IRegionService regionService;
@Resource
private RedisTemplate redisTemplate;
/**
* 已启用区域缓存更新
* 每日凌晨1点执行
*/
@XxlJob(value = "activeRegionCacheSync")
public void activeRegionCacheSync() {
log.info(">>>>>>>>开始进行缓存同步,更新已启用区域");
//1.清理缓存
String key = RedisConstants.CacheName.JZ_CACHE + "::ACTIVE_REGIONS";
redisTemplate.delete(key);
//2.刷新缓存
regionService.queryActiveRegionListCache();
log.info(">>>>>>>>更新已启用区域完成");
}
}
2)配置任务
下边在调度中心配置任务。
进入任务管理,新增任务:
填写任务信息:
3)启动任务并测试
任务配置完成,下边启动任务
启动成功:
我们在任务方法上打断点跟踪,任务方法被执行,如下图:
尝试启用或禁用一个区域,观察redis中开通区域列表缓存是否更新。
4) 小结
项目中哪里用了xxl-job?怎么用的?
3.2 首页服务列表实现
我们先实现从数据库查询"首页服务列表",将整体功能调试通过,再实现查询缓存。
下边我们先实现从数据库查询服务类型列表。
3.2.1 首页服务列表实现
1) 需求分析
1.界面原型
首页服务列表在门户的中心位置,下图红框中为首页服务列表区域:
默认展示前两个服务分类(按后台设置的排序字段进行升序排序),每个服务分类下取前4个服务项(按后台设置的排序字段进行升序排序)如下图:
一级服务分类显示的内容:服务分类的图标,服务分类的名称。
服务分类下的服务项内容:服务项图标,服务项名称。
2)接口定义
定义首页服务列表接口,查询2个一级服务分类,每个服务分类下查询4个服务项。
接口名称:首页服务列表
接口路径:GET/foundations/customer/serve/firstPageServeList
响应示例:
{
"msg": "OK",
"code": 200,
"data": [
{
"serveTypeId": 0,
"cityCode": "",
"serveTypeIcon": "",
"serveTypeSortNum": 0,
"serveResDTOList": [
{
"serveItemSortNum": 0,
"serveItemName": "",
"serveItemId": 0,
"serveItemIcon": "",
"id": 0
}
],
"serveTypeName": ""
}
]
}
定义controller方法:
门户信息查询类接口统一在FirstPageServeController类中定义,service统一写在HomeService下。
定义FirstPageServeController 类提供门户界面查询类接口。
package com.jzo2o.foundations.controller.consumer;
...
/**
* <p>
* 前端控制器
* </p>
*
* @author itcast
* @since 2023-07-03
*/
@RestController("consumerServeController")
@RequestMapping("/customer/serve")
@Api(tags = "用户端 - 首页服务查询接口")
public class FirstPageServeController {
@GetMapping("/firstPageServeList")
@ApiOperation("首页服务列表")
@ApiImplicitParams({
@ApiImplicitParam(name = "regionId", value = "区域id", required = true, dataTypeClass = Long.class)
})
public List<ServeCategoryResDTO> serveCategory(@RequestParam("regionId") Long regionId) {
return null;
}
...
3)mapper
数据来源于三张表:serve_type、serve_item、serve,区域id是非常重要的限制条件,因为要查询该区域显示在首页的服务列表。
如何查询数据?
可以先查询出服务类型,再根据服务类型id查询下边的服务项,伪代码如下:
//查询服务类型
List<ServeType> serveTypeList =...
for(ServeType serveType: serveTypeList){
Long serveTypeId = serveType.getId();
//再根据服务类型id查询下属的服务项信息
}
上边的代码会导致1+n次查询数据库,这种代码要避免。
我们可以一次将符合条件的数据查询出来,再通过java程序对数据进行处理,再封装为接口要求的数据格式,最后返回给前端。
在ServeMapper.java中添加如下方法:
/**
* 根据区域id查询服务图标
*
* @param regionId 区域id
* @return 服务图标
*/
List<ServeCategoryResDTO> findServeIconCategoryByRegionId(@Param("regionId")Long regionId);
下边在ServeMapper.xml中添加如下内容:
<select id="findServeIconCategoryByRegionId" resultMap="ServeCategoryMap">
SELECT
type.id as serve_type_id,
type.name as serve_type_name,
type.serve_type_icon,
serve.city_code,
serve.id as serve_id,
item.id as serve_item_id,
item.name as serve_item_name,
item.serve_item_icon,
item.sort_num as serve_item_sort_num
FROM
serve
inner JOIN serve_item AS item ON item.id = serve.serve_item_id
inner JOIN serve_type AS type ON type.id = item.serve_type_id
WHERE
serve.region_id = #{regionId}
AND serve.sale_status = 2
ORDER BY
type.sort_num,
item.sort_num
</select>
<!--手动的映射-->
<resultMap id="ServeCategoryMap" type="com.jzo2o.foundations.model.dto.response.ServeCategoryResDTO">
<!--id映射主键字段-->
<id column="serve_type_id" property="serveTypeId"></id>
<!--result映射普通字段-->
<result column="serve_type_name" property="serveTypeName"></result>
<result column="serve_type_icon" property="serveTypeIcon"></result>
<result column="city_code" property="cityCode"></result>
<!--column 数据库中的字段名-->
<!--property 实体类中对应的属性 该关键字可以省略... -->
<!--ofType 是javaType中的单个对象类型-->
<collection property="serveResDTOList" ofType="com.jzo2o.foundations.model.dto.response.ServeSimpleResDTO">
<id column="serve_id" property="id"></id>
<result column="serve_item_id" property="serveItemId"></result>
<result column="serve_item_name" property="serveItemName"></result>
<result column="serve_item_icon" property="serveItemIcon"></result>
<result column="serve_item_sort_num" property="serveItemSortNum"></result>
</collection>
</resultMap>
4)service
定义专门用于门户首页查询的service接口,用于实现查询缓存:
package com.jzo2o.foundations.service;
...
import java.util.List;
/**
* 首页查询相关功能
*
* @author itcast
* @create 2023/8/21 10:55
**/
public interface HomeService {
/**
* 根据区域id获取服务图标信息
*
* @param regionId 区域id
* @return 服务图标列表
*/
List<ServeCategoryResDTO> queryServeIconCategoryByRegionIdCache(Long regionId);
...
定义实现类
package com.jzo2o.foundations.service.impl;
...
/**
* 首页查询相关功能
*
* @author itcast
* @create 2023/8/21 10:57
**/
@Slf4j
@Service
public class HomeServiceImpl implements HomeService {
/**
* 根据区域id查询已开通的服务类型
*
* @param regionId 区域id
* @return 已开通的服务类型
*/
@Override
public List<ServeCategoryResDTO> queryServeIconCategoryByRegionIdCache(Long regionId) {
//1.校验当前城市是否为启用状态
Region region = regionService.getById(regionId);
if (ObjectUtil.isEmpty(region) || ObjectUtil.equal(FoundationStatusEnum.DISABLE.getStatus(), region.getActiveStatus())) {
return Collections.emptyList();
}
//2.根据城市编码查询所有的服务图标
List<ServeCategoryResDTO> list = serveMapper.findServeIconCategoryByRegionId(regionId);
if (ObjectUtil.isEmpty(list)) {
return Collections.emptyList();
}
//3.服务类型取前两个,每个类型下服务项取前4个
//list的截止下标
int endIndex = list.size() >= 2 ? 2 : list.size();
List<ServeCategoryResDTO> serveCategoryResDTOS = new ArrayList<>(list.subList(0, endIndex));
serveCategoryResDTOS.forEach(v -> {
List<ServeSimpleResDTO> serveResDTOList = v.getServeResDTOList();
//serveResDTOList的截止下标
int endIndex2 = serveResDTOList.size() >= 4 ? 4 : serveResDTOList.size();
List<ServeSimpleResDTO> serveSimpleResDTOS = new ArrayList<>(serveResDTOList.subList(0, endIndex2));
v.setServeResDTOList(serveSimpleResDTOS);
});
return serveCategoryResDTOS;
}
5) controller
在controller中调用service查询首页服务列表。
package com.jzo2o.foundations.controller.consumer;
...
/**
* <p>
* 前端控制器
* </p>
*
* @author itcast
* @since 2023-07-03
*/
@Validated
@RestController("consumerServeController")
@RequestMapping("/customer/serve")
@Api(tags = "用户端 - 首页服务查询接口")
public class FirstPageServeController {
@Resource
private HomeService homeService;
@GetMapping("/firstPageServeList")
@ApiOperation("首页服务列表")
@ApiImplicitParams({
@ApiImplicitParam(name = "regionId", value = "区域id", required = true, dataTypeClass = Long.class)
})
public List<ServeCategoryResDTO> serveCategory(@RequestParam("regionId") Long regionId) {
List<ServeCategoryResDTO> serveCategoryResDTOS = homeService.queryServeIconCategoryByRegionIdCache(regionId);
return serveCategoryResDTOS;
}
...
6) 测试
重启jzo2o-foundations服务、网关服务,打开小程序。
观察小程序的访问记录
注意:
如果访问记录中regionId为undefine,如下所示:
http://192.168.101.1:11500/foundations/customer/serve/firstPageServeList?regionId=undefine
这时需要重新定位,切换区域
测试预期结果:
在小程序首页正常显示服务列表。
示例:
3.2.2 首页服务列表缓存
1) 缓存方案分析
下边是门户的缓存设计:
td {white-space:nowrap;border:0.5pt solid #dee0e3;font-size:10pt;font-style:normal;font-weight:normal;vertical-align:middle;word-break:normal;word-wrap:normal;}| 信息内容类型 | 缓存过期时间 | 缓存结构 | 缓存key | 缓存同步方案 |
| 开通区域列表 | 永久缓存 | String | JZ_CACHE::ACTIVE_REGIONS | 查询缓存:查询开通区域列表进行缓存 启用区域:删除开通区域缓存 禁用区域:删除开通区域及其它信息 由定时任务每天凌晨更新缓存 |
| 首页服务列表 | 永久缓存 | String | JZ_CACHE:SERVE_ICON::区域id | 查询缓存:初次查询进行缓存 禁用区域:删除本区域的首页服务列表缓存 由定时任务每天凌晨更新缓存 |
| 服务类型列表 | 永久缓存 | String | JZ_CACHE:SERVE_TYPE::区域id | 查询缓存:初次查询直接缓存 禁用区域:删除本区域的服务类型列表缓存 由定时任务每天凌晨更新缓存 |
| 热门服务列表 | 永久缓存 | String | JZ_CACHE:HOT_SERVE::区域id | 查询缓存:初次查询直接缓存 禁用区域:删除本区域的热门服务列表缓存 由定时任务每天凌晨更新缓存 |
| 服务项信息 | 缓存1天 | String | JZ_CACHE:SERVE_ITEM::服务项id | 启动:添加缓存 禁用:删除缓存 修改: 修改缓存 |
| 服务信息 | 缓存1天 | String | JZ_CACHE:SERVE_RECORD::服务id | 上架:添加缓存 下架:删除缓存 修改: 修改缓存 |
下边分析首页服务列表的缓存方案:
查询缓存:查询首页服务列表,如果没有缓存则查询数据库并缓存,如果有缓存则直接返回
注意:缓存时需要考虑缓存穿透问题。
禁用区域:删除首页服务列表缓存
定时任务:每天凌晨缓存首页服务列表。
2)查询缓存
下边在首页服务列表查询方法上添加Spring Cache注解实现查询缓存。
为了避免缓存穿透,如果服务列表为空则向redis缓存空值,缓存时间为30分钟;不为空则进行永久缓存。
在Cacheable注解中有两个属性可以指定条件进行缓存:
condition:指定一个 SpEL 表达式,用于决定是否要进行缓存。只有当条件表达式的结果为 true 时,方法的返回值才会被缓存。例如:
@Cacheable(value = "myCache", condition = "#id != null")
unless:与 condition 相反,只有当 SpEL 表达式的结果为 false 时,方法的返回值才会被缓存。
例如:
@Cacheable(value = "myCache", unless = "#result.length() > 100")
result 表示方法的返回值,如果返回值结果集的长度大于100不进行缓存。
根据需求,我们需要根据方法的返回值去判断,如果结果集的长度大于0说明服务列表不空,此时缓存时间为永久缓存,否则缓存时间为30分钟。
condition不支持获取方法返回的值,不能识别#result。
我们使用unless实现。
unless 的特点是符合条件的不缓存。设置技巧:确定要缓存的条件,取反即不缓存的条件。
当方法返回的List的size为0时缓存30分钟,避免缓存穿透,设置为:#result.size() != 0
当方法返回的List的size大于0永不过期,设置为:#result.size() == 0
代码如下:
package com.jzo2o.foundations.service.impl;
...
/**
* 首页查询相关功能
*
* @author itcast
* @create 2023/8/21 10:57
**/
@Slf4j
@Service
public class HomeServiceImpl implements HomeService {
@Caching(
cacheable = {
//result为null时,属于缓存穿透情况,缓存时间30分钟
@Cacheable(value = RedisConstants.CacheName.SERVE_ICON, key = "#regionId", unless = "#result.size() != 0", cacheManager = RedisConstants.CacheManager.THIRTY_MINUTES),
//result不为null时,永久缓存
@Cacheable(value = RedisConstants.CacheName.SERVE_ICON, key = "#regionId", unless = "#result.size() == 0", cacheManager = RedisConstants.CacheManager.FOREVER)
}
)
public List<ServeCategoryResDTO> queryServeIconCategoryByRegionIdCache(Long regionId) {
//1.校验当前城市是否为启用状态
Region region = regionService.getById(regionId);
if (ObjectUtil.isEmpty(region) || ObjectUtil.equal(FoundationStatusEnum.DISABLE.getStatus(), region.getActiveStatus())) {
return Collections.emptyList();
}
//2.根据城市编码查询所有的服务图标
List<ServeCategoryResDTO> list = serveMapper.findServeIconCategoryByRegionId(regionId);
if (ObjectUtil.isEmpty(list)) {
return Collections.emptyList();
}
//3.服务类型取前两个,每个类型下服务项取前4个
//list的截止下标
int endIndex = list.size() >= 2 ? 2 : list.size();
List<ServeCategoryResDTO> serveCategoryResDTOS = new ArrayList<>(list.subList(0, endIndex));
serveCategoryResDTOS.forEach(v -> {
List<ServeSimpleResDTO> serveResDTOList = v.getServeResDTOList();
//serveResDTOList的截止下标
int endIndex2 = serveResDTOList.size() >= 4 ? 4 : serveResDTOList.size();
List<ServeSimpleResDTO> serveSimpleResDTOS = new ArrayList<>(serveResDTOList.subList(0, endIndex2));
v.setServeResDTOList(serveSimpleResDTOS);
});
return serveCategoryResDTOS;
}
...
3)查询缓存测试
下边进行测试:
启动:jzo2o-foundations服务、网关服务,打开小程序,等待首页服务列表正常显示,进入redis查看首页服务列表是否缓存。
预期结果:首页服务列表正常缓存。
示例:
4) 定时任务更新缓存
根据缓存方案的分析,对首页服务列表进行缓存。
编写定时任务代码:
/**
* 已启用区域缓存更新
* 每日凌晨1点执行
*/
@XxlJob("activeRegionCacheSync")
public void activeRegionCacheSync() throws Exception {
log.info(">>>>>>>>开始进行缓存同步,更新已启用区域");
//删除缓存
Boolean delete = redisTemplate.delete(RedisConstants.CacheName.JZ_CACHE + "::ACTIVE_REGIONS");
//通过查询开通区域列表进行缓存
List<RegionSimpleResDTO> regionSimpleResDTOS = regionService.queryActiveRegionList();
//遍历区域对该区域下的服务类型进行缓存
regionSimpleResDTOS.forEach(item->{
//区域id
Long regionId = item.getId();
//删除该区域下的首页服务列表
String serve_type_key = RedisConstants.CacheName.SERVE_ICON + "::" + regionId;
redisTemplate.delete(serve_type_key);
homeService.queryServeIconCategoryByRegionIdCache(regionId);
//todo 删除该区域下的服务类型列表缓存
});
}
5)定时任务更新缓存测试
下边测试定时任务更新缓存:
先将首页服务列表的缓存手动删除。
重启foundations服务,在上边代码中打断点,保证定时任务成功执行。
预期结果:对每个运营区域的首页服务列表进行缓存。
示例:
跟踪断点执行。
4 编码规范
门户信息查询类接口统一在FirstPageServeController类中定义,service统一写在HomeService下。
首先实现业务接口的功能,测试通过后再去实现缓存。
6) 禁用区域时删除缓存
找到禁用区域代码 ,添加删除首页服务列表缓存的代码,如下:
@Service
public class RegionServiceImpl extends ServiceImpl<RegionMapper, Region> implements IRegionService {
...
@Override
@Caching(evict = {
@CacheEvict(value = RedisConstants.CacheName.JZ_CACHE, key = "'ACTIVE_REGIONS'"),
@CacheEvict(value = RedisConstants.CacheName.SERVE_ICON, key = "#id")
})
public void deactivate(Long id) {
测试:禁用一个区域观察redis是否删除该区域的首页服务列表缓存。
7)小结
项目中有做缓存吗?考虑缓存穿透问题了吗?怎么实现的?
3.3 服务类型列表缓存(实战)
3.3.1 分析设计
1.界面原型
点击首页服务列表的服务分类或直接点击“全部服务”进入全部服务界面,
“全部服务” 界面如下图:
在全部服务界面需要展示当前区域下的服务分类,点击服务分类查询分类下的服务。
服务类型列表数据包括:服务类型的名称、服务类型id。
注意:只实现上图左侧显示的服务分类列表
- 接口定义
服务类型列表查询接口传入参数为:区域id,输出数据为服务类型列表,列表中元素包括:服务类型的名称、服务类型id,为了可扩展,列表元素包括服务类型的图标、排序字段方便进行展示。
接口名称:服务分类列表
接口路径:GET/foundations/customer/serve/serveTypeList
- 缓存方案分析
下边分析服务类型列表的缓存方案:
查询缓存:查询服务类型列表,如果缓存没有则查询数据库并缓存,如果缓存有则直接返回
禁用区域:删除该区域下服务类型列表缓存
定时任务:每天凌晨缓存服务类型列表。
缓存时注意解决缓存穿透问题。
3.3.2 设计提示
根据接口定义分析,需要根据区域id查询该区域下运营的服务项所属的分类,所以要分析数据来源于哪几张表?如果是一张表直接用MybatisPlus拼装SQL即可,如果是多张表则需要编写SQL语句。
在测试时需要切换区域进行测试。
3.4 热门服务列表(实战)
3.4.1 分析设计
1.界面原型
在门户有一块区域叫精选推荐,此信息为热门服务列表,如下图:
热门服务列表是在区域服务管理界面进行设置,如下图:
“设置热门”表示该服务项在本区域为精选推荐。
“取消热门”表示取消该服务项的精选推荐。
热门服务列表数据素包括:区域服务价格、价格单位、服务项id、服务项名称、服务项图标、服务详图。
信息来源于两张表:serve_item、serve
查询条件:区域id、是否热门(是)、是否上架(是)
按服务修改时间降序排序
2、接口设计
接口名称:首页热门服务列表
接口路径:GET/foundations/customer/serve/hotServeList
响应参数:
示例数据:
{
"msg": "OK",
"code": 200,
"data": {
"cityCode": "",
"serveItemName": "",
"serveItemId": 0,
"unit": 0,
"detailImg": "",
"price": 0,
"serveItemImg": "",
"id": 0
}
}
3. 缓存方案
查询缓存:查询热门服务列表,如果缓存没有则查询数据库并缓存,如果缓存有则直接返回
注意:缓存时需要考虑缓存穿透问题。
禁用区域:删除热门服务列表缓存
定时任务:每天凌晨缓存热门服务列表。
3.4.2 设计提示
根据接口定义分析,需要根据区域id查询该区域下运营的服务项所属的分类,所以要分析数据来源于哪几张表?如果是一张表直接用MybatisPlus拼装SQL即可,如果是多张表则需要编写SQL语句。
在测试时需要切换区域进行测试。
3.5 服务详情(实战)
3.5.1 分析设计
1.界面原型
在首页服务列表区域、热门服务列表区域以及全部服务界面,点击服务项名称进入服务详情页面,如下图:
服务详情页面显示服务相关的信息包括:服务项的名称、服务的运营价格、服务项的图片、服务项名称、价格单位。信息来源于serve_item表和serve表。
2.接口设计
接口名称:根据id查询服务
接口路径:GET/foundations/customer/serve/{id}
请求参数:服务id,即serve表的主键
3. 缓存方案
服务详情信息来源于两部分信息:服务项信息、服务信息。
分别对服务项信息、服务信息进行缓存,方案如下:
td {white-space:nowrap;border:0.5pt solid #dee0e3;font-size:10pt;font-style:normal;font-weight:normal;vertical-align:middle;word-break:normal;word-wrap:normal;}| 服务项信息 | 缓存1天 | String | JZ_CACHE:SERVE_ITEM::服务项id | 启动:添加缓存 禁用:删除缓存 修改: 修改缓存 |
| 服务信息 | 缓存1天 | String | JZ_CACHE:SERVE_RECORD::服务id | 上架:添加缓存 下架:删除缓存 修改: 修改缓存 |
分别编写方法:根据id查询服务项(已实现),根据id查询服务信息的方法(已实现)
然后实现查询缓存:
在服务详情接口中调用根据id查询服务项的service方法和 根据id查询服务信息的service方法,根据接口要求对两部分数据进行拼装返回。
针对热门服务的服务项信息和服务信息通过定时任务更新缓存,思路如下:
查询出所有热门服务,遍历热门服务列表先删除缓存,再执行查询方法对热门服务信息进行缓存。
3.5.2 设计提示
根据分析可知接口要的数据来源于serve表和serve_item表,根据上边的缓存方案需要针对服务项信息和服务信息分别实现缓存方法。
学习目标:
-
能够说出项目为什么要用Elasticsearch
-
能够说出服务搜索模块的功能列表
-
能够说出项目中进行索引同步的流程
-
能够配置Canal加MQ同步环境
-
能够完成服务变更实时同步开发
-
能够开发家政服务搜索接口
-
能够说出家政项目搜索模块的设计方案
-
能够开发电商项目搜索接口
-
能够说出电商项目搜索模块的设计方案
家政项目实战:
服务变更实时同步开发
家政服务搜索接口
电商项目实战:
电商项目搜索接口
电商项目搜索聚合接口
代码环境:
jzo2o-foundations:dev_02
mall-plus: dev_01
1 家政项目服务搜索
1.1 服务搜索技术方案
目标:理解本项目的搜索技术方案
1)需求分析
服务搜索的入口有两处:
- 在门户最上端的搜索入口对服务信息进行搜索。
如下图:
在第2部分触发搜索框进入搜索页面,输入关键字进行搜索,如下图:
- 在门户最下方点击“全部服务”进入全部服务界面。
如下图:
点击服务分类查询分类下的服务。
2)技术方案
使用ES进行全文检索
要实现服务搜索使用什么技术呢?
根据需求分析,对服务进行搜索除了根据服务类型查询其下的服务以外还需要根据关键字去搜索与关键字匹配的服务。
通过关键字去匹配服务的哪些信息呢?比如:输入关键字“家庭保洁”,它会去匹配服务相关的信息,比如:服务类型的名称、服务项的名称,甚至根据需要也可能去匹配服务介绍的信息,只要与“家庭保洁”相关的服务都会展示出来。如下效果:
这里最关键的是根据关键字去匹配,上图的搜索效果是一种全文检索方式,在搜索“家庭保洁”关键字时会对关键字先分词,分为“家庭”和“保洁”,再根据分好的词去匹配索引库中的服务类型的名称、服务项的名称、服务项的描述等字段。
如果要实现全文检索且对接口性能有一定的要求,最常用的是Elasticsearch,Elasticsearch是基于倒排索引的原理,正排索引是从文章中找词,倒排索引是根据词去找文章,本项目使用ES完成服务搜索功能的开发。
索引同步方案
如果要使用ES去搜索服务信息需要提前对服务信息在ES中创建索引,运营端在管理服务时是将服务信息保存在数据库,如何对数据库中的服务信息去创建索引,保证数据库中的信息与ES的索引信息同步呢,本节对索引同步的方案进行分析与确定。
暂时无法在飞书文档外展示此内容
方案1: 添加服务信息维护索引
在服务项的增删改查Service方法中添加维护ES索引的代码。
在区域服务的增删改查Service方法中添加维护ES索引的代码。
例如下边的代码:
public Serve onSale(Long id){
//操作serve表
//添加向ES创建索引的代码
}
首先上边的代码是在原有业务方式的基础上添加索引同步的代码,增加代码的复杂度不方便维护,扩展性差。
其次上边的代码存在分布式事务,操作serve表会访问数据库,添加索引会访问ES,使用数据库本地事务是无法控制整个方法的一致性的,比如:向ES写成功了由于网络问题抛出网络超时异常,最终数据库操作回滚了ES操作没有回滚,数据库的数据和ES中的索引不一致。
方案1通常在生产中不会使用。
方案2:使用Canal+MQ
Canal是什么?
canal [kə'næl],译意为水道/管道/沟渠,主要用途是基于 MySQL 数据库增量日志解析,对数据进行同步。
Canal可与很多数据源进行对接,将数据由MySQL同步到ES、MQ、DB等各个数据源。
对Canal+MQ方案不熟悉的同学尽快复习前边课程。
本方案需要借助Canal和消息队列,具体实现方案如下:
通过上边的技术分析下边对本项目服务搜索方案进行总结。
本项目使用Elasticsearch实现服务的搜索功能,使用Canal+MQ完成服务信息与ES索引同步。
如下图:
暂时无法在飞书文档外展示此内容
流程如下:
运营人员对服务信息进行增删改操作,MySQL记录binlog日志。
Canal定时读取binlog 解析出增加、修改、删除数据的记录。
Canal将修改记录发送到MQ。
同步程序监听MQ,收到增加、修改、删除数据的记录,请求ES创建、修改、删除索引。
C端用户请求服务搜索接口从ES中搜索服务信息。
3)小结
项目为什么要用Elasticsearch?数据很多吗?
项目中如何进行索引同步的?
能说出如何保证MQ消息的可靠性?
能说出如何保证MQ幂等性?或 如何防止重复消费?
1.2 配置数据同步环境
目标:
理解Canal+MQ的同步流程
参考文档配置Canal+MQ的同步环境
1) Canal+MQ同步流程
下边回顾Canal的工作原理,如下图:
1、Canal模拟 MySQL slave 的交互协议,伪装自己为 MySQL slave ,向 MySQL master 发送dump 协议
MySQL的dump协议是MySQL复制协议中的一部分。
2、MySQL master 收到 dump 请求,开始推送 binary log 给 slave (即 canal )
。一旦连接建立成功,Canal会一直等待并监听来自MySQL主服务器的binlog事件流,当有新的数据库变更发生时MySQL master主服务器发送binlog事件流给Canal。
3、Canal会及时接收并解析这些变更事件并解析 binary log
通过以上流程可知Canal和MySQL master主服务器之间建立了长连接。
基于Canal+MQ数据同步流程:
暂时无法在飞书文档外展示此内容
-
服务管理不仅向serve、serve_item、serve_type表写数据,同时也向serve_sync表写数据,serve_sync用于Canal同步数据使用。
-
向serve_sync写数据产生binlog
-
Canal请求读取binlog,并解析出serve_sync表的数据更新日志,并发送至MQ的数据同步队列。
-
异步同步程序监听MQ的数据同步队列,收到消息后解析出serve_sync表的更新日志。
-
异步同步程序根据serve_sync表的更新日志请求Elasticsearch添加、更新、删除索引文档。
最终实现了将MySQL中的serve_sync表的数据同步至Elasticsearch
本节实现将MySQL的变更数据通过Canal写入MQ。
2)配置Canal+MQ数据同步环境
根据Canal+MQ同步流程,下边进行如下配置:
-
配置Mysql主从同步,开启MySQL主服务器的binlog
-
安装Canal并配置,保证Canal连接MySQL主服务器成功
-
安装RabbitMQ,并配置同步队列。
-
在Canal中配置RabbitMQ的连接信息,保证Canal收到binlog消息写入MQ
对于异步程序监听MQ通过Java程序中实现。
以上四步配置详细参考“配置ES索引同步环境v1.0”。
3)小结
Canal是怎么伪装成 MySQL slave?
Canal数据同步异常了怎么处理?
1.3 索引同步
1.3.1 测试索引同步程序
刚才通过配置Canal+MQ的数据同步环境实现了Canal从数据库读取binlog并且将数据写入MQ。
下边编写同步程序监听MQ,收到消息后向ES创建索引。
1) 创建索引结构
启动ES和kibana:
如果没有安装参考“第三方软件安装说明”安装elasticsearch7.17.7 和 kibana7.17.7。
安装完成后进行启动:
docker start elasticsearch7.17.7
docker start kibana7.17.7
下边创建索引serve_aggregation,serve_aggregation索引的结构与jzo2o-foundations数据库的serve_sync表结构对应。
首先通过下边的命令查询索引
GET /_cat/indices?v
如果需要修改索引结构需要删除重新创建:
DELETE 索引名
查询索引结构
GET /索引名/_mapping
创建serve_aggregation索引 (已经存在无需重复创建)
PUT /serve_aggregation
{
"mappings" : {
"properties" : {
"city_code" : {
"type" : "keyword"
},
"detail_img" : {
"type" : "text",
"index" : false
},
"hot_time_stamp" : {
"type" : "long"
},
"id" : {
"type" : "keyword"
},
"is_hot" : {
"type" : "short"
},
"price" : {
"type" : "double"
},
"serve_item_icon" : {
"type" : "text",
"index" : false
},
"serve_item_id" : {
"type" : "keyword"
},
"serve_item_img" : {
"type" : "text",
"index" : false
},
"serve_item_name" : {
"type" : "text",
"analyzer": "ik_max_word",
"search_analyzer":"ik_smart"
},
"serve_item_sort_num" : {
"type" : "short"
},
"serve_type_icon" : {
"type" : "text",
"index" : false
},
"serve_type_id" : {
"type" : "keyword"
},
"serve_type_img" : {
"type" : "text",
"index" : false
},
"serve_type_name" : {
"type" : "text",
"analyzer": "ik_max_word",
"search_analyzer":"ik_smart"
},
"serve_type_sort_num" : {
"type" : "short"
},
"unit" : {
"type" : "long"
}
}
}
}
2)阅读同步程序
1.添加依赖
首先在foundations工程添加下边的依赖
<dependency>
<groupId>com.jzo2o</groupId>
<artifactId>jzo2o-canal-sync</artifactId>
</dependency>
<dependency>
<groupId>com.jzo2o</groupId>
<artifactId>jzo2o-es</artifactId>
</dependency>
2.配置连接 ES
修改foundations的配置文件:
修改nacos中es的配置文件shared-es.yaml
修改nacos中rabbitmq的配置文件
3.阅读同步程序
同步程序继承AbstractCanalRabbitMqMsgListener类,泛型中指定同步表对应的类型。
根据数据同步环境去配置监听MQ:
package com.jzo2o.foundations.handler;
import com.jzo2o.canal.listeners.AbstractCanalRabbitMqMsgListener;
import com.jzo2o.es.core.ElasticSearchTemplate;
import com.jzo2o.foundations.constants.IndexConstants;
import com.jzo2o.foundations.model.domain.ServeSync;
import org.springframework.amqp.core.ExchangeTypes;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.Exchange;
import org.springframework.amqp.rabbit.annotation.Queue;
import org.springframework.amqp.rabbit.annotation.QueueBinding;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.List;
/**
* 服务信息同步程序
*
* @author itcast
* @create 2023/8/15 18:14
**/
@Component
public class ServeCanalDataSyncHandler extends AbstractCanalRabbitMqMsgListener<ServeSync> {
@Resource
private ElasticSearchTemplate elasticSearchTemplate;
//@RabbitListener(queues = "canal-mq-jzo2o-foundations", concurrency = "1")
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "canal-mq-jzo2o-foundations"),
exchange = @Exchange(name = "exchange.canal-jzo2o", type = ExchangeTypes.TOPIC),
key = "canal-mq-jzo2o-foundations"),
concurrency = "1"
)
public void onMessage(Message message) throws Exception {
}
concurrency = "1":表示消费线程数为1。
在同步程序中需要根据业务需求编写同步方法,当服务下架时会删除索引需要重写抽象类中的batchDelete(List
当服务上架后需要添加索引,当服务信息修改时需要修改索引,需要重写抽象类中的batchSave(List
完整代码如下:
package com.jzo2o.foundations.handler;
import com.jzo2o.canal.listeners.AbstractCanalRabbitMqMsgListener;
import com.jzo2o.es.core.ElasticSearchTemplate;
import com.jzo2o.foundations.constants.IndexConstants;
import com.jzo2o.foundations.model.domain.ServeSync;
import org.springframework.amqp.core.ExchangeTypes;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.Exchange;
import org.springframework.amqp.rabbit.annotation.Queue;
import org.springframework.amqp.rabbit.annotation.QueueBinding;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.List;
/**
* 服务信息同步程序
*
* @author itcast
* @create 2023/8/15 18:14
**/
@Component
public class ServeCanalDataSyncHandler extends AbstractCanalRabbitMqMsgListener<ServeSync> {
@Resource
private ElasticSearchTemplate elasticSearchTemplate;
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "canal-mq-jzo2o-foundations"),
exchange = @Exchange(name = "exchange.canal-jzo2o", type = ExchangeTypes.TOPIC),
key = "canal-mq-jzo2o-foundations"),
concurrency = "1"
)
public void onMessage(Message message) throws Exception {
parseMsg(message);
}
@Override
public void batchSave(List<ServeSync> data) {
Boolean aBoolean = elasticSearchTemplate.opsForDoc().batchInsert(IndexConstants.SERVE, data);
if(!aBoolean){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
throw new RuntimeException("同步失败");
}
}
@Override
public void batchDelete(List<Long> ids) {
Boolean aBoolean = elasticSearchTemplate.opsForDoc().batchDelete(IndexConstants.SERVE, ids);
if(!aBoolean){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
throw new RuntimeException("同步失败");
}
}
}
3)测试
启动jzo2o-foundations服务。
启动成功,jzo2o-foundations服务作为MQ的消费者和MQ建立通道,进入canal-mq-jzo2o-foundations队列的管理界面,查看是否建立 了监听通道。
监听通道建立 成功,下边在同步程序打断点:
手动修改jzo2o-foundations数据库的serve_sync表的记录,这里修改了服务项的名称
正常执行同步程序:
放行继续执行到batchSave方法:
保证ES服务正常,同步方法执行成功后进入Kibana查看
执行命令:
GET /serve_aggregation/_search
{
}
查询服务信息与数据库serve_sync表中1686352662791016449记录的信息一致。
下边再将服务项名称恢复。
再进入Kibana查看索引的内容与数据库一致
3)小结
同步程序的实现步骤:
-
根据数据库同步表的结构 创建索引结构。
-
同步程序监听MQ的同步队列
-
同步程序收到数据同步消息写入Elasticsearch,写的失败抛出异常,消息回到MQ。
如何保证Canal+MQ同步消息的顺序性?
多个jvm进程监听同一个队列保证只有消费者活跃,即只有一个消费者接收消息。
消费队列中的数据使用单线程。
如何保证只有一个消费者接收消息?
队列需要增加x-single-active-consumer参数,表示否启用单一活动消费者模式。使用x-single-active-consumer参数需要修改为如下代码:
在Queue中添加:arguments={@Argument(name="x-single-active-consumer", value = "true", type = "java.lang.Boolean") }
如下所示:
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "canal-mq-jzo2o-foundations",arguments={@Argument(name="x-single-active-consumer", value = "true", type = "java.lang.Boolean") }),
exchange = @Exchange(name="exchange.canal-jzo2o",type = ExchangeTypes.TOPIC),
key="canal-mq-jzo2o-foundations"),
concurrency="1"
)
public void onMessage(Message message) throws Exception{
parseMsg(message);
}
concurrency=”1“表示 指定消费线程为1。
1.3.2 服务变更实时同步(实战)
通过测试Canal+MQ同步流程,只有当serve_sync表变化时才会触发同步,serve_sync表什么时候变化 ?
当服务信息变更时需要同时修改serve_sync表,下边先分析serve_sync的变化需求,再进行代码实现。
1)管理同步表需求
现在如何去维护serve_sync这张表呢?
根据serve_sync表的结构分析:
添加:区域服务上架向serve_sync表添加记录,同步程序新增索引记录。
删除:区域服务下架从serve_sync表删除记录,同步程序删除索引记录。
修改:
修改服务项修改serve_sync的记录。
修改服务分类修改serve_sync的记录。
修改服务价格修改serve_sync的记录。
设置热门/取消热门修改serve_sync的记录。
2)代码实现
根据需求编写代码并测试。
测试内容如下:
测试服务上架添加索引。
测试服务下架删除索引。
测试修改服务价格修改索引。
测试修改服务项名称修改索引。
测试修改修改服务分类名称修改索引。
1.4 搜索接口(实战)
目标:开发搜索接口。
1)接口如下
参数内容:区域编码,服务类型id、关键字
区域编码:用户定位成功前端记录区域编码(city_code),搜索时根据city_code搜索该区域的服务。
服务类型id:在全部服务界面选择一个服务类型查询其它下的服务列表。
关键字:输入关键字搜索服务项名称、服务类型名称。
接口名称:服务搜索接口
接口路径:GET/foundations/customer/serve/search
controller方法:
@RestController("consumerServeController")
@RequestMapping("/customer/serve")
@Api(tags = "用户端 - 首页服务查询接口")
public class FirstPageServeController {
...
@GetMapping("/search")
@ApiOperation("首页服务搜索")
@ApiImplicitParams({
@ApiImplicitParam(name = "cityCode", value = "城市编码", required = true, dataTypeClass = String.class),
@ApiImplicitParam(name = "serveTypeId", value = "服务类型id", dataTypeClass = Long.class),
@ApiImplicitParam(name = "keyword", value = "关键词", dataTypeClass = String.class)
})
public List<ServeSimpleResDTO> findServeList(@RequestParam("cityCode") String cityCode,
@RequestParam(value = "serveTypeId", required = false) Long serveTypeId,
@RequestParam(value = "keyword", required = false) String keyword) {
return null;
}
2)搜索方法
查询条件包括:
城市代码
关键字:模糊匹配服务项名称、服务分类名称
服务分类:点击服务分类查询分类下的服务。
排序字段:服务项排序字段
5 电商项目商品搜索(实战)
5.1 需求分析
首页输入关键字
首页搜索栏输入关键字进行搜索:
通过分类进行搜索
条件筛选
5.2 索引结构
根据需求梳理索引结构如下:
PUT /mall_goods
{
"mappings" : {
"properties" : {
"id" : {
"type" : "keyword"
},
"goods_id" : {
"type" : "keyword"
},
"goods_name" : {
"type" : "text",
"analyzer": "ik_max_word",
"search_analyzer":"ik_smart"
},
"goods_type" : {
"type" : "keyword"
},
"goods_video" : {
"type" : "text",
"index" : false
},
"grade" : {
"type" : "float"
},
"buy_count" : {
"type" : "long"
},
"high_praise_num" : {
"type" : "long"
},
"intro" : {
"type" : "text",
"index" : false
},
"mobile_intro" : {
"type" : "text",
"index" : false
},
"market_enable" : {
"type" : "keyword"
},
"point" : {
"type" : "long"
},
"price" : {
"type" : "float"
},
"recommend" : {
"type" : "boolean"
},
"release_time" : {
"type" : "date",
"format" : "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis"
},
"sales_model" : {
"type" : "keyword"
},
"self_operated" : {
"type" : "boolean"
},
"seller_id" : {
"type" : "keyword"
},
"seller_name" : {
"type" : "text",
"analyzer": "ik_max_word",
"search_analyzer":"ik_smart"
},
"selling_point" : {
"type" : "text",
"index" : false
},
"sku_source" : {
"type" : "long"
},
"small" : {
"type" : "text",
"index" : false
},
"sn" : {
"type" : "keyword"
},
"store_category_name_path" : {
"type" : "text",
"analyzer": "ik_max_word",
"search_analyzer":"ik_smart",
"fields" : {
"keyword" : {
"type" : "keyword",
"ignore_above" : 256
}
},
"fielddata" : true
},
"store_category_path" : {
"type" : "keyword"
},
"category_path" : {
"type" : "keyword"
},
"category_name_path" : {
"type" : "text",
"analyzer": "ik_max_word",
"search_analyzer":"ik_smart",
"fields" : {
"keyword" : {
"type" : "keyword",
"ignore_above" : 256
}
},
"fielddata" : true
},
"thumbnail" : {
"type" : "text",
"index" : false
},
"brand_url" : {
"type" : "keyword"
},
"brand_id" : {
"type" : "keyword"
},
"brand_name" : {
"type" : "text",
"analyzer": "ik_max_word",
"search_analyzer":"ik_smart",
"fields" : {
"keyword" : {
"type" : "keyword",
"ignore_above" : 256
}
},
"fielddata" : true
},
"auth_flag" : {
"type" : "keyword"
},
"attr_list" : {
"type" : "nested",
"properties" : {
"name" : {
"type" : "keyword"
},
"sort" : {
"type" : "long"
},
"type" : {
"type" : "integer"
},
"value" : {
"type" : "keyword"
}
}
},
"store_id" : {
"type" : "keyword"
},
"store_name" : {
"type" : "text",
"analyzer": "ik_max_word",
"search_analyzer":"ik_smart"
}
}
}
}
5.3 索引同步
电商项目索引同步是使用MQ实现,如下图:
暂时无法在飞书文档外展示此内容
自行阅读相关代码,并进行测试。
测试方法:
发布一个商品,审核通过将商品信息发送到MQ,Java程序监听MQ,得到商品信息后写入ES索引。
5.4 搜索接口定义
阅读以下接口定义:
搜索接口:
http://localhost:8084/buyer/doc.html#/default/买家端,商品接口/getGoodsByPageFromEsUsingGET
聚合接口(从ES中获取相关商品品牌名称,分类名称及属性):
http://localhost:8084/buyer/doc.html#/default/买家端,商品接口/getGoodsRelatedByPageFromEsUsingGET
controller方法如下:
5.5 实战
根据接口定义进行开发,实现service方法。
完成开发后对照需求分析进行测试。
1 配置Canal+MQ数据同步环境
1.1 配置Mysql主从同步
根据Canal的工作原理,首先需要开启MySQL主从同步。
1.在MySQL中需要创建一个用户,并授权
进入mysql容器:
docker exec -it mysql /bin/bash
-- 使用命令登录:
mysql -u root -p
-- 创建用户 用户名:canal 密码:canal
create user 'canal'@'%' identified WITH mysql_native_password by 'canal';
-- 授权 *.*表示所有库
GRANT SELECT,REPLICATION SLAVE,REPLICATION CLIENT ON *.* TO 'canal'@'%';
FLUSH PRIVILEGES;
-
SELECT: 允许用户查询(读取)数据库中的数据。
-
REPLICATION SLAVE: 允许用户作为 MySQL 复制从库,用于同步主库的数据。
-
REPLICATION CLIENT: 允许用户连接到主库并获取关于主库状态的信息。
在MySQL配置文件my.cnf设置如下信息,开启 Binlog 写入功能,配置 binlog-format 为 ROW 模式
ROW 模式表示以行为单位记录每个被修改的行的变更
修改如下:
vi /usr/mysql/conf/my.cnf
[mysqld]
#打开binlog
log-bin=mysql-bin
#选择ROW(行)模式
binlog-format=ROW
#配置MySQL replaction需要定义,不要和canal的slaveId重复
server_id=1
expire_logs_days=3
max_binlog_size = 100m
max_binlog_cache_size = 512m
说明:在学习阶段为了保证足够的服务器存储空间,binlog日志最大保存100m,mysql会定时清理binlog
2、重启MySQL,查看配置信息
- 使用命令查看是否打开binlog模式:
SHOW VARIABLES LIKE 'log_bin';
ON表示开启binlog模式。
show variables like 'binlog_format';
当 binlog_format 的值为 row 时,表示 MySQL 服务器当前配置为使用行级别的二进制日志记录,这对于数据库复制和数据同步来说更为安全,因为它记录了对数据行的确切更改。
- 查看binlog日志文件列表:
SHOW BINARY LOGS;
- 查看当前正在写入的binlog文件:
SHOW MASTER STATUS;
1.2 安装Canal(使用下发虚拟机无需安装)
获取canal镜像
docker pull canal/canal-server:latest
创建/data/soft/canal目录:
mkdir -p /data/soft/canal
在/data/soft/canal下创建 canal.properties,内容如下,注意修改mq的配置信息:
#################################################
######### common argument #############
#################################################
# tcp bind ip
canal.ip =
# register ip to zookeeper
canal.register.ip =
canal.port = 11111
canal.metrics.pull.port = 11112
# canal instance user/passwd
# canal.user = canal
# canal.passwd = E3619321C1A937C46A0D8BD1DAC39F93B27D4458
# canal admin config
#canal.admin.manager = 127.0.0.1:8089
canal.admin.port = 11110
canal.admin.user = admin
canal.admin.passwd = 4ACFE3202A5FF5CF467898FC58AAB1D615029441
# admin auto register
#canal.admin.register.auto = true
#canal.admin.register.cluster =
#canal.admin.register.name =
canal.zkServers =
# flush data to zk
canal.zookeeper.flush.period = 1000
canal.withoutNetty = false
# tcp, kafka, rocketMQ, rabbitMQ
canal.serverMode = rabbitMQ
# flush meta cursor/parse position to file
canal.file.data.dir = ${canal.conf.dir}
canal.file.flush.period = 1000
## memory store RingBuffer size, should be Math.pow(2,n)
canal.instance.memory.buffer.size = 16384
## memory store RingBuffer used memory unit size , default 1kb
canal.instance.memory.buffer.memunit = 1024
## meory store gets mode used MEMSIZE or ITEMSIZE
canal.instance.memory.batch.mode = MEMSIZE
canal.instance.memory.rawEntry = true
## detecing config
canal.instance.detecting.enable = false
#canal.instance.detecting.sql = insert into retl.xdual values(1,now()) on duplicate key update x=now()
canal.instance.detecting.sql = select 1
canal.instance.detecting.interval.time = 3
canal.instance.detecting.retry.threshold = 3
canal.instance.detecting.heartbeatHaEnable = false
# support maximum transaction size, more than the size of the transaction will be cut into multiple transactions delivery
canal.instance.transaction.size = 1024
# mysql fallback connected to new master should fallback times
canal.instance.fallbackIntervalInSeconds = 60
# network config
canal.instance.network.receiveBufferSize = 16384
canal.instance.network.sendBufferSize = 16384
canal.instance.network.soTimeout = 30
# binlog filter config
canal.instance.filter.druid.ddl = true
canal.instance.filter.query.dcl = false
# 这个配置一定要修改
canal.instance.filter.query.dml = true
canal.instance.filter.query.ddl = false
canal.instance.filter.table.error = false
canal.instance.filter.rows = false
canal.instance.filter.transaction.entry = false
canal.instance.filter.dml.insert = false
canal.instance.filter.dml.update = false
canal.instance.filter.dml.delete = false
# binlog format/image check
canal.instance.binlog.format = ROW,STATEMENT,MIXED
canal.instance.binlog.image = FULL,MINIMAL,NOBLOB
# binlog ddl isolation
canal.instance.get.ddl.isolation = false
# parallel parser config
canal.instance.parser.parallel = true
## concurrent thread number, default 60% available processors, suggest not to exceed Runtime.getRuntime().availableProcessors()
#canal.instance.parser.parallelThreadSize = 16
## disruptor ringbuffer size, must be power of 2
canal.instance.parser.parallelBufferSize = 256
# table meta tsdb info
canal.instance.tsdb.enable = true
canal.instance.tsdb.dir = ${canal.file.data.dir:../conf}/${canal.instance.destination:}
canal.instance.tsdb.url = jdbc:h2:${canal.instance.tsdb.dir}/h2;CACHE_SIZE=1000;MODE=MYSQL;
canal.instance.tsdb.dbUsername = canal
canal.instance.tsdb.dbPassword = canal
# dump snapshot interval, default 24 hour
canal.instance.tsdb.snapshot.interval = 24
# purge snapshot expire , default 360 hour(15 days)
canal.instance.tsdb.snapshot.expire = 360
#################################################
######### destinations #############
#################################################
canal.destinations = xzb-canal
# conf root dir
canal.conf.dir = ../conf
# auto scan instance dir add/remove and start/stop instance
canal.auto.scan = true
canal.auto.scan.interval = 5
# set this value to 'true' means that when binlog pos not found, skip to latest.
# WARN: pls keep 'false' in production env, or if you know what you want.
canal.auto.reset.latest.pos.mode = false
canal.instance.tsdb.spring.xml = classpath:spring/tsdb/h2-tsdb.xml
#canal.instance.tsdb.spring.xml = classpath:spring/tsdb/mysql-tsdb.xml
canal.instance.global.mode = spring
canal.instance.global.lazy = false
canal.instance.global.manager.address = ${canal.admin.manager}
#canal.instance.global.spring.xml = classpath:spring/memory-instance.xml
canal.instance.global.spring.xml = classpath:spring/file-instance.xml
#canal.instance.global.spring.xml = classpath:spring/default-instance.xml
##################################################
######### MQ Properties #############
##################################################
# aliyun ak/sk , support rds/mq
canal.aliyun.accessKey =
canal.aliyun.secretKey =
canal.aliyun.uid=
canal.mq.flatMessage = true
canal.mq.canalBatchSize = 50
canal.mq.canalGetTimeout = 100
# Set this value to "cloud", if you want open message trace feature in aliyun.
canal.mq.accessChannel = local
canal.mq.database.hash = true
canal.mq.send.thread.size = 30
canal.mq.build.thread.size = 8
##################################################
######### Kafka #############
##################################################
kafka.bootstrap.servers = 127.0.0.1:9092
kafka.acks = all
kafka.compression.type = none
kafka.batch.size = 16384
kafka.linger.ms = 1
kafka.max.request.size = 1048576
kafka.buffer.memory = 33554432
kafka.max.in.flight.requests.per.connection = 1
kafka.retries = 0
kafka.kerberos.enable = false
kafka.kerberos.krb5.file = "../conf/kerberos/krb5.conf"
kafka.kerberos.jaas.file = "../conf/kerberos/jaas.conf"
##################################################
######### RocketMQ #############
##################################################
rocketmq.producer.group = test
rocketmq.enable.message.trace = false
rocketmq.customized.trace.topic =
rocketmq.namespace =
rocketmq.namesrv.addr = 127.0.0.1:9876
rocketmq.retry.times.when.send.failed = 0
rocketmq.vip.channel.enabled = false
rocketmq.tag =
##################################################
######### RabbitMQ #############
##################################################
rabbitmq.host = 192.168.101.68
rabbitmq.virtual.host = /xzb
rabbitmq.exchange = exchange.canal-jzo2o
rabbitmq.username = xzb
rabbitmq.password = xzb
rabbitmq.deliveryMode = 2
创建instance.properties,内容如下:
canal.instance.master.journal.name 用于指定主库正在写入的 binlog 文件的名称。
如果不配置 canal.instance.master.journal.name,Canal 会尝试自动检测 MySQL 主库的 binlog 文件,并从最新位置开始进行复制。
#################################################
## mysql serverId , v1.0.26+ will autoGen
canal.instance.mysql.slaveId=1000
# enable gtid use true/false
canal.instance.gtidon=false
# position info
canal.instance.master.address=192.168.101.68:3306
canal.instance.master.journal.name=mysql-bin.000001
canal.instance.master.position=0
canal.instance.master.timestamp=
canal.instance.master.gtid=
# rds oss binlog
canal.instance.rds.accesskey=
canal.instance.rds.secretkey=
canal.instance.rds.instanceId=
# table meta tsdb info
canal.instance.tsdb.enable=true
#canal.instance.tsdb.url=jdbc:mysql://127.0.0.1:3306/canal_tsdb
#canal.instance.tsdb.dbUsername=canal
#canal.instance.tsdb.dbPassword=canal
#canal.instance.standby.address =
#canal.instance.standby.journal.name =
#canal.instance.standby.position =
#canal.instance.standby.timestamp =
#canal.instance.standby.gtid=
# username/password
canal.instance.dbUsername=canal
canal.instance.dbPassword=canal
canal.instance.connectionCharset = UTF-8
# enable druid Decrypt database password
canal.instance.enableDruid=false
#canal.instance.pwdPublicKey=MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALK4BUxdDltRRE5/zXpVEVPUgunvscYFtEip3pmLlhrWpacX7y7GCMo2/JM6LeHmiiNdH1FWgGCpUfircSwlWKUCAwEAAQ==
# table regex
# canal.instance.filter.regex=test01\\..*,test02\\..*
#canal.instance.filter.regex=test01\\..*,test02\\.t1
#canal.instance.filter.regex=jzo2o-foundations\\.serve_sync,jzo2o-orders-0\\.orders_seize,jzo2o-orders-0\\.orders_dispatch,jzo2o-orders-0\\.serve_provider_sync,jzo2o-customer\\.serve_provider_sync
canal.instance.filter.regex=jzo2o-orders-1\\.orders_dispatch,jzo2o-orders-1\\.orders_seize,jzo2o-foundations\\.serve_sync,jzo2o-customer\\.serve_provider_sync,jzo2o-orders-1\\.serve_provider_sync,jzo2o-orders-1\\.history_orders_sync,jzo2o-orders-1\\.history_orders_serve_sync,jzo2o-market\\.activity
# table black regex
canal.instance.filter.black.regex=mysql\\.slave_.*
# table field filter(format: schema1.tableName1:field1/field2,schema2.tableName2:field1/field2)
#canal.instance.filter.field=test1.t_product:id/subject/keywords,test2.t_company:id/name/contact/ch
# table field black filter(format: schema1.tableName1:field1/field2,schema2.tableName2:field1/field2)
#canal.instance.filter.black.field=test1.t_product:subject/product_image,test2.t_company:id/name/contact/ch
# mq config
#canal.mq.topic=topic_test01
# dynamic topic route by schema or table regex
#canal.mq.dynamicTopic=mytest1.user,mytest2\\..*,.*\\..*
#canal.mq.dynamicTopic=topic_test01:test01\\..*,topic_test02:test02\\..*
#canal.mq.dynamicTopic=canal-mq-jzo2o-orders-dispatch:jzo2o-orders-0\\.orders_dispatch,canal-mq-jzo2o-orders-seize:jzo2o-orders-0\\.orders_seize,canal-mq-jzo2o-foundations:jzo2o-foundations\\.serve_sync,canal-mq-jzo2o-customer-provider:jzo2o-customer\\.serve_provider_sync,canal-mq-jzo2o-orders-provider:jzo2o-orders-0\\.serve_provider_sync
canal.mq.dynamicTopic=canal-mq-jzo2o-orders-dispatch:jzo2o-orders-1\\.orders_dispatch,canal-mq-jzo2o-orders-seize:jzo2o-orders-1\\.orders_seize,canal-mq-jzo2o-foundations:jzo2o-foundations\\.serve_sync,canal-mq-jzo2o-customer-provider:jzo2o-customer\\.serve_provider_sync,canal-mq-jzo2o-orders-provider:jzo2o-orders-1\\.serve_provider_sync,canal-mq-jzo2o-orders-serve-history:jzo2o-orders-1\\.history_orders_serve_sync,canal-mq-jzo2o-orders-history:jzo2o-orders-1\\.history_orders_sync,canal-mq-jzo2o-market-resource:jzo2o-market\\.activity
canal.mq.partition=0
# hash partition config
#canal.mq.partitionsNum=3
#canal.mq.partitionHash=test.table:id^name,.*\\..*
#canal.mq.dynamicTopicPartitionNum=test.*:4,mycanal:6
#################################################
canal.instance.filter.regex和canal.mq.dynamicTopic的配置稍后解释。
创建日志目录:
mkdir -p /data/soft/canal/logs /data/soft/canal/conf
启动容器:
docker run --name canal -p 11111:11111 -d -v /data/soft/canal/instance.properties:/home/admin/canal-server/conf/xzb-canal/instance.properties -v /data/soft/canal/canal.properties:/home/admin/canal-server/conf/canal.properties -v /data/soft/canal/logs:/home/admin/canal-server/logs/xzb-canal -v /data/soft/canal/conf:/home/admin/canal-server/conf/xzb-canal canal/canal-server:latest
1.3 安装RabbitMQ(使用下发虚拟机无需安装)
- 拉取镜像(如果未拉取过镜像)
docker pull registry.cn-hangzhou.aliyuncs.com/itheima/rabbitmq:3.9.17-management-delayed
- 创建文件夹和文件
mkdir -p /data/soft/rabbitmq/config /data/soft/rabbitmq/data /data/soft/rabbitmq/plugins
- 启动容器
docker run \
--privileged \
-e RABBITMQ_DEFAULT_USER=czri \
-e RABBITMQ_DEFAULT_PASS=czri1234 \
--restart=always \
--name rabbitmq \
--hostname rabbitmq \
-v /data/soft/rabbitmq/config:/etc/rabbitmq \
-v /data/soft/rabbitmq/data:/var/lib/rabbitmq \
-p 15672:15672 \
-p 5672:5672 \
-d \
registry.cn-hangzhou.aliyuncs.com/itheima/rabbitmq:3.9.17-management-delayed
- 启动rabbitmq管理端
进入rabbitmq容器:docker exec -it rabbitmq /bin/bash
运行下边的命令:
# 启动rabbitmq管理端
rabbitmq-plugins enable rabbitmq_management
# 启动延迟队列插件
rabbitmq-plugins enable rabbitmq_delayed_message_exchange
5、进入rabbitmq管理界面
账号:czri
密码:czri1234
6、创建虚拟主机 /xzb
7、创建账号和密码
xzb/xzb
设置权限可以访问/ /xzb
设置成功:
1.4 配置Canal+RabbitMQ
下边通过配置Canal与RabbitMQ,保证Canal收到binlog消息将数据发送至MQ。
最终我们要实现的是:
修改jzo2o-foundations数据库下的serve_sync表的数据后通过canal将修改信息发送到MQ。
1、在Canal中配置RabbitMQ的连接信息
修改/data/soft/canal/canal.properties
# tcp, kafka, rocketMQ, rabbitMQ
canal.serverMode = rabbitMQ
##################################################
######### RabbitMQ #############
##################################################
rabbitmq.host = 192.168.101.68
rabbitmq.virtual.host = /xzb
rabbitmq.exchange = exchange.canal-jzo2o
rabbitmq.username = xzb
rabbitmq.password = xzb
rabbitmq.deliveryMode = 2
本项目用于数据同步的MQ交换机:exchange.canal-jzo2o
虚拟主机地址:/xzb
账号和密码:xzb/xzb
rabbitmq.deliveryMode = 2 设置消息持久化
2、设置需要监听的mysql库和表
修改/data/soft/canal/instance.properties
-
canal.instance.filter.regex需要监听的mysql库和表-
全库:
.*\\..* -
指定库下的所有表:
canal\\..* -
指定库下的指定表:
canal\\.canal,test\\.test库名\\.表名:转义需要用\\,使用逗号分隔多个库
-
这里配置监听 jzo2o-foundations数据库下serve_sync表,如下:
canal.instance.filter.regex=jzo2o-foundations\\.serve_sync
3、在Canal配置MQ的topic
这里使用动态topic,格式为:topic:schema.table,topic:schema.table,topic:schema.table
配置如下:
canal.mq.dynamicTopic=canal-mq-jzo2o-foundations:jzo2o-foundations\\.serve_sync
上边的配置表示:对jzo2o-foundations数据库的serve_sync表的修改消息发到topic为canal-mq-jzo2o-foundations关联的队列
4、进入rabbitMQ配置交换机和队列
创建exchange.canal-jzo2o交换机:
创建队列:canal-mq-jzo2o-foundations
绑定交换机:
绑定成功:
1.5 测试数据同步
重启canal
修改jzo2o-foundations数据库的serve_sync表的数据,稍等片刻查看canal-mq-jzo2o-foundations队列,如果队列中有的消息说明同步成功,如下 图:
如果没有同步到 MQ参考常见问题中“数据不同步”进行解决。
我们可以查询队列中的消息内容发现它一条type为"UPDATE"的消息,如下所示:
{
"data" : [
{
"city_code" : "010",
"detail_img" : "https://yjy-xzbjzfw-oss.oss-cn-hangzhou.aliyuncs.com/be1449d6-1c2d-4cca-9f8a-4b562b79998d.jpg",
"hot_time_stamp" : "1692256062300",
"id" : "1686352662791016449",
"is_hot" : "1",
"price" : "5.0",
"serve_item_icon" : "https://yjy-xzbjzfw-oss.oss-cn-hangzhou.aliyuncs.com/8179d29c-6b85-4c08-aa13-08429a91d86a.png",
"serve_item_id" : "1678727478181957634",
"serve_item_img" : "https://yjy-xzbjzfw-oss.oss-cn-hangzhou.aliyuncs.com/9b87ab7c-9592-4090-9299-5bcf97409fb9.png",
"serve_item_name" : "日常维修ab",
"serve_item_sort_num" : "6",
"serve_type_icon" : "https://yjy-xzbjzfw-oss.oss-cn-hangzhou.aliyuncs.com/c8725882-1fa7-49a6-94ab-cac2530b3b7b.png",
"serve_type_id" : "1678654490336124929",
"serve_type_img" : "https://yjy-xzbjzfw-oss.oss-cn-hangzhou.aliyuncs.com/00ba6d8a-fd7e-4691-8415-8ada95004b33.png",
"serve_type_name" : "日常维修12",
"serve_type_sort_num" : "2",
"unit" : "1"
}
],
"database" : "jzo2o-foundations",
"es" : 1697443035000.0,
"id" : 1,
"isDdl" : false,
"mysqlType" : {
"city_code" : "varchar(20)",
"detail_img" : "varchar(255)",
"hot_time_stamp" : "bigint",
"id" : "bigint",
"is_hot" : "int",
"price" : "decimal(10,2)",
"serve_item_icon" : "varchar(255)",
"serve_item_id" : "bigint",
"serve_item_img" : "varchar(255)",
"serve_item_name" : "varchar(100)",
"serve_item_sort_num" : "int",
"serve_type_icon" : "varchar(255)",
"serve_type_id" : "bigint",
"serve_type_img" : "varchar(255)",
"serve_type_name" : "varchar(255)",
"serve_type_sort_num" : "int",
"unit" : "int"
},
"old" : [
{
"serve_item_name" : "日常维修a"
}
],
"pkNames" : [ "id" ],
"sql" : "",
"sqlType" : {
"city_code" : 12,
"detail_img" : 12,
"hot_time_stamp" : -5,
"id" : -5,
"is_hot" : 4,
"price" : 3,
"serve_item_icon" : 12,
"serve_item_id" : -5,
"serve_item_img" : 12,
"serve_item_name" : 12,
"serve_item_sort_num" : 4,
"serve_type_icon" : 12,
"serve_type_id" : -5,
"serve_type_img" : 12,
"serve_type_name" : 12,
"serve_type_sort_num" : 4,
"unit" : 4
},
"table" : "serve_sync",
"ts" : 1697443782457.0,
"type" : "UPDATE"
}
1.5 配置其它同步队列
按上边的方法配置以下同步队列并与交换机绑定:
canal-mq-jzo2o-customer-provider
canal-mq-jzo2o-foundations
canal-mq-jzo2o-market-resource
canal-mq-jzo2o-orders-dispatch
canal-mq-jzo2o-orders-history
canal-mq-jzo2o-orders-provider
canal-mq-jzo2o-orders-seize
canal-mq-jzo2o-orders-serve-history
先创建队列,再将队列绑定到交换机。
配置完成如下:
查询队列如下:
常见问题
数据不同步
当发现修改了数据库后修改的数据并没有发送到MQ,通过查看Canal的日志发现下边的错误。
进入Canal目录,查看日志:
cd /data/soft/canal/logs
tail -f logs/xzb-canal.log
Canal报错如下:
2023-09-22 08:34:40.802 [destination = xzb-canal , address = /192.168.101.68:3306 , EventParser] WARN c.a.o.c.p.inbound.mysql.rds.RdsBinlogEventParserProxy - ---> find start position successfully, EntryPosition[included=false,journalName=mysql-bin.000055,position=486221,serverId=1,gtid=,timestamp=1695341830000] cost : 13ms , the next step is binlog dump
2023-09-22 08:34:40.811 [destination = xzb-canal , address = /192.168.101.68:3306 , EventParser] ERROR c.a.o.canal.parse.inbound.mysql.dbsync.DirectLogFetcher - I/O error while reading from client socket
java.io.IOException: Received error packet: errno = 1236, sqlstate = HY000 errmsg = Could not find first log file name in binary log index file
at com.alibaba.otter.canal.parse.inbound.mysql.dbsync.DirectLogFetcher.fetch(DirectLogFetcher.java:102) ~[canal.parse-1.1.5.jar:na]
at com.alibaba.otter.canal.parse.inbound.mysql.MysqlConnection.dump(MysqlConnection.java:238) [canal.parse-1.1.5.jar:na]
at com.alibaba.otter.canal.parse.inbound.AbstractEventParser$1.run(AbstractEventParser.java:262) [canal.parse-1.1.5.jar:na]
at java.lang.Thread.run(Thread.java:748) [na:1.8.0_181]
找到关键的位置:Could not find first log file name in binary log index file
根据日志分析是Canal找不到mysql-bin.000055 的486221位置,原因是mysql-bin.000055文件不存在,这是由于为了节省磁盘空间将binlog日志清理了。
解决方法:
把canal复位从最开始开始同步的位置。
1)首先重置mysql的bin log:
连接mysql执行:reset master
执行后所有的binlog删除,从000001号开始
通过show master status;查看 ,结果显示 mysql-bin.000001
2)先停止canal
docker stop canal
3)删除meta.dat
rm -rf /data/soft/canal/conf/meta.dat
- 再启动canal
docker start canal
MQ同步消息无法消费
这里以Es和MySQL之间的同步举例:
当出现ES和MySQL数据不同步时可能会出现MQ的同步消息无法被消费,比如:从MySQL删除一条记录通过同步程序将ES中对应的记录进行删除,此时由于ES中没有该记录导致删除ES中的记录失败。出现此问题的原因是因为测试数据混乱导致,可以手动将MQ中的消息删除。
进入MQ的管理控制台,进入要清理消息的队列,通过purge功能清理消息:
学习目标:
-
能够说出优惠券模块的功能列表
-
能够说出优惠券模块的表设计及数据流
-
能够说出发放优惠券模块的设计方案
-
能够实现发放优惠券模块的功能
代码环境
jzo2o-market: dev_01
1 模块介绍
1)业务流程
优惠券是最常用的一种营销活动,优惠券模块涉及到优惠券活动管理、发券、抢券、优惠券核销等几部分,涉及到的用户角色有运营人员和用户,下图是优惠券模块的整体业务流程。
优惠券活动管理:
运营人员在后台添加优惠券活动,包括:新增优惠券活动、修改优惠券活动、撤销优惠券活动等操作。
发券:
按照某规则向指定用户自动发放优惠券。
抢券:
优惠券到达发放时间用户领取优惠券,因为优惠券的数量有限,当到达发放时间后平台所有用户都可以抢券,先到先得。
优惠券核销:
用户抢到优惠券即可在下单时使用优惠券,享受优惠,如果取消订单将退回优惠券,优惠券退回后可用于其它订单。
优惠券核销是指:顾客在购买商品使用优惠券,当此次消费符合优惠券的条件时提交订单后将优惠券的折扣应用到顾客的订单中,最后将优惠券标记为已使用或作废。
优惠券核销后还可以取消核销,如果用户取消订单会将优惠券取消核销即退回优惠券,退回优惠券后可以继续使用。
暂时无法在飞书文档外展示此内容
2)界面原型
优惠券活动管理
查询优惠券活动信息:
新增优惠券活动:
抢券
到达优惠券发放时间用户开始抢券,在抢券界面列出了进行中的活动和即将开始的活动。
用券
在下单时选择优惠券进行核销。
优惠券核销是指:顾客在购买商品时使用优惠券,当此次订单的消费符合优惠券的条件时在下单会使用该优惠券在原有订单金额基础上进行优惠,优惠券使用后标记为“已使用”。
优惠券核销后还可以取消核销,如果用户取消订单会将优惠券的状态改为“未使用” 退回的优惠券可以继续使用。
支付金额减去优惠金额:
3) 业务模块
根据流程分析,优惠券模块可分为四个小模块,如下:
暂时无法在飞书文档外展示此内容
优惠券活动管理:
对优惠券活动进行管理,运营人员新增优惠券活动、修改优惠券活动、撤销优惠券活动及优惠券统计等。
发券:
使用任务调度技术按照某规则向指定用户自动发放优惠券。
抢券:
到了优惠券发放时间用户进行抢券,抢券过程对优惠券库存、对用户领取优惠券数量等进行校验,抢券成功记录用户领取优惠券的记录。
核销:
用户在下单时使用优惠券得到优惠金额,实付金额等于订单金额减去优惠金额,下单成功优惠券核销成功。
4) 小节
优惠券包括几个模块?口述业务流程。
2 模块设计
1)数据流
优惠券模块涉及到的数据表如下:
优惠券活动表(MySQL):
优惠券活动管理模块主要操作优惠券活动表。
优惠券活动记录优惠券活动信息,运营人员新增优惠券活动将写入此表,此表是优惠券管理主要维护的表。
关键字段:活动id、活动名称、优惠券类型、折扣、发放时间等。
优惠券活动表(Redis):
Redis中对应优惠券活动表MySQL中的数据,由抢券界面读取,为了提高查询效率。
待发券用户表:
存储了待发优惠券的用户信息。
优惠券表:
抢券模块主要操作优惠券表。
优惠券表记录用户领取的优惠券,用户抢券存在限制,每种优惠券一个用户只允许领取一张,优惠券的总数有限制。
关键字段:用户id、活动id、折扣、优惠券类型、有效期等。
举例:一个优惠券活动发放100张优惠券最多有100个用户去领取,每人领取一张,每个用户领取的一张优惠券会记录在优惠券表中,该优惠券活动对应优惠券表中最多100条记录。
优惠券核销表:
在使用优惠券模块时当用户成功使用一张优惠券会在优惠券核销表记录一条记录,记录是哪个用户的哪个订单使用了哪个优惠券。
关键字段 :用户id、优惠券id、订单id,核销时间。
优惠券退回表:
如果用户取消订单,则会退回优惠券,具体操作是向优惠券退回表添加一条记录(记录用户退回优惠券的信息),并向优惠券核销表删除一条对应的记录,表示取消优惠券的核销。
关键字段:用户id、优惠券id、退回时间。
根据需求分析优惠券模块的数据流如下:
暂时无法在飞书文档外展示此内容
2) 表结构设计
优惠券活动表
记录优惠券活动信息,运营人员新增优惠券活动将写入此表,此表是优惠券管理主要维护的表。
结构如下:
create table `jzo2o-market`.activity
(
id bigint not null comment '活动id'
constraint `PRIMARY`
primary key,
name varchar(100) default '0' not null comment '活动名称',
type int not null comment '优惠券类型,1:满减,2:折扣',
amount_condition decimal(10, 2) not null comment '使用条件,0:表示无门槛,其他值:最低消费金额',
discount_rate int default 0 not null comment '折扣率,折扣类型的折扣率,8折就是存80',
discount_amount decimal(10, 2) null comment '优惠金额,满减或无门槛的优惠金额',
validity_days int default 0 not null comment '优惠券有效期天数,0:表示有效期是指定有效期的',
distribute_start_time datetime not null comment '发放开始时间',
distribute_end_time datetime not null comment '发放结束时间',
status int not null comment '活动状态,1:待生效,2:进行中,3:已失效 4:作废',
total_num int default 0 not null comment '发放数量,0:表示无限量,其他正数表示最大发放量',
stock_num int default 0 not null comment '库存',
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
create_by bigint null comment '创建人',
update_by bigint null comment '更新人',
is_deleted tinyint default 0 not null comment '逻辑删除'
)
charset = utf8mb4;
说明:
amount_condition:0表示无门槛,其它值表示最低消费金额,比如:满100减20,这里存储100。
discount_rate:当优惠券类型为2,在此字段中存储折扣率,比如:八折优惠时这里存储80。
discount_amount:当优惠券类型为1,在此字段中存储优惠券金额,比如:满100减20,这里存储20。
status:活动状态字段值包括:1:待生效,2:进行中,3:已失效 4:作废 几种,优惠券活动的初始状态是待生效,当到达优惠券发放时间时状态将改为进行中,当到达结束时间时状态改为已失效,当撤销活动后状态为作废。
validity_days:优惠券有效期,用户领取优惠券后的有效期,比如:有效期30天,这里存储30
distribute_start_time:发放开始时间,到达发放开始时间用户方可领取优惠券
distribute_end_time:发放结束时间,到达发放结束时间用户不可领取该活动有优惠券
total_num:发放优惠券数量,比如:本活动共发1万张优惠券这里存储10000
stock_num:库存数量,库存数等于发放优惠券数量减去已领取数量
优惠券表
记录用户领取的优惠券,将来核销优惠券是核销此表的记录,结构如下:
create table `jzo2o-market`.coupon
(
id bigint not null comment '优惠券id'
constraint `PRIMARY`
primary key,
name varchar(255) not null comment '优惠券名称',
user_id bigint not null comment '优惠券的拥有者',
user_name varchar(50) null comment '用户姓名',
user_phone varchar(20) null comment '用户手机号',
activity_id bigint not null comment '活动id',
type int not null comment '使用类型,1:满减,2:折扣',
discount_rate int default 0 null comment '折扣',
discount_amount decimal(10, 2) null comment '优惠金额',
amount_condition decimal(10, 2) not null comment '满减金额',
validity_time datetime null comment '有效期',
use_time datetime null comment '使用时间',
status tinyint not null comment '优惠券状态,1:未使用,2:已使用,3:已失效',
orders_id varchar(50) null comment '订单id',
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
is_deleted tinyint default 0 not null comment '逻辑删除'
)
charset = utf8mb4;
create index user_my_query_index
on `jzo2o-market`.coupon (user_id, status)
comment '用户查询我的优惠券快捷索引';
activity_id:活动id,对应activity表的主键
status:优惠券状态,1:未使用,2:已使用,3:已失效,用户领取成功初始状态为1未使用,用户在下单时核销该优惠券后状态为2已使用,优惠券已过有效期状态为3已失效。
优惠券核销表
用户成功使用优惠券的记录,记录是哪个用户的哪个订单使用了哪个优惠券。
结构如下:
create table `jzo2o-market`.coupon_write_off
(
id bigint not null
constraint `PRIMARY`
primary key,
coupon_id bigint not null comment '优惠券id',
user_id bigint not null comment '用户id',
orders_id bigint not null comment '核销时使用的订单号',
activity_id bigint not null comment '活动id',
write_off_time datetime not null comment '核销时间',
write_off_man_phone varchar(20) not null comment '核销人手机号',
write_off_man_name varchar(50) not null comment '核销人姓名'
)
comment '优惠券核销表' charset = utf8mb4;
优惠券退回表
用户取消订单后成功退回优惠券的记录,记录哪个用户退回了哪个优惠券。
结构如下:
create table `jzo2o-market`.coupon_use_back
(
id bigint not null comment '回退记录id'
constraint `PRIMARY`
primary key,
coupon_id bigint not null comment '优惠券id',
user_id bigint not null comment '用户id',
use_back_time datetime not null comment '回退时间',
write_off_time datetime null comment '核销时间'
)
comment '优惠券使用回退记录' charset = utf8mb4;
3)小结
优惠券模块的核心表的哪些,是怎么设计的?
3 搭建环境
1)创建数据库
创建数据库:jzo2o-market
导入:jzo2o-market-init.sql
2)创建工程
拉取 jzo2o-market工程,提供Git仓库地址:https://gitee.com/jzo2o-v2/jzo2o-market.git
请先fork到自己仓库再从自己仓库拉取,拉取成功切换到dev_01分支。
jzo2o-market工程如下:
下边在nacos创建配置jzo2o-market.yaml文件
进入nacos创建jzo2o-market.yaml
注意:选择namespace为“75a593f5-33e6-4c65-b2a0-18c403d20f63”的命名空间添加配置文件 。
配置文件内容如下:
rabbit-mq:
enable: true
xxl-job:
executor:
port: 11610
4 优惠券活动管理(实战)
4.1 新增优惠券活动
1)界面原型
运营人员进入优惠券活动管理界面,点击“新增优惠券”进入如下界面。
优惠券新增页包括两部分内容:配置优惠券活动的基本信息和发放规则。
选择满减
优惠券类型说明
| 优惠券类型 | 说明 | 逻辑规则 | 举例 |
| 满减 | 满x元减y元,例如满100元减20元;其中,x为满额限制,可为0;y为抵扣金额。 满额限制为0时即为无满额要求的优惠券,通常称为立减券或无门槛优惠券。 |
满额限制为订单金额需满足多少钱后,方可使用优惠; 若满额限制=0时,订单金额>0就可参加; 抵扣金额为优惠券的金额; 若抵扣金额 > 订单金额,则实际抵扣金额 = 订单金额; 注意:该满减 不等于 每满减,优惠金额不叠加 |
满0元减10元:订单金额20元,实付金额=20-10=10元; 满0元减10元:订单金额5元,实付金额=5-5=0元; 满100元减10元:订单金额500元,实付金额=500-10=490元; |
| 折扣 | 规则结构为:满x元享y%折扣,其中,x为满额限制,可为0;y为折扣,百分比格式。 | 满额限制同上,当满足条件后,优惠金额=订单金额*折扣% | 满10元打8折:订单金额10元,实付金额=10*80%=8元 满0元打8折:订单金额1元,实付金额=1*80%=0.8元 |
表单内容如下:
| 属性 | 含义 | 举例 |
| 活动名称 | 营销活动的名称 | 双12五折大促,尊享双十一活动 |
| 优惠券类型 | 包括:满减和折扣 | 满减:满x元减y元,例如满100元减20元; 折扣:满x元享y%折扣,例如满100元享10%折扣 |
| 满额限制 | 设置满多少钱享受优惠,0表示无门槛 金额格式:整数,单位:元 金额大小:0=<满额限制 |
200.00:表示满200元 |
| 抵扣金额 | 用于满减类型 金额格式:整数,单位:元 金额大小:0<折扣满额限制 |
20元 |
| 抵扣比例 | 用于折扣类型 金额格式:整数,单位:无 金额大小:0<折扣满额限制<100 |
85表示85%,即85折 |
| 发放方式 | 线上领取:用户自行领取,抢券 自动发放:由系统自动将优惠券发放到用户账户中 |
|
| 发放时间 | 优惠券活动创建成功后到达发放时间将会在前台进行展示,用户即可进行抢券。 发放结束时间:即活动结束,活动结束后不可以领券。 格式:年月日时分秒~年月日时分秒 发放时间开始时间至少设置在距离当前时间的1个小时之后 |
2023-09-22 00:00 ~ 2023-09-29 00:00 |
| 使用期限 | 单位:天 从领取优惠时间加上使用期限是该优惠券的有效期限 比如:2023-09-01 07:00:00领取的优惠券,使用期限为7天,2023-09-08 07:00:00后优惠券无法使用 |
7天 |
| 发放数量 | 整数,规则:0<发放数量,单位:张 1)发放数量= 发放数量为优惠券的总数量,当领取数量=发放数量时,优惠券将在前台显示已抢光; 2)每人限领= 每个人最多领取1张优惠券; |
40张 |
| 活动状态 | 活动状态包括:1:待生效,2:进行中,3:已失效 4: 作废' | 新增后的状态为:待生效 到达发放开始时间状态改为“进行中”。 到达发放结束时间状态改为“已失效” 撤销后状态为“作废” |
数据校验
本项目优惠券只支持满减与折扣两种类型。
优惠券类型,1:满减,2:折扣
如果满减:
1、折扣金额必须输入
2、折扣金额必须大于0的整数
如果是折扣:
1、折扣比例必须输入
2、折扣比例必须大于0,小于100的整数
发放时间开始时间不能小于当前时间
发放结束时间不能早于发放开始时间
选择折扣
2)运营端接口设计
保存优惠券活动接口
接口名称:保存优惠券活动
接口功能:新增或修改一个优惠券活动信息,本接口支持新增和修改。
接口地址:POST/market/operation/activity/save
请求数据类型: application/json
4.2 查询优惠券活动
1)界面原型
登录运营端进入“优惠券管理”界面,如下图:
按条件查询优惠券活动信息。
2)运营端接口设计
分页查询优惠券活动接口
接口名称:分页查询优惠券活动
接口功能:运营端分页查询优惠券活动
接口地址:GET/market/operation/activity/page
请求数据类型: application/x-www-form-urlencoded
4.3 修改优惠券活动
1) 界面原型
待生效的活动信息可以进行修改。
进入修改页面进行修改:
数据校验
注意:只允许对待生效的活动进行修改,且发放开始时间不能早于当前时间。
本项目优惠券只支持满减与折扣两种类型。
优惠券类型,1:满减,2:折扣
如果满减:
1、折扣金额必须输入
2、折扣金额必须大于0的整数
如果是折扣:
1、折扣比例必须输入
2、折扣比例必须大于0,小于100的整数
发放时间开始时间不能小于当前时间
发放结束时间不能早于发放开始时间
2) 运营端接口设计
查询优惠券活动详情接口
接口名称:查询优惠券活动详情
接口功能:进入修改页面首先调用此接口根据活动id查询优惠券活动详情在页面显示
接口地址:GET/market/operation/activity/{id}
请求数据类型 application/x-www-form-urlencoded
保存优惠券活动接口
修改优惠券活动信息后请求保存优惠券活动接口。
修改优惠券活动与新增优惠券活动调用同一个接口:保存优惠券活动接口,参考新增优惠券活动章节的描述。
4.4 查询领取记录
1) 界面原型
运营人员进入优惠券活动查询界面,点击“领取记录”可查看用户领取优惠券的记录,如下图:
数据分析
1)发放数量 = 新增优惠券时指定的发放总数
2)领取数量 = 领取该优惠券的数量,领取数量<=发放数量
3)使用数量 = 用户领取优惠券后使用数量,使用数量<=领取数量
4)发放率= 领取数量/发放数量*100%;
5)使用率=使用数量/领取数量*100%;
6)用户手机号:领取优惠券的用户手机号
7)领取时间:领取优惠券的时间
8)使用时间:使用优惠券的时间
9)优惠券状态:待使用,已使用
10)使用订单:使用优惠券的订单号
显示规则:
1)列表内数据倒序进行展示;
2)发放率、使用率最多显示两位小数;
3)单页最多显示5条,没有分页,超过5条出滚动条,无限向下滚;
2) 接口设计
在查询领取记录页面共有两部分信息需要调用两个接口获取:优惠券活动信息,领取记录。
查询优惠券活动详情接口
接口名称:查询优惠券活动详情
接口功能:进入修改页面首先调用此接口根据活动id查询优惠券活动详情在页面显示
接口地址:GET/market/operation/activity/{id}
请求数据类型 application/x-www-form-urlencoded
查询领取记录
接口名称:根据活动ID查询优惠券领取记录
接口功能:根据活动ID查询优惠券领取记录
接口地址:GET/market/operation/coupon/page
请求数据类型 application/x-www-form-urlencoded
4.5 撤销活动
对于待生效及进行中的活动如果要进行终止可以执行撤销操作,执行后活动状态改为作废,用户已抢到还未使用的优惠券将作废。
1)界面原型
对于待生效及进行中的活动如果要进行终止可以执行撤销操作,执行后此活动将终止,用户已抢到还未使用的优惠券将作废。
点击【撤销】,出现【确认撤销】弹窗,如下:
撤销的活动可以选择发放状态:作废,进行查询
数据校验
只允许对待生效及进行中的活动进行撤销。
2) 运营端接口设计
接口名称:撤销活动
接口功能:撤销一个优惠券活动,对于待生效及进行中的活动如果要进行终止可以执行撤销操作,执行后活动状态改为作废,用户已抢到还未使用的优惠券将作废。
接口地址:POST/market/operation/activity/revoke/{id}
请求数据类型 application/x-www-form-urlencoded
注意:本接口除了更新活动状态为作废,还需要将所有抢到本活动优惠券的状态为未使用的记录的状态更改为“已失效” 。
4.6 定时任务
4.6.1 自动变更活动状态
1) 需求
状态变更的需求如下:
优惠券活动表的状态字段值包括:1:待生效,2:进行中,3:已失效 4:作废 几种,优惠券活动的初始状态是待生效,当到达优惠券发放时间状态将改为进行中,当到达结束时间状态改为已失效,当撤销活动后状态为作废。
状态变更不需要依赖人工操作,可由定时任务实现,每分钟更新一次状态:
1)对待生效的活动更新为进行中
到达发放开始时间状态改为“进行中”。
2)对待生效及进行中的活动更新为已失效
到达发放结束时间状态改为“已失效”
2) 活动状态变更定时任务
活动状态包括:1:待生效,2:进行中,3:已失效 4: 作废'
对于待生效的活动:到达发放开始时间状态改为“进行中”。
对于待生效及进行中的活动:到达发放结束时间状态改为“已失效”
使用xxl-job定义定时任务,每分钟执行一次。
4.6.2 已领取优惠券自动过期
用户领取的优惠券如果到达有效期仍然没有使用自动改为“已失效”
使用xxl-job定义定时任务,每小时执行一次。
5 我的优惠券(实战)
5.1 需求分析
1)界面原型
用户领取优惠券后进入“我的”-->"我的优惠券"查询已领取的优惠券,按领取优惠券的时间降序显示。
本查询为滚动查询,向上拖屏幕查询下一屏,一屏显示10条。
用户领取到优惠券有三个状态:
未使用:未过有效期的优惠券。
优惠券的有效期:从领取优惠券的时间加上优惠券的使用期限(“使用期限”在优惠券活动管理界面进行设置)。
已使用:已经在订单中使用的优惠券。
已过期:未使用且已过有效期的优惠券,已过期的优惠券将无法使用。
查询未使用的优惠券
查询已使用的优惠券:
查询已过期的优惠券:
2)用户端接口设计
我的优惠券列表接口
接口名称:我的优惠券列表
接口功能:用户查询自己领取的优惠券
接口地址:GET/market/consumer/coupon/my
请求数据类型 application/x-www-form-urlencoded
5.2 设计提示
本功能是c端用户查询自己领取的优惠券,由于c端用户量大所以要保证此查询接口的性能。
实现思路类似Elasticsearch的深度分页思路(search_after)。
需要在表中找一个唯一的且有序的键作为排序字段,接口传入lastId,用排序字段和lastId比较,类似下边的SQL:
降序:Select * from 表名 where 排序字段<lastId limit 10
最后完成开发后通过小程序测试我的优惠券界面,测试前向优惠券表人工添加一些测试数据模拟用户领取的优惠券。
6 获取可用优惠券(实战)
1)需求分析
界面原型
用户在下单时使用优惠券得到优惠金额,实付金额等于订单金额减去优惠金额,下单成功优惠券核销成功。
下边通过界面原型进行分析。
在下单时选择可用的优惠券:
可用优惠券列表:是用户领取到的优惠券且符合满减规则的优惠券,如:满200减20元优惠券只能用在订单金额大于200元的订单。
用户选择一张优惠券,界面显示优惠金额,订单金额减去优惠金额为实际支付金额:
一张优惠券使用完毕将不能用在其它订单。
当订单取消后已使用的优惠券会重新退回到我的优惠券,此时用户又可以使用该优惠券。
数据分析
可用优惠券列表根据以下条件对用户的优惠券进行筛选:
-
属于当前用户的优惠券
-
符合下边条件的优惠券:
订单金额大于等于满减金额
优惠金额小于订单金额
优惠券还没有过期
优惠券还没有使用
可用优惠券列表的信息包括:
-
活动名称
-
优惠券类型
-
满减金额
-
折扣率
-
针对该订单的优惠金额(需要根据订单金额和优惠券去计算得出,前端需要此数据)
-
优惠券过期时间(已过期的优惠券无法使用)
在可用优惠券列表中前端需要拿到优惠金额,从而计算出订单实付金额。
订单的实付金额=订单金额-优惠金额。
根据订单金额和优惠券的优惠金额可以计算该订单的优惠金额:
-
针对满减:优惠券的优惠金额
-
针对折扣:订单金额乘以(1-折扣率)
2) 接口设计
交互流程
根据需求,查询可用的优惠券列表从优惠券表(coupon)中进行查询,筛选规则如下:
-
属于当前用户的优惠券
-
符合下边条件的优惠券:
订单金额大于等于满减金额
优惠金额小于订单金额(因为最终的支付金额必须大于0)
优惠券还没有过期
优惠券还没有使用
要筛选到用户可用的优惠券列表需要根据订单金额和用户ID进行筛选,现在有一个问题是:
是前端直接请求优惠券服务查询可用的优惠券列表还是请求订单管理服务再由订单管理服务通过内部接口调用服务拿结果,下图中是用左边的方案还是右边的方案?
暂时无法在飞书文档外展示此内容
本项目用右边的方案。
因为订单管理服务和优惠券服务的基本职责不同,订单管理服务属于业务方,优惠券服务为业务方提供优惠券服务。
虽然优惠券服务中管理了优惠券,但是最终决定是否给用户优惠的是订单服务,比如:用户购买VIP会员,用户是否可以用优惠券是由会员系统去决定而不是由优惠券服务决定。
所以前端请求查询可用优惠券列表的交互流程如下:
暂时无法在飞书文档外展示此内容
用户端进入下单页面,请求订单管理服务获取可用优惠券,订单管理服务远程请求优惠券服务的获取可用优惠券内部接口。
接口定义
根据交互流程,先在优惠券服务定义“查询可用优惠券接口” 内部接口。
根据刚才分析的需求,要筛选到用户可用的优惠券列表需要根据订单金额和用户ID。
订单金额通过参数传入。
接口定义如下:
接口名称:获取可用优惠券(内部接口)
接口功能:根据用户ID和订单金额查询可用优惠券列表
接口路径:GET/market/inner/coupon/getAvailable
请求数据类型 application/x-www-form-urlencoded
下边在优惠券服务进行实现:
修改jzo2o-api中的CouponApi:
@FeignClient(contextId = "jzo2o-market", value = "jzo2o-market", path = "/market/inner/coupon")
public interface CouponApi {
/**
* 获取可用优惠券列表,并按照优惠金额从大到小排序
* @param totalAmount 总金额,单位分
*/
@GetMapping("/getAvailable")
List<AvailableCouponsResDTO> getAvailable(@RequestParam("userId") Long userId,@RequestParam("totalAmount") BigDecimal totalAmount);
...
修改完成CouponApi重新install到本地仓库。
编写controller:
package com.jzo2o.market.controller.inner;
import com.jzo2o.api.market.CouponApi;
import com.jzo2o.api.market.dto.response.AvailableCouponsResDTO;
import io.seata.spring.annotation.GlobalTransactional;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.util.List;
@RestController("innerCouponController")
@RequestMapping("/inner/coupon")
@Api(tags = "内部接口-优惠券相关接口")
public class CouponController implements CouponApi {
@Override
@GetMapping("/getAvailable")
@ApiOperation("获取可用优惠券列表")
@ApiImplicitParams({
@ApiImplicitParam(name = "totalAmount", value = "总金额,单位分", required = true, dataTypeClass = BigDecimal.class),
@ApiImplicitParam(name = "userId", value = "用户id", required = true, dataTypeClass = BigDecimal.class)
})
public List<AvailableCouponsResDTO> getAvailable(@RequestParam("userId") Long userId,@RequestParam("totalAmount") BigDecimal totalAmount) {
return null;
}
}
重启优惠券服务查看接口文档是否正确。
定义service接口
下边定义service接口,查询可用优惠券列表:
package com.jzo2o.market.service;
import com.jzo2o.api.market.dto.request.CouponUseBackReqDTO;
import com.jzo2o.api.market.dto.request.CouponUseReqDTO;
import com.jzo2o.api.market.dto.response.AvailableCouponsResDTO;
import com.jzo2o.api.market.dto.response.CouponUseResDTO;
import com.jzo2o.common.model.PageResult;
import com.jzo2o.market.model.domain.Coupon;
import com.baomidou.mybatisplus.extension.service.IService;
import com.jzo2o.market.model.dto.request.CouponOperationPageQueryReqDTO;
import com.jzo2o.market.model.dto.request.SeizeCouponReqDTO;
import com.jzo2o.market.model.dto.response.CouponInfoResDTO;
import java.math.BigDecimal;
import java.util.List;
/**
* <p>
* 服务类
* </p>
*
* @author itcast
* @since 2023-09-16
*/
public interface ICouponService extends IService<Coupon> {
/**
* 获取可用优惠券列表
* @param totalAmount
* @return
*/
List<AvailableCouponsResDTO> getAvailable(BigDecimal totalAmount);
...
service接口实现类请自行实现。
小结
下单时如何拿到可用的优惠券列表?
7 优惠券核销接口(实战)
7.1 接口设计
1)系统交互流程
暂时无法在飞书文档外展示此内容
说明如下:
- 优惠券核销
用户端请求订单管理服务创建订单信息,订单管理服务远程调用优惠券服务核销优惠券,下单成功且优惠券核销成功。
优惠券核销执行以下操作:
-
限制:订单金额大于等于满减金额。
-
限制:优惠券有效
-
根据优惠券id标记优惠券表中该优惠券已使用、使用时间、订单id等。
-
向优惠券核销表添加记录。
核销成功返回最终优惠的金额。
- 优惠券退回
用户端取消订单,订单管理服务执行取消订单逻辑,如果该订单使用了优惠券则请求优惠券服务退回优惠券。
优惠券退回执行以下操作:
-
添加优惠券退回记录。
-
更新优惠券,如果优惠券已过期则标记该优惠券已作废
-
更新优惠券,如果优惠券未过期,标记该优惠券未使用,清空订单id字段及使用时间字段。
-
删除核销记录。
2)优惠券核销接口定义
核销接口定义如下:
接口名称:核销优惠券(内部接口)
接口功能:下单时调用此接口核销优惠券
接口路径:POST/market/inner/coupon/use
请求数据类型 application/json
请求参数:
订单总金额:根据订单总金额判断是否符合优惠券规则。
优惠券ID: 核销接口核销优惠券需要传入优惠券ID(对应优惠券表的主键)。
订单ID:核销表需要记录具体的订单ID。
响应结果:
核销后返回优惠金额。
在jzo2o-api工程下定义接口:
package com.jzo2o.api.market;
import com.jzo2o.api.market.dto.request.CouponUseBackReqDTO;
import com.jzo2o.api.market.dto.request.CouponUseReqDTO;
import com.jzo2o.api.market.dto.response.AvailableCouponsResDTO;
import com.jzo2o.api.market.dto.response.CouponUseResDTO;
import io.swagger.annotations.ApiOperation;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import java.math.BigDecimal;
import java.util.List;
@FeignClient(contextId = "jzo2o-market", value = "jzo2o-market", path = "/market/inner/coupon")
public interface CouponApi {
/**
* 优惠券使用,并返回优惠金额
* @param couponUseReqDTO
*/
@PostMapping("/use")
CouponUseResDTO use(@RequestBody CouponUseReqDTO couponUseReqDTO);
...
编写controller方法:
@RestController("innerCouponController")
@RequestMapping("/inner/coupon")
@Api(tags = "内部接口-优惠券相关接口")
public class CouponController implements CouponApi {
@Override
@PostMapping("/use")
@ApiOperation("使用优惠券,并返回优惠金额")
public CouponUseResDTO use(@RequestBody CouponUseReqDTO couponUseReqDTO) {
return null;
}
}
3)优惠券退回接口定义
请求参数:
优惠券ID(对应优惠券表的主键)
传入用户ID:需要记录退回优惠券用户的信息
传入订单ID:需要记录退回优惠券的订单ID
响应结果:无
退回优惠券接口定义如下:
接口名称:退回优惠券(内部接口)
接口功能:取消订单调用此接口退回优惠券
接口路径:POST/market/inner/coupon/useBack
这些内部接口定义在jzo2o-api下
package com.jzo2o.api.market;
import com.jzo2o.api.market.dto.request.CouponUseBackReqDTO;
import com.jzo2o.api.market.dto.request.CouponUseReqDTO;
import com.jzo2o.api.market.dto.response.AvailableCouponsResDTO;
import com.jzo2o.api.market.dto.response.CouponUseResDTO;
import io.swagger.annotations.ApiOperation;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import java.math.BigDecimal;
import java.util.List;
@FeignClient(contextId = "jzo2o-market", value = "jzo2o-market", path = "/market/inner/coupon")
public interface CouponApi {
/**
* 优惠券退款回退
*/
@PostMapping("/useBack")
void useBack(@RequestBody CouponUseBackReqDTO couponUseBackReqDTO);
...
编写controller方法:
@RestController("innerCouponController")
@RequestMapping("/inner/coupon")
@Api(tags = "内部接口-优惠券相关接口")
public class CouponController implements CouponApi {
@Override
@PostMapping("/useBack")
@ApiOperation("优惠券退回接口")
public void useBack(@RequestBody CouponUseBackReqDTO couponUseBackReqDTO) {
}
}
4)小结
优惠券核销的交互流程是什么?
7.2 接口实现(实战)
请根据需求和设计实现优惠券核销接口、优惠券退回接口,并参考下边的测试流程进行测试。
测试流程:
重启优惠券服务
启动网关服务
使用swagger文档进行接口测试。
示例:
手动在优惠券表添加测试数据。
下边是一个优惠券测试数据:
{
"id": 1722556410543214592,
"name": "全场七折",
"user_id": 1716346406098296832,
"user_name": "普通用户72135",
"user_phone": null,
"activity_id": 1722555839446781952,
"type": 2,
"discount_rate": 70,
"discount_amount": null,
"amount_condition": 0.00,
"validity_time": "2023-12-09 18:07:06",
"use_time": null,
"status": 1,
"orders_id": null,
"create_time": "2023-11-09 18:08:00",
"update_time": "2023-11-09 18:08:00",
"is_deleted": 0
}
打开swagger文档,输入测试数据:
id:将上边优惠券ID填入
totalAmount:填写一个满足优惠规则的金额
ordersId:随便填写一个订单id
下边开始测试
在核销优惠券service中打断点。
执行成功返回优惠金额:
8 实战要求
组长组织讨论需求及设计内容,确定每个接口的具体实现。
讨论完毕每人根据分工进行开发、测试。
实战形式:分组实战,具体参考“项目实战说明文档”。
本次实战模块如下,按下边的模块进行分工:
- 管理端
新增、查询、修改活动信息
撤销活动
查询领取记录
- 用户端
用户端我的优惠券列表接口
- 定时任务
自动变更活动状态
已领取优惠券自动过期
- 内部接口
优惠券核销接口
优惠券退回接口
要求:
每个人写的类尽量单独定义,避免代码冲突。(公用配置文件统一由专人维护)
实战完成要能说出:
1、你负责模块的怎么设计的
介绍业务流程、表设计、接口设计
2、在开发中你遇到了什么问题
9 发放优惠券
9.1 需求分析
1) 定时发放
按照某种规则找到符合发放优惠券资格的用户,系统自动将指定优惠券发放到符合资格的用户账户中。
首先在添加优惠券活动时指定发放方式为:自动发放
按照什么规则来确定符合发放优惠券资格的用户呢?
这个由业务系统去决定,优惠券服务作为一个通用的管理优惠券的服务它没有业务数据,判断用户是否有获取优惠券的资格是由业务系统去决定的。
举例:用户连续登录5天可获取一张优惠券,这个需求需要由用户认证系统根据用户的登录记录决定哪些用户可以获取优惠券,用户认证系统通过与优惠券服务交互将要发放优惠券的用户名单 告诉优惠券服务,优惠券服务按照名单去发放优惠券。
2) 立即发放
系统提供立即发放优惠券功能,指定优惠券、指定用户ID(多个)将优惠券立即发放到用户的账户中。
举例:客服针对具体的用户判断是否具有发放优惠券资格,具有发放资格将优惠券立即发放到用户的账户中。
此功能也是由业务系统去决定什么时候给用户发优惠券。
9.2 系统设计
1)数据流
暂时无法在飞书文档外展示此内容
表说明:
优惠券活动表:存储运营端管理的优惠券活动。
待发放优惠券表:存储了待发放的优惠券信息,包括:活动id、用户id、发放状态。
优惠券表:存储了用户领取的优惠券信息,发放优惠券会将待发放优惠券表中的记录拷贝到优惠券表中,发放成功需要更新待发放优惠券表的状态为“已发放”。
提交待发放用户名单 :
由业务系统调用优惠券服务,将待发放优惠券记录存入“待发放优惠券表”,同时更新优惠券活动表的库存(原库存减去待发放数量),存入待发放表的记录一定会发放,提前将库存更新,更新了库存表示锁定了这个优惠券。
立即发放接口:
向一个及多个用户立即发放优惠券,指定优惠券活动ID、用户ID(多个)向用户发放优惠券。
上图中之所以先将发放记录存储到待发放记录表是为了保证一个优惠券活动一个用户只发放一次,这里是通过数据库的约束去控制,避免重复给一个用户发放相同的优惠券。
自动发放优惠券任务:
通过任务调度在凌晨执行自动发放任务,先去优惠券活动表查询需要自动发放的活动,每个活动的优惠券发放任务由一个线程去执行,首先去待发放优惠券表查询待发放记录,然后将其插入优惠券发放记录表(即优惠券表,插入时将待发放优惠券表的主键作为优惠券表的主键),发放成功需要更新待发放记录的状态为已发放。
2)表结构
create table `jzo2o-market`.coupon_issue
(
id bigint not null comment '主键',
activity_id bigint not null comment '优惠券活动id',
user_id bigint not null comment '用户id',
status tinyint not null comment '发放状态 0:待发放,1:已发放',
constraint coupon_issue_activity_id_user_id_uindex
unique (activity_id, user_id)
)
comment '优惠券待发放表' charset = utf8mb4;
注意:activity_id, user_id是唯一索引,表示一个优惠券活动向同一个用户只发放一张优惠券。
3)接口设计
提交待发放优惠券接口
此接口支持批量向多个用户发优惠券。
响应提交成功的待发放记录:
立即发放优惠券接口
此接口支持批量向多个用户发优惠券。
响应发放成功的记录:
9.3 开发
9.3.1 切换分支
基于jzo2o-market工程的dev_01创建dev_02分支并切换到dev_02,使用下发的jzo2o-market_dev02.zip的代码覆盖dev_02分支的代码。
9.3.2 提交待发放优惠券接口
1) 模型类
定义提交待发放优惠券接口请求参数的数据模型:
package com.jzo2o.market.model.dto.request;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* <p>
* 发放优惠券
* </p>
*
* @author mrt
* @since 2024-09-23
*/
@Data
public class CouponIssueReqDTO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 优惠券活动id
*/
@ApiModelProperty(value = "优惠券活动id",required = true)
private Long activityId;
/**
* 用户id,多个用户用逗号隔开
*/
@ApiModelProperty(value = "用户id,多个用户用逗号隔开",required = true)
private String userIds;
}
2)定义接口
package com.jzo2o.market.controller.inner;
import com.jzo2o.market.model.domain.CouponIssue;
import com.jzo2o.market.model.dto.request.CouponIssueReqDTO;
import com.jzo2o.market.service.ICouponIssueService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.List;
@RestController("innerIssuedCouponController")
@RequestMapping("/inner/issuedcoupon")
@Api(tags = "内部接口-发放优惠券相关接口")
public class IssuedCouponController {
@ApiOperation("提交待发放优惠券数据")
@PostMapping("/save")
public List<CouponIssue> save(@RequestBody CouponIssueReqDTO couponIssueReqDTO) {
return null;
}
}
3)定义service
定义service用于批量向待发放优惠券表保存记录。
注意:在向待发放优惠券表插入记录时需要更新优惠券活动的库存,假如向10个用户发放优惠券则需要将对应的优惠券活动表的库存减去10。
package com.jzo2o.market.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.jzo2o.common.expcetions.CommonException;
import com.jzo2o.common.utils.DateUtils;
import com.jzo2o.common.utils.IdUtils;
import com.jzo2o.common.utils.StringUtils;
import com.jzo2o.market.enums.CouponStatusEnum;
import com.jzo2o.market.mapper.CouponIssueMapper;
import com.jzo2o.market.model.domain.Activity;
import com.jzo2o.market.model.domain.Coupon;
import com.jzo2o.market.model.domain.CouponIssue;
import com.jzo2o.market.model.dto.request.CouponIssueReqDTO;
import com.jzo2o.market.service.IActivityService;
import com.jzo2o.market.service.ICouponIssueService;
import com.jzo2o.market.service.ICouponService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* @author Mr.M
* @version 1.0
* @description 发放优惠券服务类
* @date 2024/9/23 16:33
*/
@Service
@Slf4j
public class CouponIssueServiceImpl extends ServiceImpl<CouponIssueMapper, CouponIssue> implements ICouponIssueService {
//批量处理记录数
private static final int BATCH_SIZE = 1000;
//注入优惠券service
@Resource
private ICouponService couponService;
//注入优惠券活动service
@Resource
private IActivityService activityService;
@Resource
private CouponIssueServiceImpl owner;
@Override
@Transactional
public List<CouponIssue> save(CouponIssueReqDTO couponIssueReqDTO) {
if (couponIssueReqDTO == null) {
log.info("待发放优惠券数据为空,无需处理");
throw new CommonException("待发放优惠券数据为空,无需处理");
}
//校验活动id
if (couponIssueReqDTO.getActivityId() == null) {
throw new CommonException("活动id不能为空");
}
//查询活动
Activity activity = activityService.getById(couponIssueReqDTO.getActivityId());
if (activity == null) {
log.info("优惠券活动不存在,id:{}", activity.getId());
//抛出异常
throw new CommonException("优惠券活动不存在");
}
//校验优惠券活动是否过期
if (activity.getDistributeEndTime().isBefore(LocalDateTime.now())) {
throw new CommonException("活动已结束");
}
//校验用户ids
if (StringUtils.isBlank(couponIssueReqDTO.getUserIds())) {
throw new CommonException("用户id不能为空");
}
//解析userIds
List<Long> userIds = Arrays.stream(couponIssueReqDTO.getUserIds().split(","))
.map(Long::parseLong).collect(Collectors.toList());
//根据活动id和用户ids查询待发放优惠券表中存在的记录
List<CouponIssue> couponIssueList = baseMapper.selectList(new LambdaQueryWrapper<CouponIssue>()
.eq(CouponIssue::getActivityId, couponIssueReqDTO.getActivityId())
.in(CouponIssue::getUserId, userIds));
//从couponIssueList中提取出用户id
List<Long> existUserIds = couponIssueList.stream().map(CouponIssue::getUserId).collect(Collectors.toList());
//找到userIds不在existUserIds中的用户id
List<Long> newUserIds = userIds.stream().filter(userId -> !existUserIds.contains(userId)).collect(Collectors.toList());
if (newUserIds.size() == 0) {
return new ArrayList<CouponIssue>();
}
//newUserIds的数量
Integer size = newUserIds.size();
//执行sql更新activity中的库存字段,拿到扣减库存结果
boolean b= activityService.lambdaUpdate()
.setSql("stock_num= stock_num - " + size)
.eq(Activity::getId, activity.getId())
.ge(Activity::getStockNum, size)
.update();
if (!b) {
throw new CommonException("优惠券活动库存不足");
}
List<CouponIssue> couponIssueListNew = new ArrayList<>();
for (Long userId : newUserIds) {
CouponIssue couponIssue = new CouponIssue();
couponIssue.setId(IdUtils.getSnowflakeNextId());
couponIssue.setActivityId(couponIssueReqDTO.getActivityId());
couponIssue.setUserId(userId);
//发放状态为0
couponIssue.setStatus(0);
couponIssueListNew.add(couponIssue);
}
//插入待发放优惠券表
boolean b1 = saveBatch(couponIssueListNew);
if (!b1) {
throw new CommonException("提交待发放优惠券失败");
}
return couponIssueListNew;
}
}
4)完善controller
package com.jzo2o.market.controller.inner;
import com.jzo2o.market.model.domain.CouponIssue;
import com.jzo2o.market.model.dto.request.CouponIssueReqDTO;
import com.jzo2o.market.service.ICouponIssueService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.List;
@RestController("innerIssuedCouponController")
@RequestMapping("/inner/issuedcoupon")
@Api(tags = "内部接口-发放优惠券相关接口")
public class IssuedCouponController {
@Resource
private ICouponIssueService couponIssueService;
@ApiOperation("提交待发放优惠券数据")
@PostMapping("/save")
public List<CouponIssue> save(@RequestBody CouponIssueReqDTO couponIssueReqDTO) {
List<CouponIssue> save = couponIssueService.save(couponIssueReqDTO);
return save;
}
}
5) 测试
测试向待发放优惠券表批量插入数据。
观察库存更新、向待发放优惠券表批量插入数据 是否保证事务一致性。
测试同一个优惠券活动向重复的用户提交待发放记录不会重复添加。
9.3.3 立即发放优惠券接口
1)定义接口
@RestController("innerIssuedCouponController")
@RequestMapping("/inner/issuedcoupon")
@Api(tags = "内部接口-发放优惠券相关接口")
public class IssuedCouponController {
@Resource
private ICouponIssueService couponIssueService;
@ApiOperation("立即发放优惠券")
@PostMapping("/issue")
public List<CouponIssue> issue(@RequestBody CouponIssueReqDTO couponIssueReqDTO) {
}
....
2)service方法
@Service
@Slf4j
public class CouponIssueServiceImpl extends ServiceImpl<CouponIssueMapper, CouponIssue> implements ICouponIssueService {
//批量处理记录数
private static final int BATCH_SIZE = 1000;
//注入优惠券service
@Resource
private ICouponService couponService;
//注入优惠券活动service
@Resource
private IActivityService activityService;
@Resource
private CouponIssueServiceImpl owner;
@Override
@Transactional
public List<CouponIssue> issue(CouponIssueReqDTO couponIssueReqDTO) {
//活动id
Long activityId = couponIssueReqDTO.getActivityId();
//查询活动信息
Activity activity = activityService.getById(activityId);
if (activity == null) {
throw new CommonException("活动不存在");
}
//如果活动已结束不进行发放
if (activity.getDistributeEndTime().isBefore(LocalDateTime.now())) {
throw new CommonException("活动已结束");
}
//先插入到待发放优惠券表
save(couponIssueReqDTO);
//从couponIssueList中提取用户id
List<Long> userIds = Arrays.asList(couponIssueReqDTO.getUserIds().split(",")).stream().map(Long::valueOf).collect(Collectors.toList());
//根据活动id和userIds查询CouponIssue中状态为0的记录
List<CouponIssue> couponIssues = baseMapper.selectList(
new LambdaQueryWrapper<CouponIssue>()
.eq(CouponIssue::getActivityId, activityId)
.in(CouponIssue::getUserId, userIds)
.eq(CouponIssue::getStatus, 0));
//如果couponIssues为空则不进行后续操作
if (couponIssues.isEmpty()) {
return couponIssues;
}
//更新couponIssues中的状态为1
couponIssues.forEach(couponIssue -> couponIssue.setStatus(1));
boolean updateBatchById = updateBatchById(couponIssues);
if (!updateBatchById) {
throw new DBException("优惠券发放失败");
}
//根据couponIssues生成List<Coupon>
List<Coupon> couponList = couponIssues.stream().map(couponIssue -> {
Coupon coupon = new Coupon();
coupon.setId(couponIssue.getId());
coupon.setUserId(couponIssue.getUserId());
coupon.setActivityId(couponIssue.getActivityId());
coupon.setName(activity.getName());
coupon.setType(activity.getType());
coupon.setDiscountAmount(activity.getDiscountAmount());
coupon.setDiscountRate(activity.getDiscountRate());
coupon.setAmountCondition(activity.getAmountCondition());
coupon.setValidityTime(DateUtils.now().plusDays(activity.getValidityDays()));
coupon.setStatus(CouponStatusEnum.NO_USE.getStatus());
coupon.setCreateTime(DateUtils.now());
coupon.setUpdateTime(DateUtils.now());
return coupon;
}
).collect(Collectors.toList());
//将待发放优惠券数据批量插入优惠券表
boolean b1 = couponService.saveBatch(couponList);
if (!b1) {
throw new CommonException("优惠券批量发放失败");
}
return couponIssues;
}
...
3) controller方法
@RestController("innerIssuedCouponController")
@RequestMapping("/inner/issuedcoupon")
@Api(tags = "内部接口-发放优惠券相关接口")
public class IssuedCouponController {
@Resource
private ICouponIssueService couponIssueService;
@ApiOperation("立即发放优惠券")
@PostMapping("/issue")
public List<CouponIssue> issue(@RequestBody CouponIssueReqDTO couponIssueReqDTO) {
List<CouponIssue> issue = couponIssueService.issue(couponIssueReqDTO);
return issue;
}
....
4)测试
测试发放成功后:
是否正确保证待发放优惠券记录,且发放成功后待发放优惠券记录的状态为已发放。
是否正确向优惠券表插入记录。
9.3.4 自动发放优惠券任务
1)整理逻辑
暂时无法在飞书文档外展示此内容
2)线程执行器
每个线程负责对一个活动的发放记录进行处理。
这里使用分布式锁任务重复调度。
package com.jzo2o.market.handler;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.jzo2o.market.model.domain.CouponIssue;
import com.jzo2o.market.model.dto.request.CouponIssueReqDTO;
import com.jzo2o.market.service.ICouponIssueService;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RedissonClient;
import org.springframework.util.CollectionUtils;
import java.util.List;
/**
* @author Mr.M
* @version 1.0
* @description 自动发放优惠券处理器
* 根据活动id从待发放记录表查找该活动的待发放记录,然后批量进行发放,一次拿出1000条进行发放。
* @date 2024/9/23 18:43
*/
@Slf4j
public class IssuedCouponHandler implements Runnable {
//优惠券发放service
private ICouponIssueService couponIssueService;
//分布式锁
private RedissonClient redissonClient;
//活动id
private Long activityId;
//构造方法
public IssuedCouponHandler(Long activityId,ICouponIssueService couponIssueService,RedissonClient redissonClient) {
this.activityId = activityId;
this.couponIssueService = couponIssueService;
this.redissonClient = redissonClient;
}
public void run() {
//获取锁
String lockKey = "activity:issued:lock:" + activityId;
log.info("获取锁:{}", lockKey);
RLock lock = redissonClient.getLock(lockKey);
//尝试获取锁
try {
boolean tryLock = lock.tryLock(1,-1, TimeUnit.SECONDS);
if(!tryLock){
log.info("获取锁失败:{}", lockKey);
return;
}
try {
//开始发放优惠券
log.info("开始发放优惠券:{}", activityId);
//批量发放优惠券
couponIssueService.autoIssue(activityId);
} catch (Exception e) {
log.error("发放优惠券失败:{}", e.getMessage());
}finally {
lock.unlock();
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
3)自动发放
定义自动发放service
public interface ICouponIssueService extends IService<CouponIssue> {
......
/**
* 自动发放优惠券
*/
void autoIssue(Long activityId);
.......
实现类:
@Service
@Slf4j
public class CouponIssueServiceImpl extends ServiceImpl<CouponIssueMapper, CouponIssue> implements ICouponIssueService {
//批量处理记录数
private static final int BATCH_SIZE = 1000;
//注入优惠券service
@Resource
private ICouponService couponService;
//注入优惠券活动service
@Resource
private IActivityService activityService;
@Resource
private CouponIssueServiceImpl owner;
@Override
@Transactional
public void autoIssue(Long activityId) {
while (true){
//获取待发放记录
List<CouponIssue> couponIssueList = list(new LambdaQueryWrapper<CouponIssue>()
.eq(CouponIssue::getActivityId, activityId)
.eq(CouponIssue::getStatus, 0)
.last("limit " + BATCH_SIZE));
//如果待发放记录为空,则退出
if (CollectionUtils.isEmpty(couponIssueList)) {
break;
}
log.info("待发放记录:{}", couponIssueList);
//准备发放优惠券,创建CouponIssueReqDTO对象
CouponIssueReqDTO couponIssueReqDTO = new CouponIssueReqDTO();
couponIssueReqDTO.setActivityId(activityId);
List<Long> userIds = couponIssueList.stream().map(CouponIssue::getUserId).collect(Collectors.toList());
//将userIds转成字符串,中间用逗号分隔
String userIds = StringUtils.join(",",userIds);
couponIssueReqDTO.setUserIds(userIds);
log.info("准备发放优惠券:{}", couponIssueReqDTO);
try {
owner.issue(couponIssueReqDTO);
} catch (Exception e) {
log.info("发放优惠券:{}异常", couponIssueReqDTO);
e.printStackTrace();
throw e;
}
//休眠1秒
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
.......
4)定时任务
package com.jzo2o.market.handler;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.jzo2o.market.model.domain.Activity;
import com.jzo2o.market.model.domain.CouponIssue;
import com.jzo2o.market.service.IActivityService;
import com.jzo2o.market.service.ICouponIssueService;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* @author Mr.M
* @version 1.0
* @description 自动发放优惠券任务
* @date 2024/9/23 19:49
*/
@Slf4j
@Component
public class IssuedCouponHandlerJob {
//定义线程池
private static ThreadPoolExecutor threadPoolExecutor;
@Resource
private ICouponIssueService couponIssueService;
@Resource
private IActivityService activityService;
@Resource
private RedissonClient redissonClient;
static {
threadPoolExecutor = new ThreadPoolExecutor(0, 20, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(100));
}
public void start() {
log.info("自动发放优惠券任务开始");
//从活动表查询还未结束的活动。
List<Activity> activityList = activityService.list(new LambdaQueryWrapper<Activity>()
.in(Activity::getStatus, List.of(0,1)));
//以活动id为单位创建优惠券发放任务,并加入线程池
activityList.stream().forEach(activity -> {
//创建IssuedCouponHandler对象
IssuedCouponHandler issuedCouponHandler = new IssuedCouponHandler(activity.getId(), couponIssueService,redissonClient);
//将任务加入线程池
threadPoolExecutor.execute(issuedCouponHandler);
});
}
}
xxl-job调度:
package com.jzo2o.market.handler;
import com.jzo2o.market.service.IActivityService;
import com.jzo2o.market.service.ICouponService;
import com.jzo2o.redis.annotations.Lock;
import com.jzo2o.redis.constants.RedisSyncQueueConstants;
import com.jzo2o.redis.sync.SyncManager;
import com.xxl.job.core.handler.annotation.XxlJob;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import static com.jzo2o.market.constants.RedisConstants.Formatter.*;
import static com.jzo2o.market.constants.RedisConstants.RedisKey.COUPON_SEIZE_SYNC_QUEUE_NAME;
@Component
public class XxlJobHandler {
@Resource
private SyncManager syncManager;
@Resource
private IActivityService activityService;
@Resource
private ICouponService couponService;
@Resource
private IssuedCouponHandlerJob issuedCouponHandlerJob;
....
/**
* 自动发放优惠券
*/
@XxlJob("issueCouponJob")
public void issueCouponJob() {
issuedCouponHandlerJob.start();
}
}
5)测试
- 制造数据
通过提交待发放优惠券接口 提交待发放数据。
-
进入xxl-job配置任务,并启动任务
-
观察线程执行过程
-
观察是否成功将待发放优惠券表的记录插入到优惠券表,同时更新待发放优惠券记录的状态为已发放。

浙公网安备 33010602011771号