LOADING...

加载过慢请开启缓存(浏览器默认开启)

loading

P-luminary

JL

2025/2/7

招聘流程

主流网站:Boss直聘
先过系统筛选[写本科 写计算机专业 过筛选 得到面试机会]
HR会帮我们的 需要KPI当月目标和业绩
黑马课程对标3年工作经验 大厂211都不好进

每次都要微信问一下HR:**技术面** 还是 人力面
可以打听面试官侧重点,问一下是领导还是主管还是技术组长

不要乱讲话!!不要乱讲话!!不要乱讲话!!不要乱讲话!!

学信网可查吗? 先说可查 若让 发编号 就下一家 [可填专科吗???]


简历怎么写好?

基本信息 籍贯 工作年限 求职岗位(Java开发工程师) 薪资(面议)
薪资范围6-18k 拉大范围 入职时间:一周内到岗
电话必写 邮箱也必写

面试问你什么时候能到岗3个工作日左右 / (3天左右)

image-20250207105915015

教育经历
25届 19-23 也可以大三出去实习 21-24
在校:往前推一年
学校 + 专业

工作经历
每一家一年多 建议2个公司
上一家公司距离现在 不能有很空窗时期【实时修改工作经验时间

上周离职
有没有面试其他?
没有 我也是刚好碰到贵公司 有 拿到了offer但是我还是意愿最大贵公司喜欢贵公司的环境

开发技能 [15条以上]
细节引导面试官去书写开发技能
记得要设置陷阱
从优先级高到低写
前端一定要写

Java基础先写到前面
大数据统计至少要 3年经验 4个项目经验

项目周期:时间控制在5-8个月
第一个项目经历至少7-8点 需要业务+技术点
第二个项目至少5-6条 达到项目一的七成
第三四项目至少3条 减量不减质【不要龙头蛇尾】

2025.2.10号中午收集好简历

文件名称:**Java开发工程师 _ 3年 _ 潘春尧**.pdf



求职意向

期望城市:不限
期望岗位:Java开发工程师
期望薪资:面议
到岗时间:一周内/随时
写虚岁!!

教育经历

民教网3年:2018.9-2022.7 xxx大学 专业:不相关可以不写

学信网
25届:实习
23-24届:1-2年

工作经历(一家公司超过两年左右) 5-8个月 / 一个项目

xxx公司(找自己熟悉城市,不要找在线招聘会被HR直接问对面招聘中的HR)

没有社保:不写期望城市
社保:写社保所在地
公司开业时间、小公司(100人以内)、科技有限公司、上班地点交通方式、背景调查写洪哥、法人(老板)、公司地址

背调:模板
写纸质版信息表 出生年月日 → 写真实的日期

第一步:写纸质信息表(证明人:电话)洪哥敏姐
背调信息:发给洪哥敏姐

hr会根据你给的电话直接
上级领导:项目
第三者:朋友/同学/同事 [可以拒绝回答很多]

第三方背调:查社保 查工作记录 查学信网

带身份证去的话 证明楼下的大厦需要登记身份信息才能进



Boss上的投递

上传PDF版本的简历
工作经历 → 对该公司隐藏我的简历

文件名称:**潘春尧-本科-3年-Java开发工程师**.pdf

尽量先从远的地方投递简历 试试状态!最想去广州的留到最后状态好的时候再投递

外包公司:跟培训的学生匹配 哈哈哈!



五险一金:基本养老保险[工作里面累计交满20年退休时领退休金] 、 基本医疗保险 [总部上海现在在广州一般都是本地医保方便]、 失业保险[工作满一年后因公司被辞退才可以领取] 、 工伤保险 、 生育保险 及 住房公积金

【要找公积金的公司】公积金5% → 买房可以用公积金来贷款;退休后这笔钱可以一次性提取出来养老;装修/租房的名义提取(半年一次)

面试题问:上家公司的工资待遇 → 城市最低标准买的
10k扣除五险一金 保险400-500 公积金大概300-400左右

个人所得税:不超过3.6w就是3%,每个月财务都会扣除发放税后工资,起征点5k,超过5k的部分才会交税。10k以内的大概是3%,

面试:公司有绩效考核 我们没有很明确的考核 日常都能完成工作量

入职当天要签劳务合同 1年或3年?
区别是什么?
1年的劳动合同只能定1个月试用期 2年2个月 3年3个月
大概率试用期8-9折 都是3-6个月

签订劳动合同可以离职 末尾淘汰在法律上不允许
转正以后被裁可以n+1个月 n代表年 试用期被辞退会赔偿半个月薪资

民教网(1000人以下的公司) 主攻针对小型企业 和 二三线城市
浙江→绍兴杭州
远公司约线上公司

现在在外地 能不能开一轮的线上面试 如果有需求我还是能过去的
阅读全文

ElasticSearch

2025/1/3

ElasticSearch结合Kibana、Logstash、Beats,核心是elastic stack的核心,负责存储、搜索、分析数据
Lucene的优势:容易扩展、高性能(基于倒排索引)
Lucene的缺点:直选与java语言开发
ElasticSearch是基于Lucene开发的

Elasticsearch:开源的分布式搜索引擎,可以用来实现搜索、日志统计、分析、系统监控等功能

正向索引和倒排索引

传统数据库是正向索引

ElasticSearch采用倒排索引

  • 文档(document):每条数据就是一个文档
  • 词条(term):文档按照语义分成词语
id title price
1 小米手机 3499
2 华为手机 4999
3 华为小米充电器 49
4 小米手环 299

↓↓↓↓ ↓↓ ↓↓ ↓↓ ↓↓ ↓↓ ↓↓ ↓↓ ↓↓ ↓↓ ↓↓ ↓↓ ↓↓ ↓↓ ↓↓ ↓↓ ↓↓ ↓↓ ↓↓ ↓↓ ↓↓

词条(term) 文档id
小米 1,3,4
手机 1,2
华为 2,3
充电器 3
手环 4

过程:搜索华为手机 → 得到:华为手机两个词条 → 得到每个词条所在文档id:华为:2,3 手机:1,2 → 得到id为1,2,3的文档 → 存入结果集
倒排索引:对文档内容分词,对词条创建索引,并记录词条所在文档的信息。查询时现根据词条查询到文档id,而后获取到文档
正排索引:基于文档id创建索引。查询词条时必须先找到文档,而后判断是否包含词条

文档

ElasticSearch是面向文档存储的,可以是数据库中的一条商品数据,一个订单信息,文档数据会被序列化为json格式后存储在ElasticSearch中
在ElasticSearch中

  • **索引(index)**:相同类型的文档的集合
  • **映射(mapping)**:索引中文档的字段约束信息,类似表的结构约束
概念对比
MySQL ElasticSearch 说明
Table Index 索引(index),就是文档的集合,类似于数据库的表(table)
Row Document 文档(Document),就是一条条的数据,类似数据库中的行(Row),文档都是JSON格式
Column Field 字段(Field),就是JSON文档中的字段,类似数据库中的列(Column)
Schema Mapping Mapping(映射),就是索引中文档的约束,例如字段类型约束。类似数据库的表结构(Schema)
SQL DSL DSL是ElasticSearch提供的JSON风格的请求语句,用来操作ElasticSearch,实现CRUD
架构

MySQL写操作:擅长事务类型操作,可以确保数据的安全和一致性
ElasticSearch查询:擅长海量数据的搜索、分析、计算
[可以互补达到数据双写一致性]

1.1.创建网络

因为我们还需要部署kibana容器,因此需要让es和kibana容器互联。这里先创建一个网络(创建过的不用再创建):

docker network create es-net

1.2.加载镜像

这里我们采用elasticsearch的7.12.1版本的镜像,这个镜像体积非常大,接近1G。不建议大家自己pull。

课前资料提供了镜像的tar包:

image-20210510165308064

大家将其上传到虚拟机中,然后运行命令加载即可:

# 导入数据
docker load -i es.tar

同理还有kibana的tar包也需要这样做。

1.3.运行

运行docker命令,部署单点es:

docker run -d \
    --name es \
    -e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \
    -e "discovery.type=single-node" \
    -v es-data:/usr/share/elasticsearch/data \
    -v es-plugins:/usr/share/elasticsearch/plugins \
    --privileged \
    --network es-net \
    -p 9200:9200 \
    -p 9300:9300 \
elasticsearch:7.12.1

命令解释:

  • -e "cluster.name=es-docker-cluster":设置集群名称
  • -e "http.host=0.0.0.0":监听的地址,可以外网访问
  • -e "ES_JAVA_OPTS=-Xms512m -Xmx512m":内存大小
  • -e "discovery.type=single-node":非集群模式
  • -v es-data:/usr/share/elasticsearch/data:挂载逻辑卷,绑定es的数据目录
  • -v es-logs:/usr/share/elasticsearch/logs:挂载逻辑卷,绑定es的日志目录
  • -v es-plugins:/usr/share/elasticsearch/plugins:挂载逻辑卷,绑定es的插件目录
  • --privileged:授予逻辑卷访问权
  • --network es-net :加入一个名为es-net的网络中
  • -p 9200:9200:端口映射配置

在浏览器中输入:http://192.168.xxx.xxx:9200 即可看到elasticsearch的响应结果:

image-20210506101053676

2.部署kibana

kibana可以给我们提供一个elasticsearch的可视化界面,便于我们学习。

2.1.部署

运行docker命令,部署kibana,同理先加载镜像: docker load -i kibana.tar,然后启动:

docker run -d \
--name kibana \
-e ELASTICSEARCH_HOSTS=http://es:9200 \
--network=es-net \
-p 5601:5601  \
kibana:7.12.1
  • --network es-net :加入一个名为es-net的网络中,与elasticsearch在同一个网络中
  • -e ELASTICSEARCH_HOSTS=http://es:9200":设置elasticsearch的地址,因为kibana已经与elasticsearch在一个网络,因此可以用容器名直接访问elasticsearch
  • -p 5601:5601:端口映射配置

kibana启动一般比较慢,需要多等待一会,可以通过命令:

docker logs -f kibana

查看运行日志,当查看到下面的日志,说明成功:

image-20210109105135812

此时,在浏览器输入地址访问:http://192.168.xxx.xxx:5601,即可看到结果

分词效果概览 Dev Tools - Elastic

GET /_analyze
{
“analyzer”: “standard”,
“text”: “黑马程序员”
}

{
  "tokens" : [
    {
      "token" : "黑",
      "start_offset" : 0,
      "end_offset" : 1,
      "type" : "<IDEOGRAPHIC>",
      "position" : 0
    },
    {
      "token" : "马",
      "start_offset" : 1,
      "end_offset" : 2,
      "type" : "<IDEOGRAPHIC>",
      "position" : 1
    },
    {
      "token" : "程",
      "start_offset" : 2,
      "end_offset" : 3,
      "type" : "<IDEOGRAPHIC>",
      "position" : 2
    },
    {
      "token" : "序",
      "start_offset" : 3,
      "end_offset" : 4,
      "type" : "<IDEOGRAPHIC>",
      "position" : 3
    },
    {
      "token" : "员",
      "start_offset" : 4,
      "end_offset" : 5,
      "type" : "<IDEOGRAPHIC>",
      "position" : 4
    }
  ]
}

如果是分析中午就不能用它原有的,存在明显的问题:将中文逐字分词,没有任何业务语义,因此需要借助专业的分词器

3.安装IK分词器

3.1.在线安装ik插件(较慢)

# 进入容器内部
docker exec -it elasticsearch /bin/bash

# 在线下载并安装
./bin/elasticsearch-plugin  install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.12.1/elasticsearch-analysis-ik-7.12.1.zip

#退出
exit
#重启容器
docker restart elasticsearch

3.2.离线安装ik插件(推荐)

1)查看数据卷目录

安装插件需要知道elasticsearch的plugins目录位置,而我们用了数据卷挂载,因此需要查看elasticsearch的数据卷目录,通过下面命令查看:

docker volume inspect es-plugins

显示结果:

[
    {
        "CreatedAt": "2022-05-06T10:06:34+08:00",
        "Driver": "local",
        "Labels": null,
        "Mountpoint": "/var/lib/docker/volumes/es-plugins/_data",
        "Name": "es-plugins",
        "Options": null,
        "Scope": "local"
    }
]

说明plugins目录被挂载到了:/var/lib/docker/volumes/es-plugins/_data 这个目录中。

2)解压缩分词器安装包

下面我们需要把课前资料中的ik分词器解压缩,重命名为ik

image-20210506110249144

3)上传到es容器的插件数据卷中

也就是/var/lib/docker/volumes/es-plugins/_data

image-20210506110704293

4)重启容器

# 4、重启容器
docker restart es
# 查看es日志
docker logs -f es

5)测试:

IK分词器包含两种模式

  • ik_smart:最少切分

  • ik_max_word:最细切分

GET /_analyze
{
  "analyzer": "ik_max_word",
  "text": "黑马程序员学习java太棒了"
}

结果:

{
  "tokens" : [
    {
      "token" : "黑马",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "CN_WORD",
      "position" : 0
    },
    {
      "token" : "程序员",
      "start_offset" : 2,
      "end_offset" : 5,
      "type" : "CN_WORD",
      "position" : 1
    },
    {
      "token" : "程序",
      "start_offset" : 2,
      "end_offset" : 4,
      "type" : "CN_WORD",
      "position" : 2
    },
    {
      "token" : "员",
      "start_offset" : 4,
      "end_offset" : 5,
      "type" : "CN_CHAR",
      "position" : 3
    },
    {
      "token" : "学习",
      "start_offset" : 5,
      "end_offset" : 7,
      "type" : "CN_WORD",
      "position" : 4
    },
    {
      "token" : "java",
      "start_offset" : 7,
      "end_offset" : 11,
      "type" : "ENGLISH",
      "position" : 5
    },
    {
      "token" : "太棒了",
      "start_offset" : 11,
      "end_offset" : 14,
      "type" : "CN_WORD",
      "position" : 6
    },
    {
      "token" : "太棒",
      "start_offset" : 11,
      "end_offset" : 13,
      "type" : "CN_WORD",
      "position" : 7
    },
    {
      "token" : "了",
      "start_offset" : 13,
      "end_offset" : 14,
      "type" : "CN_CHAR",
      "position" : 8
    }
  ]
}

3.3 扩展词词典

注意当前文件的编码必须是 UTF-8 格式,严禁使用Windows记事本编辑,可以直接linux系统vi编辑

随着互联网的发展,“造词运动”也越发的频繁。出现了很多新的词语,在原有的词汇列表中并不存在。比如:“奥力给”,“传智播客” 等。

所以我们的词汇也需要不断的更新,IK分词器提供了扩展词汇的功能。

1)打开IK分词器config目录:/var/lib/docker/volumes/es-plugins/_data/ik/config/IKAnalyzer.cfg.xml

image-20210506112225508

2)在IKAnalyzer.cfg.xml配置文件内容添加:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
        <comment>IK Analyzer 扩展配置</comment>
      
        <entry key="ext_dict">ext.dic</entry> 
</properties>

3)新建一个 ext.dic,可以参考config目录下复制一个配置文件进行修改

传智播客
奥力给

4)重启elasticsearch

docker restart es

# 查看 日志
docker logs -f es

image-20201115230900504

日志中已经成功加载ext.dic配置文件

5)测试效果:

GET /_analyze
{
  "analyzer": "ik_max_word",
  "text": "传智播客Java就业超过90%,奥力给!"
}

注意当前文件的编码必须是 UTF-8 格式,严禁使用Windows记事本编辑,可以直接linux系统vi编辑

3.4 停用词词典

在互联网项目中,在网络间传输的速度很快,所以很多语言是不允许在网络上传递的,如:关于宗教、政治等敏感词语,那么我们在搜索时也应该忽略当前词汇。

IK分词器也提供了强大的停用词功能,让我们在索引时就直接忽略当前的停用词汇表中的内容。

1)IKAnalyzer.cfg.xml配置文件内容添加:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
        <comment>IK Analyzer 扩展配置</comment>
        <!--用户可以在这里配置自己的扩展字典-->
        <entry key="ext_dict">ext.dic</entry>
         <!--用户可以在这里配置自己的扩展停止词字典  *** 添加停用词词典-->
        <entry key="ext_stopwords">stopword.dic</entry>
</properties>

3)在 stopword.dic 添加停用词

习大大

4)重启elasticsearch

# 重启服务
docker restart es
docker restart kibana

# 查看 日志
docker logs -f es

日志中已经成功加载stopword.dic配置文件

5)测试效果:

GET /_analyze
{
  "analyzer": "ik_max_word",
  "text": "传智播客Java就业率超过95%,习大大都点赞,奥力给!"
}

注意当前文件的编码必须是 UTF-8 格式,严禁使用Windows记事本编辑



索引库操作

mapping属性
mapping是对索引库中文档的约束,常见的mapping属性包括:

• type:字段数据类型,常见的简单类型有:
• 字符串:text(可分词的文本)、keyword(精确值,例如:品牌、国家、ip地址)
• 数值:long、integer、short、byte、double、float
• 布尔:boolean
• 日期:date
• 对象:object
• index:是否创建索引,默认为true【默认倒排】
• analyzer:使用哪种分词器【只有text才需要分词】
• properties:该字段的子字段

{
    "age": 21,
    "weight": 52.1,
    "isMarried": false,
    "info": "黑马程序员Java讲师",
    "email": "zy@itcast.cn",
    "score": [99.1, 99.5, 98.9],
    "name": {
        "firstName": "云",
        "lastName": "赵"
    }
}
索引库操作
创建索引库和mapping的请求语法
PUT /索引库名称
{
  "mappings": {
    "properties": {
      "字段名":{
        "type": "text",
        "analyzer": "ik_smart"
      },
      "字段名2":{
        "type": "keyword",
        "index": "false"
      },
      "字段名3":{
        "properties": {
          "子字段": {
            "type": "keyword"
          }
        }
      },
      // ...略
    }
  }
}

↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓

# 创建索引库
PUT /heima
{
  "mappings": {
    "properties": {
      "info":{
        "type": "text",
        "analyzer": "ik_max_word"
      },
      "email":{
        "type": "keyword",
        "index": false
      },
      "name":{
        "type": "object",
        "properties": {
          "firstName":{
            "type": "keyword"
          },
          "lastName":{
            "type": "keyword"
          }
        }
      }
    }
  }
}
---------------------------------------------------------------------
{
  "acknowledged" : true,
  "shards_acknowledged" : true,
  "index" : "heima"
}

操作索引库禁止修改索引库(因为已经映射好了)

查看索引语法:

GET/索引名

删除索引库的语法:

DELETE/索引库名

可以在修改索引的过程中添加新的字段
PUT /索引库名/_mapping
{
  "properties": {
    "新字段名":{
      "type": "integer"
    }
  }
}
---------------------------
PUT /heima/_mapping
{
  "properties": {
    "age":{
      "type": "integer"
    }
  }
}

当您使用Elasticsearch(ES)的PUT请求创建索引库时,这个索引库实际上是存储在Elasticsearch集群的节点上的。如果您的Elasticsearch集群是安装在Linux服务器上的,那么是的,索引库会被创建在Linux文件系统中。

Elasticsearch为每个索引分配一个或多个主分片,并为每个主分片分配一个或多个副本分片。这些分片实际上是存储在Elasticsearch节点的文件系统上的。具体来说,索引数据存储在以下路径:

复制

/path/to/elasticsearch/data/nodes/<node-id>/<index>/<shard-id>

这里的/path/to/elasticsearch是Elasticsearch的安装路径,data目录是默认的数据存储位置,nodes目录包含了集群中各个节点的数据,<node-id>是节点的唯一标识,<index>是您创建的索引名称,而<shard-id>则是分片的ID。

索引库的增删改查汇总


# 创建索引库
PUT /heima
{
  "mappings": {
    "properties": {
      "info":{
        "type": "text",
        "analyzer": "ik_max_word"
      },
      "email":{
        "type": "keyword",
        "index": false
      },
      "name":{
        "type": "object",
        "properties": {
          "firstName":{
            "type": "keyword"
          },
          "lastName":{
            "type": "keyword"
          }
        }
      }
    }
  }
}

# 查询
GET /heima

# 修改索引库
PUT /heima/_mapping
{
  "properties":{
    "age":{
      "type": "integer"
    }
  }
}

# 修改
DELETE /heima
文档操作——添加文档
# 每次写操作的时候 版本会增加 "_version ++"

# 插入文档
POST /heima/_doc/1
{
  "info": "广州黑马198班",
  "email": "390415049@qq.com",
  "name":{
    "firstName": "春",
    "lastName": "尧"
  }
}

# 查询文档
GET /heima/_doc/1

# 删除文档
DELETE /heima/_doc/1
文档操作——修改文档
方式一:全量修改,会删除旧文档,添加新文档
PUT /索引库名/_doc/文档id
{
    "字段1": "值1",
    "字段2": "值2",
    // ... 略
}
-------------------------
PUT /heima/_doc/1
{
    "info": "黑马程序员高级Java讲师",
    "email": "zy@itcast.cn",
    "name": {
        "firstName": "云",
        "lastName": "赵"
    }
}


# 全量修改文档
POST /heima/_doc/1
{
  "info": "广州黑马198班",
  "email": "90415049@qq.com",
  "name":{
    "firstName": "尧",
    "lastName": "春"
  }
}
方式二:增量修改,修改指定字段值
POST /索引库名/_update/文档id
{
    "doc": {
         "字段名": "新的值",
    }
}
-------------------------
POST /heima/_update/1
{
  "doc": {
    "email": "ZhaoYun@itcast.cn"
  }
}


# 局部修改文档
POST /heima/_update/1
{
  "doc": {
    "email": "ZYun@itcast.cn"
  }
}
总结

文档操作有哪些?

  • 创建文档:POST /索引库名/_doc/文档id { json文档 }

  • 查询文档:GET /索引库名/_doc/文档id

  • 删除文档:DELETE /索引库名/_doc/文档id

  • 修改文档:

    • 全量修改:PUT /索引库名/_doc/文档id { json文档 }

    • 增量修改:POST /索引库名/_update/文档id { “doc”: {字段}}

JavaRestClient

Elasticsearch目前最新版本是8.0,其Java客户端有很大变化。不过大多数企业使用的还是8以下版本,所以我们选择使用早期的JavaRestClient客户端来学习。官方文档地址:Elasticsearch Clients | Elastic

阅读全文

MyBatisPlus

2024/11/23

MyBatis-Plus
简介 | MyBatis-Plus

引入MybatisPlus起步依赖写依赖+认爸爸
  • MyBatisPlus官方提供了starter,其中集成了Mybatis和MybatisPlus的所有功能,并且实现了自动装配效果。因此我们可以用MybatisPlus的starter代替Mybatis的starter:
<!--MybatisPlus-->
<dependency>    
    <groupId>com.baomidou</groupId>    
    <artifactId>mybatis-plus-boot-starter</artifactId> 
    <version>3.5.3.1</version>
</dependency>
  • 自定义的Mapper继承MybatisPlus提供的BaseMapper接口
public interface UserMapper extends BaseMapper<User> {
}
UserMapper.java

public interface UserMapper extends BaseMapper<User> {
UserMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.itheima.mp.mapper.UserMapper">

</mapper>

常用注解

  • @TableName:用来指定表名
  • @Tableld:用来指定表中的主键字段信息
    • IdType枚举
      • AUTO:数据库自增长
      • INPUT:通过set方法自行输入
      • ASSIGN_ID分配ID 默认实现类是雪花算法
  • @TableField:用来指定表中的普通字段信息默认驼峰转下划线,不一致需要改
    • 成员变量名与数据库字段名不一致
    • 成员变量名是以is开头,且是布尔值isMarried
    • 成员变量名与数据库关键字冲突order
    • 成员变量不是数据库字段address,要标记不存在不然会默认数据库字段
@Data
public class User {
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    
    @TableField("username")
    private String name;

    @TableField("is_married")
    private Boolean isMarried;  // is经过反射会默认变成变量名Married
    
    @TableField("`order`")
    private Integer order;
    
    @TableField(exist = false)
    private String address;
......
}

此时如果数据库的表名是:tb_user 就需要用到 @TableName

数据库名:tb_user (用户表)
#  名称
1  id
2  username
3  is_married
4  order

常见配置

MyBatisPlus的配置项继承了MyBatis原生配置和一些自己特有的配置
MP更擅长单表的增删改查,如果是多表还是推荐用xml

mybatis:
  mapper-locations: classpath*:mapper/*.xml # Mapper.xml文件地址,默认值
  type-aliases-package: com.itheima.po # 别名扫描包
  configuration:
    map-underscore-to-camel-case: true # 开启驼峰命名自动映射
    cache-enabled: false # 是否开启二级缓存
  global-config:
    db-config:
      id-type: assign_id # id为雪花算法生成
      update-strategy: not_null # 更新策略:只更新非空字段 类似于动态sql

MyBatisPlus使用的基本流程

  • 引入起步依赖
  • 自定义Mapper基础BaseMapper
  • 在实体类上添加注释声明 表信息
  • 在application.yml中根据需要添加配置

核心功能—条件构造器

条件构造器

MyBatisPlus支持各种复杂的where条件,满足日常开发的所有需求

  • 查询出名字中带o的,存款大于等于1000元的人的id、username、info、balance字段
# 原始SQL:
SELECT id,username,info,balance
FROM user
WHERE username LIKE ? AND balance >= ?
// MyBatisPlus:
@Test
    void testQueryWrapper(){
        // 1.构建查询条件
        QueryWrapper<User> wrapper = new QueryWrapper<User>()
                .select("id", "username", "phone")
                .like("username", "o")
                .ge("balance", 1000);
        // 2.查询
        List<User> users = userMapper.selectList(wrapper);
        users.forEach(System.out::println);
    }
// MyBatisPlus Lambda编码格式(解决硬编码):
 @Test
    void testLambdaQueryWrapper(){
        // 1.构建查询条件
        LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<User>()
                // 利用反射 解决字符串硬编码
                .select(User::getId, User::getUsername, User::getPhone)
                .like(User::getUsername, "o")
                .ge(User::getBalance, 1000);
        // 2.查询
        List<User> users = userMapper.selectList(wrapper);
        users.forEach(System.out::println);
    }

-------------------------------------------------------------------------
@Test
void testLambdaQueryWrapperWithQueryWrapper(){
    // 1. 构建查询条件
    QueryWrapper<User> queryWrapper = new QueryWrapper<User>();
    LambdaQueryWrapper<User> wrapper = queryWrapper.lambda()
            .select(User::getId, User::getUsername, User::getPhone)
            .like(User::getUsername, "o")
            .ge(User::getBalance, 1000);

    // 2. 查询
    List<User> users = userMapper.selectList(wrapper);
    users.forEach(System.out::println);
}
  • 更新用户名为jack的用户的余额为2000
# 原始SQL:
UPDATE user
    SET balance = 2000
    WHERE (username = "jack")
// MyBatisPlus:
@Test
    void testUpdateByQueryWrapper(){
        // 1.要更新的数据
        User user = new User();
        user.setBalance(2000);
        // 2.更新的条件
        QueryWrapper<User> wrapper = new QueryWrapper<User>().eq("username", "jack");
        // 3.执行更新
        userMapper.update(user, wrapper);
    }
  • 更新id为1,2,4的用户的余额,扣200
# 原始SQL:
UPDATE user
    SET balance = balance - 200
    WHERE id in (1,2,4)
// MyBatisPlus:
@Test
    void testUpdateWrapper(){
        List<Long> ids = List.of(1L, 2L, 4L);
        UpdateWrapper<User> wrapper = new UpdateWrapper<User>()
                .setSql("balance = balance - 100")
                .in("id", ids);
        userMapper.update(null, wrapper);
    }
条件构造器的用法:
  • QueryWrapper和LambdaQueryWrapper通常用来构建select、delete、update的where条件部分

  • UpdateWrapper和LambdaUpdateWrapper通常只有在set语句比较特殊才使用

  • 尽量使用LambdaQueryWrapper和LambdaUpdateWrapper,避免硬编码

4. 字段映射与表名映射

4.1 问题一:表字段与编码属性设计不同步

  • 在模型类属性上方,使用**@TableField**属性注解,通过==value==属性,设置当前属性对应的数据库表中的字段关系。

1683796001750

4.2 问题二:编码中添加了数据库中未定义的属性

  • 在模型类属性上方,使用**@TableField注解,通过==exist==**属性,设置属性在数据库表字段中是否存在,默认为true。

1683796121907

4.3 问题三:表名与编码开发设计不同步

  • 模型类上方,使用**@TableName注解,通过==value==**属性,设置当前类对应的数据库表名称。

1683798660359

四、主键生成策略

id主键生成的策略有哪几种方式?

不同的表应用不同的id生成策略

  • 日志:自增(1,2,3,4,……)
  • 购物订单:特殊规则(FQ23948AK3843)
  • 外卖单:关联地区日期等信息(10 04 20200314 34 91)
  • 关系表:可省略id
  • ……

1 id生成策略控制(@TableId注解)

雪花算法:@TableId(type= IdType.ASSIGN_ID)
ASSIGN_UUID是趋势递增
用了分库分表就不能用默认的id自增了 要用雪花算法

  • 名称:@TableId

  • 类型:属性注解

  • 位置:模型类中用于表示主键的属性定义上方

  • 作用:设置当前类中主键属性的生成策略

  • 相关属性

    type:设置主键属性的生成策略,值参照IdType枚举值

    image-20210801192449901

2 全局策略配置

mybatis-plus:
  global-config:
    db-config:
      id-type: assign_id #全局设置主键id策略
      table-prefix: tbl_  #表名前缀设置
id生成策略全局配置

image-20210801183128266

表名前缀全局配置

image-20210801183157694

自定义SQL

我们可以利用MyBatisPlus的Wrapper来**构造复杂的where条件**,然后自己定义SQL语句中剩下的部分。

将id在指定范围的用户(1,2,4)的余额扣减指定值
<update id = "updateBalanceByIds">
    UPDATE user
    SET balance = balance - #{amount}
    WHERE id IN
    <foreach collection="ids" separator="," item="id" open="(" close=")">
    #{id}
    </foreach>
</update>
  • 基于Wrapper构建where条件
// 1.更新条件
  List<Long> ids = List.of(1L, 2L, 4L);
  int amount = 200;
// 2.定义条件
  QueryWrapper<User> wrapper = new QueryWrapper<User>().in(User::getId, ids);
// 3.调用自定义SQL方法
  userMapper.updateBalanceByIds(wrapper, amount);
  • 在mapper方法参数中用Param注解声明wrapper变量名称,必须是ew
void updateBalanceByIds(@Param(Constants.WRAPPER) QueryWrapper<User> wrapper, @Param("amount") int amount);
  • 自定义SQL,并使用Wrapper条件
<update id="updateBalanceByIds">
        update user
        set balance = balance - #{amount} ${ew.customSqlSegment}
    </update>

IService接口基本用法

  • 自定义Service接口继承IService接口
package com.itheima.mp.service.impl;

import com.baomidou.mybatisplus.extension.service.IService;
import com.itheima.mp.domain.po.User;

public interface IUserService extends IService<User> {

}
  • 自定义Service实现类,实现自定义接口并继承ServiceImpl类
package com.itheima.mp.service.impl.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.itheima.mp.domain.po.User;
import com.itheima.mp.mapper.UserMapper;
import com.itheima.mp.service.impl.IUserService;
import org.springframework.stereotype.Service;

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

}
  • 搞了个测试类@Test
package com.itheima.mp.service.impl;

import com.itheima.mp.domain.po.User;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;

@SpringBootTest
class IUserServiceTest {
    @Autowired
    private IUserService userService;

    @Test
    void testSaveUser() {
        User user = new User();
        user.setId(5L);
        user.setUsername("Lucy");
        user.setPassword("123");
        user.setPhone("18688990011");
        user.setBalance(200);
        user.setInfo("{\"age\": 24, \"intro\": \"英文老师\", \"gender\": \"female\"}");
        user.setCreateTime(LocalDateTime.now());
        user.setUpdateTime(LocalDateTime.now());
        userService.save(user);
    }
    @Test
    void testQuery(){
        List<User> users = userService.listByIds(Arrays.asList(1L, 2L, 3L));
        users.forEach(System.out::println);
    }

}

IService开发基础业务接口

编号 接口 请求方式 请求路径 请求参数 返回值
1 新增用户 POST /users 用户表单实体
2 删除用户 DELETE /users/{id} 用户id
3 根据id查询用户 GET /users/{id} 用户id 用户VO
4 根据id批量查询 GET /users 用户id集合 用户VO集合
5 根据id扣减余额 PUT /users/{id}/deduction/{money} •用户id •扣减金额

解决在IDEA 的Maven下 出现 Cannot access in offline mode 问题 - Doyourself! - 博客园

管理接口文档

UserController.java
package com.itheima.mp.controller;

import cn.hutool.core.bean.BeanUtil;
import com.itheima.mp.domain.po.User;

import com.itheima.mp.domain.dto.UserFormDTO;

import com.itheima.mp.domain.vo.UserVO;
import com.itheima.mp.service.IUserService;

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@Api(tags = "用户管理接口")
@RequiredArgsConstructor
@RestController
@RequestMapping("users")
public class UserController {

    private final IUserService userService;

    @PostMapping
    @ApiOperation("新增用户")
    public void saveUser(@RequestBody UserFormDTO userFormDTO) {
        // 1.转换DTO为PO
        User user = BeanUtil.copyProperties(userFormDTO, User.class);
        // 2.新增
        userService.save(user);
    }

    @DeleteMapping("/{id}")
    @ApiOperation("删除用户")
    public void removeUserById(@PathVariable("id") Long userId) {
        userService.removeById(userId);
    }

    @GetMapping("/{id}")
    @ApiOperation("根据id查询用户")
    public UserVO queryUserById(@PathVariable("id") Long userId) {
        // 1.查询用户
        User user = userService.getById(userId);
        // 2.处理vo
        return BeanUtil.copyProperties(user, UserVO.class);
    }

    @GetMapping
    @ApiOperation("根据id集合查询用户")
    public List<UserVO> queryUserByIds(@RequestParam("ids") List<Long> ids) {
        // 1.查询用户
        List<User> users = userService.listByIds(ids);
        // 2.处理vo
        return BeanUtil.copyToList(users, UserVO.class);
    }

    @PutMapping("{id}/deduction/{money}")
    @ApiOperation("扣减用户余额")
    public void deductBalance(@ApiParam("用户id") @PathVariable("id") Long id, @ApiParam("扣减的金额") @PathVariable("money") Integer money) {
        userService.deductBalance(id, money);
    }
}
UserFormDTO.java
package com.itheima.mp.domain.dto;

import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

@Data
@ApiModel(description = "用户表单实体")
public class UserFormDTO {

    @ApiModelProperty("id")
    private Long id;

    @ApiModelProperty("用户名")
    private String username;

    @ApiModelProperty("密码")
    private String password;

    @ApiModelProperty("注册手机号")
    private String phone;

    @ApiModelProperty("详细信息,JSON风格")
    private String info;

    @ApiModelProperty("账户余额")
    private Integer balance;
}
UserQuery.java
package com.itheima.mp.domain.query;

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

@Data
@ApiModel(description = "用户查询条件实体")
public class UserQuery {
    @ApiModelProperty("用户名关键字")
    private String name;
    @ApiModelProperty("用户状态:1-正常,2-冻结")
    private Integer status;
    @ApiModelProperty("余额最小值")
    private Integer minBalance;
    @ApiModelProperty("余额最大值")
    private Integer maxBalance;
}
UserServiceImpl.java
package com.itheima.mp.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.itheima.mp.domain.po.User;
import com.itheima.mp.mapper.UserMapper;
import com.itheima.mp.service.IUserService;
import org.springframework.stereotype.Service;

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
    @Override
    public void deductBalance(Long id, Integer money) {
        // 1.查询用户
        User user = getById(id);
        // 2.判断用户状态
        if (user == null || user.getStatus() == 2) {
            throw new RuntimeException("用户状态异常");
        }
        // 3.判断用户余额
        if (user.getBalance() < money) {
            throw new RuntimeException("用户余额不足");
        }
        // 4.扣减余额
        baseMapper.deductMoneyById(id, money);
    }
}
package com.itheima.mp.mapper;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.toolkit.Constants;
import com.itheima.mp.domain.po.User;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;

import java.util.List;

public interface UserMapper extends BaseMapper<User> {

    List<User> queryUserByIds(@Param("ids") List<Long> ids);

    void updateBalanceByIds(@Param(Constants.WRAPPER) QueryWrapper<User> wrapper, @Param("amount") int amount);

    @Update("UPDATE user SET balance = balance - #{money} WHERE id = #{id}")
    void deductMoneyById(Long id, Integer money);
}

Iservice的Lambda方法

需求:实现一个根据复杂条件查询用户的接口,查询条件如下:

name:用户名关键字,可以为空
status:用户状态,可以为空
minBalance:最小余额,可以为空
maxBalance:最大余额,可以为空

<select id="queryUsers" resultType="com.itheima.mp.domain.po.User">
    SELECT *
    FROM tb_user
    <where>
        <if test="name != null">
            AND username LIKE CONCAT('%', #{name}, '%')
        </if>
        <if test="status != null">
            AND `status` = #{status}
        </if>
        <if test="minBalance != null and maxBalance != null">
            AND balance BETWEEN #{minBalance} AND #{maxBalance}
        </if>
    </where>
</select>
UserQuery.java
package com.itheima.mp.domain.query;

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

@Data
@ApiModel(description = "用户查询条件实体")
public class UserQuery {
    @ApiModelProperty("用户名关键字")
    private String name;
    @ApiModelProperty("用户状态:1-正常,2-冻结")
    private Integer status;
    @ApiModelProperty("余额最小值")
    private Integer minBalance;
    @ApiModelProperty("余额最大值")
    private Integer maxBalance;
}
UserController.java
    @ApiOperation("根据复杂条件查询用户接口")
    @GetMapping("/list")
    public List<UserVO> queryUsers(UserQuery query) {
        // 1.查询用户PO
        List<User> users = userService.queryUsers(query.getName(), query.getStatus(), query.getMinBalance(), query.getMaxBalance());
        // 2.把po拷贝到vo
        return BeanUtil.copyToList(users, UserVO.class);
    }
IUserService.java
package com.itheima.mp.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.itheima.mp.domain.po.User;

import java.util.List;

public interface IUserService extends IService<User> {
    void deductBalance(Long id, Integer money);

    List<User> queryUsers(String name, Integer status, Integer minBalance, Integer maxBalance);
}
UserServiceImpl.java
package com.itheima.mp.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.itheima.mp.domain.po.User;
import com.itheima.mp.mapper.UserMapper;
import com.itheima.mp.service.IUserService;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
   @Override
    public List<User> queryUsers(String name, Integer status, Integer minBalance, Integer maxBalance) {
        return lambdaQuery()
                .like(name!=null, User::getUsername,name)
                .eq(status!=null, User::getStatus,status)
                .gt(minBalance!=null, User::getBalance,minBalance) // 大于
                .lt(maxBalance!=null, User::getBalance,maxBalance) // 小于
                .list();
    }
}

IService的Lambda更新LambdaUpdate()、LambdaQuery()

改造根据id修改用户余额的接口,要求如下
  • 完成对用户状态校验

  • 完成对用户余额校验

  • 如果扣减后余额为0,则将用户status修改为冻结状态 (2)

    UserController.java
 @PutMapping("{id}/deduction/{money}")
    @ApiOperation("扣减用户余额")
    public void deductBalance(@ApiParam("用户id") @PathVariable("id") Long id, @ApiParam("扣减的金额") @PathVariable("money") Integer money) {
        userService.deductBalance(id, money);
    }
UserServiceImpl.java
 @Override
    public void deductBalance(Long id, Integer money) {
        // 1.查询用户
        User user = getById(id);
        // 2.判断用户状态
        if (user == null || user.getStatus() == 2) {
            throw new RuntimeException("用户状态异常");
        }
        // 3.判断用户余额
        if (user.getBalance() < money) {
            throw new RuntimeException("用户余额不足");
        }
        // 4.扣减余额 update tb_user set balance = balance - ?
        int remainBalance = user.getBalance() - money;
        lambdaUpdate()
                .set(User::getBalance, remainBalance)
                .set(remainBalance == 0,User::getStatus, 2)
                .eq(User::getId, id)
                .eq(User::getBalance, user.getBalance()) // 乐观锁
                .update();
    }

IService的批量新增

批量插入10万条用户数据,并作出对比:
  • 普通for循环插入4分钟
  • IService的批量插入30秒
  • 开启rewriteBatchedStatements=true参数【6秒】重写Statement语句,在application.yaml的sql中url拼接
Test  com/itheima/mp/service/IUserServiceTest.java
@Test
    void testSaveOneByOne() {
        long b = System.currentTimeMillis();
        for (int i = 1; i <= 100000; i++) {
            userService.save(buildUser(i));
        }
        long e = System.currentTimeMillis();
        System.out.println("耗时:" + (e - b));
    }

    private User buildUser(int i) {
        User user = new User();
        user.setUsername("user_" + i);
        user.setPassword("123");
        user.setPhone("" + (18688190000L + i));
        user.setBalance(2000);
        user.setInfo("{\"age\": 24, \"intro\": \"英文老师\", \"gender\": \"female\"}");
        user.setCreateTime(LocalDateTime.now());
        user.setUpdateTime(user.getCreateTime());
        return user;
    }
MyBatisPlus的批处理
@Test
void testSaveBatch() {
    // 准备10万条数据
    List<User> list = new ArrayList<>(1000);
    long b = System.currentTimeMillis();
    for (int i = 1; i <= 100000; i++) {
        list.add(buildUser(i));
        // 每1000条批量插入一次
        if (i % 1000 == 0) {
            userService.saveBatch(list);
            list.clear();
        }
    }
    long e = System.currentTimeMillis();
    System.out.println("耗时:" + (e - b));
}

可以看到使用了批处理以后,比逐条新增效率提高了10倍左右,性能还是不错的。

可以发现其实MybatisPlus的批处理是基于PrepareStatement的预编译模式,然后批量提交,最终在数据库执行时还是会有多条insert语句,逐条插入数据。SQL类似这样:

Preparing: INSERT INTO user ( username, password, phone, info, balance, create_time, update_time ) VALUES ( ?, ?, ?, ?, ?, ?, ? )
Parameters: user_1, 123, 18688190001, "", 2000, 2023-07-01, 2023-07-01
Parameters: user_2, 123, 18688190002, "", 2000, 2023-07-01, 2023-07-01
Parameters: user_3, 123, 18688190003, "", 2000, 2023-07-01, 2023-07-01

而如果想要得到最佳性能,最好是将多条SQL合并为一条,像这样:

INSERT INTO user ( username, password, phone, info, balance, create_time, update_time )
VALUES 
(user_1, 123, 18688190001, "", 2000, 2023-07-01, 2023-07-01),
(user_2, 123, 18688190002, "", 2000, 2023-07-01, 2023-07-01),
(user_3, 123, 18688190003, "", 2000, 2023-07-01, 2023-07-01),
(user_4, 123, 18688190004, "", 2000, 2023-07-01, 2023-07-01);

该怎么做呢?

MySQL的客户端连接参数中有这样的一个参数:rewriteBatchedStatements。顾名思义,就是重写批处理的statement语句。参考文档:

https://dev.mysql.com/doc/connector-j/8.0/en/connector-j-connp-props-performance-extensions.html#cj-conn-prop_rewriteBatchedStatements

这个参数的默认值是false,我们需要修改连接参数,将其配置为true

修改项目中的application.yml文件,在jdbc的url后面添加参数&rewriteBatchedStatements=true:

spring:
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/mp?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: MySQL123

扩展功能 —— 代码生成器生成代码的代码

3.1 代码生成

在使用MybatisPlus以后,基础的MapperServicePO代码相对固定,重复编写也比较麻烦。因此MybatisPlus官方提供了代码生成器根据数据库表结构生成POMapperService等相关代码。只不过代码生成器同样要编码使用,也很麻烦。

这里推荐大家使用一款MybatisPlus的插件,它可以基于图形化界面完成MybatisPlus的代码生成,非常简单。

3.1.1.安装插件

Idea的plugins市场中搜索并安装MyBatisPlus插件:

然后重启你的Idea即可使用。

3.1.2.使用

刚好数据库中还有一张address表尚未生成对应的实体和mapper等基础代码。我们利用插件生成一下。 首先需要配置数据库地址,在Idea顶部菜单中,找到other,选择Config Database

点击OK保存。

然后再次点击Idea顶部菜单中的other,然后选择Code Generator:

在弹出的表单中填写信息:

img

最终,代码自动生成到指定的位置了:

扩展功能 —— DB静态工具两个Service相互注入

  • 改造根据id查询用户的接口,查询用户的同时user表,查询出用户对应的所有地址address表
  • 改造根据id批量查询用户的接口,查询用户的同时,查询出用户对应的所有地址
  • 实现根据用户id查询收货地址功能,需要验证用户状态,冻结用户抛出异常(练习)

3.2.静态工具

有的时候Service之间也会相互调用,为了避免出现循环依赖问题,MybatisPlus提供一个静态工具类:Db,其中的一些静态方法与IService中方法签名基本一致,也可以帮助我们实现CRUD功能:

UserController.java
    @GetMapping("/{id}")
    @ApiOperation("根据id查询用户")
    public UserVO queryUserById(@PathVariable("id") Long id) {
        // 1.查询用户
//        User user = userService.getById(userId);
        // 2.处理vo
        return userService.queryUserAndAddressById(id);
    }
IUserService.java
public interface IUserService extends IService<User> {
    UserVO queryUserAndAddressById(Long id);
}
UserServiceImpl.java
@Override
    public UserVO queryUserAndAddressById(Long id) {
        // 1.查询用户
        User user = getById(id);
        if (user == null || user.getStatus() == 2) {
            throw new RuntimeException("用户状态异常");
        }
        // 2.查询方法
        List<Address> addresses = Db.lambdaQuery(Address.class)
                .eq(Address::getUserId, id).list();
        // 3.封装VO
        // 3.1 转User的PO为VO
        UserVO userVO = BeanUtil.copyProperties(user, UserVO.class);
        if (CollUtil.isEmpty(addresses)) {
           userVO.setAddresses(BeanUtil.copyToList(addresses, AddressVO.class));
        }
        return userVO;
    }

扩展功能—DB静态工具(练习)

UserController.java
@GetMapping
    @ApiOperation("根据id集合查询用户")
    public List<UserVO> queryUserByIds(@RequestParam("ids") List<Long> ids) {
        // 1.查询用户
//        List<User> users = userService.listByIds(ids);
        // 2.处理vo
        return userService.queryUserAndAddressByIds(ids);
    }
IUserService.java
public interface IUserService extends IService<User> {
    List<UserVO> queryUserAndAddressByIds(List<Long> ids);
}
UserServiceImpl.java
@Override
    public List<UserVO> queryUserAndAddressByIds(List<Long> ids) {
        // 1.查询用户
        List<User> users = listByIds(ids);
        if (CollUtil.isEmpty(users)) {
            return Collections.emptyList();
        }
        // 2.查询地址
        // 2.1 获取用户id集合
        List<Long> userIds = users.stream().map(User::getId).collect(Collectors.toList());
        // 2.2 根据用户id查询地址 这是全部地址
        List<Address> addresses = Db.lambdaQuery(Address.class).in(Address::getUserId, userIds).list();
        // 2.3 转换地址VO
        List<AddressVO> addressVOList = BeanUtil.copyToList(addresses, AddressVO.class);
        // 2.4 梳理地址集合分组处理,分类整理,相同用户放入一个集合(组)中
        Map<Long, List<AddressVO>> addressMap = new HashMap<>(0);
        if (CollUtil.isNotEmpty(addressVOList)){
            addressMap = addressVOList.stream().collect(Collectors.groupingBy(AddressVO::getUserId));
        }
        // 3.转换VO返回
        List<UserVO> list = new ArrayList<>(users.size());
        for (User user : users) {
            // 3.1 转换User的Po为VO
            UserVO userVO = BeanUtil.copyProperties(user, UserVO.class);
            list.add(userVO);

            // 3.2 转换地址VO
            userVO.setAddresses(addressMap.get(user.getId()));
        }
        return null;
    }

扩展功能—逻辑删除要在数据库里面创建一个deleted表

订单不进行真实删除,一旦采用逻辑删除其他都不能用,需要添加配置信息

逻辑删除就是基于代码逻辑模拟删除效果,但并不会真正删除数据。思路如下:

  • 在表中添加一个字段标记数据是否被删除
  • 当删除数据时把标记置为1
  • 查询时只查询标记为0的数据

例如逻辑删除字段为deleted:

• 删除操作:

# 是0才删除 是1就不用删除 所以用AND
UPDATE user SET deleted = 1 WHERE id = 1 AND deleted = 0

• 查询操作:

# 查询未删除的数据
SELECT * FROM user WHERE deleted = 0
逻辑删除

MybatisPlus提供了逻辑删除功能,无需改变方法调用的方式,而是在底层帮我们自动修改CRUD的语句。我们要做的就是在application.yaml文件中配置逻辑删除的字段名称和值即可:

mybatis-plus: 
  global-config:    
   db-config:
    logic-delete-field: flag # 全局逻辑删除的实体字段名,字段类型可以是boolean、integer
    logic-delete-value: 1 # 逻辑已删除值(默认为 1)
    logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
逻辑删除本身也有自己的问题,比如:

• 会导致数据库表垃圾数据越来越多,影响查询效率
• SQL中全都需要对逻辑删除字段做判断,影响查询效率

因此,我不太推荐采用逻辑删除功能,如果数据不能删除,可以采用把数据迁移到其它表的办法。

扩展功能—枚举处理器

像这种字段我们一般会定义一个枚举,做业务判断的时候就可以直接基于枚举做比较。但是我们数据库采用的是int类型,对应的PO也是Integer。因此业务操作时必须手动把枚举Integer转换,非常麻烦。

因此,MybatisPlus提供了一个处理枚举的类型转换器,可以帮我们把枚举类型与数据库类型自动转换

3.3.1.定义枚举

我们定义一个用户状态的枚举:

User.java //使用枚举类型
// 使用状态(1正常 2冻结)
private UserStatus status;

要让MybatisPlus处理枚举与数据库类型自动转换,我们必须告诉MybatisPlus,枚举中的哪个字段的值作为数据库值。 MybatisPlus提供了@EnumValue注解来标记枚举属性:

package com.itheima.mp.enums;

import com.baomidou.mybatisplus.annotation.EnumValue;
import lombok.Getter;

@Getter
public enum UserStatus {
    NORMAL(1, "正常"),
    FREEZE(2, "冻结")
    ;
    @EnumValue
    private final int value;
    private final String desc;

    UserStatus(int value, String desc) {
        this.value = value;
        this.desc = desc;
    }
}

3.3.2.配置枚举处理器MP增加了Enum和JSON处理器

在application.yaml文件中添加配置:

mybatis-plus:
  configuration:
    default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler
UserVO.java
package com.itheima.mp.domain.vo;

import com.itheima.mp.enums.UserStatus;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

import java.util.List;

@Data
@ApiModel(description = "用户VO实体")
public class UserVO {

    @ApiModelProperty("用户id")
    private Long id;

    @ApiModelProperty("用户名")
    private String username;

    @ApiModelProperty("详细信息")
    private String info;

    @ApiModelProperty("使用状态(1正常 2冻结)")
    private UserStatus status;

    @ApiModelProperty("账户余额")
    private Integer balance;

    @ApiModelProperty("用户的收货地址")
    private List<AddressVO> addresses;
}
UserServiceImpl.java
 @Override
    public UserVO queryUserAndAddressById(Long id) {
        // 1.查询用户
        User user = getById(id);
        if (user == null || user.getStatus() == UserStatus.FREEZE) {
            throw new RuntimeException("用户状态异常");
        }
        // 2.查询方法
        List<Address> addresses = Db.lambdaQuery(Address.class)
                .eq(Address::getUserId, id).list();
        // 3.封装VO
        // 3.1 转User的PO为VO
        UserVO userVO = BeanUtil.copyProperties(user, UserVO.class);
        if (CollUtil.isEmpty(addresses)) {
           userVO.setAddresses(BeanUtil.copyToList(addresses, AddressVO.class));
        }
        return userVO;
    }
想要前端返回正常还是冻结 @JsonValue
package com.itheima.mp.enums;

import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.Getter;

@Getter
public enum UserStatus {
    NORMAL(1, "正常"),
    FREEZE(2, "冻结")
    ;
    @EnumValue
    private final int value;
    @JsonValue
    private final String desc;

    UserStatus(int value, String desc) {
        this.value = value;
        this.desc = desc;
    }
}

扩展功能—JSON处理器AbstractJsonTypeHandler

数据库中user表中有一个json类型的字段

名称 数据类型 注释
info JSON 详细信息
。。。。。。。。。。。。

JSON:
{ “age”:20,
“intro”: “”青年”,
“gender”:”male”}

这样一来,我们要读取info中的属性时就非常不方便。如果要方便获取,info的类型最好是一个Map或者实体类。

而一旦我们把info改为对象类型,就需要在写入数据库时手动转为String,再读取数据库时,手动转换为对象,这会非常麻烦。

因此MybatisPlus提供了很多特殊类型字段的类型处理器,解决特殊字段类型与数据库类型转换的问题。例如处理JSON就可以使用JacksonTypeHandler处理器。

接下来,将User类的info字段修改为UserInfo类型,并声明类型处理器:

@TableField(typeHandler = JacksonTypeHandler.class) 定义类型处理器
@TableName(value = "user", autoResultMap = true)

User.java
@Data
@TableName(value = "user", autoResultMap = true)
public class User {

    /**
     * 用户id
     */
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    /**
     * 用户名
     */
    private String username;

    /**
     * 详细信息
     */
    @TableField(typeHandler = JacksonTypeHandler.class)
    private UserInfo info;
}
UserVO.java
package com.itheima.mp.domain.vo;

import com.itheima.mp.domain.po.UserInfo;
import com.itheima.mp.enums.UserStatus;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

import java.util.List;

@Data
@ApiModel(description = "用户VO实体")
public class UserVO {

    @ApiModelProperty("用户id")
    private Long id;

    @ApiModelProperty("用户名")
    private String username;

    @ApiModelProperty("详细信息")
    private UserInfo info;

    @ApiModelProperty("使用状态(1正常 2冻结)")
    private UserStatus status;

    @ApiModelProperty("账户余额")
    private Integer balance;

    @ApiModelProperty("用户的收货地址")
    private List<AddressVO> addresses;
}
package com.itheima.mp.domain.po;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor(staticName = "of")
public class UserInfo {
    private Integer age;
    private String intro;
    private String gender;
    // 添加静态方法 of
    public static UserInfo of(Integer age, String intro, String gender) {
        return new UserInfo(age, intro, gender);
    }
}
UserMapperTest.java
@SpringBootTest
class UserMapperTest {

    @Autowired
    private UserMapper userMapper;

    @Test
    void testInsert() {
        User user = new User();
        user.setId(5L);
        user.setUsername("Lucy");
        user.setPassword("123");
        user.setPhone("18688990011");
        user.setBalance(200);
        user.setInfo(UserInfo.of(14, "英文老师", "female"));
        user.setCreateTime(LocalDateTime.now());
        user.setUpdateTime(LocalDateTime.now());
        userMapper.insert(user);
    }

插件功能—分页插件基本用法

MyBatisPlus提供的内置拦截器有下面这些:

序号 拦截器 描述
1 TenantLineInnerInterceptor 多租户插件
2 DynamicTableNameInnerInterceptor 动态表名插件
3 PaginationInnerInterceptor 分页插件
4 OptimisticLockerInnerInterceptor 乐观锁插件
5 IllegalSQLInnerInterceptor SQL性能规范插件,检测并拦截垃圾SQL
6 BlockAttackInnerInterceptor 防止全表更新和删除的插件
首先,要在配置类中注册MyBatisPlus的核心插件,同时添加分页插件:【总拦截器】
@Configuration
public class MybatisConfig {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        // 1. 初始化核心插件
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 2. 添加分页插件
        PaginationInnerInterceptor pageInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
        pageInterceptor.setMaxLimit(1000L); // 设置分页上限
        interceptor.addInnerInterceptor(pageInterceptor);
        return interceptor;
    }
}
接着,就可以使用分页的API了:[IService里面就有]
@Test
    void testPageQuery() {
        // 1. 查询
        int pageNo = 1, pageSize = 5;
        // 1.1. 分页参数
        Page<User> page = Page.of(pageNo, pageSize);
        // 1.2. 排序参数, 通过OrderItem来指定
        page.addOrder(new OrderItem("balance", true));
        // 先按balance排序 再按id排序
        page.addOrder(new OrderItem("id", true));
        // 1.3. 分页查询
        Page<User> p = userService.page(page);
        // 2. 总条数
        System.out.println("total = " + p.getTotal());
        // 3. 总页数
        System.out.println("pages = " + p.getPages());
        // 4. 分页数据
        List<User> records = p.getRecords();
        records.forEach(System.out::println);
    }

插件功能—通用分页实体

遵循下面的接口规范,编写一个UserController接口,实现User的分页查询
参数 说明
请求方式 GET
请求路径 /users/page
请求参数 “pageNo”: 1
“pageSize”: 5
“sortBy”: “balance”
“isAsc”: false
“name”: “jack”
“status”: 1
返回值 “total”: 1005
“pages”: 201
“list”: 包含两个元素的数组,每个元素都是一个对象,包含以下键值对:
“id”: 1 或 2
“username”: “Jack” 或 “Rose”
“info”: 包含以下键值对的对象:
“age”: 21 或 20
“gender”: “male” 或 “female”
“intro”: “佛系青年” 或 “文艺青年”
“status”: “正常” 或 “冻结”
“balance”: 2000 或 1000
特殊说明 如果排序字段为空,默认按照更新时间排序 •排序字段不为空,则按照排序字段排序
准备一下请求参数和实体【封装成xxxQuery,若只返回前端则VO,给其他使用则DTO】

写一个 【统一的分页条件】 和 【统一的分页结果】

com/itheima/mp/domain/query/UserQuery.java
// 要记得继承哦
package com.itheima.mp.domain.query;

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

@Data
@ApiModel(description = "用户查询条件实体")
public class UserQuery extends PageQuery{
    @ApiModelProperty("用户名关键字")
    private String name;
    @ApiModelProperty("用户状态:1-正常,2-冻结")
    private Integer status;
    @ApiModelProperty("余额最小值")
    private Integer minBalance;
    @ApiModelProperty("余额最大值")
    private Integer maxBalance;
}
com/itheima/mp/domain/query/PageQuery.java
package com.itheima.mp.domain.query;

import com.baomidou.mybatisplus.core.metadata.OrderItem;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

@Data
@ApiModel(description = "分页查询实体")
public class PageQuery {
    @ApiModelProperty("页码")
    private Integer pageNo;
    @ApiModelProperty("页码个数")
    private Integer pageSize;
    @ApiModelProperty("排序字段")
    private String sortBy;
    @ApiModelProperty("是否升序")
    private Boolean isAsc;

    public <T>  Page<T> toMpPage(OrderItem ... orders){
        // 1.分页条件
        Page<T> p = Page.of(pageNo, pageSize);
        // 2.排序条件
        // 2.1.先看前端有没有传排序字段
        if (sortBy != null) {
            p.addOrder(new OrderItem(sortBy, isAsc));
            return p;
        }
        // 2.2.再看有没有手动指定排序字段
        if(orders != null){
            p.addOrder(orders);
        }
        return p;
    }

    public <T> Page<T> toMpPage(String defaultSortBy, boolean isAsc){
        return this.toMpPage(new OrderItem(defaultSortBy, isAsc));
    }

    public <T> Page<T> toMpPageDefaultSortByCreateTimeDesc() {
        return toMpPage("create_time", false);
    }

    public <T> Page<T> toMpPageDefaultSortByUpdateTimeDesc() {
        return toMpPage("update_time", false);
    }
}
com/itheima/mp/domain/dto/PageDTO.java
package com.itheima.mp.domain.dto;

import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.Collections;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;

@Data
@ApiModel(description = "分页结果")
@NoArgsConstructor
@AllArgsConstructor
public class PageDTO<V> {
    @ApiModelProperty("总条数")
    private Long total;
    @ApiModelProperty("总页数")
    private Long pages;
    @ApiModelProperty("集合")
    private List<V> list;

    /**
     * 返回空分页结果
     * @param p MybatisPlus的分页结果
     * @param <V> 目标VO类型
     * @param <P> 原始PO类型
     * @return VO的分页对象
     */
    public static <V, P> PageDTO<V> empty(Page<P> p){
        return new PageDTO<>(p.getTotal(), p.getPages(), Collections.emptyList());
    }

    /**
     * 将MybatisPlus分页结果转为 VO分页结果
     * @param p MybatisPlus的分页结果
     * @param voClass 目标VO类型的字节码
     * @param <V> 目标VO类型
     * @param <P> 原始PO类型
     * @return VO的分页对象
     */
    public static <V, P> PageDTO<V> of(Page<P> p, Class<V> voClass) {
        // 1.非空校验
        List<P> records = p.getRecords();
        if (records == null || records.size() <= 0) {
            // 无数据,返回空结果
            return empty(p);
        }
        // 2.数据转换
        List<V> vos = BeanUtil.copyToList(records, voClass);
        // 3.封装返回
        return new PageDTO<>(p.getTotal(), p.getPages(), vos);
    }

    /**
     * 将MybatisPlus分页结果转为 VO分页结果,允许用户自定义PO到VO的转换方式
     * @param p MybatisPlus的分页结果
     * @param convertor PO到VO的转换函数
     * @param <V> 目标VO类型
     * @param <P> 原始PO类型
     * @return VO的分页对象
     */
    public static <V, P> PageDTO<V> of(Page<P> p, Function<P, V> convertor) {
        // 1.非空校验
        List<P> records = p.getRecords();
        if (records == null || records.size() <= 0) {
            // 无数据,返回空结果
            return empty(p);
        }
        // 2.数据转换
        List<V> vos = records.stream().map(convertor).collect(Collectors.toList());
        // 3.封装返回
        return new PageDTO<>(p.getTotal(), p.getPages(), vos);
    }
}

com/itheima/mp/controller/UserController.java
@ApiOperation("根据复杂条件查询用户接口")
    @GetMapping("/list")
    public List<UserVO> queryUsers(UserQuery query) {
        return (List<UserVO>) userService.queryUsersPage(query);
    }
// 如果你想在字符串中表示一个大于号,
你可以直接输入 >,或者使用HTML实体 &gt; 
小于号可以使用 < 或者 &lt;,
等于号可以使用 = 或者 &equals

插件功能—通用分页实体与MP转换

需求:

  • 在PageQuery中定义方法,将PageQuery对象转为MyBatisPlus中的Page对象
  • 在PageDTO中定义方法,将MyBatisPlus中的Page结果转为PageDTO结果
最好直接封装通用部分
封装查询
 @Override
    public PageDTO<UserVO> queryUsersPage(UserQuery query) {
        String name = query.getName();
        Integer status = query.getStatus();
        // 1.构建查询条件
        // 1.1 分页条件
        Page<User> page = Page.of(query.getPageNo(), query.getPageSize());
        // 1.2 排序条件
        if (StrUtil.isNotBlank(query.getSortBy())) {
            // 不为空
            page.addOrder(new OrderItem(query.getSortBy(), query.getIsAsc()));
        }else {
            // 为空,默认按照更新时间排序
            page.addOrder(new OrderItem("update_time", false));
        }
com/itheima/mp/domain/query/PageQuery.java
package com.itheima.mp.domain.query;

import com.baomidou.mybatisplus.core.metadata.OrderItem;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

@Data
@ApiModel(description = "分页查询实体")
public class PageQuery {
    @ApiModelProperty("页码")
    private Integer pageNo;
    @ApiModelProperty("页码个数")
    private Integer pageSize;
    @ApiModelProperty("排序字段")
    private String sortBy;
    @ApiModelProperty("是否升序")
    private Boolean isAsc;

    public <T>  Page<T> toMpPage(OrderItem ... orders){
        // 1.分页条件
        Page<T> p = Page.of(pageNo, pageSize);
        // 2.排序条件
        // 2.1.先看前端有没有传排序字段
        if (sortBy != null) {
            p.addOrder(new OrderItem(sortBy, isAsc));
            return p;
        }
        // 2.2.再看有没有手动指定排序字段
        if(orders != null){
            p.addOrder(orders);
        }
        return p;
    }

    public <T> Page<T> toMpPage(String defaultSortBy, boolean isAsc){
        return this.toMpPage(new OrderItem(defaultSortBy, isAsc));
    }

    public <T> Page<T> toMpPageDefaultSortByCreateTimeDesc() {
        return toMpPage("create_time", false);
    }

    public <T> Page<T> toMpPageDefaultSortByUpdateTimeDesc() {
        return toMpPage("update_time", false);
    }
}
封装结果
// 3.封装VO结果
        PageDTO<UserVO> dto = new PageDTO<>();
        // 3.1 总条数
        dto.setTotal(p.getTotal());
        // 3.2 总页数
        dto.setPages(p.getPages());
        // 3.3 当前页数据
        List<User> records = p.getRecords();
        if (CollUtil.isEmpty(records)) {
            dto.setList(Collections.emptyList());
            return dto;
        }
        // 3.4 拷贝user的VO
        dto.setList(BeanUtil.copyToList(records, UserVO.class));
        // 4.返回
        return dto;
    }
com/itheima/mp/domain/dto/PageDTO.java
package com.itheima.mp.domain.dto;

import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.Collections;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;

@Data
@ApiModel(description = "分页结果")
@NoArgsConstructor
@AllArgsConstructor
public class PageDTO<V> {
    @ApiModelProperty("总条数")
    private Long total;
    @ApiModelProperty("总页数")
    private Long pages;
    @ApiModelProperty("集合")
    private List<V> list;

    /**
     * 返回空分页结果
     * @param p MybatisPlus的分页结果
     * @param <V> 目标VO类型
     * @param <P> 原始PO类型
     * @return VO的分页对象
     */
    public static <V, P> PageDTO<V> empty(Page<P> p){
        return new PageDTO<>(p.getTotal(), p.getPages(), Collections.emptyList());
    }

    /**
     * 将MybatisPlus分页结果转为 VO分页结果
     * @param p MybatisPlus的分页结果
     * @param voClass 目标VO类型的字节码
     * @param <V> 目标VO类型
     * @param <P> 原始PO类型
     * @return VO的分页对象
     */
    public static <V, P> PageDTO<V> of(Page<P> p, Class<V> voClass) {
        // 1.非空校验
        List<P> records = p.getRecords();
        if (records == null || records.size() <= 0) {
            // 无数据,返回空结果
            return empty(p);
        }
        // 2.数据转换
        List<V> vos = BeanUtil.copyToList(records, voClass);
        // 3.封装返回
        return new PageDTO<>(p.getTotal(), p.getPages(), vos);
    }

    /**
     * 将MybatisPlus分页结果转为 VO分页结果,允许用户自定义PO到VO的转换方式
     * @param p MybatisPlus的分页结果
     * @param convertor PO到VO的转换函数
     * @param <V> 目标VO类型
     * @param <P> 原始PO类型
     * @return VO的分页对象
     */
    public static <V, P> PageDTO<V> of(Page<P> p, Function<P, V> convertor) {
        // 1.非空校验
        List<P> records = p.getRecords();
        if (records == null || records.size() <= 0) {
            // 无数据,返回空结果
            return empty(p);
        }
        // 2.数据转换
        List<V> vos = records.stream().map(convertor).collect(Collectors.toList());
        // 3.封装返回
        return new PageDTO<>(p.getTotal(), p.getPages(), vos);
    }
}
UserServiceImpl.java
@Override
    public PageDTO<UserVO> queryUsersPage(UserQuery query) {
        String name = query.getName();
        Integer status = query.getStatus();
        // 1.构建查询条件
        // 1.1 分页条件
        Page<User> page = query.toMpPageDefaultSortByUpdateTimeDesc();
        // 2. 分页查询
        Page<User> p = lambdaQuery()
                .like(name != null, User::getUsername, name)
                .eq(status != null, User::getStatus, status)
                .page(page);
        // 3. 封装VO结果
//        return PageDTO.of(p, UserVO.class); 属性转换 ↓
        return PageDTO.of(p, user -> {
            // 1.拷贝基础属性
            UserVO vo = BeanUtil.copyProperties(user, UserVO.class);
            // 2.处理特殊逻辑 密码加**
            vo.setUsername(vo.getUsername().substring(0, vo.getUsername().length()-2)+"**");
            return vo;
        });
    }



tilas-all 成功案例

package com.itheima.domain.dto;

import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class GenderStatisticsDTO {
    private String name;
    private Integer value;

}
==================================================
package com.itheima.domain.dto;

import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("emp")
public class JobStatisticsDTO {
    private List<String> jobList;
    private List<Long> dataList;
}
package com.itheima.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.itheima.domain.dto.CombinedStatisticsDTO;
import com.itheima.domain.dto.GenderStatisticsDTO;
import com.itheima.domain.dto.JobStatisticsDTO;
import com.itheima.pojo.Emp;

import java.util.List;

public interface ReportService extends IService<Emp> {
    List<GenderStatisticsDTO> getGenderStatistics(); // 获取性别统计信息
    JobStatisticsDTO getJobStatistics(); // 获取职位统计信息
}


//    CombinedStatisticsDTO getCombinedStatistics();
com/itheima/controller/ReportController.java
package com.itheima.controller;

import com.itheima.domain.dto.CombinedStatisticsDTO;
import com.itheima.domain.dto.GenderStatisticsDTO;
import com.itheima.domain.dto.JobStatisticsDTO;
import com.itheima.pojo.Result;
import com.itheima.service.ReportService;
import io.swagger.annotations.Api;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@Api(tags = "报表统计接口")
@RestController
@RequestMapping("/report")
@RequiredArgsConstructor
public class ReportController {

    private final ReportService reportService;

    // 获取合并后的统计信息
//    @GetMapping("/statistics")
//    public Result<CombinedStatisticsDTO> getStatistics() {
//        CombinedStatisticsDTO combinedStatistics = reportService.getCombinedStatistics();
//        return Result.success(combinedStatistics);
//    }

    // 获取员工性别统计信息
    @GetMapping("/empGenderData")
    public Result<List<GenderStatisticsDTO>> getEmployeeGenderStatistics() {
        List<GenderStatisticsDTO> genderStatistics = reportService.getGenderStatistics();
        return Result.success(genderStatistics);
    }

    // 获取员工职位统计信息
    @GetMapping("/empJobData")
    public Result<JobStatisticsDTO> getEmployeeJobStatistics() {
        JobStatisticsDTO jobStatistics = reportService.getJobStatistics();
        return Result.success(jobStatistics);
    }
}
com/itheima/service/impl/ReportServiceImpl.java
package com.itheima.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.itheima.domain.dto.GenderStatisticsDTO;
import com.itheima.domain.dto.JobStatisticsDTO;
import com.itheima.mapper.ReportMapper;
import com.itheima.pojo.Emp;
import com.itheima.service.ReportService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Service
public class ReportServiceImpl extends ServiceImpl<ReportMapper, Emp> implements ReportService {

    private final ReportMapper reportMapper;

    @Autowired
    public ReportServiceImpl(ReportMapper reportMapper) {
        this.reportMapper = reportMapper;
    }

    // 获取员工职位统计信息
    @Override
    public JobStatisticsDTO getJobStatistics() {
        QueryWrapper<Emp> wrapper = new QueryWrapper<>();

        // 修改查询字段,注意这里你仍然需要写正确的字段名
        wrapper.select("CASE job WHEN 1 THEN '班主任' WHEN 2 THEN '讲师' WHEN 3 THEN '学工主管' WHEN 4 THEN '校研主管' WHEN 5 THEN '咨询师' ELSE '其他' END AS 职位",
                        "COUNT(*) AS 数量")
                .groupBy("job");

        // 通过 selectMaps 执行查询
        List<Map<String, Object>> statistics = reportMapper.selectMaps(wrapper);

        // 创建两个列表来存储职位和数量
        List<String> jobTitles = new ArrayList<>();
        List<Long> counts = new ArrayList<>();

        // 遍历查询结果并填充列表
        for (Map<String, Object> stat : statistics) {
            jobTitles.add((String) stat.get("职位"));
            counts.add((Long) stat.get("数量"));
        }

        // 返回JobStatisticsDTO对象,传入两个列表
        return new JobStatisticsDTO(jobTitles, counts);
    }

    // 获取员工性别统计信息
    @Override
    public List<GenderStatisticsDTO> getGenderStatistics() {
        QueryWrapper<Emp> wrapper = new QueryWrapper<>();
        wrapper.select("gender", "COUNT(gender) AS value")
                .groupBy("gender");

        List<Map<String, Object>> statistics = reportMapper.selectMaps(wrapper);

        // 转换为 GenderStatisticsDTO
        return statistics.stream()
                .map(stat -> {
                    String genderName = "1".equals(String.valueOf(stat.get("gender"))) ? "男性员工" : "女性员工";
                    int count = ((Number) stat.get("value")).intValue();
                    return new GenderStatisticsDTO(genderName, count);
                })
                .collect(Collectors.toList());
    }
}
上面的获取员工职位属性已修改为高级版本
// 获取员工职位统计信息
    @Override
    public JobStatisticsDTO getJobStatistics() {
        // 1. 构建查询条件
        QueryWrapper<Emp> wrapper = new QueryWrapper<>();
        wrapper.select("job", "COUNT(*) AS count")
                .groupBy("job");

        // 2. 查询数据
        List<Map<String, Object>> statistics = reportMapper.selectMaps(wrapper);

        // 如果返回结果为 null 或为空列表,返回默认对象
        if (statistics == null || statistics.isEmpty()) {
            return new JobStatisticsDTO(new ArrayList<>(), new ArrayList<>());
        }

        // 3. 转换结果:处理 `null` 值和字段映射
        List<String> jobTitles = new ArrayList<>();
        List<Long> counts = new ArrayList<>();

        for (Map<String, Object> stat : statistics) {
            if (stat == null) {
                continue; // 跳过 null 数据
            }

            // 使用 `getOrDefault` 方法,确保不会返回 null
            Integer jobCode = (Integer) stat.getOrDefault("job", -1);
            Long count = stat.get("count") == null ? 0L : ((Number) stat.get("count")).longValue();

            // 如果 jobCode 是 -1 或其他无效值,则视为“其他”
            String jobTitle = switch (jobCode) {
                case 1 -> "班主任";
                case 2 -> "讲师";
                case 3 -> "学工主管";
                case 4 -> "校研主管";
                case 5 -> "咨询师";
                default -> "其他";
            };

            jobTitles.add(jobTitle);
            counts.add(count);
        }

        // 返回封装好的 DTO 对象
        return new JobStatisticsDTO(jobTitles, counts);
    }
阅读全文

Stream模板+Lambda常用+@注释+常用方法模板集合

2024/11/22

Stream模板

中间方法

中间方法的特点惰性求值:中间操作不会立即执行,而是返回一个新的流。实际的计算会在遇到终结方法时进行。可以链式调用:多个中间操作可以链接在一起,形成一个操作链。返回类型:所有的中间操作返回的都是一个 Stream 对象。

Stream中间代码
功能:过滤流中的元素,仅保留满足给定条件的元素。
// 示例:
Stream.of(1, 2, 3, 4, 5)
      .filter(n -> n % 2 == 0) // 只保留偶数
      .forEach(System.out::println);//打印功能:将流中的元素映射为其他形式(通常是不同类型)。
// 示例:
Stream.of("a", "b", "c")
     .map(String::toUpperCase) // 将每个字符串转换为大写功能:将流中的每个元素映射为一个流,并将所有流连接成一个流。示例:Stream<List<String>> listStream = Stream.of(Arrays.asList("a", "b"), Arrays.asList("c", "d"));
listStream
    .flatMap(List::stream) // 将嵌套列表展平为一个流
    .forEach(System.out::println);功能:去除流中的重复元素。示例:Stream.of(1, 2, 2, 3, 4, 4)
     .distinct()
     .forEach(System.out::println); // 输出 1, 2, 3, 4功能:对流中的元素进行排序。示例:Stream.of(5, 3, 1, 4, 2)
     .sorted() // 默认升序排序
     .forEach(System.out::println);功能:截取流中的前 maxSize 个元素。示例:Stream.of(1, 2, 3, 4, 5)
     .limit(3) // 只保留前 3 个元素
     .forEach(System.out::println);功能:跳过流中的前 n 个元素。示例:Stream.of(1, 2, 3, 4, 5)
     .skip(2) // 跳过前 2 个元素
     .forEach(System.out::println); // 输出 3, 4, 5下面是一个示例,展示了多种中间方法的使用:import java.util.Arrays;
import java.util.List;
中间方法:
  1. 中间方法的特点
    惰性求值:中间操作不会立即执行,而是返回一个新的流。实际的计算会在遇到终结方法时进行。
    可以链式调用:多个中间操作可以链接在一起,形成一个操作链。
    返回类型:所有的中间操作返回的都是一个 Stream 对象。
stream中间操作

功能:过滤流中的元素,仅保留满足给定条件的元素。

示例:

Stream.of(1, 2, 3, 4, 5)
.filter(n -> n % 2 == 0) // 只保留偶数
.forEach(System.out::println);//打印

功能:将流中的元素映射为其他形式(通常是不同类型)。

示例:

Stream.of("a", "b", "c")
     .map(String::toUpperCase) // 将每个字符串转换为大写

功能:将流中的每个元素映射为一个流,并将所有流连接成一个流。

示例:

Stream<List<String>> listStream = Stream.of(Arrays.asList("a", "b"), Arrays.asList("c", "d"));
listStream
    .flatMap(List::stream) // 将嵌套列表展平为一个流
    .forEach(System.out::println);

功能:去除流中的重复元素。
示例:

Stream.of(1, 2, 2, 3, 4, 4)
     .distinct()
     .forEach(System.out::println); // 输出 1, 2, 3, 4

功能:对流中的元素进行排序。
示例:

Stream.of(5, 3, 1, 4, 2)
     .sorted() // 默认升序排序
     .forEach(System.out::println);

功能:截取流中的前 maxSize 个元素。
示例:

Stream.of(1, 2, 3, 4, 5)
     .limit(3) // 只保留前 3 个元素
     .forEach(System.out::println);

功能:跳过流中的前 n 个元素。
示例:

Stream.of(1, 2, 3, 4, 5)
     .skip(2) // 跳过前 2 个元素
     .forEach(System.out::println); // 输出 3, 4, 5

下面是一个示例,展示了多种中间方法的使用:

import java.util.Arrays;
import java.util.List;
public class StreamIntermediateOperations {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Eve");

    // 使用中间方法
    names.stream()
        .filter(name -&gt; name.startsWith(&quot;A&quot;)) // 过滤以 'A' 开头的名字
        .map(String::toUpperCase) // 将名字转换为大写
        .sorted() // 排序
        .forEach(System.out::println); // 输出结果
}

}


终结方法

在 Java Stream API 中,终结方法(Terminal Operations)是指那些会触发流的计算并最终产生结果的方法。与中间操作不同,终结方法会结束流的操作链,并返回一个具体的结果或副作用。以下是对终结方法的详细介绍:

1. 终结方法的特点

触发计算:终结方法会对流中的数据进行处理并生成结果,通常会遍历流中的所有元素。

返回类型:终结方法可以返回不同类型的结果,包括:

基本类型(如 int、double)

对象(如 List、Set、Map)

特殊值(如 Optional、Void)

终结方法

功能:对流中的每个元素执行指定的操作。

示例:

Stream.of("a", "b", "c").forEach(System.out::println);

功能:将流中的元素收集到集合或其他形式。

示例:

List<String> list = Stream.of("a", "b", "c").collect(Collectors.toList());

能:对流中的元素进行归约,返回一个单一的结果。

示例:

int sum = Stream.of(1, 2, 3, 4).reduce(0, Integer::sum);

功能:返回流中元素的数量。

示例:

long count = Stream.of("a", "b", "c").count();

功能:检查流中是否有任何元素满足给定的条件。

示例:

boolean hasA = Stream.of("a", "b", "c").anyMatch(s -> s.equals("a"));

功能:检查流中所有元素是否满足给定的条件。

示例:

boolean allMatch = Stream.of(1, 2, 3).allMatch(n -> n < 5);

功能:返回流中的第一个元素(如果存在)。

示例:

Optional<String> first = Stream.of("a", "b", "c").findFirst();

下面是一个示例,展示了多种终结方法的使用:

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;


public class StreamTerminalOperations {
public static void main(String[] args) {
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");

    // forEach
    names.stream().forEach(System.out::println);

    // collect
    List&lt;String&gt; filteredNames = names.stream()
        .filter(name -&gt; name.startsWith(&quot;A&quot;))
        .collect(Collectors.toList());
    System.out.println(filteredNames);

    // reduce
    String concatenated = names.stream()
        .reduce(&quot;&quot;, (a, b) -&gt; a + b);
    System.out.println(concatenated);
    
    // count
    long count = names.stream().count();
    System.out.println(&quot;Count: &quot; + count);
    
    // findFirst
    String firstName = names.stream().findFirst().orElse(&quot;No Name&quot;);
    System.out.println(&quot;First Name: &quot; + firstName);
}

}

stream流超强引用

package com.itheima.pojo.test;

import java.util.*;
import java.util.stream.Collectors;

/**
 * Arrays.asList 是 Java 中 java.util.Arrays 类的一个静态方法,
 * 用于将指定的数组或可变数量的参数转换为一个固定大小的 List。
 * 这个 List 是 ArrayList 的一个内部实现类,
 * 但它不是 java.util.ArrayList,
 * 而是一个不可变的列表
 */
public class Test2 {
    public static void main(String[] args) {
        // List<String> list:将上述列表赋值给 list 变量
        List<String> list = Arrays.asList("apple", "banana", "orange");
        // 定义一个映射,键为整数,值为字符串列表 = 创建一个新的空哈希映射
        // 键的类型是 Integer,值的类型是 List<String> 表示具有相同长度的字符串列表
        // 用HashMap  允许 null 值:键和值都可以为 null,但键只能有一个 null。
        Map<Integer, List<String>> groups = new HashMap<>();
        for (String s : list) {
            int length = s.length();
            // 检查 groups 映射中是否已经存在键为 length 的条目
            if (!groups.containsKey(length)) {
                // 将新创建的列表作为值,以 length 为键添加到 groups 映射中
                // 创建一个新的 ArrayList,并将当前字符串 s 添加到其中
                groups.put(length, new ArrayList<>(Arrays.asList(s)));
            } else {
                // 从 groups 映射中获取键为 length 的列表
                List<String> group = groups.get(length);
                group.add(s);
            }
            System.out.println(groups);
        }

        // 使用 Stream API 进行分组
        // 使用 Collectors.groupingBy 方法按字符串长度进行分组
        // 将分组结果收集到一个新的映射 group2 中。
        Map<Integer, List<String>> group2 = list.stream().collect(Collectors.groupingBy(String::length));
        System.out.println(group2);
    }
}

Lambda+Stream实用方法

// 创建一个包含字符串的列表
List<String> List = Arrays.asList("apple","banana", "orange");
1.for循环输出
for (String s : list){
System.out.println(s);}

2.表达式输出   
list.forEach(s ->
System.out.println(s);});

3.表达式最简洁输出
List.forEach(System.out::println);
// 创建一个包含字符串的列表
List<String> list = Arrays.asList("apple","banana", "orange");

1.使用重写Collections 排序
Collections.sort(list,new Comparator<String>() {
@override
public int compare(String o1, String o2) {
return o1.compareTo(o2);
}
});

2.使用lambda表达式排序
Collections.sort(list,(o1,o2) ->{
    return o1.compareTo(o2)
});

3.使用最简洁的表达式
Collections.sort(list,(o1,o2) ->{o1.compareTo(o2)});
// 创建一个包含字符串的列表
List<String> list = Arrays.asList("apple","banana", "orange");

1.普通方式过滤
List<String> list2 = new ArrayList<>();
List<String s : list2){
if(s.startsWith("a")){
     list2.add(s);
} }

2.使用 Stream API 进行过滤和收集,过滤以 'a' 开头的字符串,收集结果到一个新的 List 中
List<String>list3 = list.stream().filter(s - > s.startsWith("a")).collect(Collectrs.toList());
// 创建一个包含字符串的列表
List<String> list = Arrays.asList("apple","banana", "orange");

1.普通方式获取长度
List<Integer> List2 = new ArrayList<>();
for (String s:list){
    
list2.add(s.length());}

2.Lambda表达式+stream流获取长度
(map)这个函数对我们管道中的每个元素做了处理,在此处为把string转换为Integer类型 主要进行转换作用
List<Integer> list3 = list.stream().map(s -> s.length()).collect(Collectors.toList());
//新建一个List集合
List<Integer> list = Arrays.asList(1,2, 3, 4, 5);
1.普通方式相加操作
int sum =0;
for (Integer v : list) {
SUm +=V  }
System.out.println(sum);

2.Lambda+stream
(表达式含义):【0】操作的起始值,【a = a+b】 循环下去
int sum2 = list.stream().reduce( identity: 0, (a, b) -> a + b);
System.out.println(sum2)
//新建一个集合
List<String> list = Arrays.asList("apple", "banana", "orange");

Map<Integer,List<String>> groups = new HashMap<>();

1.普通方式分组
for (String s:list){
int length = s.length();
if (!groups.containsKey(length)){
groups.put(length,new ArrayList<>());
}
groups.get(Length) .add(s);
}
    System.out.println(groups);

2.Lambda+stream分组
Map<Integer,List<String>> groups2 = list.stream().collect(Collectors.groupingBy(String::length));
System.out.println(groups2)
1.普通方式创建线程

Thread thread = newThread(new Runnable(){
@Override
public void run(){
System.out.println("hello world");
}
});
thread.start();

2.Lambda表达式

Thread thread1 = new Threal(() -> System.out.println(hello world"));
thread1.start();
1.创建接口
interface  MyInterface{
public void doSomething(String s);}

2.普通实现接口
MyInterface myInterface = new MyInterface{
    @override
    public void doSomething(String s){
    System.out.println(s);
    }
};
myInterface.doSomething( s:"hello world");

3.Lambda表达式实现接口
MyInterface myInterface1 = (s) -> System.out.println(s);
myInterface1.doSomething( s:"hello worLd")
String str = "hello world";

1.普通方式
if(str !=null){
System.out.println(str.toUpperCase());}

2.Lambda表达式
Optional.ofNuLlable(str).map(String::toUpperCase).ifPresent(System.out::println);
List<String> List = Arrays.asList("apple","banana", "orange");
1.普通方式
List<String> list2 = new ArrayList<>();
for (String s:list2){ //遍历循环
    if (s.startsWith("a")){ //取出 包含a的元素
        list2.add(s.toUpperCase());//添加到list2中然后转换为大写
    }
}       Collections.sort(list2); //排序

2.Lambda+stream方式

List<String> list3 = list.stream().filter(s -> s.startsWith("a"))
.map(String::toupperCase).sorted().collect(collectors.toList());
public class Dept {
    private int id;

    public Dept(int id) {
        this.id = id;
    }

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    @Override
    public String toString() {
        return "Dept{id=" + id + "}";
    }
}

public class TestCollectStopOptions {

    public void testCollectStopOptions() {
        // 创建一个包含 Dept 对象的列表
        List<Dept> ids = Arrays.asList(new Dept(17), new Dept(22), new Dept(23));

        // 使用 Stream API 过滤 id 大于 20 的 Dept 对象,并收集到 List 中
        List<Dept> collectList = ids.stream()
                                   .filter(dept -> dept.getId() > 20)
                                   .collect(Collectors.toList());
        System.out.println("collectList: " + collectList);

        // 使用 Stream API 过滤 id 大于 20 的 Dept 对象,并收集到 Set 中
        Set<Dept> collectSet = ids.stream()
                                 .filter(dept -> dept.getId() > 20)
                                 .collect(Collectors.toSet());
        System.out.println("collectSet: " + collectSet);

        // 使用 Stream API 过滤 id 大于 20 的 Dept 对象,并收集到 Map 中,key 为 id,value 为 Dept 对象
        Map<Integer, Dept> collectMap = ids.stream()
                                          .filter(dept -> dept.getId() > 20)
                                          .collect(Collectors.toMap(Dept::getId, dept -> dept));
        System.out.println("collectMap: " + collectMap);
    }

    public static void main(String[] args) {
        new TestCollectStopOptions().testCollectStopOptions();
    }
}

结果

collectList:[Dept{id=22}, Dept{id=23}]
collectSet:[Dept{id=23}, Dept{id=22}]
collectMap:{22=Dept{id=22}, 23=Dept{id=23}}
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class User {
    private String id;

    public User() {
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    @Override
    public String toString() {
        return "User{id='" + id + '\'' + '}';
    }
}

public class TestStringToIntMap {

    /**
     * 演示map的用途:一对一转换
     */
    public void stringToIntMap() {
        // 创建一个包含字符串 ID 的列表
        List<String> ids = Arrays.asList("205", "105", "308", "469", "627", "193", "111");

        // 使用流操作
        List<User> results = ids.stream()
                               .map(id -> {
                                   // 创建一个新的 User 对象
                                   User user = new User();
                                   // 设置 User 对象的 id 属性
                                   user.setId(id);
                                   // 返回 User 对象
                                   return user;
                               })
                               .collect(Collectors.toList()); // 收集结果到一个新的 List 中

        // 打印结果
        System.out.println(results);
    }

    public static void main(String[] args) {
        new TestStringToIntMap().stringToIntMap();
    }
}

@注释笔记

@RequestBody :获取请全体json字符串数据 封装给java对象,封装的前提是 json字符串属性要与实体类属性名一致才可以封装。

—————————————————————————————–

#{name} 占位符 会从方法参数 对象里面调用getname封装方法获取数据映射到占位符位置。

#{参数名} 是Mybatis的参数占位符,可以自动将参数映射到SQL语句去执行

参数名要与接口方法的参数名要一致,但是方法只有一个参数时,参数名可以是任意的。

—————————————————————————————–

Spring MVC 的 @RequestMapping 注解能够处理 HTTP 请求的方法, 比如 GET, PUT, POST, DELETE 以及 PATCH。

所有的请求默认都会是 HTTP GET 类型的。比如@GetMapping

加入路径处理前端响应

—————————————————————————————–

注解@RequiredArgsConstructor 是 Lombok 提供的一个注解,其主要作用在于简化 @Autowired 的书写过程。在编写 Controller 层或 Service 层代码时,常常需要注入众多的 mapper 接口或 service 接口。若每个接口都使用 @Autowired 进行标注,代码会显得繁琐。而 @RequiredArgsConstructor 注解能够替代 @Autowired 注解,但需注意,在类上添加 @RequiredArgsConstructor 时,需要注入的类必须使用 final 进行声明。

—————————————————————————————–

<font style="color:#000000;background-color:rgb(249, 242, 244);">@Repository</font><font style="color:#000000;background-color:rgb(249, 242, 244);">@Repository</font>的作用与<font style="color:#000000;background-color:rgb(249, 242, 244);">@Controller</font><font style="color:#000000;background-color:rgb(249, 242, 244);">@Service</font>的作用都是把对象交给<font style="color:#000000;background-color:rgb(249, 242, 244);">Spring</font>管理。<font style="color:#000000;background-color:rgb(249, 242, 244);">@Repository</font>是标注在<font style="color:#000000;background-color:rgb(249, 242, 244);">Dao</font>层接口上,作用是将接口的一个实现类交给<font style="color:#000000;background-color:rgb(249, 242, 244);">Spring</font>管理。

—————————————————————————————–

@Mapper

@Mapper: 这个注解一般使用在Dao层接口上,相当于一个mapper.xml文件,它的作用就是将接口生成一个动态代理类。加入了@Mapper注解,目的就是为了不再写mapper映射文件。这个注解就是用来映射mapper.xml文件的。

使用@mapper后,不需要在spring配置中设置扫描地址,通过mapper.xml里面的namespace属性对应相关的mapper类,spring将动态的生成Bean后注入到ServiceImpl中

注意:

在Dao层不要存在相同名字的接口,也就是在Dao不要写重载。因为mapper文件是通过id与接口进行对应的,如果写了两个同名的接口,就会导致mapper文件映射出错。

—————————————————————————————–

@Transactional

Spring事务管理-控制事务 注解:@Transactional

作用:将当前方法交给spring进行事务管理,方法执行前,开启事务;成功执行完毕,提交事务;出现异常,回滚事务 放在类上 则是整个类都会启动事务 放在接口上 接口的实现类都会去启动事务。放在方法上此方法中的代码会启动事务。

—————————————————————————————–

规则:JSON数据的键名与方法形参对象的属性名相同,并需要使用@RequestBody注解标识。

—————————————————————————————–

MultipartFile 接收文件接口

—————————————————————————————–

@Service 表明这个是逻辑层 可以被调用

—————————————————————————————–

@ConfigurationPropertise()

—————————————————————————————–

@Autowired 注入bean

—————————————————————————————–

集合:@RequestParam[List ids

—————————————————————————————–

是一个在Java项目中常用的注解,特别是在使用日志框架如SLF4J时,通过在你的类上使用@Slf4j注解,Lombok会自动为你的类生成一个静态的日志字段,这个字段通常是org.slf4j.Logger类型的,并且通常命名为log。这样,你就可以在类中直接使用log.info(), log.error(), log.debug()等方法来记录日志,而无需手动声明和初始化Logger对象。

—————————————————————————————–

@PathVariable 是 Spring MVC 中用于将 URL 模板变量绑定到你控制器处理器方法参数上的注解。这个注解使得你可以从 URL 中提取出变量值,并将其作为参数传递给控制器的方法。

—————————————————————————————–

@RequestParam(defaultValue = “1”) 给参数设置默认值 如果前端没有参数传进来 默认值为1 可自己设置

—————————————————————————————–

@RequestBody 注解 使用对象去接收 的时候使用的注解

—————————————————————————————–

@RestControllerAdvice

—————————————————————————————–

@ExceptionHandler

—————————————————————————————–

限制请求的方式

@RequestMapping 可以放在类上,获取的路径可以当做所有方法的父路径

@PostMapping(value=”/depts”,method=RequestMethod.GET)注解 可以放方法上,获取前端的路径。

@GetMapping(”/depts”)

@PutMapping

@DeleteMapping

—————————————————————————————–

junnit5

@Test

@ParameterizedTest

@BeforeEach

@AfterEach

@BeforeAll 标识静态方法

@AfterAll 标识静态方法

—————————————————————————————–

@RestController = @Controller+@ResponseBody

标识当前控制类所有方法都有了@ResponseBody

@ResponseBody 将控制器方法直接输出给前端,将java对象转换为json字符串输出给前端

—————————————————————————————–

lombok

—————————————————————————————–

在定义完Filter之后,Filter其实并不会生效,还需要完成Filter的配置,Filter的配置非常简单,只需要在Filter类上添加一个注解:<font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">@WebFilter</font>,并指定属性<font style="color:rgb(51, 51, 51);background-color:rgb(243, 244, 244);">urlPatterns</font>,通过这个属性指定过滤器要拦截哪些请求。

当我们在Filter类上面加了@WebFilter注解之后,接下来我们还需要在启动类上面加上一个注解@ServletComponentScan,通过这个@ServletComponentScan注解来开启SpringBoot项目对于Servlet组件的支持。

@Order注解 控制过滤器优先级数字越小 优先级越高

—————————————————————————————–

Spring Boot 中注解的作用

Spring Boot 利用注解来简化配置和提高开发效率。主要注解包括但不限于:

@SpringBootApplication: 启动 Spring Boot 应用程序。

@Component, @Service, @Repository, @Controller: 标记组件,以便 Spring 容器可以自动检测和管理它们。

@Bean 该方法会在spring项目启动时自动调用,并将方法的返回值交给IOC容器管理 – bean对象

@Autowired: 用于自动装配 Bean。

@Bean: 在配置类中定义 Bean。

@Configuration: 定义配置类。

@EnableAutoConfiguration: 开启自动配置。

这些注解通常被组合使用,以提供一个高度可配置且易于扩展的应用程序结构。

12. @PathVariable, @RequestParam, @ModelAttribute, @RequestBody, @ResponseBody

参数绑定

这些注解用于从 HTTP 请求中提取参数,并将它们绑定到方法参数上。

@PathVariable: 用于从 URL 中提取路径变量。

@RequestParam: 用于从查询字符串中提取参数。

@ModelAttribute: 用于将多个请求参数绑定到一个对象上。

@RequestBody: 用于将请求体中的数据绑定到方法参数上。 获取请求体json字符串数据封装给java对象

@ResponseBody: 用于将方法的结果直接写入响应体。

13. @RestController

控制器注解

@RestController 注解是一个组合注解,它等价于 @Controller 和 @ResponseBody 的组合。它表示这是一个 REST 控制器,所有返回值都将被序列化为 JSON 格式并直接写入 HTTP 响应体。

全局异常处理

<font style="color:rgb(199, 37, 78);background-color:rgb(249, 242, 244);">@ControllerAdvice</font> 注解用于定义全局异常处理类,它可以捕获控制器方法抛出的所有异常,并提供统一的错误响应。

@RunWith(SpringRunner.class)

测试运行器

@RunWith(SpringRunner.class) 注解告诉 JUnit 使用 Spring 测试运行器 (SpringRunner) 来运行测试。Spring 测试运行器提供了一种方便的方式来加载 Spring 上下文并管理测试生命周期。

整体测试

<font style="color:rgb(199, 37, 78);background-color:rgb(249, 242, 244);">@SpringBootTest</font> 注解用于执行整体测试,它会加载整个 Spring 应用上下文,包括所有自动配置的 Bean。这对于集成测试非常有用,因为它可以模拟完整的 Spring Boot 应用程序。

@Configuration 用于定义配置类,配置类中的bean可以自动装配到其他bean中

@Configuration类可以使用其他Spring注解,如@ComponentScan和@Import,来扫描组件或导入其他配置类

@Configuration类在Spring容器启动时会通过CGLIB动态代理机制生成代理类,以确保@Bean方法只被调用一次,从而保证单例bean的行为

环境和属性配置:
使用@PropertySource和@Value注解可以将外部属性文件中的值注入到配置类中

—————————————————————————————–

作用:按照一定的条件进行判断,在满足给定条件才会注册对应的bean对象到Spring IOC容器中。

位置:方法、类

@Conditional 本身是一个父注解,派生出大量子注解

@ConditionalonClass:判断环境中是否有对应节码文件才注册bean到IOC容器

对应的bean(类型或名称),才注册bean到IOC容器@ConditionalOnMissingBean:判断环境中没@ConditionalonProperty:判断配置文件中有应属性和值,才注册bean到IOC容器。

—————————————————————————————–

SpringBoot 底层原理

bean获取。

@singleton 默认容器内同名称的bean只有一个实例(单例)

@prototype 每次使用该bean时会创建新的实例(非单例)

@request 每个请求范围内会创建新的实例(web环境中,了解即可)

@session 每个会话范围内都会创建新的实例(web环境中,了解)

@application 每个应用范围内会创建新的实例(web环境中,了解)

@Scope 设置bean的作用域

@Lazy 延迟加载 会延迟到第一次使用的时候才会去加载

默认singleton的bean,在容器启动时被创建,可以使用aLazy注解来延迟初始化(延迟到第一次使用时)

prototype的bean,每一次使用该bean的时候都会创建一个新的实例。

实际开发当中,绝大部分的Bean是单例的,也就是说绝大部分Bean不需要配置scope属性

– 非单例是每次使用时会创建一个全新的bean

@Import 是 Java 中 Spring 框架(特别是 Spring Framework 和 Spring Boot)中用于配置类的一个注解。它主要用于导入其他配置类,使得当前的配置类能够复用其他配置类中的配置信息,从而避免重复的配置代码。

<font style="color:rgb(5, 7, 59);">@Conditional</font> 是 Spring Framework 中的一个注解,它用于在自动配置类(@Configuration 类)中或者通过 <font style="color:rgb(5, 7, 59);">@Bean</font> 方法定义 bean 时,根据特定的条件来决定是否创建某个 bean 或配置。这个注解使得 Spring 的自动配置更加灵活和强大,因为它允许开发者基于特定的条件(如类路径上的特定类、操作系统属性、环境变量等)来启用或禁用配置。

—————————————————————————————–

@Transactional注解书写位置:

  • 方法
    • 当前方法交给spring进行事务管理
    • 当前类中所有的方法都交由spring进行事务管理 (推荐)
  • 接口
    • 接口下所有的实现类当中所有的方法都交给spring 进行事务管理

—————————————————————————————–

  • @Data是lombok注解,可以生成getter/setter方法,tostring/hashcode/equals等方法重写

    @NoArgsConstructor /添加无参构造
    
    @AllArgsConstructor //添加全参构造
    

—————————————————————————————–

@ResponseBody: 将控制器方法返回值直接输出给前端,将java对象转换为json字符串输出给前端@RestController = @controller + @ResponseBody

标识了当前控制器类所有方法就都有了@ResponseBody

@Controller : spring框架的ioc注解,用于给当前类创建实例对象,也就是加入ioc容器中。

@Autowired :依赖注入注解:在运行时会从spring容器中找当前接口实现类对象并注入

@Qualifier(“Bean对象”):指定Bean别名这与对象

@Qualifier常与@Autowired一起使用

@0ptions(useGeneratedKeys = true,keyProperty =”id”)//需要获取数据库赋值的id属性 并赋值给对象的id

—————————————————————————————–

常用方法模板集合

@Test
1:JsONUtil.toJsonStr(paramMap)//将任意对象转换为json字符串形式
                     
//使用hutool工具类把BedDto类型转换成Bed实体类 类型
2:Bed bean = BeanUtil.toBean(bedDto, Bed.class);

//判断对象是否为null 如果为null 返回true
3:Objects.isNull(xx)

//判断对象是否不为null 如果不为null 返回true
4:Objects.nonNull(xx)
                  
//工具类Objects 专门用来解决空指针异常 意思 先判断s1!=null 在调用s1.equals(s2)
5:Objects.equals(s1,s2)

//强转方法
6:String.valueOf() 

//整个对象的转换方法
7:BeanUtils.copyProperties(user,userPojo) 

8:StringUtils.toStringArray(把括号中的内容转换为一个字符串类型数组)

//在java中,JSONOBject类中的get(String key)方法接收一个字符串参数作为键名,用于从JSON对象中获取对应的值,这种方法运行通过建模来检索特定的数据项
//当我们调用JSONOBject.get("acces_token")的时候,实际上是在告诉程序:请查找名为“access_token”的键,并返回其关键的值,
//如果找到匹配的键,则返回相应的值,如果没有找到,则返回null
9:JSONOBject.get("acces_token")
//建造者设计模式:利用各种组件(各种属性)随意组合生成对象,目的是创建对象更加灵活
//与直接调用构造函数对比
//类一般要提供很多构造函数才可以灵活构建对象,这种方式很麻烦
//建造者模式创建对象底层只需需要提供一个构造函数即可,在使用的时候想设置哪个属性就设置哪个属性,最终都调用同一个构造函数
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder给当前类添加建造者设计模式创建对象    这几个注解都要有
10:member = Member.builder()
.openId(openid)
.build();
阅读全文

git详细操作

2024/11/19

@Author yuan

Git作用

Git 作用

代码回溯 版本控制 多人协作 远程备份

Git 简介

Git 是一个分布式版本控制工具,通常用来对软件开发过程中的源代码文件进行管理。通过Git 仓库来存储和管理这些文件,Git 仓库分为两种:

本地仓库:开发人员自己电脑上的 Git 仓库

远程仓库:远程服务器上的 Git 仓库

commit:提交,将本地文件和版本信息保存到本地仓库

push:推送,将本地仓库文件和版本信息上传到远程仓库

pull:拉取,将远程仓库文件和版本信息下载到本地仓库

常用的 Git 代码托管服务

Git中存在两种类型的仓库,即本地仓库和远程仓库。那么我们如何搭建Git远程仓库呢?

我们可以借助互联网上提供的一些代码托管服务来实现,其中比较常用的有GitHub、码云、GitLab等。

获取Git 仓库-从远程仓库克隆

可以通过Git提供的命令从远程仓库进行克隆,将远程仓库克隆到本地命令形式:git clone【远程Git仓库地址】

工作区、暂存区、版本库 概念

版本库:前面看到的.git隐藏文件夹就是版本库,版本库中存储了很多配置信息、日志信息和文件版本信息等工作区:包含.git文件夹的目录就是工作区,也称为工作目录,主要用于存放开发的代码暂存区:.git文件夹中有很多文件,其中有一个index文件就是暂存区,也可以叫做stage。暂存区是一个临时保存修改文件的地方

Git工作区中文件的状态

Git工作区中的文件存在两种状态:untracked 未跟踪(未被纳入版本控制)

tracked 已跟踪(被纳入版本控制)

1.Unmodified 未修改状态

2.Modified 已修改状态

3.Staged 已暂存状态

本地仓库操作

本地仓库常用命令如下:

git status 查看文件状态

git add 将文件的修改加入暂存区

git reset 将暂存区的文件取消暂存或者是切换到指定版本

git commit 将暂存区的文件修改提交到版本库

git log 查看日志

Git项目克隆

在IDEA中克隆Git项目

首先配置Git

说明:如果Git安装在默认目录中(C:\Program Files\Git),则IDEA中无需再手动配置,直接就可以使用。

第一步:

第二步:

第三步:

第四步:

第五步:

第六步:

红色:未跟踪文件

绿色:已暂存文件

蓝色:已修改文件

黑色:未修改文件

标签

1.创建一个标签

确认标签名:

添加成功:

推送到远程仓库:

分支操作

分支操作:

查看分支,本质就是执行 gitbranch 命令

创建分支,本质就是执行 git branch 分支名

命令切换分支,本质就是执行 git checkout命令

将分支推送到远程仓库,本质就是执行 git push 命令

合并分支,本质就是执行 git merge 命令

IDEA中查看分支在右下角,如图

新建分支

新建分支推送

切换分支:

合并分支:

合并分支后需要推送到远程仓库同步

切换版本开发

切换版本:

在开发过程中,有a1到a5这几个版本的项目,现在需要重新基于a3去开发后续项目。基于这种情况,我们可以右键这个版本的项目,新建分支进行开发。

新建分支:

提交 && 拉取 && 冲突

1.提交

2.推送

3.拉取

4.提交推送2

5.拉取

操作冲突:多个人操作同一个文件,其中有人基于旧的版本修改,提交新版本会成功,但是推送到远程会失败,就是发送冲突。

冲突为什么发生:a和b同时修改c1文件,a先修改完成c1文件,提交推送到远程仓库,c1文件进行更新版本成为c2,提交推送后b也修改完成c1文件,提交的时候成功 推送则失败,因为远程仓库的文件已经进行了更新。b推送的时候没有拉取最新的文件进行修改,而是使用的过期版本,所以造成冲突。

解决冲突,拉取,合并,推送

操作冲突:

解决冲突操作:1

解决冲突操作:2

解决冲突操作:3

解决冲突操作:4

推送合并后的项目到远程仓库

阅读全文

若依-AI & 帝可得

2024/11/3

RuoYi-Vue

课程版本
JDK 11
MySQL 8
Redis 5
Maven 3.6
Node 16 (Vue3)
  • 技术选型:SpringBoot、SpringSecurity、MyBatis、Jwt、VUE3、Element-Plus
  1. 从VCS导入项目

    • 点击VCS菜单。

    • 选择Get from Version Control...选项。

    • URL:https://gitee.com/y_project/RuoYi-Vue.git
      Directory:C:\Users\Pluminary\Desktop\RuoYi-Vue
      
  2. 输入项目地址

    • 在弹出的窗口中,您可以看到不同的版本控制系统(例如Git, SVN等)。
    • 选择您要导入的项目所使用的版本控制系统。
    • 在接下来的窗口中,您需要输入项目的URL地址。这通常是项目的仓库地址,例如Git仓库的HTTPS或SSH链接。

① 若模块没有导入进去没有亮 就Maven → clean → package

C:\Users\Pluminary\Desktop\RuoYi-Vue\sql 导入Sql文件两个
sql/quartz.sql
sql/ry_20240629.sql

先gitwzs28150/RuoYi-Vue3: :tada: (RuoYi)官方仓库 基于SpringBoot,Spring Security,JWT,Vue3 & Vite、Element Plus 的前后端分离权限管理系统下载这里面的资料,在里面C:\Users\Pluminary\Desktop\itcast>code ./RuoYi-Vue3导入vscode里面,再安装依赖(要进入Vue3里面才能利用package.json去生成)npm install → 运行前端项目npm run dev

// npm install慢的话 就用中国镜像去下载
npm install --registr=https://registry.npmmirror.com

C:\Users\Pluminary\Desktop\itcast\RuoYi-Vue3>npm run dev

> ruoyi@3.8.8 dev
> vite


  VITE v5.3.2  ready in 2026 ms

  ➜  Local:   http://localhost:8080/
  ➜  Network: http://10.254.3.124:8080/
  ➜  Network: http://192.168.104.38:8080/
  ➜  Network: http://192.168.22.1:8080/
  ➜  Network: http://192.168.36.1:8080/
  ➜  press h + enter to show help

再导入课程管理.sql数据库 → 在若依的后台系统 → 系统工具 → 代码生成 → 配置好后下载代码 → 导入数据库

→ 导入RuoYi生成的前端代码C:\Users\Pluminary\Downloads\ruoyi\vue\apicourse导入vscode中 C:\Users\Pluminary\Desktop\itcast\RuoYi-Vue3\src\api\course && C:\Users\Pluminary\Downloads\ruoyi\vue\viewscourse导入vscode中 C:\Users\Pluminary\Desktop\itcast\RuoYi-Vue3\src\api\course

→ 导入RuoYi生成的后端代码C:\Users\Pluminary\Desktop\itcast\RuoYi-Vue3\src\views\coursecom 并且导入配置文件 && C:\Users\Pluminary\Downloads\ruoyi\main\resourcesmapper导入到idea的resource中

功能详解

权限控制
  • 若依内置了强大的权限控制系统,为企业级项目提供了通用的解决方案
    • **demo账号 (超级管理员)**,查看所有功能菜单
    • **zhangsan账号 (市场专员)**,查看线索菜单
    • **yueyue账号 (销售专员)**,查看商机、合同等菜单
  • RBAC (基于角色的控制访问) 是一种广泛使用的访问控制模型,通过角色来分配和管理用户的菜单权限
表名 说明
sys_dept 部门表
sys_post 岗(职)位信息表
sys_menu 菜单权限表
sys_role 角色信息表
sys_role_dept 角色和部门关联表
sys_role_menu 角色和菜单关联表
sys_user 用户信息表
sys_user_post 用户与岗位关联表
sys_user_role 用户和角色关联表

创建新用户小智并关联课研人员角色,仅限课程管理和统计分析菜单访问。

创建菜单
创建角色,并分配权限课研人员
创建用户,并关联角色xiaozhi

若依通过简单的功能配置实现RBC的权限管理
数据字典
  • 若依内置的数据字典,用于维护系统中常见的静态数据。例如:性别、状态
  • 功能包括:字典类型管理、字典数据管理
  • 表关系说明【一对多】
表名 说明
sys_dict_type 字典类型表
sys_dict_data 字典数据表
dict_id dict_name dict_type
1 用户性别 sys_user_sex
2 菜单状态 sys_show_hide
dict_code dict_sort dict_label dict_value dict_type
1 1 0 sys_user_sex
2 2 1 sys_user_sex
3 3 未知 2 sys_user_sex
  • 将一些不经常修改的数据(课程管理的学科字段)改为数据字典维护,以免占用大量空间

    • 添加字典类型和数据

      系统管理 → 字典管理 → 新增 → 添加字典类型
      字典名称:学科
      字典类型:course_subject
      第二页点进去添加字典数据
      javaEE → 0 → 1

    • 修改代码生成信息

      系统工具 → 代码生成 → 编辑课程管理 → subject课程学科 显示类型从文本框改成下拉框 → 字典类型 是学科 → 下载最新的代码
      只需要修改前端vue组件 因为只改了从前端文本框到下拉框 和一些数据字典

    • 下载代码,导入前端

      C:\Users\Pluminary\Downloads\ruoyi (1)\vue\views\course\course\index.vue去替换前端的index.vue 此时去若依前端查看课程管理→课程学科就会发现已经添加新的进去了

此时去课程管理里面找课程学科:JavaEE进行筛选不会出 因为若依底层
http://10.254.2.179/dev-api/course/course/list?pageNum=1&pageSize=10&subject=0
要从数据库里把JavaEE的subject改成0
优点:降低数据库的存储压力 提高磁盘利用率

其他功能
  • 参数设置:对系统中的参数进行动态维护

    系统管理 → 参数设置 → 验证码开关 → 修改 → 参数键值 → false
    还可以开启是否 用户注册功能 → 前端代码隐藏需要修改 → src/views/login.vue → 97行注册开关
    const register = ref(true) 此时登录界面就有立即注册 跳转注册

  • 通知公告(半成品):促进组织内部信息传递

    系统管理 → 通知公告 → 新增

  • 日志管理:轻松追踪用户行为和系统运行状况

    系统管理 → 通知公告 → 日志管理 → 操作日志

系统监控
  • 若依提供了一些列强大的监控工具,能够帮助开发者和运维快速了解应用程序的性能状态

    系统监控 → 在线用户 && 缓存列表
    数据监控【Druid Monitor】 → ruoyi && 123456

定时任务
  • 若依为定时任务功能提供方便友好的web界面,实现动态管理任务

    @Component
    public class MyTask{
      @Scheduled(cron = "0/5 *****?")
      public void showTime(){
         sout("定时任务开始执行:" + new Date());
      }
    }
    // 硬编码 改代码需要重新修改 重新编译 重新上传...
    
  • 每间隔5秒,控制台输出系统时间

    • 创建任务类

      创建一个类 C:\Users\Pluminary\Desktop\RuoYi-Vue\ruoyi-quartz\src\main\java\com\ruoyi\quartz\task\MyTask.java

      package com.ruoyi.quartz.task;
      
      import org.springframework.stereotype.Component;
      
      import java.util.Date;
      
      @Component
      public class MyTask {
          public void showTime(){
              System.out.println("定时任务开始执行:" + new Date());
          }
      }
      
    • 添加任务规则

      系统监控 → 定时任务 → 新增 →
      任务名称:输出时间 任务分组:默认
      调用方式:myTask.showTime()
      Cron表达式生成器:从0秒开始,每5秒执行一次 → 0/5 * * * * ?
      开启输出时间 状态打开!

      然后每间隔5秒就会向控制台输出时间

      定时任务开始执行:Wed Nov 06 18:12:30 CST 2024
      18:12:30.001 [quartzScheduler_Worker-4] DEBUG c.r.q.m.S.insertJobLog - [debug,135] - > Preparing: insert into sys_job_log( job_name, job_group, invoke_target, job_message, status, create_time )values( ?, ?, ?, ?, ?, sysdate() ) 18:12:30.002 [quartzScheduler_Worker-4] DEBUG c.r.q.m.S.insertJobLog - [debug,135] - –> Parameters: 输出时间(String), DEFAULT(String), myTask.showTime()(String), 输出时间 总共耗时:0毫秒(String), 0(String) 18:12:30.009 [quartzScheduler_Worker-4] DEBUG c.r.q.m.S.insertJobLog - [debug,135] - < Updates: 1 定时任务开始执行:Wed Nov 06 18:12:35 CST 2024 18:12:35.006 [quartzScheduler_Worker-5] DEBUG c.r.q.m.S.insertJobLog - [debug,135] - > Preparing: insert into sys_job_log( job_name, job_group, invoke_target, job_message, status, create_time )values( ?, ?, ?, ?, ?, sysdate() ) 18:12:35.006 [quartzScheduler_Worker-5] DEBUG c.r.q.m.S.insertJobLog - [debug,135] - –> Parameters: 输出时间(String), DEFAULT(String), myTask.showTime()(String), 输出时间 总共耗时:0毫秒(String), 0(String) 18:12:35.012 [quartzScheduler_Worker-5] DEBUG c.r.q.m.S.insertJobLog - [debug,135] - < Updates: 1

    • 启动任务

      官方有提供可以训练的模型

      在这里面:com/ruoyi/quartz/task/RyTask.java

表单构建
  • 通过表单构建工具,单独制作一个添加课程的表单页面
    • 制作表单并导出

      系统工具 → 表单构建 → 左侧行容器 → 拖入第一个单行文本 → 右侧可以改名字 如果想实现一行两个文本的化 就把表单栅格调小一点 → 选择性组件的下拉选择托到右面 → 修改字段名、标题、表单栅格…
      日期范围 → 课程有效期 → 选择型组件里的日期范围 命名为:课程有效期
      文件上传组件 → 课程封面
      评分 → 推荐指数
      多行文本 → 课程介绍

    • 复制到前端工程

      搞完后打开前端工程把add.vue它放在 src/views/course/course/中

    • 创建动态菜单

      系统管理 → 菜单管理 → 添加菜单 →
      菜单类型:菜单
      菜单名称:添加课程
      显示排序:1
      路由地址:course/add
      组件路径:course/course/add
      刷新界面 就会有菜单管理→添加课程了

系统工具

代码生成
  • 代码生成器,根据数据库表结构自动生成前后端CRUD代码
  • 提供三种生成模板:单表、树表、主子表(一对多)
  • 树表是一种展示层级数据的表格,能展开折叠,清晰呈现父子关系,便于管理

系统工具 → 代码生成 → 导入部门表 → 编辑 → 生成信息 → 生成模板:树表 →
树编码字段:dept_id:部门id
树父编码字段:parent_id:父部门id
树名称字段:dept_name:部门名称
提交后下载代码
导入后就是 系统管理/部门管理 的树型结构界面了

dept_id parent_id ancestors dept_name
100 0 0 若依科技
101 100 0,100 深圳总公司
102 100 0,100 长沙分公司
108 102 0,100,101 市场部门
109 102 0,100,102 财务部门
系统接口
  • Swagger,能够自动生成API的同步在线文档,并提供Web界面进行接口调用和测试

    系统工具 → 系统接口
    若依的Token在应用程序里 需要搞token进去
    测试:获取用户列表GET
    得到Token(F12后找应用程序 → Cookie若依 → 找到Admin-Token一定是当前ip地址的Token → 在Authorize中设置Token令牌 → 去后端改swagger的请求前缀pathMapping:/因为他的地址前面默认佩戴/dev-api

    # Swagger配置
    swagger:
      # 是否开启swagger
      enabled: true
      # 请求前缀
      pathMapping: /
    

    然后重启后台项目刷新浏览器打开接口再调用Token再去测试
    此时就操作成功了

    {
      "code": 200,
      "msg": "操作成功",
      "data": [
        {
          "userId": 1,
          "username": "admin",
          "password": "admin123",
          "mobile": "15888888888"
        },
        {
          "userId": 2,
          "username": "ry",
          "password": "admin123",
          "mobile": "15666666666"
        }
      ]
    }
    

若依常用功能?

权限控制
数据字典
定时任务
表单构建
代码生成

项目结构

表结构
前端代码分析
  • api/course/course.js 用于向后端发送Ajax请求的接口代码
  • views/course/course/index.vue用于在浏览器展示课程的视图组件
src\views\course\course\index.vue
<template>
  <div class="app-container">
    <!-- :model做双向绑定 将前端录入条件封装给响应对象  v-show控制搜索栏显示隐藏-->
    <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="68px">
      <el-form-item label="课程编码" prop="code">
        <!-- v-model双向绑定code(前端课程编码) clearable清理用户输入信息 @keyup键盘回车事件完成搜索-->
        <el-input
          v-model="queryParams.code"
          placeholder="请输入课程编码"
          clearable
          @keyup.enter="handleQuery"
        />
      </el-form-item>
      <el-form-item label="课程学科" prop="subject">
        <!-- v-for遍历课程学科的字典数据列表 :lable展示label :value提交value值-->
        <el-select v-model="queryParams.subject" placeholder="请选择课程学科" clearable>
          <el-option
            v-for="dict in course_subject"
            :key="dict.value"
            :label="dict.label"
            :value="dict.value"
          />
        </el-select>
      </el-form-item>
      <el-form-item label="课程名称" prop="name">
        <el-input
          v-model="queryParams.name"
          placeholder="请输入课程名称"
          clearable
          @keyup.enter="handleQuery"
        />
      </el-form-item>
      <el-form-item label="适用人群" prop="applicablePerson">
        <el-select v-model="queryParams.applicablePerson" placeholder="请选择适用人群" clearable>
          <el-option
            v-for="dict in course_applicable_person"
            :key="dict.value"
            :label="dict.label"
            :value="dict.value"
          />
        </el-select>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
        <el-button icon="Refresh" @click="resetQuery">重置</el-button>
      </el-form-item>
    </el-form>

    <el-row :gutter="10" class="mb8">
      <el-col :span="1.5">
        <!-- @click点击新增按钮弹出新增 v-hasPermi自定义属性完成菜单显示/隐藏 -->
        <el-button
          type="primary"
          plain
          icon="Plus"
          @click="handleAdd"
          v-hasPermi="['course:course:add']"
        >新增</el-button>
      </el-col>
      <!-- :disabled表示这个框是否可用 -->
      <el-col :span="1.5">
        <el-button
          type="success"
          plain
          icon="Edit"
          :disabled="single"
          @click="handleUpdate"
          v-hasPermi="['course:course:edit']"
        >修改</el-button>
      </el-col>
      <el-col :span="1.5">
        <el-button
          type="danger"
          plain
          icon="Delete"
          :disabled="multiple"
          @click="handleDelete"
          v-hasPermi="['course:course:remove']"
        >删除</el-button>
      </el-col>
      <el-col :span="1.5">
        <el-button
          type="warning"
          plain
          icon="Download"
          @click="handleExport"
          v-hasPermi="['course:course:export']"
        >导出</el-button>
      </el-col>
      <!-- 点击会控制'搜索栏'显示隐藏 @queryTable重新加载表格展示数据 -->
      <right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
    </el-row>
    <!-- v-loading展示表格的加载状态 遍历展示courseList 事件监听器监听选中行 -->
    <el-table v-loading="loading" :data="courseList" @selection-change="handleSelectionChange">
      <!-- 当用户勾选复选框 触发@selection-change -->
      <el-table-column type="selection" width="55" align="center" />
      <!-- 展示具体数据源数据 -->
      <el-table-column label="课程id" align="center" prop="id" />
      <el-table-column label="课程编码" align="center" prop="code" />
      <el-table-column label="课程学科" align="center" prop="subject">
 <!-- 通过scope拿到整个表格数据 通过:value="scope.row.subject"拿到字典值去匹配字典数据列表 找到该值对应的标签显示给前端-->
        <template #default="scope">
          <dict-tag :options="course_subject" :value="scope.row.subject"/>
        </template>
      </el-table-column>
      <el-table-column label="课程名称" align="center" prop="name" />
      <el-table-column label="价格" align="center" prop="price" />
      <el-table-column label="适用人群" align="center" prop="applicablePerson">
        <template #default="scope">
          <dict-tag :options="course_applicable_person" :value="scope.row.applicablePerson"/>
        </template>
      </el-table-column>
      <el-table-column label="课程介绍" align="center" prop="info" />
      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<!-- 使用了模板插槽 -->
        <template #default="scope">
          <!-- @click="handleUpdate(scope.row)把当前行的数据传给当前方法  v-hasPermi自定义权限属性-->
          <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" v-hasPermi="['course:course:edit']">修改</el-button>
          <el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['course:course:remove']">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
    
    <!-- 分页区域 -->
     <!-- v-show如果大于0条就显示反之隐藏 :total展示总条数 展示分页页码 @pagination="getList"换页后新数据的查询-->
    <pagination
      v-show="total>0"
      :total="total"
      v-model:page="queryParams.pageNum"
      v-model:limit="queryParams.pageSize"
      @pagination="getList"
    />

    <!-- 添加或修改课程管理对话框 -->
     <!-- el-dialog默认隐藏的 点击会显示 append-to-body默认将对话框在body上追加显示 :title动态绑定标题(新增和修改不一样)-->
    <el-dialog :title="title" v-model="open" width="500px" append-to-body>
      <!-- :model来双向绑定 :rules校验规则-->
      <el-form ref="courseRef" :model="form" :rules="rules" label-width="80px">
        <el-form-item label="课程编码" prop="code">
          <el-input v-model="form.code" placeholder="请输入课程编码" />
        </el-form-item>
        <el-form-item label="课程学科" prop="subject">
          <el-select v-model="form.subject" placeholder="请选择课程学科">
            <el-option
              v-for="dict in course_subject"
              :key="dict.value"
              :label="dict.label"
              :value="dict.value"
            ></el-option>
          </el-select>
        </el-form-item>
        <el-form-item label="课程名称" prop="name">
          <el-input v-model="form.name" placeholder="请输入课程名称" />
        </el-form-item>
        <el-form-item label="价格" prop="price">
          <el-input v-model="form.price" placeholder="请输入价格" />
        </el-form-item>
        <el-form-item label="适用人群" prop="applicablePerson">
          <el-select v-model="form.applicablePerson" placeholder="请选择适用人群">
            <el-option
              v-for="dict in course_applicable_person"
              :key="dict.value"
              :label="dict.label"
              :value="dict.value"
            ></el-option>
          </el-select>
        </el-form-item>
        <el-form-item label="课程介绍" prop="info">
          <el-input v-model="form.info" placeholder="请输入课程介绍" />
        </el-form-item>
      </el-form>
      <template #footer>
        <div class="dialog-footer">
          <!-- 提交前先进行表单规则的校验:rules="rules"  -->
          <el-button type="primary" @click="submitForm">确 定</el-button>
          <el-button @click="cancel">取 消</el-button>
        </div>
      </template>
    </el-dialog>
  </div>
</template>

<script setup name="Course">
// 引入后端api接口文件
import { listCourse, getCourse, delCourse, addCourse, updateCourse } from "@/api/course/course";
// 获取当前实例代理对象,用于访问组件数据、方法
const { proxy } = getCurrentInstance();
// 获取课程学科的数据字典
const { course_applicable_person, course_subject } = proxy.useDict('course_applicable_person', 'course_subject');
// 列表数据
const courseList = ref([]);
// 是否显示弹框
const open = ref(false);
// 是否显示加载状态
const loading = ref(true);
// 是否显示搜索栏
const showSearch = ref(true);
// 复选框,被选中id的数组
const ids = ref([]);
// 复选框,是否单选,用于高亮修改、删除按钮
const single = ref(true);
// 复选框,是否多选,仅高亮删除按钮
const multiple = ref(true);
// 总(记录)条数
const total = ref(0);
// 用于区分新增、修改对话框标题
const title = ref("");
// 定义reactive响应式对象
const data = reactive({
  // 新增或修改表单数据
  form: {},
  // 搜索条件参数
  queryParams: {
    pageNum: 1,
    pageSize: 10,
    code: null,
    subject: null,
    name: null,
    applicablePerson: null,
  },
  // 表单校验规则
  rules: {
    code: [
      { required: true, message: "课程编码不能为空", trigger: "blur" }
    ],
    subject: [
      { required: true, message: "课程学科不能为空", trigger: "change" }
    ],
    name: [
      { required: true, message: "课程名称不能为空", trigger: "blur" }
    ],
    price: [
      { required: true, message: "价格不能为空", trigger: "blur" }
    ],
    applicablePerson: [
      { required: true, message: "适用人群不能为空", trigger: "change" }
    ],
    info: [
      { required: true, message: "课程介绍不能为空", trigger: "blur" }
    ],
  }
});
// 将data对象的三个属性,转换为ref响应式对象
const { queryParams, form, rules } = toRefs(data);

/** 查询课程管理列表 */
function getList() {
  loading.value = true;
  listCourse(queryParams.value).then(response => {
    courseList.value = response.rows;
    total.value = response.total;
    loading.value = false;
  });
}

// 取消按钮
function cancel() {
  open.value = false;
  reset();
}

// 表单重置
function reset() {
  form.value = {
    id: null,
    code: null,
    subject: null,
    name: null,
    price: null,
    applicablePerson: null,
    info: null,
    createTime: null,
    updateTime: null
  };
  proxy.resetForm("courseRef");
}

/** 搜索按钮操作 */
function handleQuery() {
  // 最新的从第一页开始 再发送请求
  queryParams.value.pageNum = 1;
  getList();
}

/** 重置按钮操作 */
function resetQuery() {
  proxy.resetForm("queryRef");
  handleQuery();
}

// 多选框选中数据
// 把选中的复选框对象传递过来
function handleSelectionChange(selection) {
  // 拿到对象调用map方法进行遍历取每个复选框的id
  // 封装给ids的响应式数组对象
  ids.value = selection.map(item => item.id);
  // 控制修改和删除按钮是否高亮可用的 23默认为true
  single.value = selection.length != 1;
  // 修改按钮只要大于0 就是false 那么修改按钮可用使用
  multiple.value = !selection.length;
}

/** 新增按钮操作 */
function handleAdd() {
  reset();
  open.value = true;
  title.value = "添加课程管理";
}

/** 修改按钮操作 */
// 拿到行对象 重置 取出当前行id或一个id
function handleUpdate(row) {
  reset();
  const _id = row.id || ids.value
  getCourse(_id).then(response => {
    form.value = response.data;
    open.value = true;
    title.value = "修改课程管理";
  });
}

/** 提交按钮 */
function submitForm() {
  // '修改课程'对表单进行校验 正则规则...是否必填
  proxy.$refs["courseRef"].validate(valid => {
    if (valid) {// 区分新增还是修改的操作
      if (form.value.id != null) {
        updateCourse(form.value).then(response => {
          proxy.$modal.msgSuccess("修改成功");
          open.value = false;
          getList();
        });
      } else {
        addCourse(form.value).then(response => {
          proxy.$modal.msgSuccess("新增成功");
          open.value = false;
          getList();
        });
      }
    }
  });
}

/** 删除按钮操作 */
function handleDelete(row) {
  // 一行或数组
  const _ids = row.id || ids.value;
  // 防止误操作
  proxy.$modal.confirm('是否确认删除课程管理编号为"' + _ids + '"的数据项?').then(function() {
    return delCourse(_ids);
  }).then(() => {
    getList();
    proxy.$modal.msgSuccess("删除成功");
  }).catch(() => {});
}

/** 导出按钮操作 */
function handleExport() {
  proxy.download('course/course/export', {
    ...queryParams.value
  }, `course_${new Date().getTime()}.xlsx`)
}

getList();
</script>
src\api\course\course.js
// 封装了请求和响应拦截器 下面return每个都调用请求
import request from '@/utils/request'

// 查询课程管理列表
// 接收用户输入参数 调用工具类把参数传过去 向后端发送请求完成课程列表的查询
// 然后返回前端并展示数据
export function listCourse(query) {
  return request({
    url: '/course/course/list',
    method: 'get',
    params: query
  })
}

// 查询课程管理详细
// 点击修改按钮时候根据id去查询 返回给前端
export function getCourse(id) {
  return request({
    url: '/course/course/' + id,
    method: 'get'
  })
}

// 新增课程管理
// 当点击确定按钮的时候 就把数据添加进来发送请求后返回前端
export function addCourse(data) {
  return request({
    url: '/course/course',
    method: 'post',
    data: data
  })
}

// 修改课程管理
// 修改完毕(根据id去查找数据库的)
export function updateCourse(data) {
  return request({
    url: '/course/course',
    method: 'put',
    data: data
  })
}

// 删除课程管理
// 批量/单体删除
export function delCourse(id) {
  return request({
    url: '/course/course/' + id,
    method: 'delete'
  })
}

再次熟悉:前+后端结构

若i18n乱码的情况下

file -> settings -> editor -> file ecoding -> default encoding for properties files:utf-8

后端代码分析

  • CourseController

  • ICourseService及实现类

  • CourseMapper及映射方法

  • Course

  • BaseController:web层通用数据处理

/**
     * 查询课程管理列表
     */
    @PreAuthorize("@ss.hasPermi('course:course:list')")
    @GetMapping("/list")
    public TableDataInfo list(Course course) {
        //1.开启分页
        startPage();
        //2.查询课程列表
        List<Course> list = courseService.selectCourseList(course);
        return getDataTable(list);
    }
// 在分页查询那块会附带着两个封装好的sql语句
// select * from tb_course where xxx 【逐步细分成两个小sql】
  1. select count(*) from tb_course where xxx      //总记录数
  2. select * from tb_course where xxx limit ?,?   //获取当前的数据列表

分页原理

AjaxResult:操作消息提醒

权限解读
  • @PreAuthorize注解是Spring Security框架中用来做权限检查的。
  • 它在运行方法前先验证权限,权限够就放行,不够就拦截
@RestController
@RequestMapping("/course/course")
public class CourseController extends BaseController {
    @Autowired
    private ICourseService courseService;

    /**
     * 查询课程管理列表
     */
// 问题:我怎么知道该用户有没有权限呢?基于RBC权限模型 
    @PreAuthorize("@ss.hasPermi('course:course:list')")
    @GetMapping("/list")
    public TableDataInfo list(Course course) {
        startPage();
        List<Course> list = courseService.selectCourseList(course);
        return getDataTable(list);
    }
}
菜单名称 排序 权限标识
课程管理 1 course:course:list
课程管理查询 1 course:course:query
课程管理新增 2 course:course:add
课程管理修改 3 course:course:edit
课程管理删除 4 course:course:remove
课程管理导出 5 course:course:export

PermissionService.java
源码解读在后期

/**
 * RuoYi首创 自定义权限实现,ss取自SpringSecurity首字母
 * 
 * @author ruoyi
 */
@Service("ss")
public class PermissionService
{
    /**
     * 验证用户是否具备某权限
     * 
     * @param permission 权限字符串
     * @return 用户是否具备某权限
     */
    public boolean hasPermi(String permission)
    {
        if (StringUtils.isEmpty(permission))
        {
            return false;
        }
        LoginUser loginUser = SecurityUtils.getLoginUser();
        if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions()))
        {
            return false;
        }
        PermissionContextHolder.setContext(permission);
        return hasPermissions(loginUser.getPermissions(), permission);
    }

二次开发 —— 苍穹外卖

若依框架修改器
  • 若依框架修改器是一个可以一键修改RuoYi框架包名、项目名等的工具

E:\Java实例项目1-20套\2024-Java若依框架专题课\01-基础篇\资料\04-二次开发\若依框架修改器.exe

选择系列:RuoYi-Vue
目录名称: sky
项目名:sky
包名:com.sky
artifactId:sky
groupId:com.sky
站点名称:外卖管理系统


C:\Users\Pluminary\Desktop\20241108150415\sky
新建业务模块
  • 新建sky-merchant子模块商家管理

    • 新建子模块
      sky-merchant → Advanced Settings → GroupId:com.sky

    • 父工程版本锁定

      pom.xml(sky总)
      <!-- 商家管理-->
                  <dependency>
                      <groupId>com.sky</groupId>
                      <artifactId>sky-merchant</artifactId>
                      <version>${sky.version}</version>
                  </dependency>
      
      pom.xml(sky-merchant)
      <dependencies>
              <dependency>
                  <groupId>com.sky</groupId>
                  <artifactId>sky-framework</artifactId>
              </dependency>
          </dependencies>
      
    • sky-admin添加依赖

      pom.xml(sky-admin) 
      <dependency>
                  <groupId>com.sky</groupId>
                  <artifactId>sky-merchant</artifactId>
              </dependency>
      

菜品管理

  • 利用若依代码生成器(主子表模板),生成菜品管理的前后端代码
tb_dish【菜品管理】
id
name
price
image
description
status
create_time
update_time
tb_dish_flavor【菜品口味关系表】
id
dish_id
name
value
  • 准备SQL并导入数据库

    E:\Java实例项目1-20套\2024-Java若依框架专题课\01-基础篇\资料\04-二次开发\菜品管理

  • 配置代码生成信息【主子表的生成】

    在若依代码生成 → 导入表tb_dishtb_dish_flavor

  • 下载代码并导入项目

  • 修改代码 → 系统工具 → 代码生成 → 菜品管理(修改) →

    基本信息:实体类名称→Dish 作者→itheima

    字段信息参考页面原型生成 → 系统管理 → 字典管理 →

    字典名称:售卖状态
    字典类型:dish_status
    第二页点进去售卖状态的字典类型 dish_status → 新增

    数据标签:停售 + 起售
    数据键值:0 + 1
    显示排序:1 + 2

​ 回到系统工具 → 代码生成 → 菜品管理修改 → 售卖状态 → 显示类型:下拉框 → 字典类型:售卖状态

→ 代码生成 → 修改菜单配置信息 → 菜品

→ 上面的生成信息

生成模板:主子表
生成模块名:merchant
生成业务名:dish
关联子表的表名:tb_dish_flavor:菜品口味关系表
子表关联的外键名:dish_id:菜品

→ 在代码生成 → tb_dish_flavor菜品口味关系表 → 点击编辑

基本信息:
实体名:DishFalvor

→ 生成代码(main后端 + vue前端 + dishMenu.sql数据库动态菜单) → 动态sql导入进去 → 前端vue将merchant导入到./src/api中,将views导入到views中 → 在前端中将java和resources导入到merchant模块中

对菜品管理进行升级改造

主键隐藏掉 售价前缀¥ 修改时间时分秒
src\views\merchant\dish\index.vue 
把  <el-table-column label="主键" align="center" prop="id" />  打注释符
////////////////////////////////////////////////////////////////////////////
插入¥流程:
<el-table-column label="售价" align="center" prop="price" /> 修改为 →
<el-table-column label="售价" align="center" prop="price">
        <template #default="scope">
          <div>
            ¥{{ scope.row.price }}
          </div>
        </template>
      </el-table-column>
通义灵码操作:
先解析那行代码,然后输入使用vue3语法在售价前显示¥
////////////////////////////////////////////////////////////////////////////
生成年月日时分秒:
<el-table-column label="更新时间" align="center" prop="updateTime" width="180">
        <template #default="scope">
          <span>{{ parseTime(scope.row.updateTime, '{y}-{m}-{d}') }}</span>
        </template>
      </el-table-column>
修改为:

修改图片回显bug

新增毛血旺的图片若依默认保存在了本地服务器而不是阿里云上
/profile/upload/2024/11/10/毛血旺_20241110093243A001.jpg

C:\Users\Pluminary\Desktop\20241108150415\sky\sky-admin\src\main\resources\application.yml

# 项目相关配置
ruoyi:
  # 名称
  name: RuoYi
  # 版本
  version: 3.8.8
  # 版权年份
  copyrightYear: 2024
  # 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath)
  profile: D:/ruoyi/uploadPath
  # 获取ip地址开关
  addressEnabled: false
  # 验证码类型 math 数字计算 char 字符验证
  captchaType: math

D:\ruoyi\uploadPath\upload\2024\11\10
文件上传组件标签修改 增加&& item.indexOf("http") === -1

watch(() => props.modelValue, val => {
  if (val) {
    // 首先将值转为数组
    const list = Array.isArray(val) ? val : props.modelValue.split(",");
    // 然后将数组转为对象数组
    fileList.value = list.map(item => {
      if (typeof item === "string") {
        if (item.indexOf(baseUrl) === -1 && item.indexOf("http") === -1) {
          item = { name: baseUrl + item, url: baseUrl + item };
        } else {
          item = { name: item, url: item };
        }
      }
      return item;
    });
  } else {
    fileList.value = [];
    return [];
  }
},{ deep: true, immediate: true });
修改口味列表数组格式 改为下拉框的口味搭配
src\views\merchant\dish\index.vue
//------------------------------------------------
// 定义口味名称和口味列表静态数据
const dishFlavorListSelect = ref([
  {name:"辣度", value:["不辣","微辣","中辣","重辣"]},
  {name:"忌口", value:["不要葱","不要蒜","不要香菜","不要辣"]},
  {name:"甜味", value:["无糖","少糖","半糖"]}
]);
//------------------------------------------------
src\views\merchant\dish\index.vue
<template #default="scope">
      <!-- <el-input v-model="scope.row.name" placeholder="请输入口味名称" /> -->
               <!-- label是最终看到下拉框的内容 value是用户提交的内容 -->
           <el-select v-model="scope.row.name"  placeholder="请选择口味名称">
                <el-option
                  v-for="dishFlavor in dishFlavorListSelect"
                  :key="dishFlavor.name"
                  :label="dishFlavor.name" 
                  :value="dishFlavor.name"
                >
                </el-option>
            </el-select>
           </template>
       </el-table-column>
src\views\merchant\dish\index.vue【修改当选中辣度时候 后面的规格】
// 存储当前选中口味列表数组
const checkValueList = ref([]);
// 定义改变口味名称时更新当前选中的口味列表
function changeFlavorName(row){
  // 清空当前行的value
  row.value = [];
  // 根据选中的name去查找静态数据的value
  checkValueList.value = dishFlavorListSelect.value.find(item=>item.name==row.name).value;
}
//增加了一个 @change   注意:如果是多选框一定要 → multiple

<el-table-column label="口味名称" prop="name" width="150">
            <template #default="scope">
              <!-- <el-input v-model="scope.row.name" placeholder="请输入口味名称" /> -->
               <!-- label是最终看到下拉框的内容 value是用户提交的内容 -->
               <el-select v-model="scope.row.name"  placeholder="请选择口味名称"
               @change="changeFlavorName(scope.row)">
                <el-option
                  v-for="dishFlavor in dishFlavorListSelect"
                  :key="dishFlavor.name"
                  :label="dishFlavor.name" 
                  :value="dishFlavor.name"
                >
                </el-option>
               </el-select>
            </template>
          </el-table-column>
          <el-table-column label="口味列表" prop="value" width="150">
            <template #default="scope">
              <!-- <el-input v-model="scope.row.value" placeholder="请输入口味列表" /> -->
          <el-select v-model="scope.row.value"  placeholder="请选择口味列表" multiple>
                <el-option
                  v-for="value in checkValueList"
                  :key="value"
                  :label="value" 
                  :value="value"
                />
          </el-select>
          </template>
          </el-table-column>
//------------------------------------------------
// 定义口味名称和口味列表静态数据
const dishFlavorListSelect = ref([
  {name:"辣度", value:["不辣","微辣","中辣","重辣"]},
  {name:"忌口", value:["不要葱","不要蒜","不要香菜","不要辣"]},
  {name:"甜味", value:["无糖","少糖","半糖"]}
]);


// 存储当前选中口味列表数组
const checkValueList = ref([]);
// 定义改变口味名称时更新当前选中的口味列表
function changeFlavorName(row){
  // 根据选中的name去查找静态数据的value
  checkValueList.value =       dishFlavorListSelect.value.find(item=>item.name==row.name).value;
}
//------------------------------------------------
// 此时报错了 
11:59:00.441 [http-nio-8080-exec-64] ERROR c.s.f.w.e.GlobalExceptionHandler - [handleRuntimeException,100] - 请求地址'/merchant/dish',发生未知异常.
org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.lang.String` from Array value (token `JsonToken.START_ARRAY`); nested exception is com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot deserialize value of type `java.lang.String` from Array value (token `JsonToken.START_ARRAY`)
 at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream); line: 1, column: 239] (through reference chain: com.sky.system.domain.Dish["dishFlavorList"]->java.util.ArrayList[0]->com.sky.system.domain.DishFlavor["value"])
    
/*
根据错误日志,问题出在请求地址 /merchant/dish 处理过程中,JSON 解析时出现了类型不匹配的问题。具体来说,Dish 对象中的 dishFlavorList 字段下的 DishFlavor 对象的 value 字段期望接收一个 String 类型的值,但实际接收到的是一个数组。
*/
解决序列化问题bug将口味列表中value通过JSON工具类转换为字符串

提交数据后是字符串 而不是数组

在331行
/** 提交按钮 */
function submitForm() {
  proxy.$refs["dishRef"].validate(valid => {
    if (valid) {
      form.value.dishFlavorList = dishFlavorList.value;
      // 将口味列表中value通过JSON工具类转换为字符串
      form.value.dishFlavorList.forEach(item=>item.value = JSON.stringify(item.value))
      if (form.value.id != null) {
        updateDish(form.value).then(response => {
          proxy.$modal.msgSuccess("修改成功");
          open.value = false;
          getList();
        });
      } else {
        addDish(form.value).then(response => {
          proxy.$modal.msgSuccess("新增成功");
          open.value = false;
          getList();
        });
      }
    }
  });
解决后端添加成功后 修改后口味列表无法回显再增加个非空判断

因为前端提交了字符串給后端,后端再回去修改的时候无法解析字符串 拿到字符串后返回JSON数组即可

/** 修改按钮操作 */
function handleUpdate(row) {
  reset();
  const _id = row.id || ids.value
  getDish(_id).then(response => {
    form.value = response.data;
    dishFlavorList.value = response.data.dishFlavorList;
    // 将口味列表的value字符串转成json数组
    if(dishFlavorList.value!=null){
      form.value.dishFlavorList.forEach(item=>item.value = JSON.parse(item.value))
    }
    open.value = true;
    title.value = "修改菜品管理";
  });
}

/** 提交按钮 */
function submitForm() {
  proxy.$refs["dishRef"].validate(valid => {
    if (valid) {
      form.value.dishFlavorList = dishFlavorList.value;
      // 将口味列表中value通过JSON工具类转换为字符串
      if(form.value.dishFlavorList!=null){
        form.value.dishFlavorList.forEach(item=>item.value = JSON.stringify(item.value))
      }
      if (form.value.id != null) {
        updateDish(form.value).then(response => {
          proxy.$modal.msgSuccess("修改成功");
          open.value = false;
          getList();
        });
      } else {
        addDish(form.value).then(response => {
          proxy.$modal.msgSuccess("新增成功");
          open.value = false;
          getList();
        });
      }
    }
  });
}
解决修改时无法修改口味列表的下拉框

給口味下拉框绑定一个获取焦点事件 再该事件内调用方法根据当前行的口味名称去查询静态数据 再赋值給静态数组

// 删除清除策略
// 定义口味列表获取焦点时更新当前选中的口味列表
function FocusFlavorName(row){
  // 根据选中的name去查找静态数据的value
  checkValueList.value = dishFlavorListSelect.value.find(item=>item.name==row.name).value;
}
// 加个 @focus
<el-table-column label="口味列表" prop="value" width="150">
            <template #default="scope">
              <!-- <el-input v-model="scope.row.value" placeholder="请输入口味列表" /> -->
              <el-select v-model="scope.row.value"  placeholder="请选择口味列表" multiple
              @focus = "FocusFlavorName(scope.row)">
                <el-option
                  v-for="value in checkValueList"
                  :key="value"
                  :label="value" 
                  :value="value"
                />
               </el-select>
            </template>
          </el-table-column>

二次开发——页面调整

将原有的页面,调整为外卖管理系统的项目标识
  • 浏览器标签页icon、标题
  • 系统页面中的logo、标题
  • 去除源码 & 文档
  • 主题和自定义图标
  • 登录页面中标题、背景图

icon:public中的favicon.ico 改为 favicon.ico.bak 就作废了
把新的图标复制进来改成favicon.ico

标题:最外层index.html
< title> 外卖管理系统 < /title>
但是改完没有效果 是因为运行环境給覆盖了
.env.development修改一下

logo放在了静态资源页面
src\assets\logo\logo.png

修改顶部源码和文档图标内容
src\layout\components\Navbar.vue

<!-- <el-tooltip content="源码地址" effect="dark" placement="bottom">
          <ruo-yi-git id="ruoyi-git" class="right-menu-item hover-effect" />
        </el-tooltip>

        <el-tooltip content="文档地址" effect="dark" placement="bottom">
          <ruo-yi-doc id="ruoyi-doc" class="right-menu-item hover-effect" />
        </el-tooltip> -->

iconfont-阿里巴巴矢量图标库
下载一个菜品管理的小图标
然后给它复制进去src\assets\icons\svg\菜品管理.svg
随后回到菜单管理修改图标就好

登录界面图片修改:
src\views\login.vue 修改一下标题
背景图 → src\assets\images\login-background1.jpg
通过css样式修改背景图
171行
background-image: url("../assets/images/login-background.jpg");
修改为
background-image: url("../assets/images/login-background1.jpg");

.login {
  display: flex;
  justify-content: right;
  align-items: center;
  height: 100%;
  background-image: url("../assets/images/login-background1.jpg");
  background-size: cover;
}
給外卖商家的员工创建角色和用户分配菜单的权限 登录后只能看到自己的功能

系统管理 → 角色管理 → 新增 →
角色名称:商家员工
权限字符串:merchant
角色顺序:4
菜单权限:父子联动
√ 菜品管理

再到用户管理 → 新增 → 添加用户 →
用户昵称:波妞
用户名称:boniu
用户密码:admin123
角色:商家员工

帝可得

  • 帝可得是一个基于物联网概念下的智能售货机运营管理系统

    • 物联网(IOT)

      让各种物品通过互联网链接起来,实现信息的交换和通信

      • 智能家居
      • 共享充电桩
      • 智能售货机
    • 智能售货机

      是物联网技术的一个典型应用

      • 物联网技术
      • 智能分析与推荐
      • 人员设备绑定管理
      • 线上线下融合
  • 售货机术语
    • 区域管理

      为了更高效地进行经营管理,公司将运营范围划分为若干个逻辑区域

    • 点位选择

      点位指的是智能售货机的具体放置位置

    • 售货机功能

      自动小店,摆满了各种零食

    • 货道设计

      售货机里的货道

角色与功能

  • 一个完整的售货机系统由**五端五角色**组成:
  • 管理员:对基础数据(区域、点位、设备、货道、商品等)进行管理
  • 运维人员:投放设备、撤除设备、维修设备。
  • 运营人员:补货。
  • 合作商:仅提供点位,坐收渔翁之利。
  • 消费者:在小程序或屏幕端下单购买商品。

帝可得在线功能文档_Docs

帝可得项目点击链接立即查看 https://codesign.qq.com/s/426304924036117

库表设计

  • 系统后台基础数据表关系说明:

AIGC

  • AI (Artificial Intelligence):即人工智能,是指通过计算机系统模拟人类思维行为一种技术

  • 它通过机器学习、深度学习等算法,使计算机具备对数据分析、理解、推理和决策的能力

  • AIGC (AI Generated content):是AI领域的一个应用分支,专注于利用AI技术自动生成内容

  • 国内常见的通用大模型(AGI)产品:

    • 文心一言
    • 讯飞星火
    • 通义千问
    • KIMI

Prompt的组成

  • 角色:给 AI 定义一个最匹配任务的角色,比如:「你是一位软件工程师」「你是一位小学老师」
  • 指示:对任务进行描述
  • 上下文:给出与任务相关的其它背景信息(尤其在多轮交互中)
  • 例子:必要时给出举例,[实践证明其对输出正确性有帮助]
  • 输入:任务的输入信息;在提示词中明确的标识出输入
  • 输出:输出的格式描述,以便后继模块自动解析模型的输出结果,比如(JSON、Java)

先定义角色,其实就是在开头把问题域收窄,减少二义性。

案例:

角色:你是一位专业的博客作者。

指示:撰写一篇关于最新AI技术发展的文章。

上下文:文章应该涵盖AI技术的当前状态和未来趋势。

例子:可以引用最近的AI技术突破和行业专家的见解。

输入:当前AI技术的相关信息和数据。

输出:一篇结构清晰、观点鲜明的文章草稿。
角色:你是一位资深的Java开发工程师。

指示:编写一个Java函数,该函数接收两个整数参数,并返回它们的和。

上下文:这个函数将被用于一个简单的数学应用程序,该程序帮助学生练习基本的算术运算。

例子:如果你调用函数 `addNumbers(3, 5)`,它应该返回 `8`。

输入:两个整数参数,分别为 `int a` 和 `int b`。

输出:返回这两个整数的和,类型为 `int`。

常见的编程相关的Prompt

表结构

你是一个软件工程师,帮我生成MySQL的表结构
需求如下:
    1,课程管理表,表名tb_course,字段有主键id、课程编码、课程学科、课程名称、课程价格、适用人群、课程介绍
其他要求:
    1,每个表中都有创建时间(create_time)、修改时间(date_time)、创建人(create_by)、修改人(update_by)、备注(remark)这些字段
    2,每个表的主键都是自增的
    3,课程价格是整型、课程编码是字符串
    4,请为每个字段都添加上comment
    5,帮我给生成的表中插入一些IT课程示例数据
        课程学科:Java、人工智能、大数据
        适用人群:小白学员、中级程序员

生成数据库说明文档

你是一个软件工程师,现在要根据数据库的sql脚本,编写数据库说明文档,sql脚本如下:
CREATE TABLE `tb_course` (
    `id` INT AUTO_INCREMENT COMMENT '主键ID',
    `course_code` VARCHAR(255) NOT NULL COMMENT '课程编码',
    `course_subject` VARCHAR(100) NOT NULL COMMENT '课程学科',
    `course_name` VARCHAR(255) NOT NULL COMMENT '课程名称',
    `course_price` INT COMMENT '课程价格',
    `target_audience` VARCHAR(100) COMMENT '适用人群',
    `course_introduction` TEXT COMMENT '课程介绍',
    `create_time` DATETIME COMMENT '创建时间',
    `update_time` DATETIME COMMENT '修改时间',
    `create_by` VARCHAR(64) COMMENT '创建人',
    `update_by` VARCHAR(64) COMMENT '修改人',
    `remark` VARCHAR(255) COMMENT '备注',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程管理表';

输出要求是:
    1,每个表以及每个表的字段都要详细说明,包括,字段名称、类型、作用
    2,使用markdown的输出格式,字段的描述需要使用表格展示
    3,如果表之间有关系,需要描述清楚表之间的关系

生成代码

代码生成算是比较常规的方案,用的也比较多,分为了几种情况

  • 给出表生成代码(项目中常见)
    • 给出表结构的ddl,可以输出这个表的增删改查的所有代码
    • 给出表结构的dll,可以输出增删改查的接口文档
  • 补全代码
    • 例1-给出实体类,帮助编写getter、setter、toString、构造方法等等
    • 例2-给出一个controller,帮助编写swagger注解等
  • 提取结构(无含金量,费时间的编程)
    • 例1-根据接口文档提取dto类或者vo类

生成代码流程图

有一些比较复杂的业务流程,往往需要画出流程图,现在就可以使用ai协助我们画流程图

你是一个软件工程师,为了方便理解代码执行流程,需要给出代码执行的流程图,代码如下:
    // 创建工单
    @Transactional
    @Override
    public int insertTaskDto(TaskDto taskDto) {
    //1. 查询售货机是否存在
    VendingMachine vm = vendingMachineService.selectVendingMachineByInnerCode(taskDto.getInnerCode());
    if (vm == null) {
        throw new ServiceException("设备不存在");
    }
    //2. 校验售货机状态与工单类型是否相符
    checkCreateTask(vm.getVmStatus(), taskDto.getProductTypeId());
    //3. 校验这台设备是否有未完成的同类型工单,如果存在则不能创建
    hasTask(taskDto.getInnerCode(), taskDto.getProductTypeId());
    //4. 校验员工是否存在
    Emp emp = empService.selectEmpById(taskDto.getUserId());
    if (emp == null) {
        throw new ServiceException("员工不存在");
    }
    // 5. 校验非同区域下的工作人员不能接受工单
    if (emp.getRegionId() != vm.getRegionId()) {
        throw new ServiceException("非同区域下的工作人员不能接受工单");
    }
    //6. 保存工单信息
    Task task = new Task();
    BeanUtil.copyProperties(taskDto, task);// 属性赋值
    task.setCreateTime(DateUtils.getNowDate());// 创建时间
    task.setTaskCode(generateTaskCode());// 工单编号 202405150001
    task.setTaskStatus(DkdContants.TASK_STATUS_CREATE);// 创建工单
    task.setAddr(vm.getAddr());
    task.setRegionId(vm.getRegionId());
    task.setUserName(emp.getUserName());
    int taskResult = taskMapper.insertTask(task);
    //7. 如果是补货工单,向工单明细表插入记录
    if (task.getProductTypeId() == DkdContants.TASK_TYPE_SUPPLY) {
        if (CollUtil.isEmpty(taskDto.getDetails())) {
            throw new ServiceException("补货工单明细不能为空");
        }
        List<TaskDetails> taskDetailsList = taskDto.getDetails().stream().map(details -> {
            TaskDetails taskDetails = BeanUtil.copyProperties(details, TaskDetails.class);
            taskDetails.setTaskId(task.getTaskId());
            return taskDetails;
        }).collect(Collectors.toList());
        taskDetailsService.insertBatch(taskDetailsList);
    }

    return taskResult;
}

输出:请使用mermaid语言来描述这个方法的流程
graph TD;
    A[查询售货机] --> B(售货机不存在?)
    B -- 是 --> C[抛出异常: 设备不存在]
    B -- 否 --> D[校验售货机状态与工单类型]
    D --> E(校验失败?)
    E -- 是 --> F[抛出异常]
    E -- 否 --> G[检查未完成同类型工单]
    G --> H(存在未完成同类型工单?)
    H -- 是 --> I[抛出异常]
    H -- 否 --> J[查询员工]
    J --> K(员工不存在?)
    K -- 是 --> L[抛出异常: 员工不存在]
    K -- 否 --> M[校验员工区域与售货机区域是否一致]
    M --> N(不一致?)
    N -- 是 --> O[抛出异常: 非同区域工作人员不能接受工单]
    N -- 否 --> P[保存工单信息]
    P --> Q(保存成功?)
    Q -- 否 --> R[处理保存失败]
    Q -- 是 --> S(工单类型为补货?)
    S -- 是 --> T[处理补货工单明细]
    T --> U[补货明细处理结果?]
    U -- 失败 --> V[抛出异常: 补货工单明细不能为空]
    U -- 成功 --> END(结束)
    S -- 否 --> END

项目搭建(后端)

复制gitee克隆的地址https://gitee.com/Pluminary/dkd-parent.git,在新的idea中找到Get from Version Control在URL中导入xxx.git
若左列模块没有高亮 右侧找maven→clean→package
MySQL的配置和导入 → sql里的sql文件导入到Database
C:\Users\Pluminary\Desktop\dkd-parent\dkd-admin\src\main\resources\application-druid.yml
连接好数据库配置信息
Redis的配置(搞个密码)
为了方便学习我没有搞redis的password 项目里的是:root【application.yml的redis里面】
打开redis服务后就可以启动啦dkd-parent\dkd-admin\src\main\java\com\dkd\DkdApplication.java

项目搭建(前端)

通过vscode克隆源码,仓库地址:https://gitee.com/ys-gitee/dkd-vue.git\

  • 打开VS Code,并确保已经安装了Git。如果未安装Git,请先下载并安装。
  • 在VS Code左侧的活动栏中点击”Source Control”图标,或者按下Ctrl+Shift+G,打开Git集成面板。
  • 在Git集成面板上方的输入框中,选择并输入要克隆的Git仓库地址。可以是HTTP或SSH地址。
  • 点击Enter键,VS Code将连接到Git仓库并拉取最新的代码

在vscode中右上角第二个小框框 点击打开命令行 导入jar包 npm install
然后npm run dev打开项目
帝可得管理系统 http://10.254.1.228/index

在VSCode项目中运行npm install命令主要是用于安装项目所需的Node.js包依赖。以下是具体的作用和步骤:

安装依赖包:当你创建一个Node.js项目或者从版本控制系统中克隆一个项目到本地时,项目中通常会包含一个package.json文件。这个文件里列出了项目所有依赖的包及其版本号。运行npm install命令会读取这个文件,然后从npm(Node Package Manager)仓库下载并安装所有列出的依赖包到项目的node_modules目录。

确保环境一致性:通过npm install,可以确保在不同的开发环境和生产环境中,项目使用的是相同版本的依赖包,这有助于避免因为环境差异导致的bug。

项目初始化:如果你是第一次在一个项目中运行npm install,它还会运行每个依赖包的install脚本,这些脚本可能会进行一些设置工作,比如编译代码、生成必要的文件等。

脚本命令:在package.json中,除了依赖项,还可以定义一些脚本命令(scripts)。npm install会使得这些命令变为可用状态,你可以在项目目录下通过npm run <script-name>来执行这些脚本。

[npm 加速,命令行修改国内镜像源【附带国内最新几个镜像】超简约版_npm 修改国内镜像-CSDN博客](https://blog.csdn.net/m0_52172586/article/details/142930356#::text=1.查看目前的镜像源 >npm get registry 2.设置镜像源 >npm,config set registry https%3A %2F%2Fregistry.npmmirror.com 3.验证)

1.查看目前的镜像源
> npm get registry
2.设置镜像源
> npm config set registry https://registry.npmmirror.com
3.验证
> npm get registry
    
/////////////////////////////////////////
后端maven镜像就先设置好maven地址(非C盘)
然后再去配置maven里的文件设置镜像

点位管理

需求说明

业务场景: 假设我们的公司现在有一个宏伟的计划——在北京发展业务。首先,我们需要确定几个有潜力的区域,这些区域可能是人流量大、消费能力高的商业区或居民区。然后,我们要与这些区域内的潜在合作商进行洽谈,比如商场、写字楼、学校等地方的管理者或所有者。

一旦我们与合作商达成协议,确定了合作的细节和点位,我们就可以安排工作人员去投放智能售货机了。这些点位将成为我们智能售货机的“家”,为消费者提供便捷的购买服务。

点位管理主要涉及到三个功能模块,业务流程如下:

  1. 登录系统:后台管理人员登录后台系统
  2. 新增区域: 后台管理人员可以添加区域范围,区域范围与运维/运维人员挂钩,区域下可关联点位。
  3. 新增合作商: 管理人员可以添加合作商,合作商与点位进行关联。
  4. 新增区域点位: 后台管理人员可以在特定区域内新增点位,这些点位是放置智能售货机的具体位置。
graph TD
    A[登录系统] 
    A --> B[新增区域]
    B --> C[新增合作商]
    C --> D[新增区域点位]

库表设计

  • 参考页面原型和具体需求完成库表设计

区域表: 主键id、区域名称、备注说明
合作商表: 主键id、合作商名称、联系人、联系电话、分成比例、账号、密码
点位表: 主键id、点位名称、详细地址、商圈类型、区域外键、合作商外键

// 你是一位软件工程师,帮我生成MySQL的表结构
需求如下:
1,区域表,表名tb_region,字段有主键id、区域名称
2,合作商表,表名tb_partner,字段有主键id、合作商名称、联系人、联系电话、分成比例(int类型)、账号、密码
3,点位表,表名tb_node,字段有主键id、点位名称、详细地址、商圈类型(int类型)
    
其他要求:
1,每张表中都有创建时间(create_time)、修改时间(date_time)、创建人(create_by)、修改人(update_by)、备注(remark)这些字段
2,每张表的主键都是自增的
3,区域与点位是一对多的关系,合作商与点位是一对多的关系,请用字段表示出来,并建立外键约束
4,请为所有字段都添加上comment
5,帮我给生成的表中插入一些北京城市相关区域、点位、合作商的测试数据
CREATE TABLE `tb_region` (
  `id` INT AUTO_INCREMENT COMMENT '主键id' PRIMARY KEY,
  `region_name` VARCHAR(255) NOT NULL COMMENT '区域名称',
  `create_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  `create_by` VARCHAR(64) COMMENT '创建人',
  `update_by` VARCHAR(64) COMMENT '修改人',
  `remark` TEXT COMMENT '备注'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='区域表';

-- 插入测试数据
INSERT INTO `tb_region` (`region_name`,`remark`) VALUES ('北京市朝阳区','北京市朝阳区'), ('北京市海淀区','北京市海淀区'), ('北京市东城区','北京市东城区');

CREATE TABLE `tb_partner` (
  `id` INT AUTO_INCREMENT COMMENT '主键id' PRIMARY KEY,
  `partner_name` VARCHAR(255) NOT NULL COMMENT '合作商名称',
  `contact_person` VARCHAR(64) COMMENT '联系人',
  `contact_phone` VARCHAR(15) COMMENT '联系电话',
  `profit_ratio` INT COMMENT '分成比例',
  `account` VARCHAR(64) COMMENT '账号',
  `password` VARCHAR(64) COMMENT '密码',
  `create_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  `create_by` VARCHAR(64) COMMENT '创建人',
  `update_by` VARCHAR(64) COMMENT '修改人',
  `remark` TEXT COMMENT '备注'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='合作商表';

-- 插入测试数据
INSERT INTO `tb_partner` (`partner_name`, `contact_person`, `contact_phone`, `profit_ratio`, `account`, `password`) VALUES
('合作商A', '张三', '13800138000', 30, 'a001', 'pwdA'),
('合作商B', '李四', '13912345678', 25, 'b002', 'pwdB');

CREATE TABLE `tb_node` (
  `id` INT AUTO_INCREMENT COMMENT '主键id' PRIMARY KEY,
  `node_name` VARCHAR(255) NOT NULL COMMENT '点位名称',
  `address` VARCHAR(255) NOT NULL COMMENT '详细地址',
  `business_type` INT COMMENT '商圈类型',
  `region_id` INT COMMENT '区域ID',
  `partner_id` INT COMMENT '合作商ID',
  `create_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  `create_by` VARCHAR(64) COMMENT '创建人',
  `update_by` VARCHAR(64) COMMENT '修改人',
  `remark` TEXT COMMENT '备注',
  FOREIGN KEY (`region_id`) REFERENCES `tb_region`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
  FOREIGN KEY (`partner_id`) REFERENCES `tb_partner`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='点位表';


-- 插入测试数据
-- 假设区域ID为1对应'北京市朝阳区',合作商ID为1对应'合作商A'
INSERT INTO `tb_node` (`node_name`, `address`, `business_type`, `region_id`, `partner_id`) VALUES
('三里屯点位', '北京市朝阳区三里屯路', 1, 1, 1),
('五道口点位', '北京市海淀区五道口', 2, 2, 2);

对于点位管理数据模型,下面是示意图:

  • 关系字段:region_id、partner_id

  • 数据字典:business_type

生成基础代码

使用若依代码生成器,生成区域管理、合作商管理、点位管理前后端基础代码,导入项目中:
  • 创建目录菜单
  • 添加数据字典
  • 配置代码生成信息
  • 下载代码并导入项目

系统管理 → 菜单管理 → 新增菜单
主类目、点位管理、2、node

→ 系统管理 → 字典管理
字典名称:商圈类型
字典类型:business_type
→ 在第二页找到商圈类型点进去
新增:旅游区
<u>数据标签:旅游区
数据键值:1
显示排序:1
→ 新增:商场写字楼、2、2;学校33、交通枢纽44

→ 系统工具 → 代码生成 → 导入表(tb_node、tb_partner、tb_region)
分别配置表的生成信息
→ 点击区域表字段信息
→ 根据新增区域弹出菜单显示 需要增加区域名称全打勾 备注说明除了查询全打勾 其余全×

→ 点击**合作商表** → 生成信息
包路径:com.dkd.manage、生成模块名:manage、生成业务名:region、生成功能名:区域管理、上级菜单:点位管理
→ 代码生成:Partner
→ 基本信息:
实体类名称:Partner
作者:itheima
→ 字段信息:见**帝可得后台管理系统.md**
→ 生成信息:
生成包路径:com.dkd.manage
生成模块名:manage
生成功能名:合作商管理
上级菜单:点位管理

→ 点击**点位表**
→ 生成信息:
生成包路径:com.dkd.manage
生成模块名:manage
生成功能名:点位管理
上级菜单:点位管理
→ 字段信息:见**帝可得后台管理系统.md**
→ 基本信息:
实体类名称:Node
表描述:点位表
作者:itheima

回到代码生成 选中三张表 生成!!
分别在前后端和数据库 导入java/manage、vue/manage、sql代码

细节:如果当你创建一个模块以后 src.main.java里面没有任何代码 resources里面也没有 它提交仓库的时候是默认空的 所以可以手动添加一个占位符.gitkeep虽然什么都不是,但是可以提交空项目模块

区域管理改造

基础页面

需求

  • 参考页面原型,完成基础布局展示改造
// 让前端页面自动排序
src\views\manage\region\index.vue
<el-table-column label="序号" type="index" width="50" align="center" prop="id" />
区域管理改造
  • 查看详情,需要显示所有区域下所有点位列表(稍后完成)

  • 在查询区域列表时,同时显示每个区域的点位数,还要新增查看详情

    修改后端要参考接口文档,修改前端要参考产品原型

  • 实现此功能方案:

    (1) 同步存储在区域表中有点位数的字段,当点位发生变化时候,同步区域表中的点位数(在tb_region里面新增一个node_count 方案可行考虑缺点:每次点位数据变化时都要更新区域表[增加了工作量],添加数据不一致也会)
    (2) 关联查询编写关联查询语句,在mapper层封装

SQL查询:先聚合统计每个区域的点位数,然后与区域表进行关联查询
[提前在idea中下面的通义灵码状态勾选 本地补全模型云端模型自动触发]
数据库返回的数据 要结合前端所需要的返回数据来写
比如接口文档需要返回remark id name nodeCount ==> select r.id,r.region_name,r.remark,ifnull(n.node_count,0) as node_count

-- 传统模式
-- 1.先聚合统计每个区域下的点位数
-- 确定查询表 tb_node
-- 确定分组字段 region_id
select region_id,count(*) as node_count from tb_node group by region_id;
-- 2.然后与区域表进行关联查询   内连接是两个表的交集
select r.id,r.region_name,r.remark,ifnull(n.node_count,0) as node_count 
   from tb_region r
left join (select region_id,count(*) as node_count 
   from tb_node group by region_id) n 
on r.id=n.region_id;

-- AI辅助编程模式
-- 查询区域表所有的信息,需要显示每个区域的点位数
SELECT r.*, COUNT(n.id) AS node_count FROM tb_region r LEFT JOIN tb_node n ON r.id = n.region_id GROUP BY r.id;
com/dkd/manage/domain/Region.java
package com.dkd.manage.domain;

import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import com.dkd.common.annotation.Excel;
import com.dkd.common.core.domain.BaseEntity;

/**
 * 区域管理对象 tb_region
 * 
 * @author itheima
 * @date 2024-11-12
 */
public class Region extends BaseEntity
{
    private static final long serialVersionUID = 1L;

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

    /** 区域名称 */
    @Excel(name = "区域名称")
    private String regionName;

    public void setId(Long id) 
    {
        this.id = id;
    }

    public Long getId() 
    {
        return id;
    }
    public void setRegionName(String regionName) 
    {
        this.regionName = regionName;
    }

    public String getRegionName() 
    {
        return regionName;
    }

    @Override
    public String toString() {
        return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
            .append("id", getId())
            .append("regionName", getRegionName())
            .append("createTime", getCreateTime())
            .append("updateTime", getUpdateTime())
            .append("createBy", getCreateBy())
            .append("updateBy", getUpdateBy())
            .append("remark", getRemark())
            .toString();
    }
}
com/dkd/manage/domain/vo/RegionVo.java
package com.dkd.manage.domain.vo;

import com.dkd.manage.domain.Region;
import lombok.Data;

@Data
public class RegionVo extends Region {
    // 点位数量
    private Integer nodeCount;

}
com/dkd/manage/mapper/RegionMapper.java
/**
     * 查询区域列表
     * @param regionVo
     * @return
     */
    public List<RegionVo> selectRegionVoList(RegionVo regionVo);
mapper/manage/RegionMapper.xml    
<select id="selectRegionVoList" resultType="com.dkd.manage.domain.vo.RegionVo">
        SELECT r.*, COUNT(n.id) AS node_count FROM tb_region r LEFT JOIN tb_node n ON r.id = n.region_id GROUP BY r.id
    </select>
思考:上面的xml中提取的字段是 node_count 而前端让返回的是驼峰式命名 nodeCount, 若依默认关闭了此功能需要手动开启此功能
dkd-parent → resources → mybatis → mybatis-config.xml
<!-- 使用驼峰命名法转换字段 -->
<setting name="mapUnderscoreToCamelCase" value="true"/>
Ctrl+F9是热部署 在不新增文件的时候可以直接部署

RegionMapper

/**
 * 查询区域管理列表
 * @param region
 * @return RegionVo集合
 */
public List<RegionVo> selectRegionVoList(Region region);

RegionMapper.xml

<select id="selectRegionVoList" resultType="com.dkd.manage.domain.vo.RegionVo">
select r.id,r.region_name,r.remark,ifnull(n.node_count,0) as node_count from tb_region r
    left join (select region_id,count(*) as node_count from tb_node group by region_id) n on r.id=n.region_id
    <where>
       <if test="regionName != null  and regionName != ''"> and r.region_name like concat('%', #{regionName}, '%')</if>
    </where>
</select> 

IRegionService

/**
 * 查询区域管理列表
 * @param region
 * @return RegionVo集合
 */
public List<RegionVo> selectRegionVoList(Region region);

RegionServiceImpl

/**
 * 查询区域管理列表
 * @param region
 * @return RegionVo集合
 */
@Override
public List<RegionVo> selectRegionVoList(Region region) {
    return regionMapper.selectRegionVoList(region);
}

RegionController

/**
 * 查询区域管理列表
 */
@PreAuthorize("@ss.hasPermi('manage:region:list')")
@GetMapping("/list")
public TableDataInfo list(Region region)
{
    startPage();
    List<RegionVo> voList = regionService.selectRegionVoList(region);
    return getDataTable(voList);
}

region/index.vue

<!-- 区域列表 -->
<el-table v-loading="loading" :data="regionList" @selection-change="handleSelectionChange">
  <el-table-column type="selection" width="55" align="center" />
  <el-table-column label="序号" type="index" width="50" align="center" prop="id" />
  <el-table-column label="区域名称" align="center" prop="regionName" />
  <el-table-column label="点位数" align="center" prop="nodeCount" />
  <el-table-column label="备注说明" align="center" prop="remark" />
  <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
    <template #default="scope">
      <el-button link type="primary"  @click="handleUpdate(scope.row)" v-hasPermi="['manage:region:edit']">修改</el-button>
      <el-button link type="primary"  @click="handleDelete(scope.row)" v-hasPermi="['manage:region:remove']">删除</el-button>
    </template>
  </el-table-column>
</el-table>
小结
  • 区域列表改造步骤
    • 确定关联查询方案并编写sql
    • 创建RegionVo
    • 在RegionMapper和xml中添加查询Vo方法和sql
    • 在RegionService接口和实现类中添加查询Vo方法
    • 修改RegionController查询方法
    • 修改前端视图组件

合作商改造-查看详情

密码是明文 改成密文
<el-form-item label="密码" prop="password">
   <el-input v-model="form.password" type="password" placeholder="请输入密码" />
</el-form-item>
隐藏修改时的账号密码id存在时隐藏不存在显示,因为修改和新增共用了一个对话框ui
<el-form-item label="账号" prop="account" v-if="form.id==null">
    <el-input v-model="form.account" placeholder="请输入账号" />
</el-form-item>
<el-form-item label="密码" prop="password" v-if="form.id==null">
    <el-input v-model="form.password" type="password" placeholder="请输入密码" />
</el-form-item>
前端需要返回创建时间因为数据返回时有,用v-if判断是否修改显示创建时间
 <el-form-item label="创建时间" prop="contactPhone" v-if="form.id!=null">
          {{form.createTime}}
        </el-form-item>
新增时保存的数据是以明文保存到了数据库此时新增的合作商就是密文了
com/dkd/manage/service/impl/PartnerServiceImpl.java  
/**
     * 新增合作商管理
     *
     * @param partner 合作商管理
     * @return 结果
     */
    @Override
    public int insertPartner(Partner partner) {
        // 使用SpringSecurity工具类,对前端传入的密码进行加密
        partner.setPassword(SecurityUtils.encryptPassword(partner.getPassword()));
        partner.setCreateTime(DateUtils.getNowDate());
        return partnerMapper.insertPartner(partner);
    }
合作商管理改造—合作商详情
  • 查看详情,需要显示合作商名称、联系人、联系电话、分成比例
  • 在查询合作商列表时,同时显示每个合作商的点位数
  • 重置密码,初始密码为123456
/** 借鉴修改流程
/** 修改按钮操作 */
function handleUpdate(row) {
  reset();
  const _id = row.id || ids.value
  getPartner(_id).then(response => {
    form.value = response.data;
    open.value = true;
    title.value = "修改合作商管理";
  });
}
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
  <template #default="scope">
    <el-button link type="primary" icon="Edit" @click="getPartnerInfo(scope.row)" v-hasPermi="['manage:partner:query']">查看详情</el-button>
  </template>
</el-table-column>
com/dkd/manage/controller/PartnerController.java
    /**
     * 获取合作商管理详细信息
     */
    @PreAuthorize("@ss.hasPermi('manage:partner:query')")
    @GetMapping(value = "/{id}")
    public AjaxResult getInfo(@PathVariable("id") Long id)
    {
        return success(partnerService.selectPartnerById(id));
    }
/** 查看合作商详情 **/
  const partnerInfoOpen = ref(false)
  function getPartnerInfo(row){
    reset();
    const _id = row.id
    getPartner(_id).then(response => {
      form.value = response.data;
      partnerInfoOpen.value = true;
    });
  }
    <!-- 查看合作商详情对话框 -->
<el-dialog title="合作商详情" v-model="partnerInfoOpen" width="500px" append-to-body>
    <!-- 使用el-descriptions组件以卡片形式展示信息,更加整洁 -->
    <el-descriptions :column="2" border>
        <el-descriptions-item label="合作商名称">{{ form.partnerName }}</el-descriptions-item>
        <el-descriptions-item label="联系人">{{ form.contactPerson }}</el-descriptions-item>
        <el-descriptions-item label="联系电话">{{ form.contactPhone }}</el-descriptions-item>
        <el-descriptions-item label="分成比例">{{ form.profitRatio }}%</el-descriptions-item>
    </el-descriptions>
</el-dialog>

合作商改造—列表查询点位管理→合作商管理→增加点位数

  • 实现此功能方案:

后端改造

关联查询编写关联查询语句,在mapper层封装

tb_node(点位表)的partner_id(合作商ID)
关联
tb_partner(合作商表)的id

-- 查询合作商表的所有信息,同时显示每个合作商的点位数
select p.*, count(n.id) as node_count
from tb_partner p
    left join tb_node n on p.id = n.partner_id
group by p.id

首先先创建一个需要查询新东西的方法增加

com/dkd/manage/domain/vo/PartnerVo.java
package com.dkd.manage.domain.vo;

import com.dkd.manage.domain.Partner;
import lombok.Data;

@Data
public class PartnerVo extends Partner {
    // 点位数量
    private Integer nodeCount;
}
com/dkd/manage/controller/PartnerController.java
 /**
     * 查询合作商管理列表
     */
    @PreAuthorize("@ss.hasPermi('manage:partner:list')")
    @GetMapping("/list")
    public TableDataInfo list(Partner partner) {
        startPage();
        List<PartnerVo> voList = partnerService.selectPartnerVoList(partner);
        return getDataTable(voList);
    }
com/dkd/manage/service/IPartnerService.java
/**
     * 查询合作商列表
     * @param partner
     * @return
     */
    public List<PartnerVo> selectPartnerVoList(Partner partner);
com/dkd/manage/service/impl/PartnerServiceImpl.java
/**
     * 查询合作商列表
     * @param partner
     * @return
     */
    @Override
    public List<PartnerVo> selectPartnerVoList(Partner partner) {
        return partnerMapper.selectPartnerVoList(partner);
    }
com/dkd/manage/mapper/PartnerMapper.java
/**
     * 查询合作商列表
     * @param partner
     * @return
     */
    public List<PartnerVo> selectPartnerVoList(Partner partner);
mapper/manage/PartnerMapper.xml
</select>
        <select id="selectPartnerVoList" resultType="com.dkd.manage.domain.vo.PartnerVo">
        select p.*, count(n.id) as node_count
        from tb_partner p
                 left join tb_node n on p.id = n.partner_id
        <where>
            <if test="partnerName != null  and partnerName != ''"> and partner_name like concat('%', #{partnerName}, '%')</if>
        </where>
        group by p.id
    </select>

前端改造

<el-table-column type="selection" width="55" align="center" />
      <el-table-column label="序号" type="index" width="50" align="center" prop="id" />
      <el-table-column label="合作商名称" align="center" prop="partnerName" />
      <el-table-column label="点位数" align="center" prop="nodeCount" />
      <el-table-column label="账号" align="center" prop="account" />
      <el-table-column label="分成比例" align="center" prop="profitRatio">
        <template #default="scope">
          {{ scope.row.profitRatio + '%' }}
        </template>
      </el-table-column>
      <el-table-column label="联系人" align="center" prop="contactPerson" />
      <el-table-column label="联系电话" align="center" prop="contactPhone" />
      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">

合作商改造——重置密码

  • 查看详情,需要显示合作商名称、联系人、联系电话、分成比例
  • 在查询合作商列表时,同时显示每个合作商的点位数
  • 重置密码,初始密码为123456

后端部分

在PartnerController中

/**
 * 重置合作商密码
 */
@PreAuthorize("@ss.hasPermi('manage:partner:edit')")
@Log(title = "重置合作商密码", businessType = BusinessType.UPDATE)
@PutMapping("/resetPwd/{id}")
public AjaxResult resetpwd(@PathVariable Long id) {//1. 接收参数
    //2. 创建合作商对象
    Partner partner = new Partner();
    partner.setId(id);// 设置id
    partner.setPassword(SecurityUtils.encryptPassword("123456"));// 设置加密后的初始密码
    //3. 调用service更新密码
    return toAjax(partnerService.updatePartner(partner));
}

前端部分

manage/partner.js请求api中

// 重置合作商密码
export function resetPartnerPwd(id){
  return request({
    url: '/manage/partner/resetPwd/' + id,
    method: 'put'
  })
}

partner/index.vue视图组件中参考@click=”handleDelete”,此方法删除时弹出对话框

<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="300px">
    <template #default="scope">
        <el-button link type="primary" @click="resetPwd(scope.row)" v-hasPermi="['manage:partner:edit']">重置密码</el-button>
    </template>
</el-table-column>

<script>
    import { listPartner, getPartner, delPartner, addPartner, updatePartner,resetPartnerPwd } from "@/api/manage/partner";
    /* 重置合作商密码 */
    function resetPwd(row) {
        proxy.$modal.confirm('你确定要重置该合作商密码吗?').then(function () {
            return resetPartnerPwd(row.id);
        }).then(() => {
            proxy.$modal.msgSuccess("重置成功");
        }).catch(() => { });
    }
</script>

点位管理改造——基础布局

修改前端更简单一些 可以把前端一次性请求1w条数据 后端接口就可以重复调用

src\views\manage\node\index.vue视图组件中

import {listRegion} from "@/api/manage/region";

/* 查询所有条件对象 */
const loadAllParams=reactive({
  pageNum:1,
  pageSize:10000
})
/** 查询区域列表 **/
const regionList=ref([]);
function getRegionList() {
  listRegion(loadAllParams).then(response=>{
    regionList.value=response.rows;
  })
}
getRegionList();
getList();

src\api\manage\region.js

// 查询区域管理列表
export function listRegion(query) {
  return request({
    url: '/manage/region/list',
    method: 'get',
    params: query
  })
}
新增点位管理的时候想把合作商输入的改成自动获取的下拉框
  • 定义js代码向后台发送请求,将请求后的结果封装给合作商parnterList集合
  • 将文本框改成下拉框来遍历展示每个合作商的名称,提交时关联合作商的id
      <el-table-column label="详细地址" align="center" prop="address" show-overflow-tooltip/> 详细地址多出的部分隐藏只有鼠标移动到才会显示


import {listPartner} from "@/api/manage/partner"

<el-form-item label="合作商ID" prop="partnerId">
          <!-- <el-input v-model="form.partnerId" placeholder="请输入合作商ID" /> -->
           <el-select v-model="form.partnerId" placeholder="请选择合作商">
            <el-option
              v-for="item in partnerList"
              :key="item.id"
              :label="item.partnerName"
              :value="item.id">
            </el-option>
           </el-select>
</el-form-item>

/* 查询合作商列表 */
const partnerList=ref([]);
function getPartnerList(){
  listPartner(loadAllParams).then(response=>{
    partnerList.value = response.rows;
  })
}
getPartnerList();
避免每次都要写pageSize:10000 直接搞入js里面
src\api\page.js
/* 查询所有条件对象 */
// const loadAllParams=reactive({
//   pageNum:1,
//   pageSize:10000
// }) →

export const loadAllParams = reactive({
  pageNum: 1,
  pageSize: 10000,
});

点位管理改造点位中增加个查看详情(将单表查询改为多表查询咯)

  • 查看详情,需要显示当前点位下所有设备列表(稍后完成)
  • 在区域详情中,需要显示每个点位的设备数
  • 在点位列表查询中,关联显示区域、合作商等信息
  • 关联查询:对于设备数量的统计,我们需要执行关联查询,在mapper层封装
  • 关联实体对于区域和合作商的数据,我们会采用Mybatis提供的嵌套查询功能

点位管理改造表设计.png

<resultMap>......</resultMap>   #完成手动映射

#解决一对一 或 多对一 映射结果集只有一个对象时完成的ORM的映射封装
<association>......</association>  #点位和点位1对1  点位和合作商1对多

#解决一对多场景下来映射多个结果的集合 单个区域表+区域点位列表 映射的是集合!!
<collection>......</collection> 
# AI辅助编程模式
-- AI辅助编程模式
-- 你是一个软件开发工程师,现在要根据数据库的sql脚本,查询并显示点位表所有的字段信息,同时显示每个点位的设备数量,sql脚本如下:
create table tb_node
(
    id            int auto_increment comment '主键id'
        primary key,
    node_name     varchar(255)                        not null comment '点位名称',
    address       varchar(255)                        not null comment '详细地址',
    business_type int                                 null comment '商圈类型',
    region_id     int                                 null comment '区域ID',
    partner_id    int                                 null comment '合作商ID',
    create_time   timestamp default CURRENT_TIMESTAMP null comment '创建时间',
    update_time   timestamp default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP comment '修改时间',
    create_by     varchar(64)                         null comment '创建人',
    update_by     varchar(64)                         null comment '修改人',
    remark        text                                null comment '备注',
    constraint tb_node_ibfk_1
        foreign key (region_id) references tb_region (id)
            on update cascade on delete cascade,
    constraint tb_node_ibfk_2
        foreign key (partner_id) references tb_partner (id)
            on update cascade on delete cascade
)
    comment '点位表';
    
create table tb_vending_machine
(
    id                   bigint auto_increment comment '主键'
        primary key,
    inner_code           varchar(15) default '000'                 null comment '设备编号',
    channel_max_capacity int                                       null comment '设备容量',
    node_id              int                                       not null comment '点位Id',
    addr                 varchar(100)                              null comment '详细地址',
    last_supply_time     datetime    default '2000-01-01 00:00:00' not null comment '上次补货时间',
    business_type        int                                       not null comment '商圈类型',
    region_id            int                                       not null comment '区域Id',
    partner_id           int                                       not null comment '合作商Id',
    vm_type_id           int         default 0                     not null comment '设备型号',
    vm_status            int         default 0                     not null comment '设备状态,0:未投放;1-运营;3-撤机',
    running_status       varchar(100)                              null comment '运行状态',
    longitudes           double      default 0                     null comment '经度',
    latitude             double      default 0                     null comment '维度',
    client_id            varchar(50)                               null comment '客户端连接Id,做emq认证用',
    policy_id            bigint                                    null comment '策略id',
    create_time          timestamp   default CURRENT_TIMESTAMP     not null comment '创建时间',
    update_time          timestamp   default CURRENT_TIMESTAMP     null comment '修改时间',
    constraint vendingmachine_VmId_uindex
        unique (inner_code),
    constraint tb_vending_machine_ibfk_1
        foreign key (vm_type_id) references tb_vm_type (id),
    constraint tb_vending_machine_ibfk_2
        foreign key (node_id) references tb_node (id),
    constraint tb_vending_machine_ibfk_3
        foreign key (policy_id) references tb_policy (policy_id)
)
    comment '设备表';
-- 查询并显示点位表所有的字段信息,同时显示每个点位的设备数量
SELECT
    n.id,
    n.node_name,
    n.address,
    n.business_type,
    n.region_id,
    n.partner_id,
    n.create_time,
    n.update_time,
    n.create_by,
    n.update_by,
    n.remark,
    COUNT(v.id) AS vm_count
FROM
    tb_node n
LEFT JOIN
    tb_vending_machine v ON n.id = v.node_id
GROUP BY
    n.id;
package com.dkd.manage.domain.vo;

import com.dkd.manage.domain.Node;
import com.dkd.manage.domain.Partner;
import com.dkd.manage.domain.Region;
import lombok.Data;

@Data
public class NodeVo extends Node {
    // 设备数量
    private Integer vmCount;
    // 区域信息
    private Region region;
    // 合作商信息
    private Partner partner;
}
com/dkd/manage/mapper/NodeMapper.java
/**
     *  查询点位管理列表
     * @param node
     * @return
     */
    public List<NodeVo> selectNodeVoList(Node node);
<!--
resultType="com.dkd.manage.domin.vo.NodeVo">...
这个是mybatis以前搞的自动映射封装直接把结果映射给了NodeVo的实体类了
嵌套查询就不能使用resultType自动映射 要改为resultMap做自动映射
<resultMap type="NodeVo" id="NodeVoResult">
多表查询一定要起别名噢 不然会报错没有指明where的子句是来自tb_node表的region_id  还是来自tb_vending_machine表的region_id
-->

<select id="selectNodeVoList" parameterType="Node" resultMap="NodeVoResult">
        SELECT
        n.id,
        n.node_name,
        n.address,
        n.business_type,
        n.region_id,
        n.partner_id,
        n.create_time,
        n.update_time,
        n.create_by,
        n.update_by,
        n.remark,
        COUNT(v.id) AS vm_count
        FROM
        tb_node n
        LEFT JOIN
        tb_vending_machine v ON n.id = v.node_id
        <where>
            <if test="nodeName != null  and nodeName != ''"> and n.node_name like concat('%', #{nodeName}, '%')</if>
            <if test="regionId != null "> and n.region_id = #{regionId}</if>
            <if test="partnerId != null "> and n.partner_id = #{partnerId}</if>
        </where>
        GROUP BY
        n.id
    </select>
多对一标签用association
<resultMap type="NodeVo" id="NodeVoResult">
        <result property="id"    column="id"    />
        <result property="nodeName"    column="node_name"    />
        <result property="address"    column="address"    />
        <result property="businessType"    column="business_type"    />
        <result property="regionId"    column="region_id"    />
        <result property="partnerId"    column="partner_id"    />
        <result property="createTime"    column="create_time"    />
        <result property="updateTime"    column="update_time"    />
        <result property="createBy"    column="create_by"    />
        <result property="updateBy"    column="update_by"    />
        <result property="remark"    column="remark"    />
        <result property="vmCount"    column="vm_count"    />
<!--
sql语法拿到region_id去执行区域当中的selectRegionById 方法执行的时候需要传递区域的id
原理:RegionMapper.java中的 public Region selectRegionById(Long id)把条件拿到手并封装给区域的Region对象 返回的Region对象最终映射给NodeVo.java的 private Region region;
怎么执行的映射呢?需要指定java属性名和执行的类型:
property="region" 
javaType="Region"
至此完成了mybatis的嵌套查询
-->
        <association property="region" javaType="Region" column="region_id" select="com.dkd.manage.mapper.RegionMapper.selectRegionById"/>
        <association property="partner" javaType="Partner" column="partner_id" select="com.dkd.manage.mapper.PartnerMapper.selectPartnerById"/>
    </resultMap>
<resultMap>......</resultMap>   // 完成手动映射 为了实现多表映射情况组合查询

// 解决一对一 或 多对一 映射结果集只有一个对象时完成的ORM的映射封装
<association>......</association>  #点位和点位1对1  点位和合作商1对多

// 解决一对多场景下来映射多个结果的集合 单个区域表+区域点位列表 映射的是集合!!
<collection>......</collection> 


/*
// <association> 标签允许你在查询结果中嵌套另一个对象。
 这样可以方便地在 NodeVo 对象中直接访问 Region 和 Partner 的属性,而不需要额外的查询
 
property:指定 NodeVo 类中的属性名称,该属性将引用关联的对象。
javaType:指定关联对象的 Java 类型。
column:指定用于关联查询的列名,通常是外键。
select:指定一个子查询的方法,用于根据外键查询关联对象
*/

NodeService

/**
 * 查询点位管理列表
 * @param node
 * @return NodeVo集合
 */
public List<NodeVo> selectNodeVoList(Node node);

NodeServiceImpl

/**
 * 查询点位管理列表
 *
 * @param node
 * @return NodeVo集合
 */
@Override
public List<NodeVo> selectNodeVoList(Node node) {
    return nodeMapper.selectNodeVoList(node);
}

NodeController

/**
 * 查询点位管理列表
 */
@PreAuthorize("@ss.hasPermi('manage:node:list')")
@GetMapping("/list")
public TableDataInfo list(Node node)
{
    startPage();
    List<NodeVo> voList = nodeService.selectNodeVoList(node);
    return getDataTable(voList);
}
com/dkd/manage/mapper/NodeMapper.java
/**
     *  查询点位管理列表
     * @param node
     * @return
     */
    public List<NodeVo> selectNodeVoList(Node node);

node/index.vue

<!-- 点位列表 -->
<el-table v-loading="loading" :data="nodeList" @selection-change="handleSelectionChange">
  <el-table-column type="selection" width="55" align="center" />
  <el-table-column label="序号" type="index" width="50" align="center" prop="id" />
  <el-table-column label="点位名称" align="center" prop="nodeName" />
  <el-table-column label="所在区域" align="center" prop="region.regionName" />
  <el-table-column label="商圈类型" align="center" prop="businessType">
    <template #default="scope">
      <dict-tag :options="business_type" :value="scope.row.businessType" />
    </template>
  </el-table-column>
  <el-table-column label="合作商" align="center" prop="partner.partnerName" />
  <el-table-column label="详细地址" align="center" prop="address" show-overflow-tooltip="true"/>
  <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
    <template #default="scope">
      <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" v-hasPermi="['manage:node:edit']">修改</el-button>
      <el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['manage:node:remove']">删除</el-button>
    </template>
  </el-table-column>
</el-table>

区域管理改造-地区详情新增查看详情

src\views\manage\region\index.vue
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
        <template #default="scope">
          <el-button link type="primary"  @click="getRegionInfo(scope.row)" v-hasPermi="['manage:region:list']">查看详情</el-button>
          <el-button link type="primary"  @click="handleUpdate(scope.row)" v-hasPermi="['manage:region:edit']">修改</el-button>
          <el-button link type="primary"  @click="handleDelete(scope.row)" v-hasPermi="['manage:region:remove']">删除</el-button>
        </template>
      </el-table-column>

<!-- 
template 插槽:
#default="scope":定义默认插槽,scope 是当前行的数据对象。

@click="getRegionInfo(scope.row)":点击按钮时调用 getRegionInfo 函数,并传入当前行的数据。
-->

...
...

/* 查看详情操作按钮 */
function getRegionInfo(row) {
  // 查询区域信息
  reset();
  const _id = row.id
  getRegion(_id).then(response => {
    form.value = response.data
  });
}

<!-- 
nodeList 变量:

const nodeList = ref([]):定义一个响应式数组 nodeList,用于存储点位列表。
getRegionInfo 函数:

reset():调用 reset 函数,可能用于重置表单或其他状态。
const _id = row.id:获取当前行的 id。
getRegion(_id).then(response => { form.value = response.data }):调用 getRegion 函数查询区域信息,并将返回的数据赋值给 form.value。
loadAllParams.regionId = row.id:设置 loadAllParams 对象的 regionId 属性为当前行的 id。
listNode(loadAllParams).then(response => { nodeList.value = response.rows }):调用 listNode 函数查询点位列表,并将返回的行数据赋值给 nodeList.value。
-->
区域管理里引入点位的api文件
import { listNode } from "@/api/manage/node";
import { loadAllParams } from "@/api/page";
...
/* 查看详情操作按钮 */
const nodeList = ref([]);
function getRegionInfo(row) {
  // 查询区域信息
  reset();
  const _id = row.id
  getRegion(_id).then(response => {
    form.value = response.data
  });
  // 查看点位列表
  loadAllParams.regionId=row_id
  listNode(loadAllParams).then(response => {
    nodeList.value = response.rows;
  });
}
添加区域管理对话框
 <!-- 添加或修改区域管理对话框 -->
    <el-dialog :title="title" v-model="open" width="500px" append-to-body>
      <el-form ref="regionRef" :model="form" :rules="rules" label-width="80px">
        <el-form-item label="区域名称" prop="regionName">
          <el-input v-model="form.regionName" placeholder="请输入区域名称" />
        </el-form-item>
        <el-form-item label="备注说明" prop="remark">
          <el-input v-model="form.remark" type="textarea" placeholder="请输入内容" />
        </el-form-item>
      </el-form>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="submitForm">确 定</el-button>
          <el-button @click="cancel">取 消</el-button>
        </div>
      </template>
    </el-dialog>
   <!-- 查看详情对话框 -->
<el-dialog title="区域详情" v-model="regionInfoOpen" width="500px" append-to-body>
    <el-form-item label="区域名称" prop="regionName">
        <el-input v-model="form.regionName" disabled />
    </el-form-item>
    <label>包含点位:</label>
    <el-table :data="nodeList">
        <el-table-column label="序号" type="index" width="50" align="center" />
        <el-table-column label="点位名称" align="center" prop="nodeName" />
        <el-table-column label="设备数量" align="center" prop="vmCount" />
    </el-table>
</el-dialog>
/* 查看详情操作按钮 */
const nodeList = ref([]);
const regionInfoOpen=ref(false);
function getRegionInfo(row) {
  // 查询区域信息
  reset();
  const _id = row.id
  getRegion(_id).then(response => {
    form.value = response.data
  });
  // 查看点位列表
  loadAllParams.regionId=row_id
  listNode(loadAllParams).then(response => {
    nodeList.value = response.rows;
  });
  regionInfoOpen.value=true;
}
数据完整性
  • 在删除区域或合作商数据时,关联的点位数据该如何处理?

tb_region(区域表) tb_node(点位表) tb_partner(合作商表)
id ←region_id:id ← region_id id
partner_id partner_id:id →→→↑

找到设置外键约束(取消约束)dkd → tb_node → Modify Table(old) 找到Foreign Keys 双击打开后将Update ruleDelete rule修改为:no action

**CASCADE(级联操作):**当父表中的某行记录被删除或更新时,与其关联的所有子表中的匹配行也会自动被删除或更新。这种方式适用于希望保持数据一致性的场景,即父记录不存在时,相关的子记录也应该被移除。

**SET NULL(设为空):**若父表中的记录被删除或更新,子表中对应的外键字段会被设置为NULL。选择此选项的前提是子表的外键列允许为NULL值。这适用于那些子记录不再需要明确关联到任何父记录的情况。

**RESTRICT(限制):**在尝试删除或更新父表中的记录之前,数据库首先检查是否有相关联的子记录存在。如果有,则拒绝执行删除或更新操作,以防止意外丢失数据或破坏数据关系的完整性。这是一种保守策略,确保数据间的引用完整性。

**NO ACTION(无操作):**在标准SQL中,NO ACTION是一个关键字,它要求数据库在父表记录被删除或更新前,检查是否会影响子表中的相关记录。在MySQL中,NO ACTION的行为与RESTRICT相同,即如果子表中有匹配的行,则禁止执行父表的删除或更新操作。这意味着如果存在依赖关系,操作将被阻止,从而保护数据的参照完整性。

修改完毕后,如果你尝试进行删除操作,会发现数据库的完整性约束生效了,它会阻止删除操作并给出错误提示。但是,这个错误提示信息可能对于用户来说不够友好,可能会让用户感到困惑。

SQLIntegrityConstraintViolationException是Java中的一个异常类,这个类通常用于表示SQL数据库操作中的完整性约束违反异常

例如:外键约束、唯一约束等。当数据库操作违反了这些约束时,就会抛出这个异常。

这个错误是由于外键约束导致的。它表明在删除或更新父表的行时,存在外键约束,子表中的相关行会受到影响。

是因为在删除tb_region表中的行时,tb_node表中的region_id外键约束会阻止操作。

如果你在使用Spring框架进行数据库操作,可能会先遇到DataIntegrityViolationException,它是对SQLIntegrityConstraintViolationException的一个更高层次的抽象,旨在提供一种更加面向应用的错误表示。

而SQLIntegrityConstraintViolationException是更底层的异常,直接来源于数据库驱动,包含更多底层数据库相关的细节。

在实际开发中,推荐捕获并处理DataIntegrityViolationException,因为它更符合Spring应用的异常处理模式,同时也可以通过其内部的cause(原因)属性来获取具体的SQLIntegrityConstraintViolationException,进而获取详细的错误信息。

为了提升用户体验,我们可以使用Spring Boot框架的全局异常处理器来捕获这些错误信息,并返回更友好的提示信息给用户。这样,当用户遇到这种情况时,他们将收到一个清晰、易懂的提示,告知他们操作无法完成的原因。

com/dkd/framework/web/exception/GlobalExceptionHandler.java
/**
     * 数据完整性异常
     */
    @ExceptionHandler(DataIntegrityViolationException.class)
    public AjaxResult handleDataIntegrityViolationException(DataIntegrityViolationException e) {
        log.error(e.getMessage(), e);
        if (e.getMessage().contains("foreign")) {
            return AjaxResult.error("无法删除该数据,有其他数据引用");
        }
        return AjaxResult.error("数据完整性异常,请联系管理员");
    }

16

阅读全文

Java面试专项

2024/10/31

Redis篇

我看你做的项目中,都用到了redis,你在最近的项目中哪些场景使用了redis呢?
  • 验证你项目场景的真实性,二是为了深入发问的切入点
  • 缓存 缓存三兄弟(穿透、击穿、雪崩)、双写一致、持久化、数据过期策略、数据淘汰策略
  • 分布式锁 setnx、redisson
  • 消息队列、延迟队列 何种数据类型

==缓存穿透==:查询一个不存在的数据,mysql查询不到数据也不会直接写入缓存,就会导致每次请求都查询数据库(可能原因是数据库被攻击了 发送了假的/大数据量的请求url)

  • 解决方案一缓存空数据,查询返回的数据为空,仍把这个空结果进行缓存 {key:1, value:null}
    优点:简单
    缺点:消耗内存,可能会发生不一致的问题

  • 解决方案二布隆过滤器 (拦截不存在的数据)

    在缓存预热时,要预热布隆过滤器。根据id查询文章时查询布隆过滤器如果不存在直接返回

    bitmap(位图):相当于一个以bit位为单位的数组,数组中每个单元只能存储二进制数0或1

    布隆过滤器作用:可以用于检索一个元素是否在集合中

    • 存储数据:id为1的数据,通过多个hash函数获取hash值,根据hash计算数组对应位置改为1
    • 查询数据:使用相同hash函数获取hash值,判断对应位置是否都为1

    存在误判率:数组越小 误判率越大

    bloomFilter.tryInit(size, 0.05) //误判率5%
    

==缓存击穿==:给某一个key设置了过期时间,当key过期的时候,恰好这个时间点对这个key有大量的并发请求过来,这些并发请求可能一瞬间把DB击穿

  • 解决方案一互斥锁【数据强一致性 性能差 (银行)】

    1.查询缓存,未命中 → 2.获取互斥锁成功 → 3.查询数据库重建缓存数据 → 4.写入缓存 → 5.释放锁

    1.查询缓存,未命中 → 2.获取互斥锁失败 → 3.休眠一会再重试 → 4.写入缓存重试 → 5.缓存命中

  • 解决方案二逻辑过期[不设置过期时间] 【高可用 性能优 不能保证数据绝对一致 (用户体验)】
    也可以搞个永不过期 具体是先在业务里写好某种情况下 某些时候不会过期 比如疫情卖口罩时期

    在数据库一条数据里面添加一个 “expire”: 153213455

    1.查询缓存,发现逻辑时间已过期 → 2.获取互斥锁成功 → 3.开启线程 ↓→ 4.返回过期数据

    ​ 【在新的线程】→ 1.查询数据库重建缓存数据 → 2.写入缓存,重置逻辑过期时间 → 3.释放锁
    1.查询数据缓存,发现逻辑时间已过期 → 2.获取互斥锁失败 → 3.返回过期数据

==缓存雪崩==:在同一个时段内大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来压力

  • 解决方案一:给不同的key的TTL(过期时间)添加随机值
  • 解决方案二:利用Redis集群提高服务的可用性 【哨兵模式、集群模式】
  • 解决方案三:给缓存业务添加降级限流策略【nginx、springcloud、gateway】
  • 解决方案四:给业务添加多级缓存 【Guava(做一级缓存 然后Redis是二级缓存)或Caffeine】
《缓存三兄弟》
穿透无中生有key,布隆过滤null隔离。
缓存击穿过期key,锁与非期解难题。
雪崩大量过期key,过期时间要随机。
面试必考三兄弟,可用限流来保底。
redis作为缓存,mysql的数据如何与redis进行同步呢?(双写一致性)

二、Redis 和 MySQL 不一致会发生什么?

如果同步失败,会出现:

  • 缓存中的数据是脏的(脏读)
  • 缓存中没有数据,但数据库中有(缓存穿透)
  • 数据库更新了,但 Redis 仍是旧值(一致性问题)
  • 并发写操作丢失最新值(写丢失)

先插入数据库
更新先更新数据库 更新数据库成功但redis不成功 影响不大 因为后面会有过期删除 最终会一致,更新mysql后缓存可以删除也可以修改
更新完数据库直接删除缓存了 有过期时间兜底 最终会保持一致 我们项目中对数据敏感性一致性不高 我们追求实时性
如果是最终保持一致性的就MQ 我们对实时性不高 对数据敏感性 一致性高
删除问题不大 哪里都行!
读多写少的可以上缓存
mysql保存购物车表 但是再页面操作的时候 只操作redis 用mq给到消费者修改或定时任务 更新数据到mysql,MQ问题:我们对数据实时性要求不高 只需要保存最终一致性就行

你如果只写redis 万一丢了数据怎么办
购物车丢点订单无影响 数据安全性要求不太高 mysql尽量不要搞购物车的表 都在redis的表 丢就丢了呗。
或者异步同步/定时任务
实时性要求 安全性要求 → MySQL
电商一般数据库和mysql都要存 → 读多写少

一定、一定、一定要设置前提,介绍自己的业务背景 (一致性要求高?允许延迟一致?)

① 介绍自己简历上的业务,我们当时是把文章的热点数据存入到了缓存中,虽然是热点数据,但是实时要求性并没有那么高,所以我们采用的是异步的方案同步的数据

② 我们当时是把抢卷的库存存入到了缓存中,这个需要实时的进行数据同步,为了保证数据的强一致性,我们当时采用的是redission提供的读写锁来保证数据的同步

双写一致性:当修改了数据库的数据也要同时更新缓存的数据,缓存和数据库的数据要保持一致

  • 读操作:缓存命中,直接返回;缓存未命中查询数据库,写入缓存,设定超时时间

  • 写操作:延迟双删 [因为无论先删除缓存还是先删除数据库都可能会出数据不一致问题 有脏数据]

  • ==基于redisson互斥锁:==[放入缓存中的数据 读多写少] 【强一致性业务 性能低】

    • 共享锁:读锁readLock,加锁之后,其他线程可以共享读操作,但**不允许写操作**
    • 排他锁:独占锁writeLock也叫,加锁之后,阻塞其他线程读写操作(只允许一个用户或进程独占地对数据进行读取和写入操作)排他锁确保了写操作的原子性和一致性
    • 读数据的时候添加共享锁(读不互斥、写互斥)
    • 写数据的时候添加排他锁(阻塞其他线程的读写 因为读多写少)

    redissionClient.getReadWriteLock(“xxxx”);

  • ==异步通知==: 异步通知保证数据的最终一致性(需要保证MQ的可靠性)需要在Redis中更新数据的同时,通知另一个服务进行某些操作。

    • 使用场景
      • 缓存与数据库双写: 当应用需要同时更新Redis缓存和数据库时,可以先将数据写入Redis,然后通过异步通知机制触发数据库的更新操作。
      • 跨地域数据复制: 在跨地域部署的服务中,为了实现数据的最终一致性,可以在一个地域写入数据后,通过异步通知机制在另一个地域进行数据复制。
      • 系统间数据同步: 在微服务架构中,不同的服务可能有自己的数据存储。当一个服务更新了数据后,可以通过异步通知机制告知其他相关服务进行数据同步。
  • ==基于Canal的异步通知==:监听mysql的binlog

    • 使用MQ中间件,更新数据之后,通知缓存删除
    • 利用canal中间件,不需要修改业务代码,伪装为mysqls的一个从节点,canal通过读取binlog数据更新缓存

✅ 为什么会出现 Redis 和 MySQL 数据不一致?

Redis 是缓存,MySQL 是数据库,它们的数据生命周期不同,导致在更新时会出现以下几种情况:


💥 情况1:先更新数据库,再删除缓存(常见写法)

update DB
delete cache
  • 假设刚执行完 update DB,还没来得及 delete cache,此时某个高并发请求进来:
    • 它先查缓存,发现是旧数据;
    • 然后返回了错误的数据;
    • 后续即使删了缓存,已经晚了。

这就是:缓存未及时失效,读取到了脏数据


💥 情况2:先删除缓存,再更新数据库

delete cache
update DB
  • 这个时候如果并发查询线程来得很快:
    • 缓存刚被删,查询请求就查不到;
    • 就会穿透查数据库,查到旧数据;
    • 然后又把旧数据写入了 Redis,覆盖了更新后的数据!

💥 情况3:缓存过期后查询数据库,正好遇到更新未完成

Redis key 过期
查询数据库返回旧数据
写入 Redis(错的数据被缓存)

这就是缓存击穿 + 数据同步延迟的问题。


✅ 数据为什么会“丢”?需要同步吗?

Redis 是内存数据库,不具备强一致性保障,以下情况会导致“看起来丢数据”:

  1. 更新了数据库,但缓存没更新/没删除
  2. 缓存提前过期,重新加载了旧数据
  3. Redis 重启,缓存丢失
  4. 并发穿透,旧数据反复写入缓存
  5. 程序异常,缓存更新/删除逻辑没执行

✅ 怎么保证 Redis 和 MySQL 的一致性?

这就是我们说的:缓存与数据库双写一致性问题,常见策略如下:

1️⃣ 读写操作采用延迟双删策略(推荐)

update DB
delete Redis
sleep 500ms
delete Redis again
  • 延迟双删可以尽可能避免并发查询旧缓存的情况。

2️⃣ 加分布式锁

  • 给关键资源加锁,串行化更新操作,避免并发穿透。

3️⃣ 异步更新缓存(利用消息队列)

  • 变更数据后,发送消息通知缓存异步刷新。

4️⃣ 设置合理的缓存 TTL + 定时刷新

  • 防止长期过期数据驻留,降低不一致几率。

5️⃣ 不缓存非热点数据

  • 某些冷门数据没必要缓存,避免无谓一致性维护。
Redis作为缓存,数据的持久化是怎么做的?

Redis持久化:RDB全称Redis Database Backup file(Redis数据备份文件),也被叫做Redis数据快照,简单来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,数据恢复。

[root@localhost ~]# redis-cli
127.0.0.1:6379> save          #由Redis主进程来执行RDB,会阻塞所有命令
ok

127.0.0.1:6379> bgsave        #开 启子进程执行RDB,避免主进程受到影响
Background saving started

Redis内部有触发RDB的机制,可以在redis.conf文件中找到,格式如下:

// 900秒内,如果至少有1个key被修改,则执行bgsave
save 900 1
save 300 10
save 60 10000

==RDB的执行原理?==数据完整性高用RDB
save就是直接让主线程去执行

bgsave开始时会fork主进程得到子进程,子进程共享主进程的内存数据,完成fork后读取内存数据并写入RDB文件

在LInux中主进程并无法直接读取物理内存,它只能通过虚拟内存去读。因此有页表(记录虚拟地址与物理地址的映射关系)去执行操作 同时 主进程也会fork(复制页表) 成为一个新的子进程(携带页表) → 写新RDB文件替换旧的RDB文件 → 磁盘

fork采用的是copy-on-write技术:

  • 当主进程执行读操作时,访问共享内存
  • 当主进程执行写操作时,则会拷贝一份数据,执行写操作

优点:二进制数据重启后 Redis无需过多解析 直接恢复

==AOF==对数据不敏感要求不高

AOF全称为Append Only File(追加文件)底层硬盘顺序读写。Redis处理的每个写命令都会记录在AOF,可以看作是命令日志文件
AOF默认是关闭的,需要修改redis.conf配置文件来开启AOF

# 是否开启AOF功能,默认是no
appendonly yes
# AOF文件的名称
appendfilename "appendonly.aof"

AOF的命令记录的频率也可以通过redis.conf文件来配

# 表示每执行一次写命令,立即记录到AOF文件
appendfsync always
# 写命令执行完毕先放入AOF缓冲区,然后表示每隔一秒将缓冲区数据写到AOF文件,是默认方案
appendfsync everysec
# 写命令执行完先放入AOF缓冲区,由操作系统决定何时将缓冲区内容写回磁盘
appendfsync no
配置项 刷盘时机 优点 缺点
Always 同步刷盘 可靠性高,几乎不丢数据 性能影响大
everysec 每秒刷盘 性能适中 最多丢失1秒数据
no 操作系统控制 性能最好 可靠性较差,可能丢失大量数据

因为是记录命令,AOF文件会比RDB文件大的多。而且AOF会记录对同一个key的多次写操作,但只有最后一次写操作才有意义通过执行bgrewriteaof命令,可以让AOF文件执行重读功能,用最少的命令达到相同效这是AOF文件越来越大的处理方式

Redis会在出发阈值时自动重写AOF文件。阈值也可以在redis.conf中配置

# AOF文件比上次文件 增多超过多少百分比则触发重写
auto-aof-rewrite-percentage 100
# AOF文件体积最小多大以上才触发重写
auto-aof-rewrite-min-size 64mb

★★★★★★★★ RDB与AOF对比 ★★★★★★★★

RDB和AOF各有优缺点,如果对数据安全性要求较高,在实际开发中往往会结合两者来使用
RDB是二进制文件,在保存时体积较小恢复较快,但也有可能丢失数据,我们通常在项目中使用AOF来恢复数据,虽然慢但丢失数据风险小,在AOF文件中可以设置刷盘策略(每秒批量写入一次命令)

RDB AOF
持久化方式 定时对整个内存做快照 记录每一次执行的命令
数据完整性 不完整,两次备份之间会丢失 相对完整,取决于刷盘策略
文件大小 会有压缩,文件体积小 记录命令,文件体积大
宕机恢复速度 很快
数据恢复优先级 低,因为数据完整性不如AOF 高,因为数据完整性更高
系统资源占用 高,大量CPU和内存消耗 低,主要是磁盘IO资源
但AOF重写时会占用大量CPU和内存资源
使用场景 可以容忍数分钟的数据丢失,追求更快的启动速度 对数据安全性要求较高常见
假如Redis的key过期之后,会立即删除吗

Redis对数据设置数据的有效时间,数据过期以后就需要将数据从内存中删除掉。可以按照不同的规则进行删除,这种删除规则就被称之为数据的删除策略(数据过期策略)

==Redis数据删除策略-惰性删除==

惰性删除:设置该key过期时间后,我们不去管它,当需要该key时,我们在检查其是否过期,如果过期,我们就删掉它,反之返回该key

set name zhangsan 10
get name # 发现name过期了,直接删除key

优点:对CPU友好,只会在使用该key时才会进行过期检查,对于很多用不到的key不会浪费时间进行过期检查
缺点:对内存不友好,如果一个key已经过期,但是一直没有使用,那么该key就会一直存在内存中,内存永远不会释放

==Redis数据删除策略-定期删除==

定期删除:每隔一段时间,我们就会对一些key进行检查,删除里面过期的key (从一定数量的数据库中取出一定数量的随机key进行检查,并删除其中的过期key)

定期清理的两种模式:

  • SLOW模式是定时模式,执行频率默认为10hz,每次不超过25ms,以通过修改配置文件redis.conf的hz选项来调整这个次数
  • FAST模式执行频率不固定,但两次间隔不低于2ms,每次耗时不超过1ms

优点:可以通过限制删除操作执行的时长和频率来减少删除操作对CPU的影响。另外定期删除,也能有效释放过期键占用的内存
难点:难以确定删除操作执行的时长和频率

Redis过期删除策略: 惰性删除 + 定期删除 两种策略进行配合使用

假如缓存过多,内存是有限的,内存被占满了怎么办?

==数据淘汰策略==

当Redis中的内存不够用时,此时在向Redis中添加新的key,那么Redis就会按照某一种规则将内存中的数据制除掉,这种数据的制除规则被称之为内存的淘汰策略

Redis支持8种不同策略来选择要删除的key:

  • noeviction:不淘汰任何key,但是内存满时不允许写入新数据,默认就是这种策略

    maxmemory-policy noeviction
  • volatile-ttl:对设置了TTL的key,比较key的剩余TTL值,TTL越小越先被淘汰 (TTL:过期时间的key)

  • allkeys-random:对全体key,随机进行淘汰

  • volatile-random:对设置了TTL的key,随机进行淘汰

  • allkeys-lru:对全体key,基于LRU算法进行淘汰

    LRU(Least Recently Used):最近最少使用,用当前时间减去最后一次访问时间,这个值越大测淘汰优先级越高 [逐出访问时间最少的]
    LFU(Least Frequently Used):最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高。[逐出频率最低的] 【电商会应用】

  • allkeys-lfu:对全体key,基于LFU算法进行淘汰

  • volatile-lfu:对设置了TTL的key,基于LFU算法进行淘汰

淘汰策略 - 使用建议

1.优先使用 allkeys-lru 策略。充分利用LRU算法的优势,把最近最常访问的数据留在缓存中,如果业务有明显的冷热数据区分,建议使用。
2.如果业务中数据访问频率差别不大,没有明显冷热数据区分,建议使用allkeys-random,随机选择淘汰
3.如果业务中有置顶的需求,可以使用volatile-lru策略,同时置顶数据不设置过期时间,这些数据就一直不会被删除,会淘汰其他设置过期时间的数据
4.如果业务中有短时高频访问的数据,可以使用allkeys-lfuvolatile-lfu策略

数据库有1000万数据,Redis只能缓存20w数据,如何保证Redis中的数据都是热点数据?
  • 使用allkeys-lru(挑选最近最少使用的数据淘汰) 淘汰策略,留下来的都是经常访问的热点数据
Redis的内存用完了会发生什么?
  • 主要看数据淘汰策略是什么?如果是默认的配置(noeviction),会直接报错
redis分布式锁,是如何实现的?

需要结合项目中的业务进行回答,通常情况下,分布式锁的使用场景:
集群情况下的定时任务、抢单、幂等性场景
如果使用互斥锁的话 那么在集群项目有多个服务器就会出现问题

==Redis分布式锁==

Redis实现分布式锁主要利用Redis的setnx命令,setnx是**SET if not exists**(如果不存在,则SET)的简写
在同一时刻,只有一个线程/进程/服务节点能拿到锁,执行关键代码。其他的只能等或者失败退出。

  • 获取锁

    添加锁,NX是互斥、PX是设置超时时间
    SET lock value NX PX 10

  • 释放锁

    释放锁,删除即可
    DEL key

Redis实现分布式锁如何合理的控制锁的有效时长?
  • 根据业务执行时间预估
  • 给锁续期
你“自己用 Redis 实现分布式锁” vs 用 Redisson 实现锁 —— 有哪些区别?
对比点 手动实现(自己用 Redis 命令) ✅ Redisson 实现
🛠 实现方式 使用 SET key value NX PXGET + DEL 内部封装好,直接调用 .lock().unlock()
🔐 安全性 容易写错,不易保证“只有加锁者能解锁 Redisson 自动做到了“加锁者才能解锁”
💣 死锁处理 自己必须手动加过期时间(PX),否则会死锁 自动设置超时时间,支持 watchdog 自动续命
🧵 可重入锁 不支持,需要自己实现复杂逻辑 ✅ 内建支持可重入锁(ReentrantLock)
⚠ 主从不一致 Redis 主从复制延迟下可能“锁丢失” Redisson 有 RedLock 模式,可用多个实例容错
🚦 阻塞等待 需要自己写轮询逻辑(比如 while循环) ✅ Redisson 支持阻塞、等待、超时等参数
⏱ 自动续租 没有,需要自己定时续约 ✅ 有“看门狗机制”自动续租防止业务太长释放锁
🔁 分布式支持 支持有限,自己写 RedLock 非常麻烦 ✅ 内置 RedLock、联锁、多锁等高级功能
✅ 使用体验 复杂,代码易错 ✅ 简单、线程安全、功能全面

==redisson实现分布式锁 - 执行流程==
✅ 只有在 你没指定超时时间 时,Watchdog 才会自动开启

加锁 ↓→ 加锁成功 → Watch dog(看门狗)每隔(releaseTime/3的时间做一次续期) → Redis
↓ 操作redis → Redis
↓→→ 释放锁↑ → 通知看门狗无需继续监听 → Redis

加锁 → → → 是否加锁成功?→→→ ↓
↑←←while循环不断尝试获取锁←←←↓

public void redisLock() throws InterruptedException{
    RLock lock = redissonClient.getLock("heimalock");
 // boolean isLock = lock.tryLock(10, 30, TimeUnit.SECONDS);
// 如果不设置中间的过期时间30 才会触发看门狗
// 加锁,设置过期时间等操作都是基于lua脚本完成的[调用redis命令来保证多条命令的原子性]
    boolean isLock = lock.tryLock(10, TimeUnit.SECONDS);
    if(isLock){
        try{
            sout("执行业务");
        } finally{
            lock.unlock();
        }
    }
}
要加依赖
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>3.7.0</version>
</dependency>

❌ 如果你这样写:

lock.lock(10, TimeUnit.SECONDS);

就不会有自动续命,看门狗不会工作。这种锁就是严格 10 秒后自动释放,不管你业务是否完成。

🔄 Watchdog 自动续命流程图

lock.lock() 被调用
     ↓
Redisson 设置锁为30秒过期
     ↓
启动看门狗线程,每10秒刷新一次锁 TTL
     ↓
如果线程还持有锁 → Redis.expire(lockKey, 30秒)
     ↓
直到 unlock() 调用 → 结束看门狗 + 删除锁

==redisson实现分布式锁 - 可重入==

redis实现分布式锁是不可重入的 但是 redisson实现分布式锁是可以重入的
可重入原理:它俩是同一个线程 每个线程都有唯一的线程id 根据线程id唯一标识做判断 判断之前获取锁是不是同一个线程
利用hash结构记录线程id重入次数

KEY VALUE VALUE
field value
heimalock thread1 0
public void add1(){
  RLock lock = redissonClient.getLock("heimalock");
  boolean isLock = lock.tryLock();
// 执行业务
  add2();
// 释放锁
  lock.unlock();
}
public void add2(){
  RLock lock = redissonClient.getLock("heimalock");
  boolean isLock = lock.tryLock();
// 执行业务
// 释放锁 锁次数-1不完全释放
  lock.unlock();
}

==redisson实现分布式锁 - 主从一致性==

Redis Master主节点:主要负责 写操作(增删改) 只能写
Redis Slave从节点:主要负责读操作只能读

当RedisMaster主节点突然宕机后 Java应用会去格外获取锁 这时两个线程就同时持有一把锁 容易出现脏数据
怎么解决呢?

  • RedLock(红锁):不能只在一个redis实例上创建锁,应该是在多个redis实例上创建锁(n/2+1),避免在一个redis实例【实现复杂、性能差、运维繁琐】怎么解决?→ CP思想zookeeper
Redis集群有哪些方案?
  • ==主从复制==

    单节点Redis的并发能力是有上限的,要进一步提高Redis的并发能力,就需要搭建主从集群,实现读写分离
    主节点写操作→增删改 从节点读操作→查

    介绍一下redis的主从同步

    单节点Redis的并发能力是有上限的,要进一步提高Redis的并发能力,就要搭建主从集群,实现读写分离。一般都是一主多从,主节点负责写数据,从节点负责读数据

    主从数据同步原理:

    • 主从全量同步

    slave从节点执行replicaof命令建立链接 → 请求master主节点数据同步(replid+offset) → master判断是否是第一次同步(判断replid是否一致) → 是第一次, 返回master的数据版本信息(replid+offset) → slave保存版本信息 → master执行bgsave, 生成RDB → 发送RDB文件给slave → slave清空本地数据加载RDB数据 → 此时master记录RDB期间所有命令repl_balklog → 发送repl_backlog中的命令 → slave执行接收到的命令

    Replication ld: 简称replid,是数据集的标记,id一致则说明是同一数据集。每一个master都有唯一的replid,slave则会继承master节点的replid
    offset: 偏移量,随着记录在repl baklog中的数据增多而逐渐增大。save完成同步时也会记录当前同步的offset,如果slave的offset小于master的offset,说明slave数据落后于master,需要更新。

    简述全量同步的流程?

    • slave节点请求增量同步

    • master节点判断replid,发现不一致,拒绝增量同步

    • master将完整内存数据生成RDB,发送RDB到slave

    • slave清空本地数据,加载master的RDB

    • master将RDB期间的命令记录在repl_baklog,并持续将log中的命令发送给slave

    • slave执行接收到的命令,保持与master之间的同步

    能说一下,主从同步数据的流程吗?

    全量同步

    1.从节点请求主节点同步数据(replication id、offset)
    2.主节点判断是否为第一次请求,是第一次就与从节点同步版本信息(replication id和offset)
    3.主节点执行bgsave, 生成RDB文件后, 发送给从节点去执行
    4.在RDB生成执行期间, 主节点会从命令的方式记录到缓冲区(日志文件)

    • 主从增量同步
      主从增量同步(slave重启或后期数据变化)

    ① slave重启后 → 携带(replid+offset)找master → master判断请求replid是否一致 → 是第一次, 返回主节点replid和offset → 保存版本信息
    ② slave重启后 → 携带(replid+offset)找master → master判断请求replid是否一致 → 不是第一次, 回复continue向slave → master 去repl_baklog中获取offset后的数据 → 发送offset后的命令给slave → 执行命令

    增量同步

    1.从节点请求主节点同步数据,主节点判断不是第一次请求,不是第一次就获取从节点的offset值
    2.主节点从命令日志中获取offset值后的数据,发送给节点进行数据同步

简述全量同步和增量同步区别?

•全量同步:master将完整内存数据生成RDB,发送RDB到slave。后续命令则记录在repl_baklog,逐个发送给slave。

•增量同步:slave提交自己的offset到master,master获取repl_baklog中从offset之后的命令给slave

什么时候执行全量同步?

•slave节点第一次连接master节点时

•slave节点断开时间太久,repl_baklog中的offset已经被覆盖时

什么时候执行增量同步?

•slave节点断开又恢复,并且在repl_baklog中能找到offset时

  • ==哨兵模式==搭过集群,具体多少个节点是组长那边,不太清楚[并发量不是太多 搭哨兵可以节省一点资源]~
    Redis提供了哨兵(Sentinel)机制来实现主从集群的自动故障恢复

    • 监控:Sentinel会不断检查您的master和slave是否按预期工作
    • 自动故障恢复:如果master故障,Sentinel会将一个slave提升为一个master。当故障实例恢复后也以新的master为主
    • 通知:Sentinel充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端

    服务状态监控
    Sentinel基于心跳机制监测服务状态,每隔1秒向集群的每个实例发送ping命令 期待回复pong

    • 主观下线:如果某sentinel节点发现或某实例未在规定时间相应,则认为该实例主观下线
    • 客观下线:若超过指定数量(quorum)的sentinel都认为该实例主观下线,则该实例客观下线,quorum值最好超过Sentinel实例数量的一半

    哨兵选主规则

    • 首先判断主与从节点断开时间长短,如超过指定值就排该从节点
    • 然后判断从节点的slave-priority值,越小优先级越高
    • 如果slave-priority值一样,则判断slave节点的offset值,越大优先级越高 (数据是最全的)
    • 最后是判断slave节点的运行id大小,越小优先级越高

    redis集群(哨兵模式) 脑裂
    因网络问题 主节点和从节点分别在不同的网络分区 这样sentinel只会监控到一部分从节点网络分区 导致RedisClient继续写主节点的数据,这时网络恢复了,哨兵会将老的master强制降级到slave(携带着脑裂前的最新数据),这个时候slave就会把自己数据清空去同步master数据,这时就存在真正的数据丢失了

    怎么解决

    redis中有两个配置参数:【若不能达成就拒绝客户端请求 这样就会避免大量数据丢失】
    min-replicas-to-write 1 表示最少的salve节点为1
    min-replicas-max-lag 5 表示数据复制和同步的延迟不能超过5秒
    达不到这两个条件就拒绝写入,从而避免数据丢失。

    怎么保证Redis的高并发高可用呢?

    哨兵模式:实现主从集群的自动故障恢复(监控、自动故障恢复、通知)

    你们使用redis是单点还是集群,哪种集群?

    主从(1主1从) + 哨兵就可以了。单节点不超过10G内存,如果Redis内存不足则可以给不同服务分配独立的Redis主从节点

    redis集群脑裂,该怎么解决?

    集群脑裂是由于主节点和从节点和sentinel处于不同网络分区,使得sentinel没有能够心跳感知到主节点,所以通过选举的方式提升了一个从节点为主,这样就存在了两个master,就像大脑分裂了一样,这样会导致客户端还在老的主节点那里写入数据,新节点无法同步数据,当为网络恢复后,sentinel会将老的主节点降为从节点,此时再从新master同步数据,就会导致数据丢失
    解决:我们可以修改redis的配置,可以设置最少的从节点数量以及缩短主从数据同步的延迟时间,达不到要求就拒绝请求,这样就会避免大量数据丢失。

  • ==分片集群==

    主从和哨兵可以解决高可用、高并发读的问题,但是依然有两个问题没有解决:

    • 海量数据存储问题
    • 高并发写的问题

    使用分片集群可用解决上述问题,分片集群特征:

    • 集群中有多个master,每个master保存不同数据
    • 每个master都可用有多个slave节点
    • master之间通过ping监测彼此健康状态
    • 客户端请求可用访问集群任意节点,最终都会被转发到正确节点

    分片集群结果 - 数据读写

    Redis分片集群引入了哈希槽的概念,Redis集群有16384个哈希值,每个key通过CRC16校验后对16384取模来决定放置哪个槽,集群的每个节点负责一部分hash槽

    存数据流程:
    set name itheima → CRC16计算name的hash值(666666) → 666666%16384=11306 → 根据11306找寻所对应哈希槽的范围 并且插入数据

    redis的分片集群有什么用?

    • 集群中有多个master,每个master保存不同数据。(解决高并发的问题)
    • 每个master都可以有多个slave节点。(解决高并发的问题)
    • master之间通过ping监测彼此健康状态
    • 客户端请求可用访问集群任意节点,最终都会被转发到正确节点

    redis的分片集群中数据是怎么存储和读取的?

    • Redis 分片集群引入了哈希槽的概念,Redis 集群有16384个哈槽
    • 将16384个插槽分配到不同的实例
    • 读写数据:根据key的**有效部分**计算哈希值,对16384取余(有效部分,如果key前面有大括号,大括号的内容就是有效部分,如果没有,则以key本身做为有效部分)余数做为播槽,寻找插所在的实例

🚀 Redis 集群常见三种方式(解决不同问题)

模式 主要解决问题 特点
① 主从复制(+读写分离) 读写压力分担 一主多从,主写从读,不能自动故障转移
② 哨兵模式(Sentinel) 高可用(自动故障转移) 在主从基础上,Sentinel 实现监控、自动选主、通知客户端
③ 分片集群(Cluster) 海量数据、高写吞吐 数据分片+多主多从,每个主分管部分槽位(16384 slots)

Redis是单线程的,但是为什么还那么快

  • Redis是纯内存操作,执行速度非常快
  • 采用单线程,避免不必要的上下文切换可竞争条件,多线程还要考虑线程安全问题
  • 使用I/O多路复用模型,非阻塞IO

解释一下I/O多路复用模型?

Redis是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度,I/O多路复用模型主要就是实现了高效的网络请求

  • 是指利用单个线程来同时监听多个Socket ,并在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源,目前的I/O多路复用都是采用的epol模式实现,它会在通知用户进程Socket就绪的同时,把已就绪的Socket写入用户空间,不需要换个Socket来判断是否就绪,提升了性能

  • Redis网络模型:

    就是使用I/O多路复用结合事件的处理器来应对多个Socket请求

    • 连接应答处理器

    • 命令回复处理器,在Redis6.0之后,为了提升更好的性能,使用了多线程来处理回复事件

    • 命令请求处理器,在Redis6.0之后,将命令的转换使用了多线程,增加命令转换速度,在命令执行的时候,依然是单线程

  • ==用户空间和内核空间==

    • Linux系统中一个进程使用的内存情况划分两部分:内核空间、用户空间
    • 用户空间只能执行受限的命令Ring3,而且不能直接调用系统资源必须通过内核提供的接口来访问
    • 内核空间可以执行特权命令Ring0,调用一切系统资源

    Linux系统为了提高IO效率,会在用户空间和内核空间都加入缓冲区

    • 写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备
    • 读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区
  • 常见的IO模型

    • ==阻塞IO==

      阻塞IO就是两个阶段都必须阻塞等待:
      阶段一:

      • 用户进程尝试读取数据(网卡数据等)
      • 此时数据尚未到达,内核需要等待数据
      • 此时用户进程也处于阻塞状态

      阶段二:

      • 数据到达并拷贝到内核缓冲区,代表已就绪
      • 将内核数据拷贝到用户缓冲区
      • 拷贝过程中,用户进程依然阻塞等待
      • 拷贝完成,用户进程解除阻塞,处理数据
    • ==非阻塞IO==

      阶段一

      • 用户进程尝试读取数据(比如网卡数据)
      • 此时数据尚未到达,内核需要等待数据
      • 返回异常给用户进程
      • 用户进程拿到error后,再次尝试读取
      • 循环往复,直到数据就绪

      阶段二:

      • 将内核数据拷贝到用户缓冲区
      • 拷贝过程中,用户进程依然阻塞等待
      • 拷贝完成,用户进程解除阻塞,处理数据
    • ==IO多路复用==

      是利用单个线程来同时监听多个Socket,并在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源

      IO多路复用是利用单个线程来同步监听多个Socket,并在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。不过监听Socket的方式、通知的方式又有多种实现

      • select
      • poll
      • epoll

      差异:
      ★ select和polI只会通知用户进程有Socket就绪,但不确定具体是哪个Socket,需要用户进程逐个历Socket来确认
      ★ epoll则会在通知用户进程Socket就绪的同时,把已就绪的Socket写入用户空间,

      阶段一:

      • 用户进程调用select,指定要监听的Socket集合
      • 内核监听对应的多个socket
      • 任意一个或多个sacket数据就绪则返回readable
      • 此过程中用户进程阻塞

      阶段二:

      • 用户进程找别就格的socket
      • 依次调用recvfrom读取数据
      • 内核将数据拷贝到用户空间
      • 用户进程处理数据
  • Redis网络模型

MySQL篇

在MySQL中,如何定位慢查询?

1.介绍一下当时产生问题的场景(我们当时的一个接口测试的时候非常的慢,压测的结果大概5秒钟)
2.我们系统中当时采用了运维工具(Skywalking),可以监测出哪个接口,最终因为是sql的问题
3.在mysql中开启了慢日志查询,我们设置的值就是2秒,一旦sql执行超过2秒就会记录到日志中(调试阶段)

产生原因:

  • 聚合查询
  • 多表查询
  • 表数据量过大查询
  • 深度分页查询

方案一:==开源工具==[调试阶段才会开启 生产阶段不会开启]

  • 调试工具Arthas
  • 运维工具:Prometheus、Skywalking(接口访问时间)

方案二:==MySQL自带慢日志==

慢查询日志记录了所有执行时间超过指定参数(long_query_time, 单位:秒,默认10秒)的所有SQL语句的日志,如果要开启慢查询日志,需要在MySQL的配置文件(/etc/my.cnf)中配置信息:

# 开启MySQL慢日志查询开关
slow_query_log = 1
# 设置慢日志的时间为2秒,SQL语句执行时间超过2秒,就会被视为慢查询,记录慢查询日志
long_query_time = 2

那这个SQL语句执行很慢,如何分析呢?

可以采用MySQL自带的分析工具 explain

  • 通过keykey_len检查是否命中了索引(索引本身存在是否有失效的情况)
  • 通过type字段查看sql是否有进一步的优化空间,是否存在全索引扫描全盘扫描
  • 通过extra建议判断,是否出现了回表的情况,如果出现了,可以尝试添加索引或修改返回字段来修复

产生原因:

  • 聚合查询 → 新增临时表的数据
  • 多表查询 → 优化SQL语句结构
  • 表数据量过大查询 → 添加索引
  • 深度分页查询
一个SQL语句执行很慢,如何分析?

可以采用EXPLAIN或者DESC命令获取MySQL如何执行SELECT语句的信息

# 直接在select语句之前加上关键字 explain/desc
EXPLAIN SELECT 字段列表 FROM 表名 WHERE 条件;

mysql > explain select * from t_user where id = ‘1’

id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE t_user NULL const PRIMARY PRIMARY 98 const 1 100.00 NULL
  • possible_key:当前sql可能会使用到的索引
  • key:当前sql实际命中的索引 通过它俩查看是否可能会命中索引
  • key_len索引占用的大小 通过它俩查看是否可能会命中索引
  • Extra:额外的优化建议 看是否走过覆盖索引或回表查询
Extra 含义
Using where; Using Index 查找使用了索引,需要的数据都在索引列中能找到,不需要回表查询数据
Using index condition 查找使用了索引,但是需要回表查询数据
  • type:这条sql的连接的类型,性能由好到差为
    • NULL
    • system:查询系统中的表
    • const:根据主键查询
    • eq_ref:主键索引查询或唯一索引查询
    • ref:索引查询
    • range:范围查询
    • index:索引树扫描
    • all:全盘扫描

了解过索引吗?(什么是索引)

索引(index)是帮助MySQL高效获取数据的数据结构(有序),在数据之外,数据库系统还维护着满足特定查找算法的数据结构**(B+树)**,这些数据结构以某种方式引用(指向)数据,这样就可以在这些数据结构上实现高级查找算法,这种数据结构就是索引

  • 索引(index)是帮助MySQL高效获取数据的数据结构(有序)
  • 提高数据检索的效率,降低数据库的IO成本(不需要全表扫描)
  • 通过索引列对数据进行排序,降低数据排序的成本,降低了CPU的消耗

索引的底层数据结构了解过吗?

MySQL的InnoDB引擎采用的B+树的数据结构来存储索引

  • 阶数更多,路径更短
  • 磁盘读写代价B+树更低,非叶子节点只存储指针,叶子阶段存储数据
  • B+树便于扫库和区间查询,叶子节点是一个双向链表

**MySQL默认使用的索引底层数据结构是B+树**。再聊B+树之前,先来聊聊二叉树和B树

==B Tree(矮胖树)== B树是一种多叉路衡查找树,相对于二叉树,B树每个节点可以有多个分支,即多叉。以一颗最大度数(max-degree)为5(5阶)的b-tree为例,那这个B树每个节点最多存储4个key

==B+Tree== 是再BTree基础上的一种优化,使其更适合实现外存储索引结构,InnoDB存储引擎就是B+Tree实现其索引结构

B树与B+树对比

  • 磁盘读写代价B+树更低
  • 查询效率B+树更加稳定
  • B+树便于扫库和区间查询

B树要找12 首先找38 左面小 再去缩小范围16和29 找到12 → 但是我们只想要12的数据 B树会额外的把38,16,29的数据全查一遍最后才到12的数据

B+树是在叶子节点才会存储数据,在非叶子节点全是指针,这样就没有其他乱七八糟的数据影响 。且查找路径是差不多的,效率较稳定

便于扫库:比如我们要查询6-34区间的数据,先去根节点扫描一次38 → 16-29 → 由于叶子节点之间有双向指针,就可以一次性把所有数据都给拿到[无需再去根节点找一次]

什么是聚簇索引?什么是非聚簇索引(二级索引)?什么是回表?

  • 聚簇索引(聚集索引)数据索引放到一块,B+树的叶子节点保存了整行数据,有且只有一个
  • 非聚簇索引(二级索引)数据索引分开存储,B+树的叶子节点保存对应的主键,可以有多个
  • 回表查询:通过二级索引找到对应的主键值,到聚集索引中查找正行数据,这个过程就是回表
分类 含义 特点
==聚集索引(Clustered Index)== 将数据存储与索引放到了一块,索引结构的叶子节点保存了行数据 必须有, 而且只有一个
==二级索引(Secondary Index)== 将数据与索引分开存储,索引结构的叶子节点关联的是对应的主键 可以存在多个

聚集索引选取规则:

  • 如果存在主键,主键索引就是聚集索引
  • 如果不存在主键,将使用第一个唯一 (UNIQUE) 索引作为聚集索引
  • 如果表没有主键,或没有合适的唯一索引,则InnoDB会自动生成一个rowid作为隐藏的聚集索引

聚集索引和非聚集索引的具体区别

特点 聚集索引 非聚集索引
数据存储方式 数据与索引存储在一起,叶子节点存储整行数据。 数据与索引分开存储,叶子节点存储指针或主键值。
物理排序 数据按索引键的顺序物理排序。 数据的物理顺序与索引无关。
索引数量 一个表只能有一个聚集索引。 一个表可以有多个非聚集索引。
查询性能 查询效率高,尤其是范围查询和排序操作。 查询效率相对较低,可能需要“回表”操作。
更新操作影响 插入、删除或更新数据可能需要重新排序。 更新操作影响较小,仅修改索引和指针。
适用场景 范围查询、排序、分组等操作频繁的列。 查询条件筛选、快速定位数据的列。

==回表查询==

select * from user where name = 'Arm';

知道什么叫覆盖索引吗?

覆盖索引是指查询使用了索引,返回的列,必须在索引中全部能够找到

  • 使用id查询,直接走聚集索引查询,一次索引描述,直接返回数据,性能高
  • 如果返回的列中没有创建索引,有可能会触发回表查询,尽量避免使用 **select *** [除非用的聚簇索引(主键)]

==覆盖索引==是指查询使用了索引,并且需要返回的列,在该索引中已经全部能够找到

id name gender createdate
2 Arm 1 2021-01-01
3 Lily 0 2021-05-04
5 Rose 0 2021-04-21
6 Zoo 1 2021-07-31
8 Doc 1 2021-02-26
11 Lee 1 2021-09-11
  • id为主键,默认是主键索引
  • name字段为普通索引
select * from tb_user where id = 1;                     【覆盖索引】
select id, name from tb_user where name = 'Arm'         【覆盖索引】
select id, name, gender from tb_user where name = 'Arm' 【非覆盖索引】(需要回表查询)

MySQL超大分页怎么处理?

问题:再数据量比较大时,limit分页查询,需要对数据进行排序,效率低
解决方案:可以用覆盖索引 + 子查询处理
[我们先分页查询获取表中的id 并且对表的id进行排序 就能筛选出分页后的id集合(因为id是覆盖索引效率高) 最后再根据id集合到原来的表中做关联查询就可以得到提升了]

在数据量比较大时,如果用limit分页查询,在查询时,越往后,分页查询效率越低

mysql > select * from tb_sku limit 0,10;
10 rows in set (0.00 sec)

mysql > select * from tb_sku limit 9000000,10;
10 rows in set (11.05 sec)

因为,当在进行分页查询时,如果执行 limit 9000000,10,此时需要MySQL排序前9000010记录,仅仅返回9000000 - 9000010 的记录,其他记录丢失,查询排序的代价非常大。

==MySQL超大分页查询优化思路==:一般分页查询时,通过创建覆盖索引能够比较好地提高性能,可以通过覆盖索引子查询形式进行优化

# 超大分页处理:先通过覆盖索引找到符合条件的id,再通过这个id的覆盖索引查询到所有的列
select * 
from tb_sku t,
(select id from tb_sku order by id limit 9000000,10) a
where t.id = a.id

# 10 rows in set (7.15 sec)

索引创建原则有哪些?

数据量较大,且查询比较频繁的表
常作为查询条件、排序、分组的字段
③ 字段内容区分度高
④ 内容较长,使用前缀索引
尽量联合索引
要控制索引的数量
⑦ 如果索引列不能存储NULL值,请在创建表时使用NOT NULL约束它

  • 先陈述自己再实际工作中是怎么用的
  • 主键索引
  • 唯一索引
  • 根据业务创建的索引(复合索引)
创建索引的方式1
① SQL的方式
ALTER TABLE user_innodb ADD INDEX idx_name(name)

② 在建表的时候 去指定索引
...
PRIMARY KEY('id'),
KEY 'idx_name' ('name') USING HASH

③ 通过界面化工具去指定索引
字段旁边有个`索引` 可以去添加

=============================================
单个字段的索引 → 单列索引
多个字段的索引 → 联合索引
索引的类型

索引可以增加查询速度 同时也增加了更新/修改速度因为更新的第一步就是查询

普通索引 经过特殊设计的数据结构
唯一索引 唯一约束
[索引必须是唯一的 比如name就不行 因为名字可以很多建立普通索引]
主键索引 在主键索引上添加了非空约束
全文索引 一般使用搜索引擎,因为对中文的搜索不太友好美国英文开发的
[特殊的sql:select * from 表名 where match(字段名) against(‘马士兵教育’ IN NATURAL LANGUAGE MODE);]

AVL树 右右型左旋 左子树与右子树的深度差绝对值不超过1
树的节点里应该放:键值+Value值+左右子树的地址left+right
Innodb一次会加载16k(16384字节=Redis的槽位) 内存到内存
不选红黑树是因为它是二叉的,我们需要多叉树
要用==B+树==全盘扫描能力更强 叶子节点是双向链表
因为稳定性比较好 B树非所见所得 B+树是稳定几层的查找数据因为数据都在最后一层叶子节点上
Innodb的索引方法是BTREE 不能改成HASH

**数据结构可视化网**:Data Structure Visualization

  • 针对数据量较大,且查询比较频繁的表建立索引。单表超过10万数据(增加用户体验)
  • 针对常作为查询条件(where)、排序(order by)、分组(group by) 操作的字段建立索引
  • 尽量选择区分度高的列作为索引,尽量建立唯一索引,区分度越高,使用索引的效率越高 (比如address都在北京市)
  • 如果是字符串类型的字段,字段的长度越长(描述信息…),可以针对于字段的特点,建立前缀索引
  • 尽量使用联合索引,减少单列索引,查询时,联合索引很多时候可以覆盖索引(避免回表),节省存储空间,提高查询效率
  • 要控制索引的数量,索引并不是多多益善,索引越多,维护索引结构的代价也就越大,会影响增删改的效率
  • 如果索引列不能存储NULL值,请在创建表时使用NOT NULL约束它。当优化器知道每列是否包含NULL值时,它可以更好地确定哪个索引最有效地用于查询。

什么情况下索引会失效?

  • 违反最左前缀法则
  • 范围查询右边的列,不能使用索引
  • 不要在索引列上进行运算操作,索引将失效
  • 字符串不加单引号,造成索引失效。(类型转换)
  • 以**%开头的Like模糊查询**,索引失效
    [不影响正常查询业务 但未运用超大分页查询优化 会导致索引失效]

怎么哪块读判断索引是否失效了呢

# 执行计划explain

【2024最新版MySQL索引讲解!一个视频带你彻底搞懂MySQL索引!!【马士兵】】https://www.bilibili.com/video/BV17z421i7Kb?vd_source=5966d6c3cf3709c10b3c53b278b0f4d3

什么情况下索引会失效?
如果索引了多列,要遵守最左前缀法则。指的是查询从索引的最左前列开始,并且不跳过索引中的列。匹配最左前缀法则,走索引:

谈谈你对sql的优化经验?

  • 表的设计优化,数据类型的选择
  • 索引优化,索引创建原则
  • sql语句优化,避免索引失效,避免使用select
  • 主从复制、读写分离,不让数据的写入,影响读操作
  • 分库分表
  • 表的设计优化(参考阿里开发手册《嵩山版》)

    • 比如设置合适的数值(tinyint、int、bigint) ,要根据实际情况选择
    • 比如设置合适的字符串类型(char和varchar) char定长效率高,varchar可变长度,效率低

    候选人: 这个我们主要参考的阿里出的那个开发手册《嵩山版》,就比如,在定义字段的时候需要结合字段的内容来选择合适的类型,如果是数值的话,像tinyint、int、bigint这些类型,要根据实际情况选择。如果是字符串类型,也是结合存储的内容来选择char和varchar或者text类型

  • 索引优化(参考优化创建原则和索引失效)

  • SQL语句优化

    • SELECT语句务必指明字段名称 (避免直使用select *)回表

    • SQL语句要避免造成索引失效的写法

    • 尽量使用union all代替union,union(不会重复)会多一次过滤, 效率低

      select * from t_user where id > 2
      union all | union
      select * from t_user where id < 5
      
    • 避免在where子句中对字段进行表达式操作

    • join优化 能用inner join 就不用left join, right 如必须使用 一定要以小表为驱动;内链接会对两个表进行优化,优先把小表放到外边,把大表放到里边。left join 或 right join,不会重新调整顺序

      for(int i = 0; i < 3; i++){ //只链接查询3次
       for(int j = 0; j < 1000; j++){
      
       }  
      }
      
  • 主从复制、读写分离(在生产环境下一般会搭建主库和从库 分开读操作和写操作)

    如果数据库的使用场景读的操作比较多的时候,为了避免写的操作所造成的性能影响 可以采用读写分离的架构。读写分离解决的是,数据库的写入,影响了查询的效率。[Master(写) 和 Slave(读)]

  • 分库分表(后面有介绍)

事务的特性是什么?可以详细的说一下吗?【ACID】

事务是一组操作的集合,它是一个不可分割的工作单位,事务会把所有的操作作为一个整体一起向系统提交或撤销操作请求,即这些操作要么同时成功,要么同时失败。

候选人:嗯,这个比较清楚,ACID,分别指的是:原子性、一致性、隔离性、持久性;
我举个例子:A向B转账500,转账成功,A扣除500元,B增加500元。
原子性操作体现在要么都成功,要么都失败
在转账的过程中,数据要一致性,A扣除了500,B必须增加500
在转账的过程中,隔离性体现在A像B转账,不能受其他事务干扰
在转账的过程中,持久性体现在事务提交后,要把数据持久化(可以说是落盘操作)

  • **原子性(**Atomicity):事务是不可分割的最小操作单元,要么全部成功,要么全部失败。
  • 一致性(Consistency):事务完成时,必须使所有的数据都保持一致状态。
  • 隔离性(lsolation):数据库系统提供的隔离机制,保证事务在不受外部并发操作影响的独立环境运行
  • 持久性(Durabiity):事务一旦提交或回滚,它对数据库中的数据的改变就是永久的。

并发事务带来哪些问题?怎么解决这些问题?MySQL默认隔离级别是?

  • ==并发事务问题==:脏读、不可重复读、幻读
  • ==隔离级别==:读未提交、读已提交、可重复读、串行化
问题 描述
脏读 一个事务读到另外一个事务还没有提交的数据
不可重复读 一个事务先后读取同一条事务,但两次读取的数据不同,称之为不可重复读
幻读 一个事务按照条件查询数据时,没有对应的数据行,这同时另一个事务B(insert且commit)了事务,此时事务A在插入数据时候,又发现这行数据已经存在了,好像出现了”幻影“
怎么解决并发事务的问题呢??

对事务进行隔离 (× 是代表可以解决此问题)

隔离级别 脏读 不可重复读 幻读
Read uncommitted 未提交读
Read committed 读已提交 ×
==Repeatable Read(默认) 可重复读== × ×
Serializable 串行化 × × ×

注意:**事务隔离级别越高,数据越安全,但是性能越低**

数据库的undo log 和 redo log的区别?

redo log:记录的是数据页的物理变化,服务宕机可用来同步数据
undo log:记录的是逻辑日志,当事务回滚时,通过逆操作恢复原来的数据
redo log 保证了事务的持久性,undolog保证了事务的原子性和一致性

redo logundo log 是 InnoDB 为了实现事务的 原子性持久性 而设计的两种日志机制:

日志类型 作用 类比
redo log 崩溃恢复,重做操作(实现持久性)【恢复】 保存键 Ctrl+S
undo log 回滚事务,撤销操作(实现原子性)【撤销】 撤销键 Ctrl+Z
  • 缓冲池(buffer pool):主内存中的一个区域,里面可以缓存磁盘上经常操作的真实数据,在执行增删改査操作时,先操作缓冲池中的数据(若缓冲池没有数据,则从磁盘加载并缓存),以一定频率刷新到磁盘,从而减少磁盘IO,加快处理速度
  • 数据页(page):是InnoD8 存储引擎磁盘管理的最小单元,每个页的大小默认为 16KB。页中存储的是行数据

假设你执行一条 SQL:

UPDATE account SET balance = balance - 100 WHERE id = 1;

InnoDB 的执行顺序是:

  1. 写入 undo log(记录原值,便于回滚) ✅
  2. 修改内存中的数据(Buffer Pool)✅
  3. 写入 redo log(记录新的值,崩溃后可重做)✅
  4. 提交事务时将 redo log 落盘(刷到磁盘)
🔄 崩溃恢复时如何用它们?
  • 💥 宕机恢复(crash recovery)时,MySQL 会用 redo log 把“已提交但还没写到磁盘的数据”重做一遍,确保数据不丢失(持久性)。
  • 事务失败或回滚时,MySQL 用 undo log 把数据恢复到修改之前的样子,确保事务“要么全做,要么全不做”(原子性)。

==redo log==

重做日志,记录的是事务提交时数据页的物理修改,是用来实现事务的持久性
该日志文件由两部分组冲:重做日志缓冲(redo log buffer) 以及 **重做日志文件(redo log file)**,前者是在内存中,后者是在磁盘中。当事务提交之后会把所有修改信息都保存到该日志文件中,用于在刷新脏页到磁盘,发生错误时,进行数据恢复使用。

==undo log==

回滚日志,用于记录数据被修改前的信息,作用包含两个:提供回滚MVCC(多版本并发控制)。undolog 和 redolog记录物理日志不一样,它是逻辑日志

  • **可以认为当delete一条记录时,undo log中会记录一条对应的insert记录**,反之亦然
  • 当update一条记录时,它记录一条对应相反的update记录。当执行rolback时,就可以从undolog中的逻辑记录读取到相应的内容并进行回滚。

undo log可以实现事务的一致性和原子性

事务中的隔离性是如何保证的呢?

事务的隔离性主要是通过锁机制MVCC(多版本并发控制) 来实现的。

对于更新操作 (写),MySQL 会使用加锁机制,比如行级锁中的排他锁(X锁)来避免并发写冲突;
对于查询操作 (读),MySQL 使用 MVCC 来避免加锁带来的性能开销,从而支持高并发读操作。
MVCC 的核心思想是:
为同一条数据维护多个版本
,从而实现 “读写不冲突、并发更高效”。

排他锁 (如果一个事务获取到了一个数据行的排他锁,其他事务就不能再获取该行的其他锁)
mvcc: 多版本并发控制 让MySQL中的多版本并发控制。指维护一个数据的多个版本,使得读写操作没有冲突
隐藏字段:
每一行数据都会有两个隐藏字段
① trx _id(事务id),记录每一次操作的事务id,是自增的 [当前这条记录是由哪个事务创建的(事务ID)]
② roll_pointer(回滚指针),指向上一个版本的事务版本记录地址(形成一个版本链)

undo log:
① 回滚日志,存储老版本数据
② 版本链:多个事务并行操作某一行记录,记录不同事务修改数据的版本,通过rollpointer指针形成一个链表

readView:解决的是一个事务查询选择版本的问题
根据readView的匹配规则和当前的一些事务id判断该访问那个版本的数据》不同的隔离级别快照读是不一样的,最终的访问的结果不一样RC:每一次执行快照读时生成ReadView
RR:仅在事务中第一次执行快照读时生成ReadView,后续复用

面试官: 事务中的隔离性是如何保证的呢?(你解释一下MVCC)
候选人: 事务的隔离性是由锁和mvcc实现的。
其中mvcc的意思是多版本并发控制。指维护一个数据的多个版本,使得读写操作没有冲突,它的底层实现主要是分为了三个部分,第一个是隐藏字段,第二个是undolog日志,第三个是readView读视图
隐藏字段是指:在mysq!中给每个表都设置了隐藏字段,有一个是x_id(事务id),记录每一次操作的事务id,是自增的;另一个字段是roll-pointer(回滚指针),指向上一个版本的事务版本记录地址
undolog主要的作用是记录回滚日志,存储老版本数据,在内部会形成一个版本链,在多个事务并行探作某一行记录,记录不同事务修改数据的版本,通过roll_pointer指针形成一个链表
readview解决的是一个事务查询选择版本的问题,在内部定义了一些匹配规则和当前的一些事务id判断该访问那个版本的数据,不同的隔离级别快照读是不一样的,最终的访问的结果不一样。如果是rc隔离级别,每一次执行快照读时生成ReadView,如果是r隔离级别仅在事务中第一次执行快照读时生成ReadView,后续复用

MVCC底层的三个关键机制

🔹 1. 隐藏字段

每一行数据都会有两个隐藏字段:

  • trx_id:当前这条记录是由哪个事务创建的(事务ID)
  • roll_pointer:回滚指针,指向这条记录的上一个版本(形成一个版本链)

🔹 2. undo log(回滚日志)

  • 当事务对数据进行修改时,会记录修改前的旧数据到 undo log
  • 所有旧版本数据通过 roll_pointer 串成一个“版本链”
  • 查询时可以根据版本选择合适的数据版本,从而“读老数据”

🔹 3. ReadView(读视图)

  • 在执行快照读时,InnoDB 会生成一个 ReadView
  • 它记录了当前活跃的事务ID列表,以及当前事务的ID
  • 查询时,会根据 ReadView 判断:这条记录版本是否“可见”

总的来说,写操作靠加锁,读操作靠 MVCC。MVCC 通过维护多个版本的数据 + ReadView 机制,让不同事务之间在查询时互不干扰,从而保证隔离性,同时提升并发性能。

🔐 写用锁、📚 读用 MVCC,🔁 多版本 + 🔍 读视图 + 🧾 回滚日志,性能高,隔离强!

解释一下MVCC?

全程 Multi-Version Concurrency Control多版本并发控制。指维护一个数据的多个版本,使得读写操作没有冲突

问题的来源:(橙色的)查询的是哪个事务版本的记录?
事务2 事务3 事务4 事务5
开始事务 开始事务 开始事务 开始事务
修改id为30记录, age改为3 查询id为30的记录
提交事务
修改id为30记录, name改为A3
查询id为30的记录
提交文件 修改id为30的记录, age改为10
查询id为30的记录 查询id为30的记录
提交事务
MVCC-实现原理
  • 记录中的隐藏字段
id age name DB_TRX_ID DB_ROLL_PTR DB_ROW_ID
  • DB_TRX_ID:最近修改事务ID,记录插入这条记录或最后一次修改该记录的事务ID
  • DB_ROLL_PTR:回滚指针,指向这条记录的上一个版本,用于配合undo log, 指向上一个版本
  • DB_ROW_ID:隐藏主键,如果表结构没有指定主键,将会生成该隐藏字段
undo log
  • 回滚日志,在insert、update、delete的时候产生的便于数据回滚的日志相反的语句
  • 当insert的时候,产生的undolog日志只在回滚时需要,在事务提交后,可被立即删除。
  • 而update、delete的时候,产生的undo log日志不仅在回滚时需要,mvcc版本访问也需要,不会立即被删除。
undo log版本链

不同事务或相同事务对同一条记录进行修改,会导致该记录的undolog生成一条记录版本链表,链表的头部是最新的旧记录,链表尾部是最早的旧记录

  • readview

ReadView(读视图) 是 快照读 SQL执行时MVCC提取数据的依据,记录并维护系统当前活跃的事务(未提交的)id

ReadView中包含了四个核心字段

字段 含义
m_ids 当前活跃的事务ID集合
min_trx_id 最小活跃事务ID
max_trx_id 预分配事务ID, 当前最大事务ID+1 (事务ID是自增的)
creator_trx_id ReadView创建者的事务ID
  • 当前读

读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。对于我们日常的操作,如:select .. lock in share mode(共享锁),select .. for update、update、insert、delete(排他锁)都是一种当前读。

  • 快照读

简单的select(不加锁)就是快照读,快照读,读取的是记录数据的可见版本,有可能是历史数据,不加锁,是非阻塞读。
Read Committed:每次select,都生成一个快照读。
Repeatable Read:开启事务后第一个select语句才是快照读的地方。

MySQL主从同步原理?

主从同步就是主库把所有数据修改写到日志文件(Binlog)里,从库再去“抄作业”——读日志,写自己,中继日志相当于中转站,最终把主库的修改同步过来。

MySQL主从复制的核心就是二进制日志binlog[DDL(数据定义语言)语句DML(数据操纵语言)语句]
主库在事务提交时,会把数据变更记录在二进制日志文件 Binlog 中。
从库读取主库的二进制日志文件 Binlog,写入到从库的中继日志 Relay Log
从库重做中继日志中的事件,将改变反映它自己的数据。


MySQL 主从同步分三步:

  • 写日志:主库提交事务 → 写 Binlog
  • 拉日志:从库 I/O 线程拉取 Binlog → 写 Relay Log
  • 执行日志:从库 SQL 线程执行 Relay Log → 同步数据

整个过程就是:主库写 → 从库拉 → 从库执行

MySQL主从复制的核心就是二进制日志

二进制文件(BINLOG) 记录了所有的DDL(数据定义语言)语句DML(数据操纵语言)语句,但不包括数据查询(SELECT、SHOW)语句

       +---------------------+
       |      Master 主库     |
       | 写 Binlog(记录变更) |
       +---------------------+
                  ↓
       [ I/O 线程拉取 Binlog ]
                  ↓
       +---------------------+
       |      Slave 从库      |
       | 写 Relay Log(中继) |
       | 执行日志 → 同步数据  |
       +---------------------+
复制分成三步:
  • Master主库在事务提交时,会把数据变更记录在二进制日志文件Binlog中
  • 从库读取主库的二进制日志文件Binlog,写入到从库的中继日志Relay Log
  • slave重做中继日志中的事件,将改变反应他自己的数据

你们项目用过分库分表吗?

是的,我们项目在业务数据达到一定规模后使用了分库分表策略。
具体来说,我们在做【举个真实业务:比如订单系统、日志存储系统、会员行为分析系统】时,由于单表数据量突破了 1000W+,单表容量超过 20GB,查询响应变慢,索引命中率降低,磁盘和网络 I/O 成为瓶颈。
一开始我们尝试通过
主从读写分离、加索引、缓存优化
等方式缓解,但效果有限,最终采用了分库分表来从根本上解决性能问题。

  • 业务介绍
    1,根据自己简历上的项目,想一个数据量较大业务(请求数多或业务累积大)
    2,达到了什么样的量级(单表1000万或超过20G)

  • 具体拆分策略
    1,水平分库,将一个库的数据拆分到多个库中,解决海量数据存储和高并发的问题
    2,水平分表,解决单表存储和性能的问题
    3,垂直分库,根据业务进行拆分,高并发下提高磁盘I0和网络连接数
    4,垂直分表,冷热数据分离,多表互不影响

分担了访问压力、解决存储压力

分库分表的时机:

前提:项目业务数据逐渐增多,业务发展比较迅速【单表数据量达1000W或20G以后】
② 优化解决不了性能问题(主从读写分离、查询索引)
IO瓶颈(磁盘IO、网络IO)、CPU瓶颈(聚合查询、连接数太多)

拆分策略【垂直 ≈ 微服务、水平 ≈ 分配数值】

  • ==垂直拆分==
    • 垂直分库:以表为依据,根据业务将不同表拆分到不同库中
      (特点:按业务对数据分级管理、维护、监控、扩展;在高并发下,提高磁盘IO和数据量连接数)
      • tb_user → 用户微服务
      • tb_order → 订单微服务
      • tb_sku → 商品微服务
    • 垂直分表:以字段为依据,根据字段属性将不同字段拆分到不同表中
      (把不常用的字段单独放在一张表;把text, blob等大字段[描述]拆分出来放在附表中)
      (特点:冷热数据分离、减少IO过渡争抢,两表互不影响)
  • ==水平拆分==
    • 水平分库:将一个库的数据拆分到多个库中
      (解决了单库大数量,高并发的性能瓶颈问题;提高了系统的稳定性和可用性)
      路由规则
      • 根据id节点取模
      • 按id也就是范围路由,节点1(1-100万),节点2(100万-200万)
    • 水平分表:将一个库的数据拆分到多个表中(可以在同一个库内)
      (优化单一表数据量过大而产生的性能问题;避免IO争抢并减少锁表的几率)
分库后的问题:↓↓
  • 分布式事务一致性问题
  • 跨节点关联查询
  • 跨节点分页、排序函数
  • 主键避重
使用分库分表中间件
  • sharding-sphere
  • mycat

Spring框架中的单例bean是线程安全的吗?

不是线程安全的,是这样的

当多用户同时请求一个服务时,容器会给每一个请求分配一个线程,这是多个线程会并发执行该请求对应的业务逻辑(成员方法),如果该处理逻辑中有对该单列状态的修改(体现为该单例的成员属性),则必须考虑线程同步问题。

Spring框架并没有对单例bean进行任何多线程的封装处理。关于单例bean的线程安全和并发问题需要开发者自行去搞定。
比如:我们通常在项目中使用的Springbean都是不可可变的状态(比如Service类和DAO类),所以在某种程度上说Spring的单例bean是线程安全的。

如果你的bean有多种状态的话(比如 View Model对象),就需要自行保证线程安全。最浅显的解决办法就是将多态bean的作用由“singleton”变更为“prototype”。

Spring框架中的bean是单例的

@Service
@Scope("singleton")
public class UserServiceImpl implements UserService{

}
  • singleton:bean在每个Spring IOC容器中只有一个实例
  • prototype:一个bean的定义可以有多个实例

Spring bean并没有可变的状态(比如Service类和DAO类), 所以在某种程度上说Spring的单例bean是线程安全的。但要尽可能的少创造可变参数比如count

@Controller
@RequeestMapping("/user")
public class UserController{
    private int count; //成员方法需要考虑线程安全问题

    @Autowired
    private UserService userService;

    @GetMapping("/getById/{id}")
    public User getById(@PathVariable("id") Integer id){
        count++;
        sout(count);
        return userService.getById(id);
    }
}
  1. 单例Bean就像共享单车
    • 整个小区(Spring容器)只有一辆共享单车(单例Bean),所有居民(线程)都要轮流骑这辆车。
    • 如果只是骑车(调用无状态方法),不会出问题。
    • 但如果有人在车筐里放东西(修改成员变量),下个人可能就会看到/改动这些东西。
  2. 什么时候安全?
    • 比如Service、DAO这类Bean,它们通常只干活不记账(没有成员变量),就像只提供骑行服务的单车,很安全。
    • 这也是为什么我们平时用@Autowired注入的Service不会出问题。
  3. 什么时候危险?
    • 如果Bean里有个计数器count(就像你代码里的例子),多个线程同时”+1”就会乱套。
    • 就像多个人同时往单车筐里放苹果,最后苹果数量肯定对不上。
  4. 怎么解决?
    • 方法一:不记账 → 永远不在Bean里放成员变量(推荐)
    • 方法二:用锁 → 像公共厕所那样,一个人用的时候锁门(加synchronized)
    • 方法三:每人发一辆车 → 改用@Scope(“prototype”),每次请求都新建Bean(但浪费资源)
  5. 实际开发建议
    • 大多数情况下,Service/Dao写成单例完全没问题
    • 遇到要记录状态的场景(比如计数器),要么改成prototype,要么把变量存在ThreadLocal里
    • 绝对不要在Controller里定义成员变量!你代码里的count就是个典型反例

简单说:单例Bean本身不是线程安全的,但只要我们遵守”不用成员变量记事情”的原则,就能安全使用。就像共享单车,只要大家都不往车筐里放私人物品,就不会有问题。

什么是AOP,你们项目中有没有用到AOP?

AOP称为面向切面编程,用于将那些与业务无关,但却对多个对象产生影响的公共行为和逻辑,抽取并封装为一个可重用的模块,这个模块被命名为“切面”(Aspect),减少系统中的重复代码,降低了模块间的耦合度,同时提高了系统的可维护性。

常见AOP使用场景:
  • 拒绝策略记录操作日志

    nginx → 新增用户 → @Around(“pointcut()”) 环绕通知

  • 缓存处理

  • Spring中内置的事务处理

Spring中的事务是如何实现的
Spring支持 编程式事务管理声明式事务 管理两种方式

  • 编程式事务控制:需使用TransactionTemplate来进行实现,对业务代码有侵入性,项目中很少使用
  • 声明式事务管理:声明式事务管理建立在AOP之上的。其本质是通过AOP功能,对方法前后进行拦截,将事务处理的功能编织到拦截的方法中,也就是在目标方法开始之前加入一个事务,在执行完目标方法之后根据执行情况提交或者回滚事务。

AOP(面向切面编程)可以理解为 “在不修改原有代码的情况下,给程序动态添加功能”

生活中的例子:
  • 假设你开了一家咖啡店,主要业务是 做咖啡(核心业务)。
  • 但除了做咖啡,你还要 记录销售日志、检查权限、处理异常(如咖啡机坏了)等(横切关注点)。
  • 如果用传统OOP(面向对象编程),你需要在每个做咖啡的方法里都写日志、权限检查代码,这样代码会变得臃肿且难以维护。
  • 而AOP的做法是:把这些公共逻辑(如日志、权限)抽出来,做成一个“切面”,然后“织入”到需要的地方,不影响原有业务代码

AOP的核心概念
  1. 切面(Aspect):封装横切逻辑的模块(比如日志、事务)。
  2. 连接点(Join Point):程序执行的点(如方法调用、异常抛出)。
  3. 通知(Advice):切面在连接点执行的动作(如方法执行前、后、异常时做什么)。
  4. 切点(Pointcut):定义哪些连接点会被切面影响(如“所有Service层的方法”)。
  5. 织入(Weaving):把切面应用到目标对象的过程(编译期、类加载期、运行时)。

Spring中事务失效的场景有哪些?

异常捕获处理,自己处理了异常,没有抛出,解决:手动抛出
抛出检查异常,配置rollbackFor属性为Exception
非public方法导致的事务失效,改为public

考察对spring框架的深入理解、复杂业务的编码经验

  • ==异常捕获处理==【异常被try-catch吃掉】

    原因:事务通知只有捉到了目标抛出的异常,才能进行后续的回滚处理,如果目标自己处理掉异常,事务通知无法知悉【Spring 的事务是基于 AOP 的,只有方法抛出异常,事务管理器才能感知并触发回滚;你在方法内部 try-catch 了异常,但没有再往外抛,就会导致事务不能回滚

    解决在catch块添加throw new RuntimeException(“转账失败”) 抛出

  • ==抛出检查异常==

    原因:Spring 默认只对 非检查异常(RuntimeException 及其子类)、 进行回滚

    @Transactional
    public void update(...) throw FileNotFoundException{
        ...
        new FileInputStream("dddd")
        ...
    }
    

    解决:配置rollbackFor属性

    @Transcational(rollbackFor=Exception.class)
    
  • ==非public方法==
    Spring 的事务本质是基于 AOP 代理实现的,而 AOP 默认只对 public 方法生效。

    @Transcational(rollbackFor=Exception.class)
    void update(...) throw FileNotFoundException{
        ...
        new FileInputStream("dddd")
        ...
    }
    

    原因:Spring为方法创建代理、添加事务通知、前提条件都是该方法是public
    解决:把方法改为public

  • ==同类内部调用,导致代理失效==

    原因:Spring AOP 基于代理机制。如果类内部方法调用类内的另一个 @Transactional 方法,实际上不会经过代理,事务不会生效。

    // ❌ 会失效
    public void methodA() {
        methodB(); // 不经过代理
    }
        
    @Transactional
    public void methodB() {
        // 无效
    }
    
    • 解决
      • 将方法调用抽出到另一个 bean 中;
      • 或使用 AopContext.currentProxy() 获取当前代理对象执行调用。

Spring的bean的生命周期?

Spring容器是如何管理和创建bean实例
方便调试和解决问题

① 通过BeanDefinition获取bean的定义信息 [Spring 会将 XML 或注解配置的 Bean 信息封装成 BeanDefinition 对象,用于描述 Bean 的元数据信息,如 class 类型、作用域、是否懒加载等]
② 调用构造函数实例化bean [通过构造函数或工厂方法创建 Bean 对象,还没进行依赖注入]
③ bean的依赖注入 [Spring 根据 BeanDefinition 中的配置信息,进行依赖注入,例如通过 @Autowired@Resource 等注解注入其它 Bean]
④ 处理Aware接囗回调(BeanNameAware、BeanFactoryAware、ApplicationContextAware)
⑤ Bean的后置处理器BeanPostProcessor-前置
⑥ 初始化方法(InitializingBean、init-method)
⑦ Bean的后置处理器BeanPostProcessor-后置
⑧ 销毁bean

BeanDefinition

Spring容器在进行实例化时,会将xml配置的< bean >的信息封装成一个BeanDefinition对象,Spring根据BeanDefinition来创建Bean对象,里面有很多的属性来描述Bean

<bean id="userDao" class="com.itheima.dao.impl.UserDaolmpl" lazy-init="true"/><bean id="userService" class="com.itheima.service.UserServicelmpl" scope="singleton">
  <property name="userDao" ref="userDao"></property>
</bean>

Spring中的循环引用?

循环依赖:循环依赖其实就是循环引用, 也就是两个或两个以上的bean互相持有对方,最终形成闭环。比如A依赖于B,B依赖于A
★ 循环依赖在spring中是允许存在,spring框架**依据三级缓存已经解决了大部分的循环依赖**
一级缓存:单例池,缓存已经经历了完整的生命周期,已经初始化完成的bean对象
二级缓存:缓存早期的bean对象(生命周期还没走完)
三级缓存:缓存的是ObjectFactory,表示对象工厂,用来创建某个对象的

✅ 处理流程简要说明:

  1. Spring 创建 A → A 依赖 B → 创建 B
  2. B 依赖 A,发现 A 还没完成创建
  3. Spring 把 A 的 半成品(early reference) 暴露到二级缓存,让 B 先注入用
  4. 最后 A 初始化完成,加入一级缓存

✅ 这样就解决了大多数基于 setter 注入 / 字段注入 的循环依赖问题。

构造方法出现了循环依赖怎么解决?

A依赖于B,B依赖于A,注入的方式是构造函数
原因:由于bean的生命周期中构造函数是第一个执行的,spring框架并不能解决构造函数的的依赖注入
解决方案:使用**@Lazy进行懒加载**,什么时候需要对象再进行bean对象的创建

public A(@Lazy B b){
sout(“A的构造方法执行了”);
this.b=b;
}

@Component @Component
public class A{ → ← public class B{
@Autowired ↑ ↑ @Autowired
private B b; →↑ ↑← private A a;
} }

🧠 三大缓存:

缓存级别 对应源码字段名 作用说明
一级缓存 singletonObjects 已完全初始化的 Bean(生命周期完成),正式放入单例池
二级缓存 earlySingletonObjects 暂时暴露的半成品 Bean 实例(未执行初始化方法)
三级缓存 singletonFactories 存的是创建代理对象的 ObjectFactory,用于解决代理类循环依赖
Spring 把 A 的 半成品(early reference) 暴露到二级缓存,让 B 先注入用为啥要放到二级缓存 而不是一级呢?

✅ 答案核心:

因为此时 A 只是一个 尚未完成初始化的半成品对象,它还没有执行:

  • 属性填充(依赖注入)
  • Aware 接口回调
  • 初始化方法(如 @PostConstructafterPropertiesSet()

➡️ 这个 Bean 还不“完整”,不能放入正式的一级缓存!🔍 为什么不能直接放入一级缓存?

一级缓存是 singletonObjects,是 Spring 的“成品仓库”:

  • 放入这个缓存意味着:
    • Bean 已经完成生命周期(包括依赖注入、初始化)
    • 可以被别人安全引用

但在处理循环依赖时,我们是中途打断流程,把未初始化完成的 Bean 暴露出来给另一个 Bean 注入(比如 A 注入给 B)

🔴 如果此时放入一级缓存,有两个风险:

  1. 状态不一致风险
    其他 Bean 拿到这个未初始化完成的 Bean,会误以为它是“成品”,使用后可能报错或引发不一致。
  2. 生命周期混乱
    BeanPostProcessor、InitializingBean、@PostConstruct 等生命周期操作可能被跳过或错乱,严重破坏 Spring 的生命周期管理逻辑。

✅ 二级缓存的作用正是:**”暴露早期引用”,但不当成成品!**

// 二级缓存 earlySingletonObjects:仅仅作为“抢先使用”的通道
this.earlySingletonObjects.put(beanName, earlyReference);

它是 Spring 对“对象未完成但又必须提前使用”这个两难问题的妥协方案。✅ 最终完整的 Bean 放入一级缓存:

当整个 Bean 初始化完成(属性注入 + 初始化方法 + 后置处理器都执行完),才会放入一级缓存:

addSingleton(beanName, exposedObject); // 放入 singletonObjects


那按照你这样说 三级缓存又是干啥的?你问得非常到位!
✅ 我们已经知道:

  • 一级缓存:成品 Bean 的正式仓库
  • 二级缓存:未初始化完成的“早期引用”,暴露给依赖它的 Bean 使用。
✅ 简明结论(先记住):

三级缓存的本质作用是:为了解决“代理对象”在循环依赖中的提前暴露问题。

🎯 三级缓存的角色是什么?

三级缓存存的是:

Map<String, ObjectFactory<?>> singletonFactories

也就是说:它不是直接存 Bean 实例本身,而是存一个对象工厂 ObjectFactory,这个工厂可以在需要的时候返回一个 Bean(甚至是它的代理对象)。

🧠 为什么需要三级缓存?——解决AOP 代理对象的提前暴露

Spring 在三级缓存里放一个 ObjectFactory,用于 延迟构建代理对象

等到别的 Bean(比如 B)需要注入 A 时,先从二级缓存找不到,就用三级缓存的 ObjectFactory.getObject() 来构造这个 Bean,此时可以通过 SmartInstantiationAwareBeanPostProcessor(比如 AOP 后置处理器)提前生成代理。

什么是Spring的循环依赖??

==一级缓存==作用:限制bean在beanFactory中只存一份,即实现singleton scope,解决不了循环依赖

如果想打破循环依赖,就需要一个中间人的参与,这个中间人就是==二级缓存==如果一个对象是代理对象(被增强了)就不行

针对如果是代理对象的话如何解决呢? → ==三级缓存==

那如果构造方法出现了循环依赖怎么解决?

@Component @Component
public class A{ → ← public class B{
private B b; ↑ ↑ private A a;
public A(B c){ →↑ ↑← public B(A c){
sout(“A的构造方法执行了”) sout(“B的构造方法执行了”)
this.b=b; this.b=b;
} }
} }

报错信息:Is there an unresolvable circular reference?
解决:@Lazy 延迟加载→什么时候需要对象的时候什么时候实例化对象

public A(@Lazy B b){
 sout("A的构造方法执行了");
 this.b=b;
}

Spring解决循环依赖是通过三级缓存

// 单实例对象注册器
public class DefaultSingletonBeanRegistry extends SimpleAliasRegistry implements SingletonBeanRegistry {
private static final int SUPPRESSED EXCEPTIONS LIMIT= 100;  
private final Map<String, Object>singletonObjects = new ConcurrentHashMap(256); 一级缓存
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap(16); 三级缓存
private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap(16); 二级缓存 
}
缓存名称 源码名称 作用
一级缓存 singletonObject 单例池,缓存已经经历了完整的生命周期,已经初始化完成的bean对象
二级缓存 earlySingletonObjects 缓存早期的bean对象(生命周期还没走完)
三级缓存 singletonFactories 缓存的是ObjectFactory,表示对象工厂,用来创建某个对象的

SpringMVC的执行流程知道吗?

  • 视图阶段(老旧JSP等)
  • 前后端分离阶段(接口开发,异步)

==视图阶段(jsp)==

  • 用户发送出请求到前端控制器DispatcherServlet
  • DispatcherServlet收到请求调用HandlerMapping(处理器映射器)
  • HandlerMapping找到具体的处理器,生成处理器对象及处理器拦截器(如果有)
  • DispatcherServlet调用HandlerAdapter(处理器适配器)HandlerAdapter经过适配调用具体的处理器(Handler/Controller)Controller执行完成返回
  • ModelAndView对象HandlerAdapter将Controller执行结果ModelAndView返回给DispatcherServlet
  • DispatcherServlet将ModelAndView传给ViewReslover(视图解析器)
  • ViewReslover解析后返回具体View(视图)
  • DispatcherServlet根据View进行渲染视图(即将模型数据填充至视图中)
  • DispatcherServlet响应用户

==前后端分离阶段(接口开发,异步请求)==

  • 用户发送出请求到前端控制器DispatcherServlet
  • DispatcherServlet收到请求调用HandlerMapping(处理器映射器)
  • HandlerMapping找到具体的处理器,生成处理器对象及处理器拦截器(如果有),再一起返回给DispatcherServlet
  • DispatcherServlet调用HandlerAdapter(处理器适配器)
  • HandlerAdapter经过适配调用具体的处理器(Handler/Controller)

✅ 一、SpringMVC 是什么?

SpringMVC 是基于Servlet 的原生请求处理模型封装的一套 MVC Web 框架,它通过 DispatcherServlet 实现请求分发,解耦了控制器、视图解析器等组件之间的关系。

🚀 1. 用户发起请求

浏览器访问一个 URL,例如:http://localhost:8080/user/list,请求会先到达 前端控制器 DispatcherServlet


🔄 2. DispatcherServlet 接收到请求

DispatcherServlet 是 SpringMVC 的核心入口,用于请求分发和生命周期管理。


🔎 3. 查找 HandlerMapping(处理器映射器)

  • DispatcherServlet 调用 HandlerMapping 来查找当前请求所匹配的 Handler(Controller 方法)
  • 会封装成 HandlerExecutionChain,里面包含目标处理器和拦截器链。

⚙️ 4. 调用 HandlerAdapter(处理器适配器)

SpringMVC 不直接调用 Handler,而是交给 HandlerAdapter 来统一调用逻辑(比如支持 @RequestMapping/@RestController 方法)。


🧠 5. 执行 Handler(也就是 Controller 方法)

通过适配器调用具体的 Controller 中的业务处理方法,如:

java复制代码@GetMapping("/user/list")
public List<User> list() {
    return userService.findAll();
}

📦 6. 返回 ModelAndView(传统视图模式)或 @ResponseBody 数据(前后端分离)

  • 传统 MVC 场景下,Controller 返回一个 ModelAndView
  • 如果是前后端分离,通常会返回 JSON 数据,经过 HttpMessageConverter 处理后直接写入响应体。

🪞 7. 调用 ViewResolver(视图解析器)【视图模式专属】

  • 如果返回的是视图名(如 “userList”),SpringMVC 会调用 ViewResolver 解析为具体的 JSP 或 Thymeleaf 模板。

🎨 8. 渲染视图 View(视图模式专属)

  • 将模型数据(Model)填充进视图模板,生成 HTML 页面。

📤 9. DispatcherServlet 返回响应给浏览器

  • 前后端分离下是 JSON 响应;
  • 传统模式下是完整渲染后的 HTML。
  [用户请求]
       ↓
 DispatcherServlet
       ↓
 HandlerMapping → 找到 Handler + 拦截器链
       ↓
 HandlerAdapter  → 统一执行 Handler
       ↓
 Controller      → 执行业务逻辑
       ↓
 返回 ModelAndView / JSON
       ↓
(传统)ViewResolver → 找视图模板
       ↓
 渲染视图 / 写入 JSON 响应体
       ↓
 DispatcherServlet 响应浏览器

✅ 四、不同开发阶段下的区别

阶段 返回值 是否走视图解析器 常见注解
JSP 阶段 ModelAndView ✅ 是 @Controller
前后端分离 JSON 数据 ❌ 否 @RestController + @ResponseBody

🎯 五、总结金句(可背):

SpringMVC 核心就是一个请求经过 DispatcherServlet,根据 HandlerMapping 找到处理器,由 HandlerAdapter 调用 Controller 执行逻辑,最终通过视图解析或消息转换,返回结果给客户端。

SpringBoot自动配置原理?

@SpringBootApplication = 
    @SpringBootConfiguration +
    @EnableAutoConfiguration +
    @ComponentScan

SpringBoot中最高频的一道面试题,也是框架最核心的思想
==@SpringBootConfiguration==:该注解与 @Configuration 注解作用相同,用来声明当前也是一个配置类
==@EnableAutoConfiguration==:SpringBoot实现自动化配置的核心注解,通过配置选择器导入自动配置类
==@ComponentScan==:组件扫描,默认扫描当前引导类所在包及其子包

1,在Spring Boot项目中的引导类上有一个注解@SpringBootApplication,这个注解是对三个注解进行了封装,分别是:

  • @SpringBootConfiquration
  • @EnableAutoConfiquration
  • @ComponentScan

2,其中@EnableAutoConfiguration是实现自动化配置的核心注解。该注解通过@Import注解导入对应的配置选择器。内部就是读取了该项目和该项目引用的jar包的classpath路径下META-INF/spring.factories文件中的所配置的类的全类名。在这些配置类中所定义的Bean会根据条件注解所指定的条件来决定是否需要将其导入到Spring容器中

3,条件判断会有像@ConditionalOnClass这样的注解,判断是否有对应的class文件,如果有则加载该类,把这个配置类的所有的Bean放入spring容器中使用。

package com.itheima;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

// SpringBoot的启动类
// 注意: 我们写的代码要在启动类的包或者子包中
// @SpringBootApplication注解中包含了 @ComponentScan,没有指定扫描哪个包,默认扫描当前类所在的包和子包
@SpringBootApplication
public class Day15TliasManagement01IocDiApplication {
    // 启动项目, 内嵌的Tomcat会启动, 把项目部署到这个内嵌Tomcat中
    public static void main(String[] args) {
        SpringApplication.run(Day15TliasManagement01IocDiApplication.class, args);
    }
}

按住ctrl+左键点击@SpringBootApplication会弹到SpringBootApplication.class界面

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(
    excludeFilters = {@Filter(
    type = FilterType.CUSTOM,
    classes = {TypeExcludeFilter.class}
), @Filter(
    type = FilterType.CUSTOM,
    classes = {AutoConfigurationExcludeFilter.class}
)}
)

按住ctrl+左键点击@EnableAutoConfiguration会弹到EnableAutoConfiguration.class界面

# @Import({AutoConfigurationImportSelector.class})
# AutoConfigurationImportSelector是自动配置的选择器 
# 会加载META-INF中的spring.factories文件的自动配置类...AutoConfiguration...
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.boot.autoconfigure;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.context.annotation.Import;

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@AutoConfigurationPackage
@Import({AutoConfigurationImportSelector.class})
public @interface EnableAutoConfiguration {
    String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";

    Class<?>[] exclude() default {};

    String[] excludeName() default {};
}

Spring框架常见的注解有哪些?

注解 说明
@Component、@Controller、@Service、@Repository 使用在类上用于实例化Bean
@Autowired 使用在字段上用于根据类型依赖注入
@Qualifier 结合@Autowired一起使用用于根据名称进行依赖注入
@Scope 标注Bean的作用范围默认单例的
@Configuration 指定当前类是一个Spring配置类,当创建容器时会从该类上加载注解
@ComponentScan 用于指定Spring在初始化容器时要扫描的包
@Bean 使用在方法上,标注将该方法的返回值存储到Spring容器中
@Import 使用@Import导入的类会被Spring加载到IOC容器中
@Aspect、@Before、@After、@Around、@Pointcut 用于切面编程(AOP)

SpringMVC框架常见的注解有哪些?

注解 说明
@RequestMapping 用于映射请求路径,可以定义在类上和方法上。用于类上,则标识类中的所有的方法都是以该地址作为父路径
@RequestBody 注解实现接收http请求的json数据,将json转换为java对象
@RequestParam 指定请求参数的名称
@PathViriable 从请求路径中获取请求参数(/user/{id}),传递给方法的形式参数
@ResponseBody 注解实现将Controller方法返回对象转换成json对象响应给客户端
@RequestHeader 获取指定的请求头数据
@RestController @Controller + @RequestBody

SpringBoot常见的注解有哪些?

注解 说明
@SpringBootConfiguration 组合了 -@Configuration注解,实现配置文件的功能
@EnableAutoConfiguration 打开自动配置的功能,也可以关闭某个自动配置的选项
@ComponentScan Spring组件扫描

MyBatis执行流程?

  • 读取MyBatis配置文件:mybatis-config.xml加载运行环境和映射文件
  • 构造会话工厂SqlSessionFactory
  • 会话工厂创建SqlSession对象(包含了执行SQL语句的所有方法)
  • 操作数据库的接口,Executor执行器,同时负责查询缓存的维护
  • Executor接口的执行方法中有一个MappedStatement类型的参数,封装了映射信息
  • 输入参数映射
  • 输出结果映射

执行流程从读取配置文件、创建 SqlSession、查找 MappedStatement、参数映射、执行 SQL 到结果映射,每个环节都有对应的组件协作完成。

  • 理解了各个组件的关系
  • Sql的执行过程(参数映射、sql解析、执行和结果处理)

MyBatis是否支持延迟加载?

  • 延迟加载的意思是:就是在需要用到数据时才进行加载,不需要用到数据时就不加载数据。
  • Mybatis支持一对一关联对象一对多关联集合对象的延迟加载
  • Mybatis配置文件中,可以配置是否启用延迟加载 lazyLoadingEnabled=true/false,默认是关闭的
<settings>
  <setting name="lazyLoadingEnabled" value="true"/>
  <setting name="aggressiveLazyLoading" value="false"/>
</settings>

延迟加载(懒加载):查询主对象时不立即加载关联对象,而是在访问关联属性时再去执行查询语句加载数据
查询 User 时不查 Order,只有调用 user.getOrders() 时,才执行 select * from order where user_id = ?

延迟加载的底层原理知道吗?

MyBatis 延迟加载的核心是代理模式 + 拦截器机制,底层主要通过 CGLIB 创建目标对象的代理对象

  • 使用CGLIB创建目标对象的代理对象
  • 当调用目标方法时,进入拦截器invoke方法,发现目标方法是nul值,执行sql查询
  • 获取数据以后,调用set方法设置属性值,再继续查询目标方法,就有值了

查询用户的时候,把用户所属的订单数据也查询出来,这个是==立即加载==
查询**用户的(sql)时候,暂时不查询订单数据,当需要订单的时候,再查询订单(sql)**,这个就是==延迟加载==

执行流程 👇

  1. 查询主对象时,不会立即查关联对象,而是用 CGLIB 创建一个代理对象(继承目标类)
  2. 当访问关联属性时,进入代理类的 intercept 方法(拦截器)
  3. 拦截器判断属性是否已加载
    • 没加载 → 执行对应的 SQL(如 selectOrdersForUser
    • 查出结果后 → 通过 setXxx() 方法注入属性值
  4. 属性值设置好之后,后续访问就是直接取值,不再触发数据库查询

延迟加载的实现步骤:

  1. 配置开启延迟加载: 在MyBatis的配置文件中(通常是mybatis-config.xml),需要设置两个属性:
    • lazyLoadingEnabled=true:开启延迟加载。
    • aggressiveLazyLoading=false:关闭积极的延迟加载,即访问对象的时候不会立即加载其所有属性。
  2. 映射文件配置: 在对应的Mapper映射文件中,对于需要延迟加载的关联查询,使用select标签定义延迟加载的SQL语句,并通过fetchType="lazy"属性明确指定使用延迟加载。
  3. 创建代理对象: 当执行查询操作时,MyBatis不会立即执行关联查询的SQL,而是返回一个代理对象。这个代理对象是使用CGLIB库创建的,它继承自目标对象。
  4. 拦截器方法调用: 当我们首次访问这个代理对象的某个方法(比如访问订单详情)时,实际上会调用CGLIB生成的代理对象的拦截器方法(intercept方法)。在拦截器方法中,会判断当前要访问的属性是否已经被加载:
    • 如果属性已经被加载,则直接返回属性值。
    • 如果属性未被加载,则会执行之前定义好的延迟加载SQL语句,从数据库中查询数据。
  5. 设置属性值: 查询得到数据后,MyBatis会将这些数据设置到目标对象的相应属性上,这样下次访问该属性时,就不需要再次查询数据库了。

底层原理:

  • CGLIB代理:MyBatis使用CGLIB库创建目标对象的代理,当调用目标方法时,实际上会进入拦截器(Interceptor)的intercept方法。
  • 拦截器逻辑:在拦截器中,会判断当前调用的方法是否需要触发延迟加载。如果需要,则执行延迟加载的SQL查询。
  • 结果处理:查询结果会被处理并设置到目标对象的属性上,这样目标对象的相关属性就持有了数据,后续访问将直接返回这些数据,而无需再次查询。MyBatis在执行完延迟加载的SQL查询后,会获取查询结果,并将这些结果映射到目标对象的相应属性中

示例说明:

假设有一个用户User和订单Order的关系,在查询用户时,通常不会立即加载其订单信息,而是当需要时再加载。以下是简化的代码示例:

<!-- UserMapper.xml -->
<resultMap id="userMap" type="User">
  <id property="id" column="id"/>
  <result property="name" column="name"/>
  <!-- 延迟加载订单信息 -->
  <collection property="orders" column="id" ofType="Order" select="selectOrdersForUser" fetchType="lazy"/>
</resultMap>

<select id="selectUser" resultMap="userMap">
  SELECT * FROM user WHERE id = #{id}
</select>

<select id="selectOrdersForUser" resultType="Order">
  SELECT * FROM order WHERE user_id = #{id}
</select>

在上述配置中,当调用selectUser查询用户信息时,不会立即查询订单信息。只有当程序中访问User对象的orders属性时,才会执行selectOrdersForUser查询,这就是延迟加载的具体实现。

MyBatis的一级、二级缓存用过吗?

  • 一级缓存:基于 PerpetualCache 的 HashMap 本地缓存,其存储作用域为 Session,当Session进行flush或close之后,该Session中的所有Cache就将清空,默认打开一级缓存
  • 二级缓存是基于namespace和mapper的作用域起作用的,不是依赖于SQLsession,默认也是采用PerpetualCache,HashMap 存储。需要单独开启,一个是核心配置,一个是mapper映射文件

MyBatis的二级缓存什么时候会清理缓存中的数据?

  • 当某一个作用域(一级缓存 Session/二级缓存Namespaces)的进行了新增、修改、删除操作后,默认该作用域下所有 select 中的缓存将被 clear。
  • 本地缓存,基于PerpetualCache,本质是一个HashMap
  • 一级缓存:作用域是session级别
    • 基于 PerpetualCache 的 HashMap 本地缓存,其存储作用域为 Session,当Session进行flush或close之后,该Session中的所有Cache就将清空,默认打开一级缓存
  • 二级缓存:作用域是namespace和mapper的作用域,不依赖于session
    • 二级缓存是基于namespace和mapper的作用域起作用的,不是依赖于SQLsession,默认也是采用 PerpetualCache
      HashMap 存储

注意事项
  • 对于缓存数据更新机制,当某一个作用域(一级缓存 Session/二级缓存Namespaces)的进行了新增、修改、删除操作后,默认该作用域下所有 select 中的缓存将被 clear
  • 二级缓存需要缓存的数据实现Serializable接口
  • 只有会话提交或者关闭以后,一级缓存中的数据才会转移到二级缓存中

SpringCloud篇

SpringCloud 5大组件有哪些?

回答原则:简单的问题不能答错

通常情况 SpringCloudAlibba
Eureka:注册中心 Nacos:注册中心/配置中心
Ribbon:负载均衡 Ribbon:负载均衡
Feign:远程调用 Feign:远程调用
Hystrix:服务熔断 sentinel:服务保护
Zuul/Gateway:网关 Gateway:服务网关

服务注册和发现是什么意思? SpringCloud 如何实现服务注册发现?

  • 我们当时项目采用的eureka作为注册中心,这个也是SpringCloud体系的一个核心组件
  • 服务注册:服务提供者需要把自己的信息注册到eureka来保存这些信息,比如**服务名称、ip、端口**等等
  • 服务发现:消费者向eureka拉取服务列表信息,如果服务提供者有集群,则消费者利用负载均衡算法,选择一个发起调用
  • 服务监控:服务提供者会每隔30秒向eureka发送心跳,报告健康状态,如果eureka服务90秒没有收到心跳,从eureka中剔除
  • 微服务中必须要使用的组件,考虑我们使用微服务的程度
  • 注册中心的核心作用是:服务注册和发现
  • 常见的注册中心:eureka、nocas、zookeeper

请你说一下nacos与eureka的区别?

  • Nacos与Eureka的共同点 (注册中心)
    • 都支持服务注册和服务拉取
    • 都支持服务者心跳方式做健康检测
  • Nacos与Eureka的区别 (注册中心)
    • Nacos支持服务端主动检测提供者状态:临时实例采用心跳模式,非临时实例采用主动检测模式
    • 临时实例心跳不正常会被剔除,非临时实例则不会被提出
    • Nacos支持服务列表变更的消息推送模式,服务列表更新更及时
    • Nacos集群默认采用AP方式高可用模式,当集群中存在非临时实例时,采用CP模式;Eureka采用AP方式
  • Nacos还支持了配置中心,Eureka只有注册中心,也是选择选用nacos的一个重要原因

把RestTemplate替换成OpenFeign后它们的底层还是一样的吗?OpenFeign是远程调用

OpenFeign的底层原理也是根据服务名称,首先去远程注册中心拉取服务列表,底层也会在本地缓存一份,也会根据负载均衡选出一个实例,又运用了jdk的动态代理生成代理类,也会涉及到反射机制,最终拼出完整的url,发起http远程调用

@FeignClient(name = "service-provider")
public interface ServiceProviderClient {
    // 定义接口方法,映射到服务提供者的具体API
    @GetMapping("/api/resource")
    String getResource();
}

✅ 一、使用方式不同

  • RestTemplate 是显式调用(自己写 URL,拼参数)

    restTemplate.getForObject("http://user-service/user/1", String.class);
    
  • OpenFeign 是声明式调用(只写接口 + 注解,SpringBoot自动帮你拼URL发请求)

    @FeignClient("user-service")
    public interface UserClient {
        @GetMapping("/user/{id}")
        String getUser(@PathVariable("id") Long id);
    }
    

✅ 二、底层原理差不多,但实现机制不同

对比点 RestTemplate OpenFeign
发起方式 手动构造 URL + 参数 注解接口 + 动态代理自动拼 URL
底层通信方式 HttpClient / OKHttp / JDK Http 同样也是基于 HttpClient 或 OKHttp
注册中心拉取服务 可搭配 Ribbon 手动实现服务发现 默认集成 Spring Cloud LoadBalancer 自动发现服务
负载均衡 手动配置 Ribbon 或 LoadBalancer 自动内置 LoadBalancer,基于服务名均衡选择
动态代理 有,基于 JDK 动态代理生成接口实现类
扩展能力 灵活性高,配置复杂 扩展性强,统一规范,支持熔断/重试/拦截器等

✅ 三、OpenFeign 更高级,集成更好

OpenFeign 是对 RestTemplate 的一层封装 + 声明式远程调用:

  • 内置了服务注册发现(Eureka/Nacos)
  • 内置了负载均衡(Ribbon / Spring Cloud LoadBalancer)
  • 可配合 Hystrix / Sentinel 实现熔断降级
  • 支持拦截器、日志、重试、压缩等功能
  • 统一异常处理、超时配置更方便

✅ 四、结论一句话总结:

虽然最终底层都是通过 HTTP 客户端发起请求(如 OkHttp / HttpClient),但 OpenFeign 是基于动态代理+注解的声明式封装,实现了更强大的远程调用能力和集成能力,远比 RestTemplate 更高级、更易维护。

如果面试官继续追问:“你们项目是怎么替换的?”,你可以说:

我们之前用 RestTemplate 是在业务代码里拼 URL,很冗余。后来统一封装为 OpenFeign,只保留接口定义,调用方更清晰,服务注册与发现、负载均衡也变成自动处理,配合 Sentinel 做了服务熔断与限流,提升了整体的系统健壮性。

怎么个自动处理法?

默认情况下 —— OpenFeign 自动处理

✅ 默认配置时:

  • 服务注册与发现:依赖 Nacos / Eureka 等注册中心,OpenFeign 会根据服务名自动从注册中心拉取可用实例。
  • 负载均衡:默认通过 Spring Cloud LoadBalancer(以前是 Ribbon)对服务列表进行轮询或权重等策略选择一个实例。
  • HTTP请求:通过 HttpClient / OkHttp 等客户端执行。

你只写:

@FeignClient("user-service")
public interface UserClient {
    @GetMapping("/user/{id}")
    User getUser(@PathVariable("id") Long id);
}

OpenFeign 会自动做:

  • 拉取 user-service 的服务列表
  • 选出一个实例(负载均衡)
  • 拼接 URL 发起 HTTP 请求

🧠 一、什么叫“选出一个实例”?

微服务架构 中,一个服务往往会 部署多个实例 来应对高并发或容灾,例如:

服务名 实例地址
user-service 10.0.0.1:8080
user-service 10.0.0.2:8080
user-service 10.0.0.3:8080

当你通过 OpenFeign 发送请求:

@FeignClient("user-service")
User getUser(...);

你只写了一个服务名 user-service,但后台其实有多个实例,必须从这些实例中选出一个具体地址来发请求,比如选中 10.0.0.2:8080


⚖️ 二、这就是负载均衡要干的事!

✅ 负载均衡做的事:

就是 从多个可用实例中选择一个

✅ 为什么要选?不选行不行?

如果不做选择,就不知道到底该请求哪个服务器,容易:

  • 总是访问同一个实例 → 某一个实例过载
  • 有的实例空闲,有的压力大 → 资源利用不均衡
  • 某些实例宕机,没人检测 → 请求失败

✅ 所以负载均衡的作用是:

  • 分摊压力:让不同请求打到不同实例,防止某一个服务器崩掉
  • 提高可用性:某个实例挂了,下一次选择其他实例,系统还能继续用
  • 自动切换:负载均衡组件还能感知服务状态变化,动态更新可用实例列表

🧮 三、OpenFeign 背后使用了什么做负载均衡?

在 Spring Cloud 中,OpenFeign 默认集成了:

✅ Spring Cloud LoadBalancer(或老版 Ribbon)

它的核心就是在每次请求前调用:

choose("user-service")

然后根据你配置的策略,比如:

  • 轮询(RoundRobin)👉 按顺序一个一个来
  • 随机(Random)👉 随机挑一个
  • 最少连接数(LeastConnections)
  • 权重(Weighted)

最终得到一个实例,例如 10.0.0.2:8080,然后拼接成最终 URL:

http://10.0.0.2:8080/api/user/1

再发起 HTTP 请求。


🎯 总结一句话:

OpenFeign 中的“选出一个实例”就是 从多个服务实例中选择一个合适的来发送请求的过程,这个选择过程就是“负载均衡”负责的。它的目标是为了 提升性能、避免单点压力、实现高可用

你们项目负载均衡如何实现的?图1.1

微服务的负载均衡主要使用了一个组件Ribbon,比如,我们再使用feign远程调用的过程中,底层的负载均衡就是使用了Ribbon 【与RestTemplate不同,OpenFeign默认是LoadBalancer

  • 负载均衡Ribbon,发起远程调用feign就会使用Ribbon
  • Ribbon负载均衡策略有哪些?
  • 如果想自定义负载均衡策略如何实现?
Ribbon已经进入维护模式,Netflix不再积极开发新功能。而Spring Cloud LoadBalancer作为替代,不仅提供了Ribbon的核心功能,还引入了一些新特性和改进

Ribbon负载均衡策略有哪些?简单、权重、随机、区域

  • RoundRobinRule简单轮询服务列表来选择服务器
  • WeightedResponseTimeRule:按照权重来选择服务器,响应时间越长,权重越小
  • RandomRule随机选择一个可用的服务器
  • BestAvaliableRule:忽略那些短路的服务器,并选择并发数较低的服务器
  • RetryRule:重试机制的选择逻辑
  • AvaliabilityFilteringRule:可用性敏感策略,先过滤非健康的,再选择连接数较小的实例
  • ZoneAvoidanceRule:以区域可用的服务器为基础进行服务器的选择。使用Zone对服务器进行分类,这个Zone可用理解为一个机房、一个机架等。而后再对Zone内的多个服务做轮询
✅ Ribbon 常见负载均衡策略一览
策略名 简介 核心逻辑 适用场景
RoundRobinRule 轮询策略 依次选择服务列表中的每个实例,循环使用 简单、适用于服务性能相当、请求量均匀的场景
RandomRule 随机策略 随机选一个可用实例 测试环境、低并发系统或对分布无要求的场景
WeightedResponseTimeRule 权重 + 响应时间 根据服务实例响应时间动态调整权重,响应快的被选中几率高 适用于实例性能差异明显,希望高性能实例被优先选中
RetryRule 带重试机制的轮询 每次选择失败后会在一段时间内重试其他实例(默认使用 RoundRobinRule) 适用于请求容错性强、临时性网络波动频繁的情况
BestAvailableRule 最少并发策略 忽略短路(熔断)的实例,选择并发数最少的可用实例 适用于高并发下控制服务压力
AvailabilityFilteringRule 可用性过滤策略 过滤掉连接失败次数多的和并发高的实例,避免访问不健康服务 适用于系统对可用性要求高、网络波动大的场景
ZoneAvoidanceRule(默认) 区域感知策略 综合评估 zone(区域)内实例的可用性和负载(Zone可以理解为机房或机架),选出最优 zone 再轮询选择服务 适用于跨机房、跨地域部署,希望优先选择本地/低延迟实例的系统

📌 补充小Tips

  • Spring Cloud 2020 后 Ribbon 官方不再维护,推荐用 Spring Cloud LoadBalancer 替代,但思想类似。
  • 如果你项目还用 Ribbon,推荐配合 Hystrix 或 Sentinel 做服务熔断和限流

如果想自定义负载均衡策略如何实现?图1.2

  • 创建类实现IRule接口,可以指定负载均衡策略(全局)
  • 在客户端的配置文件中,可以配置某一个服务调用的负载均衡(局部)

首先,你需要创建一个类来实现 IRule 接口,这样就能自定义负载均衡的策略。
实现 IRule 接口

import com.netflix.loadbalancer.IRule;
import com.netflix.loadbalancer.Server;
import com.netflix.loadbalancer.ZoneAwareLoadBalancer;
import com.netflix.loadbalancer.RandomRule;

import java.util.List;

public class CustomLoadBalancerRule implements IRule {

private IRule delegate = new RandomRule();  // 默认策略

@Override
public Server choose(Object key) {
  // 在这里实现自己的负载均衡算法
  // 比如,你可以使用 RoundRobin、Random 或者基于健康检查的策略
  return delegate.choose(key);
}

@Override
public void setLoadBalancer(ZoneAwareLoadBalancer<?> lb) {
  delegate.setLoadBalancer(lb);
}

@Override
public ZoneAwareLoadBalancer<?> getLoadBalancer() {
  return delegate.getLoadBalancer();
}
}

然后,你需要在 Spring 配置类或者启动类上注入该自定义的负载均衡策略。
配置 Bean 注册到容器(全局策略)

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RibbonConfig {

@Bean
public IRule customLoadBalancerRule() {
  return new CustomLoadBalancerRule();
}
}

SpringCloud中什么是服务雪崩,怎么解决这个问题?

  • 什么是==服务雪崩==?一个服务失败,导致整条链路的服务都失败的情形
  • 服务雪崩:当某个服务因为故障不可用,导致依赖它的上游服务纷纷失败,进而整个系统连锁崩溃的现象
    • 常发生在服务调用链路较长
    • 一个服务挂了,所有调用它的服务都会超时挂起,线程资源被耗尽,最终整个系统瘫痪

📌 通俗比喻:你访问淘宝商品详情页面 → 商品服务依赖库存服务 → 库存服务挂了 → 商品服务一直卡着等 → 网页加载失败 → 淘宝崩了

  • ==熔断降级== && ==服务熔断==(解决)Hystix 服务熔断降级

服务降级部分服务不可用:服务自我保护的一种方式,或者保护下游服务的一种方式,用于确保服务不会受请求突增影响变得不可用,确保服务不会崩溃,一般在实际开发中与Feign接口整合,编写降级逻辑 (某个服务挂了或响应慢,不让请求卡住,而是返回预设的“备胎数据”,用户体验不会很差)

服务熔断整个服务不可用:默认关闭,需要手动打开,如果监测到10秒内请求的失败率超过50%,就触发熔断机制。之后每隔5秒重新尝试请求微服务,如果微服务不能响应,继续走熔断机制。如果微服务可达,则关闭熔断机制,恢复正常请求

  • 限流(预防)微服务限流(漏桶算法、令牌桶算法)

    限制单位时间的请求数,避免流量洪峰打垮服务

    • 漏桶算法:匀速出水,适合平滑限流
    • 令牌桶算法:按需取令牌,适合突发流量控制

    📍 实现工具:Sentinel、Gateway限流、Bucket4j、RateLimiter

你们项目中有没有做到限流?怎么做的?&& 限流常见的算法有哪些??

① 先来介绍一下业务,什么情况下去做限流,需要说明QPS具体是多少

  • 我们有一个活动,到了假期就会抢购优惠券,QPS最高可以达到2000,平时10-50之间,为了应对突发流量,需要做限流
  • 常规限流,为了防止恶意攻击,保护系统正常运行,我们当时系统能够承受最大的QPS是多少(压测结果)

nginx限流

  • 控制速率(突发流量),使用的漏桶算法来实现过滤,让请求以固定的速率处理请求,可以应对突发流量
  • 控制并发数,限制单个ip的连接数和并发链接的总数

网关限流

  • 在SpringCloudGateway中支持局部过滤器RequestRateLimiter来做限流,使用的是令牌桶算法
  • 可以根据ip或路径进行限流,可以设置每秒填充平均速率,和令牌桶总容量

解释原理:

QPS(Queries Per Second,每秒查询率)是衡量一个系统处理请求能力的指标,它表示服务器在一秒钟内能够处理的查询数量。这个指标常用于数据库和web服务器等应用,以评估系统在高并发情况下的性能。
以下是对您提到的两句话的分析:

  1. 活动期间的高并发处理:
  • 背景知识: 在电子商务等应用中,促销活动往往会引起用户的大量点击和购买行为,导致短时间内流量剧增。
  • 限流原理: 为了应对这种突发的高流量,系统需要实施限流措施。限流是为了保护系统资源不被过度消耗,确保系统的稳定性和可靠性。常见的限流算法有固定窗口、滑动窗口、令牌桶和漏桶等。

固定窗口: 假设每 1 分钟允许 100 次请求,10:00:00 到 10:01:00 期间的 100 次请求被允许,超出 100 次则会被限流,10:01:00 到 10:02:00 则重新开始计算。

滑动窗口: 每 60 秒内最多允许 100 次请求,滑动窗口的时间长度为 60 秒,窗口内的请求数会随着时间滑动更新,防止请求在时间边界上积压

令牌桶:假设每秒生成 10 个令牌,令牌桶的容量为 100 个令牌。如果 1 秒内有 15 个请求到达,则前 10 个请求能获得令牌并继续执行,剩余的 5 个请求需要等到下一个时间窗口令牌生成后再执行。

漏桶:假设每秒钟流出 10 个请求,漏桶的容量为 100 个请求。如果 1 秒钟内接收了 30 个请求,系统只会处理 10 个请求,剩余的 20 个请求被丢弃,直到下一个时间点。

  • 实施方式: 在您提到的情况下,可以采用以下策略:
    • 预判性扩容: 根据历史数据和活动规模预测流量,提前进行服务器资源的扩容。
    • 动态限流: 在活动期间,根据实时监控的QPS数据动态调整限流阈值,保证系统平稳运行。
    • 排队处理: 对于超出系统处理能力的请求,可以采用队列进行缓冲,分批次处理。
  1. 常规限流与系统最大承受QPS:
  • 背景知识: 常规限流是为了在日常运行中防止恶意攻击(如DDoS攻击)和保护系统资源不被滥用。
  • 压测结果: 系统的最大承受QPS是通过压力测试得出的。压力测试(也称为负载测试)是通过模拟高并发访问来测试系统的极限性能,以确定系统在保证稳定运行的前提下能够承受的最大QPS。
  • 原理分析:
    • 保护系统: 通过设定一个QPS上限,可以防止系统过载,保障系统的正常运行。
    • 资源分配: 了解系统的最大承受QPS有助于合理分配资源,如数据库连接、内存和CPU等。
    • 用户体验: 适当的限流可以保证用户的体验,避免因系统过载导致的响应缓慢或服务不可用。
      在实施限流策略时,还需要考虑以下因素:
  • 业务优先级: 对于不同的业务请求,可能需要有不同的限流策略,优先保证核心功能的可用性。
  • 用户体验: 限流策略应尽量减少对用户体验的影响,例如通过友好的错误提示或降级方案。
  • 数据监控: 实时监控系统的QPS和其他关键指标,以便快速响应并调整限流策略。
    综上所述,限流是确保系统在高并发情况下稳定运行的重要措施,而了解系统的最大承受QPS是制定合理限流策略的基础。

为什么要限流?

  • 并发业务量大(突发流量)
  • 防止用户恶意刷接口
限流的实现方式:
  • ==Tomcat==单体项目可以,分布式不行:可以设置最大连接数 <Connector port="8080"...maxThreads="150"...>

  • ==Nginx==:漏桶算法固定速率露出(平滑)

    控制速率(突发流量)

  • ==网关==:令牌桶算法

  • 自定义拦截器

echos-gateway真实案例

🔷 1. 网关服务名称 + 动态发现配置

spring:
  application:
    name: ech-gateway
  cloud:
    nacos:
      discovery:
        server-addr: 192.168.188.120:8848
  • 知识点:Gateway 是 Spring Cloud 架构中的 API 网关,用于请求路由、统一鉴权、日志跟踪、限流、熔断等功能。
  • 动态路由注册中心配置:接入 Nacos 注册中心,自动发现服务实例。

🔷 2. Gateway 路由规则(重点)

spring:
  cloud:
    gateway:
      routes:
        - id: zk-sam-service
          uri: lb://ech-sam-cs
          predicates:
            - Path=/iclock/**

🧠 面试要点:

  • id: 路由唯一标识
  • uri: lb://xxx: 表示使用 负载均衡方式 路由到注册中心中 xxx 的实例(通过 Ribbon/LoadBalancer 实现)
  • Path: 路径断言,匹配路径开头为 /iclock/** 的请求
  • filters: StripPrefix=1: 去除路径中的第一级前缀(如 /ech-service/a/b/a/b

🔷 3. 自动路由发现开启

discovery:
  locator:
    enabled: true
    lower-case-service-id: true

🧠 面试延申:

  • enabled=true:允许网关根据注册中心中注册的服务自动创建路由(简化配置)
  • lower-case-service-id=true:将服务名小写化,避免大小写不一致导致路由失败

面试官可能问:
“你们项目中是手动配置路由还是用 locator 自动发现?哪种方式更推荐?”

回答思路:

  • 自动发现适合内部测试环境,快速接入新服务
  • 生产建议手动配置,便于管理、加权限、加限流、避免误暴露

✅ 三、Feign配置部分(Gateway下游调用)

feign:
  client:
    config:
      default:
        connectTimeout: 5000
        readTimeout: 5000
        loggerLevel: full
  httpclient:
    enabled: false
  okhttp:
    enabled: false

✅ 面试可延申:

  • Feign 的连接/读取超时时间如何设置?
  • loggerLevel 有哪些级别(NONE/BASIC/HEADERS/FULL)?
  • Feign 支持哪几种底层 HTTP 客户端?为什么要关掉 HttpClient/OkHttp?

✅ 四、Redis连接池配置(网关限流、缓存常用)

spring:
  redis:
    database: 12
    host: 192.168.188.120
    port: 6379
    lettuce:
      pool:
        max-active: 1000
        max-idle: 10
        min-idle: 5

✅ 面试展开:

  • 为什么使用 Redis?

    作为 限流、缓存、黑名单过滤、Token 存储 后端,具备高性能分布式能力

  • 你们用 Lettuce 还是 Jedis?区别在哪?


✅ 五、Actuator配置(服务监控)

management:
  server:
    port: 9090
  • 暴露 Spring Boot Actuator 指标信息,常用于结合 Prometheus/Grafana 做监控
  • 可暴露如 /actuator/health/metrics/gateway/routes

你们的微服务是怎么监控的?

我们项目中采用的skywalking进行监控的

  • skywalking主要可以监控接口、服务、物理实例的一些状态。特别是在压测的时候可以看到众多服务中哪些服务和接口比较慢,我们可以针对性的分析和优化。
  • 我们还在skywalking设置了告警规则,特别是在项目上线以后,如果报错,我们分别设置了可以给相关负责人发短信和发邮件,第一时间知道项目的bug情况,第一时间修复
skywalking

一个分布式系统的应用程序性能监控工具(Application Performance Management), 提供了完善的链路追踪能力,apache的顶级项目(前华为产品经理吴晟主导开源)

✅ 面试追问题 & 答法建议

面试官可能追问的问题 答题建议
🔸SkyWalking 是怎么接入项目的? 我们使用 Java Agent 的方式,启动时通过 -javaagent:/path/to/skywalking-agent.jar 加载探针,同时在 agent.config 中配置服务名、采集后端等
🔸和 Zipkin、Prometheus 相比有什么优劣? SkyWalking 支持 UI 更强,适合链路分析;Prometheus 更适合指标监控配合 Grafana;Zipkin 更轻量但功能少。SkyWalking 是综合性最强的一款
🔸SkyWalking 的数据存储用的什么? 默认是 ElasticSearch,也支持 H2(测试环境),生产建议搭配 ES 做查询与聚合
🔸你怎么通过 SkyWalking 优化过接口? 我们发现某个接口 RT 超过 1 秒,通过 Trace 发现是 MySQL 多表 Join 查询导致,优化了 SQL 才解决的
🔸SkyWalking 能采集哪些组件的数据? HTTP请求、Dubbo、MySQL、Redis、Kafka、RocketMQ、Elasticsearch 等主流中间件都有探针支持

✅ 加分拓展:如何实现全链路追踪?

SkyWalking 通过在每个服务部署时挂载探针(agent),实现对请求头中 traceId 的自动传递,采集:

  • 入参出参(拦截 Controller 层、Feign、RestTemplate)
  • RPC调用(如Dubbo/Feign)
  • 数据库执行耗时
  • Redis访问
  • MQ消息链路

最终统一聚合在 SkyWalking OAP 端,再通过 Web UI 展示,形成完整的 “调用链 + 指标 + 日志” 三位一体监控体系。

✅ 项目答题串联建议

你可以在项目介绍环节自然引入这段内容,例如👇:

为了保障我们微服务系统的稳定性,我们在项目中接入了 SkyWalking 作为 APM 工具。通过它我们做到了链路追踪、性能分析、异常预警等。特别在压测和上线之后,能第一时间通过短信和邮件告警通知我们,提升了系统稳定性和定位效率。

探针(Agent)本质上是一个Java 程序运行时的字节码增强器,可以在不修改源码的前提下,对目标应用的类和方法进行增强,从而实现请求数据的“埋点采集”。


✅ 简单理解:探针干了啥?

可以这样比喻👇

你写的业务代码是演员
探针就是藏在台下的摄像机
它在你表演的时候偷偷把你的一举一动都记录下来(记录你调了哪个接口,用了多久,是不是出错了)


✅ 具体工作原理:

  1. 运行时注入字节码
    • 探针是以 -javaagent 参数的形式加载到 JVM 中
    • JVM 启动时,探针会监听所有类的加载过程,选择特定的类(如 ControllerRestTemplateJdbcTemplateRedisTemplate)进行增强
  2. 插入监控逻辑
    • 在方法的前后插入监控逻辑:
      • 方法开始时记录时间
      • 方法执行完记录耗时、返回值
      • 如果抛出异常,也能捕获异常堆栈
  3. traceId 传递
    • 每个请求入口(比如 SpringMVC Controller)会生成一个全局 traceId
    • 这个 ID 会自动透传到下游服务,比如 Feign 调用、MQ 发送、数据库访问等
    • 最终串成一条完整的调用链路

✅ 示例:

比如你访问这个接口:

@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
    return userService.getById(id);
}

探针实际会在你这个方法前后偷偷插入逻辑(伪代码):

// 前置逻辑:记录 traceId、时间戳
recordTrace("traceId-xxx");
startTime = System.currentTimeMillis();

User result = userService.getById(id);

// 后置逻辑:记录耗时
long cost = System.currentTimeMillis() - startTime;
sendToSkywalking(traceId, methodName, cost);

探针就是自动化“打点采集 + 数据上传”的代码增强器,开箱即用,不入侵业务代码。

解释一下CAP和BASE分布式系统理论

  • CAP 定理(一致性、可用性、分区容错性)
  1. 分布式系统节点通过网络连接,一定会出现分区问题(P)
  2. 当分区出现时,系统的一致性(C)和可用性(A)就无法同时满足
  • BASE理论
  1. 基本可用
  2. 软状态
  3. 最终一致
  • 解决分布式事务的思想和模型
  1. 最终一致思想:各分支事务分别执行并提交,如果有不一致的情况,再想办法恢复数据(AP)
  2. 强一致思想:各分支事务执行完业务不要提交,等待彼此结果。而后统一提交或回滚(CP)
  • 分布式事务方案的指导
  • 分布式系统设计方向
  • 根据业务指导使用正确的技术选择
==CAP定理==分布式系统无法同时满足三个指标

CAP 定理是 数学证明出来的分布式理论限制不是人为规定的规则,而是无法回避的“物理规律”

  • ==Consistency==(一致性):用户访问分布式系统中的任意节点,得到的数据必须一致。主从一致
  • ==Availability==(可用性):用户访问集群中的任意健康节点,必须能得到响应,而不是超时或拒绝
  • ==Partition tolerance==(分区容错性):当出现网络分区现象后,系统能够继续运行
    • Partition(分区):因为网络故障或其他原因导致分布式系统中的部分节点与其他节点失去链接,形成独立分区
    • Tolerance(容错):在集群出现分区时,整个系统也要持续对外提供服务

结论:

  • 分布式系统节点之间肯定是需要网络链接的,分区 (P) 必然存在
  • 如果保证访问的高可用性(A)可以持续对外提供服务,但不能保证数据的强一致性 AP
  • 如果保证访问的数据强一致性(C)就要放弃高可用性 CP

🎯 CAP 的结论:

在分布式系统中,由于网络问题不可避免(P必选),所以只能在 C 和 A 之间选其一:

类型 特点 场景
CP 系统 放弃可用性,保证一致性 银行/支付系统(宁可服务不可用,也不能出错)
AP 系统 放弃一致性,保证可用性 电商商品浏览、社交系统(稍微不一致无所谓)

为什么 CAP 无法同时满足?

我们来看一个例子理解「一致性(C) vs 可用性(A) 在分区故障(P)下的矛盾」:


❗ 场景设定:

  • 系统有两个节点:节点A 和 节点B
  • 正常时,A 和 B 通过网络通信同步数据
  • 现在发生了网络分区(P):A 和 B 之间断网了!

🧩 你怎么选?C 和 A 只能二选一:

✅ 如果你要保证【一致性 C】:

  • 当客户端向 A 节点写数据时,为了保证一致性,A 必须等待 B 同步成功
  • 但现在 A 和 B 网络断了,同步不了
  • 所以 A 只能拒绝请求:不响应 —— ❌ 违反了可用性 A

✅ 如果你要保证【可用性 A】:

  • A 收到写请求就立刻接受并返回成功
  • 但 B 此时拿到的还是老数据
  • 数据就不一致了 —— ❌ 违反了一致性 C

🎯 所以:在【网络分区 P】已发生的前提下 ——

你只能选:C+A ❌,C+P ✅,A+P ✅

这就是 CAP 定理的本质逻辑。
所以我们在设计系统时必须做权衡 —— 这正是分布式架构的难点和美感所在。

==BASE理论==

BASE理论是对CAP的一种解决思路,包含三个思想:

  • ==Basically Avaliable==(基本可用):分布式系统在出现故时,允许损失部分可用性,即保证核心可用
  • ==Soft State==(软状态):在一定时间内,允许出现中间状态,比如临时的不一致状态
  • ==Eventually Consistent==(最终一致性):虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致性

🎯 举个通俗易懂的例子:

你去银行转账,系统提示:“资金将在2小时内到账”。这就是:

  • ✅ 系统可用(你能转账)
  • ✅ 软状态(中间状态是“处理中”)
  • ✅ 最终一致(2小时内一定到账)

这就符合 BASE 理论。


✅ BASE 与 CAP 的关系?

理论 类型 强调
CAP 理论限制 三选二原则,强调不可能同时满足一致性、可用性、分区容错
BASE 实践理念 放弃强一致性,追求最终一致,以换取系统可用性与性能
CAP如何选择?
  • CP[支付宝]或者AP[超级跑跑系统维护]
  • 在什么场合,可用性高于一致性?
    • 网页必须要保障可用性(一定能看到最重要 是不是最新的不重要)和分区容错
    • 支付的时候一定要保障一致性(我可以保证不可用 但我不允许余额不一致)和分区容错
  • 合适的才是最好的

你们采用哪种分布式事务解决方案?

● 简历上写的微服务,只要是发生了多个服务之间的写操作,都需要进行分布式事务控制

● 描述项目中采用的哪种方案(seataMQ)
⚪ seata的XA模式,CP,需要互相等待各个分支事务提交,可以保证强一致性,性能差 (银行业务 )
⚪ seata的AT模式,AP,底层使用undolog 实现,性能好 (互联网业务 )
⚪ seata的TCC模式,AP,性能较好,不过需要人工编码实现 (银行业务 )
⚪ MQ模式实现分布式事务,在A服务写数据的时候,需要在同一个事务内发送消息到另外一个事务异步,性能最好 (互联网业务 )

✅ 你的理解:Seata 的 XA ≈ CP,AT ≈ AP?

Seata 是实现,CAP 是原则

你这样理解有一定道理,但这两者并不是等价关系。
Seata 是一种“解决分布式事务问题的技术方案”,目的是确保多个数据库操作的一致性,属于 CAP 中的一致性实现策略之一,但不能反过来说它就是 CAP 的实现。

对比 Seata XA / AT 模式 CAP 理论中的 CP / AP
本质 一种分布式事务协议实现 一种分布式系统权衡模型
关注点 一致性、事务原子性 可用性 vs 一致性 vs 分区容忍性
使用背景 数据库操作级别的事务控制 整个分布式系统的架构设计选择

🟢 Seata 为什么要设计成 XA、AT 两种模式?

因为不同的业务场景对性能 vs 一致性 的需求不同,Seata 提供了“可插拔”式的事务解决方案:

模式 一致性 性能 特点
XA模式(两阶段提交) 强一致性 性能差、资源占用多 接近传统分布式事务,事务期间资源锁定
AT模式(自动补偿) 最终一致性 性能好 非侵入,靠 Undo Log 回滚操作,适用于大部分业务场景
  • Seata框架(XA、AT、TCC)
  • MQ

Seata架构

  • TC(Transaction Coordinator) - 事务协调者维护全局和分支事务的状态,协调全局事务提交或回滚
  • TM(Transaction Manager) - 事务管理器:定义全局事务的范围、开启全局事务、提交或回滚全局事务
  • RM(Resource Manager) - 资源管理器:管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚

Seata 的 XA 和 AT 确实在一致性与可用性方面体现了 CAP 的设计权衡,但它并不是 CAP 的实现,而是面向分布式事务的一种可插拔事务解决方案。Seata 架构之所以独立,是因为它提供了统一的事务协调服务,支持多种协议(XA/AT/TCC/SAGA),以满足不同业务场景对一致性和性能的需求,这在真实的微服务架构中非常关键。

分布式服务的接口幂等性如何设计?

  • 幕等:多次调用方法或者接口不务状态,可以保证重复调用的结果和单次调用的结果一致;常用于支付、下单等关键业务防止重复提交
  • 如果是**新增数据**,可以使用数据库的唯一索引
  • 如果是**新增或修改数据**
    • 分布式锁,性能较低
    • 使用token+redis来实现,性能较好
      ● 第一次请求,生成一个唯一token存入redis,返回给前端
      ● 第二次请求,业务处理,携带之前的token,到redis进行验证,如果存在,可以执行业务,删除token; 如果不存在,则直接返回,不处理业务

🧠 一、什么是幂等性?

  • 幂等性是指:一次和多次请求同一个接口,对资源的影响是相同的
  • 幂等操作的特性是:无副作用(No Side Effect)

📌 二、幂等性为什么重要?

会出现重复调用的原因:
  • 网络抖动、页面重复点击
  • 分布式事务重试机制
  • 网关重试、MQ重复投递
  • 前端误操作(如双击支付按钮)

🎯 三、幂等性保障方案(重点)

场景 适用范围 实现方式 特点
✅ 数据库唯一约束 新增接口 通过唯一索引防止重复插入 简单高效
✅ Token机制 + Redis 提交类(订单、支付) 一次性Token防止重复提交 推荐,效率高
✅ 分布式锁 修改、转账等接口 基于Redisson、ZK等实现同步控制 精准,但性能稍差
✅ 接口幂等表 所有需幂等接口 记录请求唯一标识 + 状态 控制最强,适合高并发
✅ 乐观锁(版本号机制) 更新操作 防止并发更新,CAS思想 要求带版本号字段
✅ 幂等组件中间件 通用接口平台 拦截层统一幂等处理 企业封装,灵活可控

🔨 Token+Redis 实现幂等(推荐)

流程:

  1. 客户端请求创建订单,先调用接口获取 幂等Token
  2. 后续提交时在请求头中携带该 Token
  3. 服务端收到请求:
    • 判断 Redis 中是否存在该 Token
    • 若存在,则处理业务并删除 Token
    • 若不存在,说明已处理或重复请求,直接返回

优点:

  • 轻量级,无锁高并发
  • 可扩展性强,适用于 POST/PUT 等需要控制的接口

幂等多次调用方法或者接口不会改变业务状态,可以保证重复调用的结果和单次调用的结果一致

需要幂等场景
  • 用户重复点击(网络波动)
  • MQ消息重复
  • 应用使用失败或超时
请求方式 说明 是否天然幂等
GET 查询操作,天然幂等 ✅ 是
POST 新增操作,请求一次与请求多次造成的结果不同,不是幂等的 ❌ 否
PUT 更新操作,如果是以绝对值更新,则是幂等的。如果是通过增量的方式更新,则不是幂等的 ✅ 是(全量)或❌ 否(增量)
DELETE 删除操作,根据唯一值删除,是幂等的 ✅ 是(按主键)
-- 幂等更新:将余额设置为固定值
UPDATE account SET money = 500 WHERE id = 1;

-- 非幂等更新:余额加上500
UPDATE account SET money = money + 500 WHERE id = 1;
  • 数据库唯一索引【新增】

  • ==token + redis== 【新增+修改】**AND** ==分布式锁== 【新增+修改】

    🧠 面试答题思路模板

    我们项目中对于需要幂等性的接口(如支付、下单等),主要采用了Token + Redis机制

    • 请求前由前端向后端申请一次性 Token
    • 后续接口请求中携带该 Token
    • 后端通过 Redis 判断 Token 是否存在,从而保证接口只被处理一次

    此外,对于批量创建类操作,还会结合数据库唯一索引控制幂等,对于状态更新类操作会使用乐观锁分布式锁

    创建商品、提交订单、转账、支付等操作

你们项目中使用了什么分布式任务调度

xxl-job 是一个分布式任务调度平台,它致力于解决分布式场景下的任务调度问题,主要由调度中心和执行器两部分组成。调度中心负责统一管理任务调度,而执行器则是负责接收调度并执行任务逻辑的客户端。

🎯 一句话总结

我们项目使用的是 XXL-Job 分布式任务调度平台,用来实现定时任务管理,比如取消订单、同步库存、发送通知等,解决了分布式环境下定时任务重复、不可控的问题。

  • xxl-job路由策略有哪些?

    xxl-job提供了很多的路由策略,我们平时用的较多的就是:轮询、故障转移、分片广播

    🚦 路由策略有哪些?(面试重点)

    路由策略就是调度中心选 哪个执行器节点去执行任务 的方式。

    路由策略 描述
    轮询(Round) 多个节点轮流执行任务,负载均衡常用
    故障转移(Failover) 优先使用健康节点,失败时自动切换执行
    分片广播(Broadcast) 每个节点都执行一次任务,适合大数据并行处理
    随机(Random) 随机选取一个可用节点
    一致性哈希 根据任务参数哈希选节点,适合状态保持
    最少运行节点 选当前执行任务最少的机器
    指定机器 手动指定执行器
  • xxl-job任务执行失败怎么解决?

    • 路由策略选择故障转移,使用健康的实例来执行任务
    • 设置重试次数
    • 查看日志+邮件警告来通知相关负责人解决
    • 配置 任务超时时间 + 告警通知(邮件、短信)
    • 失败日志可在调度中心查看,有堆栈信息
  • 如果有大数据量的任务同时都现需要执行,怎么解决?

    • 让多个实例一块去执行(部署集群),路由策略分片广播
    • 在任务执行的代码中可以获取分片总数和当前分片,按照取模的方式分摊到各个实例执行;通过 ShardingContext 拿到当前分片信息
    @JobHandler("xxxHandler")
    public ReturnT<String> execute(ShardingContext context) {
        int totalShards = context.getTotalShardCount();
        int shardIndex = context.getShardIndex();
    
        List<Data> allData = getData();
        for (int i = 0; i < allData.size(); i++) {
            if (i % totalShards == shardIndex) {
                process(allData.get(i));
            }
        }
        return SUCCESS;
    }
    

💡 面试高频问法总结

面试问题 回答提示
你们怎么做定时任务调度? 我们使用 XXL-Job 做分布式定时调度,支持失败重试、分片执行、失败告警
XXL-Job 怎么实现任务分片? 配置为广播策略,任务中使用 ShardingContext 获取分片信息
路由策略都有哪些?你们用哪个? 常用轮询、故障转移、分片广播;我们大任务用广播,小任务轮询
如果一个任务执行失败怎么办? 设置了最大重试次数、使用了 Failover 策略、并配置了邮件告警通知
为什么不用 @Scheduled? @Scheduled 适用于单体项目,不能解决多实例下重复执行问题;XXL-Job 支持分布式调度、管理界面、执行日志等

✅ 详细对比分析:SpringTask vs XXL-Job

对比维度 SpringTask XXL-Job
定位 本地轻量级定时调度工具 分布式定时任务调度平台
部署方式 内嵌在应用中(@Scheduled) 独立部署调度中心 + 执行器
集群支持 不支持,单实例任务 支持分布式执行和 failover
任务失效恢复 无(如服务挂了任务丢) 支持失败重试 + 调度日志记录
任务管理 无可视化界面 Web 界面管理、动态配置任务
任务执行方式 方法注解 + 固定周期 支持 Bean 调用、Shell、HTTP、RPC 等
执行结果监控 有日志管理、失败报警、状态追踪
调度策略 固定时间(cron) cron、分片广播、失败重试、手动触发等
适用场景 简单、稳定的定时逻辑,如定期清理缓存 多任务调度、跨服务控制、任务分发、分片执行、手动补偿等复杂场景
xxl-job解决的问题
  • 解决集群任务的重复执行问题 xxl-job路由策略有哪些?
  • cron表达式定义灵活在页面上 xxl-job任务执行失败怎么解决?
  • 定时任务失败了,重试和统计 如果有大数据量的任务同时都需要执行,怎么解决?
  • 任务量大,分片执行

场景 1: 定时处理过期订单

假设用户下单后如果订单超过了某个时间没有支付,平台需要自动取消该订单并释放库存。这个任务需要在每天的某个固定时间(比如凌晨 2 点)运行。

解决的问题:
  1. 定时任务调度:XXL-Job 可以轻松管理该任务的执行时间和周期,确保每天准时执行,不需要开发者手动触发。
  2. 任务失败重试:如果该任务因为某些原因执行失败,XXL-Job 可以自动进行重试,并设置重试次数,确保任务最终被执行。
  3. 分布式执行:假设电商平台是一个分布式系统,订单数据存储在多个数据库中,XXL-Job 可以通过分布式执行确保每个数据库的订单都被正确处理

假设每晚 2 点有一个任务需要取消未支付的订单

public class OrderJob {
    @JobHandler("orderCancelJobHandler")
    public void cancelUnpaidOrders() {
        // 查询所有未支付的订单
        List<Order> unpaidOrders = orderService.findUnpaidOrders();
        for (Order order : unpaidOrders) {
            if (order.isExpired()) {
                orderService.cancelOrder(order);
                inventoryService.releaseStock(order.getProductId(), order.getQuantity());
                // 发送订单取消通知给用户
                notificationService.sendOrderCancelledNotification(order.getUserId());
            }
        }
    }
}

场景 2: 定时更新商品库存

假设电商平台上销售的是一些有时效性的商品,商家需要定期更新商品的库存状态(例如,库存数量达到一定阈值时,自动下架商品,或者增加库存数量)。这个任务同样需要定时执行。

解决的问题:
  1. 任务分片:在商品很多的情况下,XXL-Job 可以通过任务分片的方式并行处理不同商品的库存更新,提升任务的执行效率。
  2. 任务优先级:根据不同商品的重要程度,XXL-Job 可以设置任务的优先级,确保关键商品的库存更新优先执行。
public class InventoryJob {
    @JobHandler("inventoryUpdateJobHandler")
    public void updateProductInventory() {
        // 获取需要更新库存的商品
        List<Product> productsToUpdate = productService.findProductsForInventoryUpdate();
        for (Product product : productsToUpdate) {
            inventoryService.updateInventory(product);
            if (product.getStockQuantity() <= product.getLowStockThreshold()) {
                productService.deactivateProduct(product);
                // 发送商品下架通知
                notificationService.sendOutOfStockNotification(product.getId());
            }
        }
    }
}

场景 3: 定时发送促销活动通知

假设电商平台有一个促销活动,每个活动的开始和结束时间都由后台系统控制。需要在活动开始前 1 小时、活动结束时发送通知给用户。这些通知可以是短信、邮件或 APP 推送通知。

解决的问题:
  1. 定时任务管理:XXL-Job 可以定时触发通知任务,确保用户在活动前后及时收到通知。
  2. 高并发支持:在促销活动开始或结束时,平台可能会有大量的通知需要发送,XXL-Job 支持任务的并行处理,可以帮助我们高效地分发通知,避免性能瓶颈。
  3. 任务状态监控:XXL-Job 提供任务的实时监控功能,平台可以随时查看任务的执行情况,确保通知任务按时执行。
public class PromotionJob {
    @JobHandler("promotionNotifyJobHandler")
    public void sendPromotionNotifications() {
        // 获取当前正在进行的促销活动
        List<Promotion> activePromotions = promotionService.findActivePromotions();
        for (Promotion promotion : activePromotions) {
            if (promotion.isStartingSoon()) {
                notificationService.sendStartNotification(promotion);
            } else if (promotion.isEndingSoon()) {
                notificationService.sendEndNotification(promotion);
            }
        }
    }
}

✅ 这段代码做了什么?

java复制编辑@JobHandler("orderCancelJobHandler")
public void cancelUnpaidOrders() { ... }

这是一个 XXL-Job 的定时任务处理方法,任务名叫 orderCancelJobHandler,作用是:

定时扫描所有未支付订单,判断是否过期,过期则取消订单、释放库存并通知用户


🔧 XXL-Job 是怎么运行这个方法的?

🧩 步骤 1:添加依赖

在你的 pom.xml 中,需要加入 XXL-Job 的执行器依赖:

<dependency>
    <groupId>com.xuxueli</groupId>
    <artifactId>xxl-job-core</artifactId>
    <version>2.4.0</version>
</dependency>

🧩 步骤 2:执行器配置(application.yml)

xxl:
  job:
    admin:
      addresses: http://localhost:8080/xxl-job-admin
    executor:
      appname: order-job-executor
      address:
      ip:
      port: 9999
      logpath: /data/applogs/xxl-job/jobhandler
      logretentiondays: 30
    accessToken:

说明:

  • admin.addresses: 调度中心地址
  • executor.*: 当前服务是执行器,配置端口、日志路径等

🧩 步骤 3:启动类开启执行器功能

@EnableXxlJob
@SpringBootApplication
public class OrderJobExecutorApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderJobExecutorApplication.class, args);
    }
}

🧩 步骤 4:创建 JobHandler 并注册到容器

@Component
public class OrderJob {

    @XxlJob("orderCancelJobHandler")
    public void cancelUnpaidOrders() {
        // 执行任务逻辑
    }
}

注意:你用的是 @JobHandler,它是旧版本用法,新版本统一改为 @XxlJob(推荐)


🧩 步骤 5:在调度中心页面注册该任务

登录调度中心(xxl-job-admin)

  • 新建任务
    • 执行器:选择 order-job-executor
    • JobHandler名称:orderCancelJobHandler
    • 调度类型:如“CRON”
    • 执行方式:BEAN 模式
    • CRON 表达式:比如每天凌晨 2 点 0 0 2 * * ?
  • 保存并启动任务
xxl-job路由策略有哪些?

实例找任务项执行任务 这种找机器的方式就是路由策略

消息中间件RabbitMQ+Kafka

消息中间件提供了服务与服务之间的异步调用,还可以服务与服务之间解耦

RabbitMQ:**消息不丢失、消息重复消费、消息堆积、延迟队列、死信队列、高可用机制**
Kafka:**消息不丢失、消息重复消费、高可用机制、高性能设计吞吐量达到百万级数据存储和清理**

RabbitMQ-如何保证消息不丢失?

  • 开启生产者确认机制,确保生产者的消息能到达队列
    confirm到交换机ack 不到nack 和 return没到返回nack机制保证生产者把消息发过去

    达到队列成功返回ack,失败返回nacknegative acknowledgment

    1. 生产者发送消息到交换机。
    2. 交换机收到消息后,根据绑定规则(是否有匹配的队列)决定消息是否被正确路由。
    3. 如果消息成功路由到队列,交换机会向生产者返回 ack 确认。
    4. 如果消息没有成功路由到任何队列,交换机会通过 return 将消息退回给生产者。
    5. 生产者收到 acknack,可以处理消息确认或重试逻辑。
  • 开启持久化功能,确保消息未消费前在队列中不会丢失 durable = True
    万一broker挂掉就惨了 保证至少成功一次消费
    MQ是默认内存存储信息,开启持久化功能可以确保缓存在MQ中的消息不丢失[把数据存在磁盘上]

    # 声明持久化交换器
    channel.exchange_declare(exchange='exchange_name', durable=True)
            
    # 声明持久化队列
    channel.queue_declare(queue='queue_name', durable=True)
            
    # 发送持久化消息
    channel.basic_publish(exchange='exchange_name',
                          routing_key='routing_key',
                          body='Hello World!',
                          properties=pika.BasicProperties(
                             delivery_mode=2,  # 使消息持久化
                          ))
    
  • 开启消费者确认机制为auto,由spring确认消息处理成功后完成ack
    消费者三种机制:

    RabbitMQ支持消费者确认机制,即:**消费者处理消息后可以向MQ发送ack回执,MQ收到ack回执后才会删除该消息**,而Spring AMQP则允许配置三种确认模式:

    • manual:手动ack,需要在业务代码结束后,调用api发送ack。

    • auto:自动ack,由spring监测listener代码是否出现异常,没有异常则返回ack;抛出异常则返回nack

    • none:关闭ack,MQ假定消费者获取消息后会成功处理,因此消息投递后立即被删除

  • 开启消费者失败重试机制,多次重试失败后将消息投递到异常交换机,交由人工处理

    在开启重试模式后,重试次数耗尽,如果消息依然失败,则需要有MessageRecoverer接口来处理,它包含三种不同的实现:

    • RejectAndDontRequeueRecoverer:重试耗尽后,直接reject,丢弃消息。默认就是这种方式

    • ImmediateRequeueMessageRecoverer:重试耗尽后,返回nack,消息重新入队

    • RepublishMessageRecoverer:重试耗尽后,将失败消息投递到指定的交换机

  • 异步发送(验证码、短信、邮件)
  • MySQL和Redis,ES之间的数据同步
  • 分布式事务
  • 削峰填谷
==生产者确认机制==

RabbitMQ提供了publisher confirm机制来避免消息发送到MQ过程中丢失。消息发送到MQ以后,会返回一个结果给发送者,表示消息是否处理成功

RabbitMQ消息的重复消费问题如何解决的?

我们当时消费者是设置了自动确认机制,当服务还没来得及给MQ确认的时候,服务宕机了,导致服务重启之后,又消费了一次消息,这样就重复消费了
因为我们当时处理的支付(订单|业务唯一标识),它有一个业务的唯一标识,我们再处理消息时,先到数据库查询一下,这个数据是否存在,如果不存在,说明没有处理过,这个时候就可以正常处理这个消息了。如果已经存在这个数据了,就说明消息重复消费了,我们就不需要再消费了

原因
  • 网络抖动
  • 消费者挂了

解决方案:适用于任何MQ(Kafka,RabbitMQ,RocketMQ)

  • 每条消息设置一个唯一的标识id
  • 幂等方案:【分布式锁、数据库锁(悲观锁、乐观锁)】

RabbitMQ中死信交换机?(RabbitMQ延迟队列有了解过吗)

如果用原来的定时任务 也可以但是 可能会有订单空窗期 如果没人消费的时候 它内部还是回去sql查询已下单 +(now()-下单时间)?15min : true, false

  • 我们当时一个什么业务使用到了延迟队列(超时订单、限时优惠、定时发布)
  • 其中延迟队列就用到了死信交换机TTL(消息存活时间)实现的
  • 消息超时未消费就会变成死信(死信的其他情况:拒绝被消费,队列满了

我们用延迟队列处理未支付订单:用户下单后 15 分钟未付款,自动取消订单+释放库存;其他场景也用到延迟队列,比如限时促销、定时发布文章、定时发通知等。

延迟队列插件实现延迟队列DelayExchange
延迟队列的底层实现就是通过 TTL(消息存活时间)+ 死信交换机(DLX)组合完成的。

  • 声明一个交换机,添加delayed属性为true
  • 发送消息时,添加x-delay头,值为超过时间
什么样的消息会成为死信?
★ 消费者返回reject或者nack,且requeue参数设置为false【消息被拒绝并且不重入队列】
★ 消息超时未消费
★ 队列满了

如何给队列绑定死信交换机?
★ 给队列设置dead-letter-exchange属性,指定一个交换机
★ 给队列设置dead-letter-routing-key属性,设置死信交换机与死信队列的RoutingKey

------------------------------------------------------------------------
★ ★ ★ 使用 Spring AMQP 配置 ★ ★ ★ 
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.Exchange;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RabbitMQConfig {

    // 定义普通队列
    @Bean
    public Queue normalQueue() {
        return new Queue("normalQueue", true, false, false, 
                Map.of("x-dead-letter-exchange", "dlx_exchange", 
                       "x-dead-letter-routing-key", "dlx_routing_key"));
    }

    // 定义死信队列
    @Bean
    public Queue dlxQueue() {
        return new Queue("dlxQueue", true);
    }

    // 定义普通交换机
    @Bean
    public Exchange normalExchange() {
        return new TopicExchange("normal_exchange");
    }

    // 定义死信交换机
    @Bean
    public Exchange dlxExchange() {
        return new TopicExchange("dlx_exchange");
    }

    // 将普通队列与交换机绑定
    @Bean
    public Binding bindNormalQueue() {
        return BindingBuilder.bind(normalQueue()).to(normalExchange()).with("normal.routing.key").noargs();
    }

    // 将死信队列与死信交换机绑定
    @Bean
    public Binding bindDLXQueue() {
        return BindingBuilder.bind(dlxQueue()).to(dlxExchange()).with("dlx_routing_key").noargs();
    }
}
------------------------------------------------------------------------
如果你希望将死信队列配置成带有过期时间或其他特殊属性的队列,可以在定义 dlxQueue 时增加更多的设置,例如 TTL(过期时间)。
例如,设置死信队列的 TTL:

@Bean
public Queue dlxQueue() {
    return QueueBuilder.durable("dlxQueue")
            .withArgument("x-message-ttl", 60000) // 设置TTL为60秒
            .build();
}
✅ Spring AMQP 中配置延迟队列 + 死信队列(简洁版)

@Bean
public Queue orderQueue() {
    return QueueBuilder.durable("order.queue")
            .ttl(15 * 60 * 1000) // 15分钟TTL
            .deadLetterExchange("dlx.exchange") // 超时后投递到死信交换机
            .deadLetterRoutingKey("order.dlx")  // 指定死信路由key
            .build();
}

@Bean
public Queue orderDLXQueue() {
    return new Queue("order.dlx.queue");
}
延迟队列 = 死信交换机 + TTL (生存时间)
  • 延迟队列:进入队列的消息会被延迟消费的队列
  • 场景:超时订单、限时优惠,定时发布
死信交换机

当一个队列中的消息满足下列情况之一时,可以成为死信(dead letter):

  • 消费者使用basic.reject 或 basic.nack声明消费失败,并且信息的requeue参数设置为false
  • 消息是一个过期消息,超时无人消费
  • 要投递的队列消息堆积满了,最早的消息可能成为死信

如果该队列配置了dead-letter-exchange属性,指定了一个交换机,那么队列中的死信就会投递到这个交换机中,而这个交换机称为死信交换机(Dead Letter Exchange,简称DLX)

@Bean
public QUeue ttlQueue(){
    return QueueBuilder.durable("simple.queue") // 指定队列名称,并持久化
        .ttl(10000) // 设置队列的超时时间 10秒
        .deadLetterExchange("dl.direct") // 指定死信交换机
        .build();
}
✅ 死信交换机配置核心属性(面试考点)

x-dead-letter-exchange: 死信交换机名称
x-dead-letter-routing-key: 投递死信用的路由key
x-message-ttl: 消息过期时间(单位:毫秒)
TTL

TTL(Time-To-Live)。如果一个队列中的消息TTL结束仍未消费,则会变成死信,ttl超时分为两种情况:

  • 消息所在的队列设置了存活时间
  • 消息本身设置了存活时间

死信图片

RabbitMQ如果有100万消息堆积在MQ,如何解决(消息堆积怎么解决)?

消息堆积的本质是消费能力不足,解决重点是提升消费者处理能力 + 降低系统压力。

**解决消息堆积有三种思路 **

  • 增加更多消费者,提高消费速度
  • 在消费者内开启线程池加快消息处理速度
  • 扩大队列容积,提高堆积上限
    • 在声明队列的时候可以设置x-queue-model为lazy,即为惰性队列
    • 基于磁盘存储,消息上限高
    • 性能比较稳定,但基于磁盘存储,受限于磁盘IO,时效性会降低

生产者发送消息的速度超过了消费者处理消息的速度,就会导致队列中的消息堆积,直到队列存储消息达到上限。之后发送的消息就会成为死信,可能会被丢弃,这就是堆积问题

**解决消息堆积有三种思路 **

  • 增加更多消费者,提高消费速度
  • 在消费者内开启线程池加快消息处理速度
  • 扩大队列容积,提高堆积上限
达到上限发送的消息会变成死信,那我为什么不搞个死信交换机 而是用了上面的三种思路??

使用死信交换机(DLX, Dead Letter Exchange)是另一种处理消息堆积的方式,但它的作用更偏向于“消息过期”或“处理失败”的情况下将消息转发到另一个队列。并不直接解决生产者发送消息过快或消费者处理速度过慢的问题。通过死信交换机,你可以将无法处理的消息转发到其他队列,方便你后续进行分析或处理,但它并不能提高消费者处理消息的速度。

针对消息堆积的本质问题,解决方式更多的是优化消费者处理能力,而不是仅依赖死信交换机。具体而言,死信交换机和你的三种思路的关系如下:

  1. 死信交换机(DLX):当消息达到队列上限或无法消费时,消息被转发到死信队列。你可以分析死信队列中的消息,了解原因,并决定是重试、丢弃还是进行其他处理。它的作用是不丢失消息,但并不能帮助消除堆积。
  2. 增加消费者:这是直接针对堆积的根本解决方案,通过增加消费者数量来加速消息的处理。死信交换机无法直接解决消费者处理能力不足的问题。
  3. 开启消费者线程池:在单个消费者上开启线程池,可以提高消费者的处理能力,减少堆积。死信交换机并不能增加消息处理速度,它只是用来应对消费失败的情况。
  4. 扩大队列容量并使用惰性队列:惰性队列可以将消息存储在磁盘上,而非内存中,减轻内存压力,但这也会降低时效性,并不能解决生产者生产过快或消费者消费过慢的问题。死信交换机同样无法直接解决这一点。

总结来说,死信交换机是处理消息丢失或无法消费的方式,它和通过增加消费者、线程池、队列优化这些手段并不冲突,但也无法替代这些更直接的解决方案。你可以结合这两者,使用死信交换机来保障消息不丢失,同时采取上述方法来提高消息消费速度。

死信交换机主要作用是保底机制,用于处理失败的消息,比如超时、拒绝、队列满。它不能真正解决堆积问题,只能帮我们兜底不丢消息
所以,我们会搭配使用:前面是用多线程和惰性队列来压堆积,后面用死信队列来兜住异常消息,保障系统稳定。

惰性队列

惰性队列特征如下:

  • 接收到消息后直接存入磁盘而非内存
  • 消费者要消费消息时才会从磁盘中读取并加载到内存
  • 支持数百万条的消息存储
@Bean
public Queue lazyQueue(){
    return QueueBuilder
            .durable("lazy.queue")
            .lazy() // 设置为惰性队列,落盘存储
            .build();
}

// 惰性队列是 RabbitMQ 提供的一种机制,消息直接写入磁盘,内存消耗小,堆积能力强,适合处理海量不紧急消息。但缺点是消费速度会稍慢,因为要从磁盘读。
@RabbitListener(queuesToDeclare = @Queue){
    name = "lazy.queue",
    durable = "true",
    arguments = @Argument(name = "x-queue-mode"), value="lazy"
}
public void listenLazyQUeue(String msg){
    log.info("接收到lazy.queue的消息:{}",msg);
}

提高消费能力是解决堆积的根本;惰性队列能缓解内存压力,死信机制是辅助保障不丢消息。三者应配合使用。

RabbitMQ高可用机制有了解过吗? &&  请描述 RabbitMQ 镜像队列的工作原理及其在高可用性场景下的优缺点

在我们项目中,为了保障消息队列的高可用性,我们采用了 RabbitMQ 的镜像队列集群部署方案,后来也了解并测试了仲裁队列来提升一致性保障。

我们当时的项目在生产环境下,采用的是镜像模式搭建的集群,共有3个节点
镜像队列结构是一主多从(从就是镜像),所有(写)操作都是主节点完成,然后同步给镜像节点
主宕机后,镜像节点会代替成为新的主(如果在主从同步完成前,主就已经宕机,可能出现数据丢失)

🔹 RabbitMQ 高可用机制对比三种:
模式 原理 是否同步消息 容灾能力 是否强一致 应用场景
普通集群 共享元数据,不同步消息内容 测试、非关键业务
镜像队列 主节点写入,同步到镜像节点 弱~中 读多写少
仲裁队列 Raft 协议选主,多节点确认写入 ✅(强一致) 关键数据、支付

那出现丢数据怎么解决呢?

我们可以采用仲裁队列,与镜像队列一样,都是主从模式,支持主从数据同步,主从同步基于wwwwwwwwwwwwwwwwwwwww,强一致性,并且使用起来也非常简单,不需要格外的配置,在声明队列的时候只需要指定这个是仲裁队列即可

Raft 协议 是一种 共识算法(Consensus Algorithm)
🔴 在分布式系统中,让多个节点就某个值达成一致(即使有部分节点故障)
Raft 就是“几个节点如何选出一个 Leader,让它来统筹所有更新,然后让大多数节点都确认成功后再算真正提交成功”。
为了避免这些 “脑裂、数据冲突” 问题,就需要一种 “大家达成共识” 的机制,Raft 协议就是这种机制🏗️ Raft 核心角色和流程

✅ Raft 有三种角色:

角色 说明
Leader 主节点,唯一能接收客户端写请求
Follower 跟随者,接受 Leader 同步数据
Candidate 候选人,用于选举新 Leader

✅ 正常流程:

  1. 所有节点刚启动时,都是 Follower。
  2. 一段时间内没收到 Leader 的心跳,会变成 Candidate,发起投票选举。
  3. 多数投票成功后,变成新的 Leader。
  4. 所有写请求只能由 Leader 处理,然后同步给其他节点(Follower)。
  5. 如果 大多数节点都确认写入成功,则这条数据才算真正提交成功。
  • 在生产环境下,使用集群来保证高可用性
  • 普通集群、镜像集群、仲裁队列(Raft协议)
普通集群

普通集群,或者叫标准集群(classic cluster)

  • 会在集群的各个节点间共享部分数据,包括:交换机、队列元信息。不包含队列中的信息
  • 当访问集群某节点时,如果队列不在该节点,会从数据所在节点传递到当前节点并返回
  • 队列所在节点宕机,队列中的消息就会丢失
镜像集群

镜像集群:本质是主从模式,具备下面的特征:

  • 交换机、队列、队列中的消息会在各个mq的镜像节点之间同步备份
  • 创建队列的节点被称为该队列的主节点,备份到的其他节点叫做该队列的镜像节点
  • 一个队列的主节点可能是另一个队列的镜像节点
  • 所有操作都是主节点完成,然后同步给镜像节点
  • 主宕机后,镜像节点会替代成新的主

🔹 镜像队列原理(项目中实际使用)

在我们的生产环境中,RabbitMQ 集群采用的是 镜像队列(Classic Mirrored Queue) 模式,有以下特点:

  • 主从同步: 队列存在一个主节点,多个镜像节点。
  • 所有写操作都落在主节点,随后同步给镜像节点
  • 如果主节点宕机,镜像节点会被选举为新主。
  • 同步存在延迟,一旦主节点宕机且尚未同步完,就可能出现数据丢失

💡 镜像队列优点:

  • 数据有副本,具备一定容灾能力
  • 节点可切换,保证服务不中断

⚠ 镜像队列缺点:

  • 主从同步可能延迟 → 存在数据丢失风险
  • 配置复杂、资源开销大,大集群下性能差

🔹 那出现丢数据怎么办?

我们后来开始测试和使用了 仲裁队列(Quorum Queue),它在 3.8+ 中推出,作为镜像队列的替代方案,支持强一致性

仲裁队列:.quorum()

仲裁队列是3.8版本以后才有的新功能,用来替代镜像队列

  • 与镜像队列一样,都是主从模式,支持主从数据同步
  • 使用非常简单,没有复杂的配置
  • 主从同步基于Raft协议,强一致性

仲裁队列的工作原理如下:

  1. 主从模式:仲裁队列也是主从模式,支持主从数据同步。
  2. Raft 协议:主从同步基于 Raft 协议,确保数据的一致性和可靠性。
  3. 强一致性:所有写操作必须得到大多数节点的确认后才能完成,避免了数据丢失。

仲裁队列通过以下机制保证数据不丢失:

  • 多数派确认每次写操作需要得到大多数节点的确认,确保数据已经成功复制到多个节点
  • 自动故障转移:如果主节点宕机,Raft 协议会自动选举新的主节点,确保服务的连续性。
  • 数据一致性Raft 协议保证了数据的强一致性,即使在网络分区或节点宕机的情况下,也不会出现数据不一致的问题。

仲裁队列的优点是配置简单、数据强一致,但需要至少 3 个节点,并且在写操作上的延迟和资源消耗可能会比镜像队列高。

@Bean
public Queue quorumQueue(){
    return QueueBuilder
            .durable("quorum.queue") // 持久化
            .quorum() // 仲裁队列
            .build();
}

Kafka是如何保证消息不丢失?

需要从三个层面去解决这个问题

  • 生产者发送消息到Brocker丢失

    • 设置异步发送,发送失败使用回调进行记录或重发
    • 失败重试,参数配置,可以设置重试次数消息
  • 在Brocker中存储丢失

    发送确认acks,选择all,让所有的副本都参与保存数据后确认

  • 消费者从Brocker接收消息丢失

    • 关闭自动提交偏移量,开启手动提交偏移量
    • 提交方式:最好是同步+异步提交

使用Kafka在消息的收发过程中都会出现消息丢失,Kafka分别给出了解决方案

  • 生产者发送消息到Brocker丢失
  • 消息在Brocker中存储丢失
  • 消费者从Brocker接收消息丢失

kafka-高产出的分布式消息系统(A high-throughput distributed messaging system)。

Kafka是一个高吞吐、分布式、基于发布订阅的消息系统,利用Kafka技术可以在廉价的PC Server上搭建起大规模消息系统。

Kafka的特性:
  • 高吞吐量、低延迟:kafka每秒可以处理几十万条消息,它的延迟最低只有几毫秒,每个topic可以分多个partition, consumer group 对partition进行consume操作;
  • 可扩展性:kafka集群支持热扩展;
  • 持久性、可靠性:消息被持久化到本地磁盘,并且支持数据备份防止数据丢失;
  • 容错性:允许集群中节点失败(若副本数量为n,则允许n-1个节点失败);
  • 高并发:支持数千个客户端同时读写;
  • 支持实时在线处理和离线处理:可以使用Storm这种实时流处理系统对消息进行实时进行处理,同时还可以使用Hadoop这种批处理系统进行离线处理;

Kafka和其他组件比较,具有消息持久化、高吞吐、分布式、多客户端支持、实时等特性,适用于离线和在线的消息消费,如常规的消息收集、网站活性跟踪、聚合统计系统运营数据(监控数据)、日志收集等大量数据的互联网服务的数据收集场景。

  1. 日志收集:一个公司可以用Kafka可以收集各种服务的log,通过kafka以统一接口服务的方式开放给各种consumer,例如Hadoop、Hbase、Solr等;
  2. 消息系统:解耦和生产者和消费者、缓存消息等;
  3. 用户活动跟踪:Kafka经常被用来记录web用户或者app用户的各种活动,如浏览网页、搜索、点击等活动,这些活动信息被各个服务器发布到kafka的topic中,然后订阅者通过订阅这些topic来做实时的监控分析,或者装载到Hadoop、数据仓库中做离线分析和挖掘;
  4. 运营指标:Kafka也经常用来记录运营监控数据。包括收集各种分布式应用的数据,生产各种操作的集中反馈,比如报警和报告;
  5. 流式处理:比如spark streaming和storm;
  6. 事件源;
  7. kafka在FusionInsight中的位置:

Kafka是如何保证消费的顺序性?

Kafka 保证消息不丢失需要从 生产者、Broker、消费者 三个维度考虑:

  1. 生产者侧:通过设置 acks=all、启用 幂等性(enable.idempotence=true)、配置 重试机制 等,确保消息可靠送达 Kafka。
  2. Broker 侧:通过消息持久化、副本同步机制(ISR)、故障转移机制等,确保存储端可靠。
  3. 消费者侧:通过 关闭自动提交 offset,使用手动同步/异步提交,并搭配幂等消费逻辑,避免消息处理丢失。

多项机制结合,共同保障 Kafka 在高吞吐、高并发下仍具备良好的可靠性与稳定性。

问题原因:
一个topic的数据可能存储在不同的分区中 ,每个分区都有一个按照顺序的存储的偏移量,如果消费者关联了多个分区不能保证顺序性

==解决方案:==

  • 发送消息时指定分区号
  • 发送消息时按照相同的业务设置相同的key

应用场景:

  • 即时消息中的单对单聊天和群聊,保证发送方消息发送顺序与接收方的顺序一致
  • 充值转账两个渠道在同一个时间进行金额变更,短信通知必须要有顺序

承接上图消费者从Brocker接收消息丢失
如何做?→ topic分区中消息只能由消费者组中的唯一一个消费者处理,所以消息肯定是按照先后顺序进行处理的。但是它也仅仅是保证Topic的一个分区顺序处理,不能保证跨分区的消息先后处理顺序。所以,如果你想要顺序的处理Topic的所有消息,那就只提供一个分区。

// 指定分区
kafkaTemplate.sent("springboot-kafka-topic",0,"key-001","value-001");
// 相同的业务key
kafkaTemplate.sent("springboot-kafka-topic","key-001","value-001");

会计算key的hashcode值推断出它在哪个分区,如果要求有顺序性 就可以设置同一个key,此时hash值都是一样的 就可以在同一个分区存储

Kafka的高可用机制有了解过吗?

  • ==集群模式==

    一个kafka集群由多个broker实例组成,即使某一台宕机,也不会耽误其他broker继续对外提供服务

  • ==分区备份机制==

    • 一个topic有多个分区,每个分区有多个副本,有一个leader,其余的是follower,副本存储在不同的broker中
    • 所有的分区副本的内容都是相同的,如果leader发生故障时,会自动将其中一个follower提升为leader,保证了系统的容错性、高可用性
✅ 真实业务场景案例:订单创建消息流的高可用设计(基于 Kafka)
🟡 业务背景

你在做一个电商平台系统,其中订单创建之后,需要进行如下异步操作:

  1. 通知库存中心扣减库存
  2. 通知积分服务赠送用户积分
  3. 通知数据中心写入大数据平台(如 HDFS)

为了解耦服务、削峰填谷,并保障消息不丢失,我们决定使用 Kafka 进行消息中转。


🟧 Kafka 高可用设计
▶ 架构图概览(文字版)
[订单服务 OrderService] 
    |
    | 生产消息 send(order_id=1001)
    ▼
[Kafka集群 topic=order-create partition=0,1,2]        
    |  partition-0:leader 在 broker1,follower 在 broker2,3
    |  partition-1:leader 在 broker2,follower 在 broker1,3
    |  partition-2:leader 在 broker3,follower 在 broker1,2
    ▼
[消费者集群]
  ├── 库存服务(group=stock)
  ├── 积分服务(group=points)
  └── 数据服务(group=bigdata)
🔵 Kafka 高可用机制在这里怎么体现?
✅ 1. Kafka 集群部署(Broker 多节点)

部署 3 个 Kafka Broker:

broker.id=1,2,3 分别配置在三台服务器上

即便其中任意一台 Broker 宕机,剩下两台仍然可以继续服务。

✅ 2. Topic 分区 + 副本机制

创建 topic 时设定副本数:

bin/kafka-topics.sh --create \
  --topic order-create \
  --partitions 3 \
  --replication-factor 3 \
  --zookeeper zk1:2181
  • 每个分区会有 1 个 leader + 2 个 follower

  • Leader 负责读写,follower 同步数据

  • 当 Leader 所在 Broker 宕机时,Kafka 会自动从 ISR 列表中选一个 follower 升级为 leader
    🔁 例如:

  • 原来 partition-0 的 leader 是 broker1

  • broker1 崩了 → Kafka Controller 会选 broker2 或 broker3 提升为新 leader

✅ 3. 生产者配置 acks=all + 幂等性

为了保障消息可靠写入 Kafka:

props.put("acks", "all"); // 所有副本都确认才算成功
props.put("enable.idempotence", "true"); // 避免重复投递

📝 所以生产者即便 retry 了,也不会生成重复消息。

✅ 4. 消费者使用手动提交 offset

为了保证消费者处理完消息再提交 offset,避免消息处理失败却误提交偏移量:

props.put("enable.auto.commit", "false");

while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
    for (ConsumerRecord<String, String> record : records) {
        // 处理消息
        process(record);
    }
    consumer.commitSync(); // 处理完手动提交
}
🔻 模拟突发事件:Kafka Broker 宕机,系统是否正常?
❓ 假设 Broker1 宕机,会发生什么?
  • partition-0 的 leader 在 broker1 → Kafka Controller 自动将 broker2 提升为新的 leader
  • 生产者发送消息依然正常(因为 follower 已同步数据)
  • 消费者照常消费 partition-0 的数据

✅ 总结一波这个真实案例带来的知识点

组件 高可用机制 关键作用
Kafka 集群 多 Broker + Controller 任一节点挂掉不影响整体服务
Topic 分区副本 leader + follower 保证消息副本安全、自动故障转移
Producer acks=all + 幂等性 防止生产过程中消息丢失或重复
Consumer 手动提交 offset 避免处理失败误提交偏移量

❓面试模拟追问

面试官:你们 Kafka 的 leader 挂了会不会影响消费者?

你可以回答:

Kafka 的消费者是从分区的 leader 拉取消息的,如果 leader 节点挂了,Kafka 会自动从 ISR 列表中挑选新的 follower 作为 leader,整个过程对消费者来说是无感知的,消费过程会自动恢复。

解释一下复制机制中的ISR?

ISR (in-sync replica) 需要同步复制保存的follower;跟 Leader 保持同步 的副本集合
分区副本分为了两类,一个是ISR,与leader副本同步保存数据,另外一个普通的副本,是异步同步数据,当leader挂掉后,会优先从ISR副本列表中选取一个作为leader

Kafka 每个分区(Partition)都会有多个副本(Replicas):

  • 一个 Leader
  • 若干个 Follower

这些副本被分成两类:

类型 描述
ISR 副本 与 Leader 保持数据同步的副本(最健康的副本集
OSR(Out-of-Sync Replicas) 落后太多,未能及时同步 Leader 数据的副本

🧠 工作机制详解

1️⃣ 消息写入

  • Producer 只写 Leader
  • Leader 负责将数据同步到 ISR 中的所有 Follower
  • 所有 ISR 成员都成功写入后,才向 Producer 发送 ack

如果 Leader 挂了:

  • Kafka 只会从 ISR 列表中挑选一个副本晋升为新的 Leader
  • 这样可以确保新 Leader 中的数据是完整的

🟩 举个例子助记

假设 topic 有 3 个副本:

Partition-0:
  - broker1 (Leader)
  - broker2 (Follower)
  - broker3 (Follower)

此时,**ISR = [broker1, broker2]**(broker3 落后太多,暂不在 ISR 中)

如果 broker1 挂掉了 → Kafka 会从 broker2 中选一个当 Leader(因为它是同步副本)

📌 broker3 不在 ISR 中,就算数据多,也不会被选为 leader(因为可能数据不一致)


Kafka 中每个分区都会有多个副本,其中 ISR(In-Sync Replicas)表示与 Leader 保持同步的副本集合。

ISR 是 Kafka 高可用的重要保障,Kafka 只会从 ISR 中选 leader,避免使用落后副本导致数据丢失。

可以通过配置 min.insync.replicas 来控制 ISR 最小数量,从而配合 acks=all 确保数据写入可靠性。

// 一个topic默认分区的replication个数,不能大于集群中broker的个数。默认为1
default.replication.factor=3
// 最小的ISR副本个数
min.insync.replicas=2

Kafka数据清理机制了解过吗?

  • kafka文件存储机制
    • Kafka中topic的数据存储在分区上,分区如果文件过大会分段存储segment
    • 每个分段都在磁盘上以索引(xxxx.index)和日志文件(xxx.log)的形式存储
    • 分段的好处是,第一能够减少单个文件内容的大小,查找数据方便,第二方便kafka进行日志清理
  • 数据清理机制
    • 根据消息的保留时间,当消息保存的时间超过了指定的时间,就会触发清理,默认168小时(7天)
    • 根据topic存储的数据大小,当topic所占的日志文件大小大于一定的阈值,则开始删除最久的消息(默认关闭)

Kafka 和 RabbitMQ 对比

对比维度 Kafka RabbitMQ
核心模型 发布-订阅模型(Pub-Sub) 基于消息队列(Queue)
架构设计 分布式、高吞吐、日志存储 面向消息、消息中间人
消息存储 持久化日志,磁盘为主,保留时间可配 内存为主,结合磁盘,可配置 TTL
性能吞吐量 非常高(百万级) 相对较低(万级)
消息顺序性 同一个分区内有序 默认无序(队列先进先出 FIFO)
消息重复消费 可能(需要幂等性) 通过消息确认机制避免
消息确认机制 Offset 手动提交或自动提交(可恢复) Ack 机制、可配置手动/自动确认
可靠性机制 副本机制 + ISR 保证 持久化、确认机制、死信队列、重试机制
高可用性 分区副本机制 + Leader选举(Raft) 镜像队列或仲裁队列
消息丢失保护 acks=all + min.insync.replicas confirm机制 + durable队列 + ack
延迟队列支持 原生不支持(需要定时调度或借助外部插件) 原生支持延迟队列(TTL + 死信队列)
使用场景 日志采集、行为跟踪、数据管道、实时流处理 任务异步处理、延迟任务、订单超时处理等
管理界面 UI界面较弱,需要依赖第三方 自带 Web 管理后台,功能强大
学习曲线 略陡峭,需要理解 partition、offset、consumer group 等 简单易上手,文档丰富
协议支持 Kafka 自有协议 支持 AMQP、MQTT、STOMP 等多种协议
成熟度 大数据生态首选,社区活跃,企业常用 轻量级场景常用,灵活扩展,适合中小型应用

🧠 总结一句话对比

  • 🔥 Kafka 更适合高吞吐、日志/流式处理、大数据实时系统
  • 🔧 RabbitMQ 更适合业务系统间的解耦、任务异步处理、延迟消息机制

Kafka 和 RabbitMQ 都是消息中间件,但定位不同:

  • Kafka 是高吞吐、分布式日志系统,更适合大数据场景和实时流式处理,使用 Partition 和 Offset 保证扩展性。
  • RabbitMQ 是传统的消息队列中间件,支持 AMQP 协议,适合轻量级业务异步解耦,原生支持延迟队列和消息确认机制。

我们项目中如果要处理高并发日志或用户行为采集就选 Kafka,如果是下单通知、订单超时等场景就更适合 RabbitMQ。

Kafka中实现高性能的设计有了解过吗?

  • 消息分区:不受单台服务器的限制,可以不受限的处理更多的数据
  • 顺序读写:磁盘顺序读写,提升读写效率
  • 页缓存:把磁盘中的数据缓存到内存中,把对磁盘的访问变为对内存的访问
  • 零拷贝:减少上下文切换及数据拷贝
  • 消息压缩:减少磁盘IO和网络IO
  • 分批发送:将消息打包批量发送,减少网络开销
零拷贝

集合面试篇

算法复杂度分析

什么是算法时间复杂度?

  • 时间复杂度表示了算法的执行时间数据规模之间的增长关系

常见的时间复杂度有哪些?口诀:常对幂指阶

  • O(1)、O(n)、O(n^2)、O(logn)

什么是算法的空间复杂度?

  • 表示算法占用的额外存储空间数据规模之间的增长关系
    常见的空间复杂度:O(1)、O(n)、O(n^2)
为什么要进行复杂度分析?
  • 指导你编写出性能更优的代码
  • 评判别人写的代码的好坏
时间复杂度分析:来评估代码的执行耗时的
  • 大O表示法:不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势

  • 只要代码的执行时间**不随着n的增大而增大,这样的代码复杂度都是O(1)**

  • 复杂度分析就是要弄清楚代码的执行次数数据规模n之间的关系

时间复杂度:全称是渐进空间复杂度,表示算法占用的额外存储空间数据规模之间的增长关系

List相关面试题

  • 数组是一种用连续的内存空间存储相同数据类型数组的线性数据结构

  • 数组下标为什么从0开始

    寻址公式是:baseAddress + i * data TypeSize 计算下标的内存地址效率较高

  • 查找的时间复杂度

    • 随机(通过下标)查询的时间复杂度是O(1)
    • 查找元素(未知下标)的时间复杂度是O(n)
    • 查找元素(未知下标但排序)通过二分查找的时间复杂度是O(logn)
  • 插入和删除时间复杂度

    插入和删除的时候,为了保证数组的内存连续性,需要挪动数组元素,平均复杂度为O(n)

底层实现
  • 数据结构—数组
  • ArrayList源码分析
面试问题
  • ArrayList底层的实现原理是什么
  • ArrayList list = new ArrayList(10)中的list扩容几次
  • 如何实现数组和List之间的转换
  • ArrayList和LinkedList的区别是什么

ArrayList源码分析

List< Integer > list = new ArrayList< Integer >();
list.add(1)

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable{

    private static final long serialVersionUID = 8683452581122892189L;

    /**
     * Default initial capacity.
     */
    private static final int DEFAULT_CAPACITY = 10;

    /**
     * 用于空实例的共享空数组实例
     */
    private static final Object[] EMPTY_ELEMENTDATA = {};

    /**
     * 用于默认大小的空实例的共享空数组实例
     * 与上面的区分开,以了解添加第一个元素时要膨胀多少
     */
    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
  
    /* 存储ArrayList元素的数组缓冲区,ArrayList的容量就是这个数组缓冲区的长度 */
    transient Object[] elementData; // non-private to simplify nested class access

    /**
     * ArrayList的大小(包含的元素数量)
     * @serial
     */
    private int size;
...
}

--------------------------------------------------------------------------------

public ArrayList(int initialCapacity) {
        if (initialCapacity > 0) {
       // 创建一个真正存储集合位置的数组
            this.elementData = new Object[initialCapacity];
        } else if (initialCapacity == 0) {
    // 如果容量是0则创建一个新的数组给elementData
            this.elementData = EMPTY_ELEMENTDATA;
        } else {
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        }
    }
    public ArrayList() { 
         // 无参构造函数,默认创建空集合
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    }

-------------------------------------------------------------------------------
// Collection是所有单列集合的父接口
// 将 Collection 对象转换成数组,然后将数组的地址赋给 elementData
 public ArrayList(Collection<? extends E> c) {
        elementData = c.toArray();
        if ((size = elementData.length) != 0) {
            // 判断集合类型是否为不为object[] 
            // 在其他jdk此处是 ?? == ArrayList.class
            if (elementData.getClass() != Object[].class)
                // 不是的话就拷贝到数组elementData中
                elementData = Arrays.copyOf(elementData, size, Object[].class);
        } else {
            // replace with empty array.
            this.elementData = EMPTY_ELEMENTDATA;
        }
    }
ArrayList源码分析-添加和扩容操作(第1次添加数据)

ArrayList底层的实现原理是什么

  • ArrayList底层是用动态数组实现的
  • ArrayList初始容量为0,当第一次添加数据的时候才会初始化容量为10
  • ArrayList在进行扩容的时候是原来容量的1.5倍,每次扩容都需要拷贝数组
  • ArrayList在添加数据的时候
    • 确保数组已使用长度(size)加1之后足够存下下一个数据
    • 计算数组的容量,如果当前数组已使用长度+1后的大于当前的数组长度,则调用grow方法扩容(原来的1.5倍)
    • 确保新增的数据有地方存储之后,则将新元素添加到位于size的位置上
    • 返回添加成功布尔值

ArrayList list = new ArrayList(10)中的list扩容几次

  • 该语句只是声明和实例了一个ArrayList,指定了容量为10,未扩容

如何实现数组和List之间的转换

  • 数组转List,使用JDK中java.util.Arrays工具类的asList方法
  • List转数组,使用List的toArray方法,无参toArray方法返回Object数组,传入初始化长度的数组对象,返回该对象数组

使用 Hutool 工具库可以非常方便地实现数组和 List 之间的转换。Hutool 提供了 ArrayUtilCollUtil 工具类来处理数组和集合之间的转换。

问:我不能用BeanUtil吗?
答:BeanUtil 是 Hutool 工具库中用于 Java Bean 操作的工具类,主要用于 对象属性拷贝Bean 转 MapMap 转 Bean 等操作。它并不适用于 数组和 List 之间的转换

如果你误以为 BeanUtil 可以用于数组和 List 的转换,可能是因为它的名字容易让人误解。实际上,数组和 List 的转换应该使用 ArrayUtilCollUtil

正确的工具类选择

  1. 数组转 List:使用 ArrayUtil.toList
  2. List 转数组:使用 CollUtil.toArray
  3. Bean 属性拷贝:使用 BeanUtil.copyProperties
  4. Bean 转 Map:使用 BeanUtil.beanToMap
  5. Map 转 Bean:使用 BeanUtil.fillBeanWithMap
更多的Hutool工具使用高能预警
1. 字符串工具类:StrUtil
  • 功能:字符串操作,如判空、格式化、截取、替换等。
  • 常用方法
    • StrUtil.isEmpty():判断字符串是否为空。
    • StrUtil.format():格式化字符串。
    • StrUtil.split():拆分字符串。
    • StrUtil.join():连接字符串。

2. 日期时间工具类:DateUtil
  • 功能:日期和时间的格式化、解析、计算等。
  • 常用方法
    • DateUtil.now():获取当前时间。
    • DateUtil.format():格式化日期。
    • DateUtil.parse():解析字符串为日期。
    • DateUtil.offsetDay():日期加减。

3. 文件工具类:FileUtil
  • 功能:文件和目录的操作,如读写、复制、删除等。
  • 常用方法
    • FileUtil.readUtf8String():读取文件内容为字符串。
    • FileUtil.writeUtf8String():将字符串写入文件。
    • FileUtil.copy():复制文件或目录。
    • FileUtil.del():删除文件或目录。

4. JSON 工具类:JSONUtil
  • 功能:JSON 的解析和生成。
  • 常用方法
    • JSONUtil.parseObj():将 JSON 字符串解析为 JSON 对象。
    • JSONUtil.parseArray():将 JSON 字符串解析为 JSON 数组。
    • JSONUtil.toJsonStr():将对象转换为 JSON 字符串。

5. 集合工具类:CollUtil
  • 功能:集合操作,如创建集合、判空、过滤、分组等。
  • 常用方法
    • CollUtil.newArrayList():快速创建 ArrayList。
    • CollUtil.isEmpty():判断集合是否为空。
    • CollUtil.filter():过滤集合。
    • CollUtil.group():对集合进行分组。

6. 反射工具类:ReflectUtil
  • 功能:反射操作,如调用方法、获取字段、创建对象等。
  • 常用方法
    • ReflectUtil.invoke():调用方法。
    • ReflectUtil.getFieldValue():获取字段值。
    • ReflectUtil.newInstance():创建对象实例。

7. HTTP 工具类:HttpUtil
  • 功能:HTTP 请求的发送和响应处理。
  • 常用方法
    • HttpUtil.get():发送 GET 请求。
    • HttpUtil.post():发送 POST 请求。
    • HttpUtil.downloadFile():下载文件。

8. 加密解密工具类:SecureUtil
  • 功能:常见的加密解密操作,如 MD5、SHA、AES 等。
  • 常用方法
    • SecureUtil.md5():计算 MD5 值。
    • SecureUtil.sha256():计算 SHA-256 值。
    • SecureUtil.aes():AES 加密解密。

9. IO 工具类:IoUtil
  • 功能:IO 流操作,如读写、关闭流等。
  • 常用方法
    • IoUtil.read():读取流内容。
    • IoUtil.write():写入流内容。
    • IoUtil.close():关闭流。

10. 随机工具类:RandomUtil
  • 功能:生成随机数、随机字符串等。
  • 常用方法
    • RandomUtil.randomInt():生成随机整数。
    • RandomUtil.randomString():生成随机字符串。
    • RandomUtil.randomEle():从集合中随机选择一个元素。

11. 验证工具类:Validator
  • 功能:数据验证,如邮箱、手机号、身份证等。
  • 常用方法
    • Validator.isEmail():验证是否为邮箱。
    • Validator.isMobile():验证是否为手机号。
    • Validator.isCitizenId():验证是否为身份证号。

12. 缓存工具类:CacheUtil
  • 功能:简单的缓存操作。
  • 常用方法
    • CacheUtil.newTimedCache():创建定时缓存。
    • CacheUtil.put():添加缓存。
    • CacheUtil.get():获取缓存。

13. 线程工具类:ThreadUtil
  • 功能:线程操作,如睡眠、创建线程池等。
  • 常用方法
    • ThreadUtil.sleep():线程睡眠。
    • ThreadUtil.newExecutor():创建线程池。

14. Excel 工具类:ExcelUtil
  • 功能:Excel 文件的读写操作。
  • 常用方法
    • ExcelUtil.getReader():读取 Excel 文件。
    • ExcelUtil.getWriter():写入 Excel 文件。

15. 压缩工具类:ZipUtil
  • 功能:文件或目录的压缩和解压缩。
  • 常用方法
    • ZipUtil.zip():压缩文件或目录。
    • ZipUtil.unzip():解压缩文件。

16. 日志工具类:Log
  • 功能:简化日志操作。
  • 常用方法
    • Log.get():获取日志对象。
    • Log.info():输出日志信息。

17. 数学工具类:MathUtil
  • 功能:数学计算,如四舍五入、最大值、最小值等。
  • 常用方法
    • MathUtil.round():四舍五入。
    • MathUtil.max():获取最大值。
    • MathUtil.min():获取最小值。

18. 网络工具类:NetUtil
  • 功能:网络相关操作,如获取本机 IP、Ping 等。
  • 常用方法
    • NetUtil.getLocalhost():获取本机 IP。
    • NetUtil.ping():Ping 测试。
  • Arrays.asList转List后,如果修改了数组内容,list受影响吗
  • List用toArray转数组后,如果修改了List内容,数组受影响吗
再答:
  • Arrays.asList转换list之后,如果修改了数组的内容,list会受影响,因为它的底层使用的Arrays类中的一个内部类ArrayList来构造的集合,在这个集合的构造器中,把我们传入的这个集合进行了包装而已,最终指向的都是同一个内存地址
  • list用了toArray转数组后,如果修改了list内容,数组不会受影响,当调用了toArray以后,在底层是它进行了数组拷贝,跟原来的元素就没啥关系了,所以即使list修改了以后,数组也不受影响

LinkedList的数据结构—链表

单向链表

  • 链表中的每一个元素称之为结点(Node)
  • 物理存储单元上,非连续、非顺序的存储结构
  • 单向链表:每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。记录下个结点地址的指针叫作后继指针 next
1.单向链表和双向链表的区别是什么
  • 单向链表只有一个方向,结点只有一个后继指针 next。
  • 双向链表它支持两个方向,每个结点不止有一个后继指针next指向后面的结点,还有一个前驱指针prev指向前面的结点
2.链表操作数据的时间复杂度是多少
查询 新增删除
单向链表 头O(1), 其他O(n) 头O(1), 其他O(n)
双向链表 头尾O(1), 其他O(n), 给定节点O(1) 头尾O(1), 其他O(n), 给定节点O(1)

ArrayList和LinkedList的区别

  • 底层数据结构
    • ArrayList 是动态数组的数据结构实现
    • LinkedList 是双向链表的数据结构实现
  • 操作数组效率

    • ArrayList 按照下标查询的时间复杂度O(1);【内存是连续的,根据寻址公式】,LinkedList不支持下标查询

    • 查找(未知索引):ArrayList需要遍历,链表也需要遍历,时间复杂度都是O(n)

    • 新增删除【查询多用ArrayList;插入/删除多用Linked

      • ArrayList 尾部插入和删除,时间复杂度是O(1);其他部分增删需要挪动数组,时间复杂度是O(n)
      • LinkedList 头尾节点增删时间复杂度是O(1),其他都需要遍历链表,时间复杂度是O(n)
  • 内存空间占用

    • ArrayList 底层是数组,内存连续,节省内存
    • LinkedList 是双向链表需要存储数据,和两个指针,更占用内存
  • 线程安全

    • ArrayList和LinkedList都不是线程安全的

    • 如果要保证线程安全,有两种方法

      • 方法内使用,局部变量则是线程安全的

      • 使用线程安全的ArrayList和LinkedList

        List<Object> syncArrayList = Collections.synchronizedList(new ArrayList<>());
        List<Object> syncLinkedList = Collections.synchronizedList(new LinkedList<>());
        

HashMap相关面试题

  • 二叉树

    • 满二叉树

    • 完全二叉树

    • 二叉搜索树

      二叉搜索树又名二叉查找树,有序二叉树或者排序二叉树,是二叉树中比较常用的一种类型二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值

    • 红黑树

  • 红黑树

  • 散列表

✅ 1. 二叉树(Binary Tree)

每个节点最多有两个子节点,称为左子节点和右子节点。


✅ 2. 满二叉树(Full Binary Tree)

每个节点要么是叶子节点,要么恰好有两个子节点,且所有叶子都在同一层


✅ 3. 完全二叉树(Complete Binary Tree)

除了最后一层,其他每一层的节点数都达到最大值,且最后一层节点集中在左侧


✅ 4. 二叉搜索树(Binary Search Tree,BST)

又叫:二叉查找树、有序二叉树

特点:

  • 对于任意一个节点 node
    • 左子树中所有节点值 < node
    • 右子树中所有节点值 > node
  • 中序遍历是升序排列

用途:

  • 用于快速查找、插入、删除(时间复杂度平均为 O(log n),最坏为 O(n))

✅ 5. 红黑树(Red-Black Tree)

红黑树是自平衡的二叉搜索树,在多种语言的底层集合结构(如 Java 的 TreeMapTreeSet,C++ 的 mapset)都有使用。

特点:

  • 每个节点是红或黑
  • 根节点是黑色
  • 每个叶子节点(NIL)是黑色
  • 红色节点不能有红色子节点(即不能连续两个红)
  • 任意一节点到其所有后代叶子节点的路径上,黑色节点数量相同

目的:

  • 保证在最坏情况下,查找、插入、删除的时间复杂度是 O(log n)

✅ 6. 散列表(Hash Table)

与树不同,散列表是通过哈希函数(Hash Function)将键映射到数组下标进行查找。

特点:

  • 查找时间接近 O(1)
  • 冲突处理方式如链地址法、开放定址法等
  • 用于实现如 Java 的 HashMapHashSet、Python 的 dictset 等结构

数据结构—红黑树 什么是红黑树?

  • 红黑树:也是一种自平衡的二叉搜索树(BST)
  • 所有的红黑规则都是希望红黑树能够保证平衡
  • 红黑树的时间复杂度:查找、添加、删除都是O(logn)

散列表

什么是散列表?

  • 散列表(Hash Table)又叫哈希表/Hash表
  • 根据键(Key)直接访问再内存存储位置值(Value)的数据结构
  • 由数组演化而来的,利用了数组支持按照下标进行随机访问数据

散列冲突

  • 散列冲突又成为哈希冲突,哈希碰撞
  • 指多个key映射到同一个数组下标位置

散列冲突—链表法(拉链)

  • 数组的每个下标位置称之为(bucket) 或者 (slot)
  • 每个桶(槽)会对应一条链表
  • hash冲突后的元素都放到相同槽位对应的链表中或红黑树中

HashMap中的最重要的一个数据结构就是散列表,在散列表中又用到了红黑树链表
散列表(Hash Table)又名为哈希表/Hash表,是根据键(Key)直接访问在内存存储位置值(value)的数据结构,它是由数组演化而来的,利用了数组支持按照下标进行随机访问数据的特性[根据寻址公式,时间复杂度O(1)]

说一下HashMap的实现原理

1.说一下HashMap的实现原理

  • HashMap的数据结构:底层使用hash表数据结构,即数组和链表或红黑树

  • 添加数据时,计算key的值确定元素在数组中的下标

    • key相同则替换
    • 不同则存入链表或红黑树中

    获取数据通过key的hash计算数组下标获取元素

2.HashMap的jdk1.7和jdk1.8有什么区别

  • JDK1.8之前采用的拉链法,数组+链表
  • JDK1.8之后采用数组+链表+红黑树
    链表长度大于8且数组长度大于64则会从链表转化为红黑树

当我们往HashMap中put元素时(扰动函数),利用key的hashCode重新hash计算出当前对象的元素在数组中的下标

HashMap的put方法的具体流程

1.判断键值对数组table是否为空或为null,否则执行resize()进行扩容 [初始化]
2.根据键值key计算hash值得到数组索引
3.判断table[i] == null,条件成立,直接新建节点添加
4.如果table[i] == null,不成立
4.1 判断table[i]的首个元素是否和key一样,如果相同直接覆盖value
4.2 判断table[i]是否为treeNode,即table[i]是否是红黑树,如果是红黑树,则直接在树中插入键值对
4.3 遍历table[i],链表的尾部插入数据,然后判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,遍历过程中若发现key已经存在直接覆盖value

讲一下HashMap的扩容机制

HashMap源码分析

桶下标是hash值取模数组(长度)下标 capacity

HashMap的寻址算法

Hash值右移16位后与原来的hash值进行异或运算【扰动算法hash值更加均匀,减少hash冲突
数组长度必须是2的n次幂 按位与运算的效果才能代替取模

int hash = h ^ (h >>> 16); // hashCode 的扰动处理
index = hash & (table.length - 1); // 更快的取模运算

HashMap在1.7情况下的多线程死循环问题

jdk7的数据结构是:数组+链表
在数组进行扩容的时候,因为链表是头插法(在并发情况下可能出现链表反转成环形结构),在进行数据迁移的过程中,有可能导致死循环

进程和线程的区别?

两者对比:
  • 进程是整个在运行程序的实例进程中包含了线程,每个线程执行不同的任务
  • 不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
  • 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)
类型 简要定义
进程 程序的执行实例,资源分配的最小单位
线程 进程内的执行单元,cpu调度的最小单位
项目 进程 线程
概念 正在运行的程序实例 进程中的执行单元
所属关系 进程可包含多个线程 线程依附于进程存在
内存空间 拥有独立地址空间 共享所属进程的内存空间
创建开销 创建/销毁成本高(需要资源分配) 创建/销毁成本低(共享资源)
通信方式 进程间通信较复杂(如管道/套接字) 线程通信简单(共享变量)
崩溃影响 一个进程崩溃不会影响其他进程 一个线程崩溃可能影响整个进程
切换开销 上下文切换开销大 上下文切换开销小
资源隔离 资源独立,安全性高 资源共享,效率高但易出错
[ 进程A ]
 ├── 线程1:负责计算
 ├── 线程2:负责文件IO
 └── 线程3:负责网络通信

[ 进程B ]
 └── 线程1:完全独立

程序由指令数据组成,但这些指令要运行,数据要读写,就必须将指令加载至CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备,进程就是用来加载指令、管理内存、管理IO的。
当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个**进程**

一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给CPU执行。一个进程之内可以分为一到多个线程

core → 线程1[指令1,指令2,指令3…] 线程2[指令1,指令2,指令3…]

并行和并发的区别?

现在都是多核CPU,在多核CPU下

  • 并发是同一时间应对多件事情的能力,多个线程轮流使用一个或多个CPU
  • 并行是同一时间动手做多件事的能力,4核CPU同时执行4个线程
概念 定义
并发 同一时间段处理多个任务的能力(任务轮流切片执行)
并行 同一时刻真正同时执行多个任务(多个核同时执行)

==单核CPU== → 单核CPU下线程实际还是串行执行的

  • 操作系统中有一个组件叫做任务调度器,将cpu的时间片(windows下时间片最小约为15ms)分给不同的程序使用,只是由于cpu在线程间(时间片很短)的切换非常快,人类感觉是同时运行的
  • 每个时间片只能用有一个线程被执行
  • 总结一句话:**微观串行,宏观并行**
  • 一般会将这种线程轮流使用CPU的做法称为并发(concurrent)
CPU 时间片1 时间片2 时间片3
core 线程1 线程2 线程3

==多核CPU== → 每个核(core)都可以调度运行线程,这个时候线程是可以并行的

CPU 时间片1 时间片2 时间片3 时间片4
core1 线程1 线程2 线程3 线程3
core2 线程2 线程4 线程2 线程4

并发 (concurrent) 是同一时间应对 (dealing with) 多件事情的能力
并行 (parallel) 是同一时间动手做 (doing) 多件事情的能力

  • 家庭主妇做饭、打扫卫生、给孩子喂奶,她一个人轮流交替做这么多件事,这就是并发【单核CPU】
  • 家庭主妇雇了个保姆,她们一起做这些事,这时既有并发,也有并行【会产生竞争,例如锅只有一个,一个人用锅时,另一个人就要等待】
  • 雇了3个保姆,一个专门做饭,一个专门打扫卫生,一个专门喂奶,互不干扰,这就是并行

创建线程的方式有哪些?

  • 继承Thread类,重写run方法
public class MyThread extends Thread{
    @Override
    public void run(){
        sout("MyThread...run...");
    }
    public static void main(String[] args){
        // 创建MyThread对象
        MyThread t1 = new MyThread();
        MyThread t2 = new MyThread();
        
        // 调用start方法启动线程
        t1.start();
        t2.start();
    }
}
  • 实现Runnable接口,重写run方法
public class MyRunnable implements Runnable {

    @Override
    public void run() {
        // 在这里编写要执行的任务
        System.out.println("线程正在执行任务...");
    }
    public static void main(String[] args) {
        // 创建MyRunnable实例
        MyRunnable myRunnable = new MyRunnable();
        
        // 创建线程并启动
        Thread t1 = new Thread(myRunnable);
        Thread t2 = new Thread(myRunnable);
        // 调用start方法启动线程
        t1.start();
        t2.start();
    }
}
  • 实现Callable< T >接口,重写call方法泛型和重写方法一致
public class MyCallable implements Callable<String> {
 @Override
    public String call() throws Exception {
        sout(Thread.currentThread().getName());
        return "ok";
    }
     public static void main(String[] args) {
        // 创建MyCallable实例
        MyCallable myCallable = new MyCallable();
        
        // 使用FutureTask来包装Callable对象
        FutureTask<String> ft = new FutureTask<String>(myCallable);
        
        // 创建并启动线程
        Thread t1 = new Thread(ft);
        t1.start();
        // 调用ft的get方法获取执行结果
        String result = ft.get();
        sout(result)
    }
}
  • 线程池创建线程 (项目中使用的方式)
public class MyExecutors implements Runnable{
    @Override
    public void run(){
        sout("MyRunnable...run...");
    }
    public static void main(String[] args){
        // 创建线程池对象
        ExecutorService threadPool = Executors.newFixedThreadPool(3);
        threadPool.submit(new MyExecutors()); 
        //submit用来提交线程
        
        // 关闭线程池
        threadPool.shutdown();
    }
}
刚刚你说过,使用runnable和callable都可以创建线程,它们有什么区别呢?
  • Runnable接口run方法没有返回值
  • Callable接口call方法有返回值,要结合FutureTask配合可以用来获取异步执行的结果

FutureTaskFuture 的实现类,它可以包装一个 CallableRunnable 对象,并允许我们在任务执行完毕后获取执行结果或取消任务。

FutureTask 可以在子线程中异步执行任务,而主线程可以通过调用 FutureTask.get() 方法获取任务执行的结果。

  • Callable接口的call()方法允许抛出异常;而Runnabble接口的run()方法的异常只能在内部消化,不能继续上抛
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;

public class CallableExample {

    public static void main(String[] args) throws Exception {
        // 创建一个Callable任务
        Callable<Integer> task = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                System.out.println("Task is running in the background...");
                // 模拟耗时操作
                Thread.sleep(2000);
                return 42; // 返回计算结果
            }
        };

        // 创建FutureTask对象,包装Callable任务
        FutureTask<Integer> futureTask = new FutureTask<>(task);

        // 启动线程执行FutureTask
        Thread thread = new Thread(futureTask);
        thread.start();

        // 主线程可以做一些其他工作
        System.out.println("Main thread is doing something else...");

        // 获取异步执行结果,阻塞直到任务完成
        Integer result = futureTask.get(); // 这会阻塞主线程直到获取到结果
        System.out.println("Task result: " + result); // 打印任务执行结果
    }
}

在启动线程的时候,可以使用run方法吗?run()和start()有什么区别?

start()是开启一个线程 run()跟开启普通方法一样

start():用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次
run():封装了要被线程执行的代码,可以被调用多次

线程包括哪些状态,状态之间是如何变化的?

状态:
新建New、可运行Runnable、阻塞Blocked、等待Waiting、时间等待Timed_waiting、终止Terminated

线程状态之间如何变化:

  • 创建线程对象是新建状态
  • 调用了start()方法转变为可执行状态
  • 线程获取到了CPU的执行权,执行结束是终止状态
  • 在可执行状态的过程中,如果没有获取CPU的执行权,可能会切换其他状态
    • 如果没有获取锁(synchronized或lock) 进入阻塞状态,获得锁再切换为可执行状态
    • 如果线程调用了wait()方法进入等待状态,其他线程调用notify()唤醒后可转换为可执行状态
    • 如果线程调用了sleep(50)方法,进入计时等待状态,到时间后可切换为可执行状态
Thread.java
public enum State {
    /**
     * 新建状态。线程已经被创建,但尚未启动。
     */
    NEW,

    /**
     * 可运行状态。线程在JVM中是可运行的,这并不意味着它一定在运行,它可能在等待其他线程或操作系统的资源。
     */
    RUNNABLE,

    /**
     * 阻塞状态。线程正在等待监视器锁,以进入一个同步块/方法,或者在调用Object.wait后等待重新进入同步块/方法。
     */
    BLOCKED,

    /**
     * 等待状态。线程在等待另一个线程执行特定操作。例如,一个线程调用了Thread.join,它在等待指定的线程终止。
     */
    WAITING,

    /**
     * 超时等待状态。线程在等待另一个线程执行特定操作,但它设置了超时时间。如果线程在指定时间内没有等待到所需条件,它将自动返回。
     */
    TIMED_WAITING,

    /**
     * 终止状态。线程已经完成了执行。
     */
    TERMINATED;
}

新建T1、T2、T3三个线程,如何保证它们按顺序执行?

可以使用线程中的join方法解决
join() 等待线程运行结束

t.join() 阻塞调用此方法的线程进入timed_waiting 直到线程t执行完毕后,此线程再继续执行
Thread t1 = new Thread(()->{
    sout("t1");
});
Thread t2 = new Thread(()->{
    try{
        t1.join();
    }catch(InterruptedException e){
        e.printStackTrance();
    }
    sout("t2");
})
Thread t3 = new Thread(()->{
    try{
        t2.join();
    }catch(InterruptedException e){
        e.printStackTrance();
    }
    sout("t3");
});
// 启动线程
t1.start();
t2.start();
t3.start();

notify() 和 notifyAll() 有什么区别?

  • notifyAll:唤醒所有wait的线程
  • notify:只随机唤醒一个wait线程

java中wait和sleep方法有什么区别?wait要和synchronized一起使用

方法 归属类 是否释放锁 唤醒方式 使用前提
sleep() Thread ❌ 不释放锁 时间到、被打断 直接调用即可
wait() Object ✅ 释放锁 notify()/时间到/被打断 必须配合 synchronized 使用
共同点

wait(),wait(long)和sleep(long)的效果都是让当前线程暂时放弃CPU的使用权,进入阻塞状态

不同点
  • 方法归属不同
    • sleep(long)是Thread的静态方法
    • 而wait(),wait(long)都是Object的成员方法,每个对象都有
  • 醒来时机不同
    • 执行sleep(long)和wait(long)的线程都会在等待相应毫秒后醒来
    • wait(long)和wait()还可以被notify唤醒,wait()如果不唤醒就一直等下去wait要和synchronized一起使用
    • 它们都可以被打断唤醒
  • 锁特性不同【重点】
    • wait方法的调用必须先获取wait对象的锁,而sleep则无此限制
    • wait方法执行后会释放锁对象,允许其他线程获得该锁对象 (我放弃cpu,但你们还可以用)
    • 而sleep如果在synchronized代码块中执行,并不会释放锁对象 (我放弃cpu,你们也用不了)

✅ 核心区别(面试高频)

维度 sleep() wait()
所属类 Thread 静态方法 Object 实例方法
是否释放锁 ❌ 不释放锁 ✅ 释放当前对象锁
是否需要锁 ❌ 不需要任何锁 ✅ 必须持有该对象的锁(synchronized
唤醒方式 到时间/中断 到时间/中断/notify / notifyAll
使用目的 让线程暂停执行,但持有锁不让别人进 让线程等待并释放锁,协调多线程通信

✅ 场景对比

场景 使用方法 原因
想暂停线程几秒钟(不释放锁) Thread.sleep(ms) 常用于模拟网络延迟/定时任务
多线程协作(生产者-消费者模型) wait() / notify() 用于线程间通信,让出锁资源

✅ 通俗类比 🌰

  • sleep():你在厕所里睡觉,门上锁了(别人不能进),你虽然休息了,但别人也进不来。
  • wait():你说“我出去抽根烟(释放锁)”,别人可以进去用厕所(释放资源),抽完烟再回来(被唤醒)继续工作。

如何停止一个正在运行的线程?

有三种方式可以停止线程
  • 使用退出标志位,使线程正常退出,也就是当run方法完成后线程终止
  • 使用stop方法强行终止(不推荐,方法已作废)
  • 使用interrupt方法中断线程
    • 打断阻塞的线程(sleep, wait, join)的线程,线程会抛出InterruptedException异常
    • 打断正常的线程,可以根据打断状态来标记是否退出线程

synchronized关键字的底层原理?底层:Monitor

  • synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】
  • 它的底层由monitor实现的,monitor**是jvm级别的现象(C++实现)**,线程获得锁需要使用对象(锁)关联monitor
  • 在monitor内部有三个属性,分别是owner、entrylist、waitset
    • owner是关联的获得锁的线程,并且只能关联一个线程;
    • entrylist关联的是处于阻塞状态的线程;
    • waitset关联的是处于Waiting状态的线程;

synchronized关键字的底层原理—进阶

Monitor实现的锁属于重量级锁,你了解过锁升级吗?

一旦锁发生了竞争,都会升级为重量级锁

Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。

描述
重量级锁 底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低 【有多个线程来抢】
轻量级锁 线程加锁的时间是错开的(也就是没有竞争)可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性
偏向锁 一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令
  • Monitor实现的锁属于重量级锁,里面涉及到了用户态权限低和内核态权限高的切换、进程的上下文切换,成本较高,性能比较低
  • 在JDK1.6引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下使用传统锁机制带来的性能开销问题

每一个 Java 对象在 JVM 中都有一个对象头,其中包含 MarkWord,用于存储锁信息。
当线程访问 synchronized 方法或代码块时,会尝试获取对象关联的 Monitor,进入临界区:Monitor 中包含:

字段 作用说明
owner 当前持有锁的线程
entryList 等待获取锁(阻塞)的线程队列
waitSet 调用 wait() 被挂起的线程队列

获取锁流程(简化):

  1. 检查对象头中的 MarkWord;
  2. 如果未被锁,尝试通过 CAS 设置为当前线程(偏向或轻量级);
  3. 如果竞争失败 → 升级为重量级锁(Monitor);
  4. 等待唤醒或抢占锁。
锁升级过程(从偏向锁 → 轻量级锁 → 重量级锁)
锁类型 触发条件 优点 场景
偏向锁 只有一个线程访问(无竞争) 几乎无开销,不用 CAS 单线程长时间持有的锁
轻量级锁 多线程访问,但加锁时间错开 使用 CAS,无阻塞,性能较高 少量线程短时间交替访问
重量级锁 多线程同时竞争同一把锁 线程阻塞 + 唤醒,开销大 并发激烈,必须互斥的场景
偏向锁 / 轻量级锁 / 重量级锁细节图解(简述)

🚀 偏向锁原理:

  • 第一次访问:CAS 记录当前线程 ID 到对象头
  • 再次访问时:只判断对象头的线程 ID 是否是自己
  • 如果有竞争,偏向锁就会被撤销,升级为轻量级锁

🚀 轻量级锁原理:

  • 线程栈中创建 LockRecord
  • 尝试 CAS 将 LockRecord 指针复制到对象头;
  • 成功 → 获取锁;失败 → 说明竞争,升级为重量级锁

🚀 重量级锁原理(Monitor):

  • Monitor 的实现是基于 操作系统的互斥量(mutex)
  • 涉及线程的挂起、唤醒(用户态 → 内核态切换,开销大)
Monitor重量级锁

每个Java对象都可以关联一个Monitor对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word中就被设置指向Monitor对象的指针

加锁流程
  • 在线程栈中创建一个Lock Record,将其obj字段指向锁对象。
  • 通过CAS指令将Lock Record的地址存储在对象头的mark word中,如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。
  • 如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分为nul,起到了一个重入计数器的作用。
  • 如果CAS修改失败,说明发生了竞争,需要膨胀为重量级锁。
解锁过程
  • 遍历线程栈,找到所有obj字段等于当前锁对象的Lock Record.
  • 如果Lock Record的Mark Word为null,代表这是一次重入,将obj设置为null后continue。
  • 如果Lock Record的 Mark Word不为nul,则利用CAS指令将对象头的mark word恢复成为无锁状态。如果失败则膨胀为重量级锁。
偏向锁性能比轻量级锁好
  • 轻量级锁在没有竞争时(就自己这个线程)每次重入仍然需要执行 CAS 操作。
  • Java6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有

你谈谈JMM (Java内存模型)

Java内存模型
  • JMM(Java Memory Model)Java内存模型,定义了共享内存多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的正确性
  • JMM把内存分为两块,一块是私有线程的工作区域(工作内存),一块是所有线程的共享区域(主内存)
  • 线程跟线程之间是相互隔离,线程跟线程相互需要通过主内存

CAS你知道吗?乐观锁

  • CAS全称是:Compare And Swap(比较再交换),它体现的一种无锁(乐观锁)的思想,在无锁情况下保证线程操作共享数据的原子性。
  • CAS使用到的地方很多:AQS框架、AtomicXXX类
  • 在操作共享变量的时候使用自旋锁,效率上更高一些
  • CAS的底层是调用的Unsafe类中的方法,都是操作系统提供的,其他语言实现

比较内存值是否与预期值相等,如果相等则更新为新值;否则不做操作,重新尝试(一般配合自旋)

// 伪代码
if (value == expectedValue) {
  value = newValue;
}

💡 应用场景:

  • java.util.concurrent.atomic 包下的 AtomicInteger
  • ReentrantLock 的底层 AQS
  • ConcurrentHashMap 局部并发控制

在JUC(java.util.concurrent)包下实现的很多类都用到了CAS操作

  • AbstractQueuedSynchronizer (AQS框架)
  • AtomicXXX类

乐观锁和悲观锁的区别?

乐观锁 vs 悲观锁(对比表)

特性 乐观锁(CAS) 悲观锁(synchronized/Lock)
思想 默认不会冲突,失败后重试 默认可能冲突,先加锁
开销 CPU 开销高(自旋) 上下文切换成本高
性能 高并发下优于悲观锁 并发低时更稳定
实现方式 CAS,自旋锁,版本号控制 synchronized, ReentrantLock等

谈一谈你对volatile的理解?轻量级的同步机制

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

  • 保证线程间的可见性

    用volatile修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见

    当一个线程修改了被 volatile 修饰的变量,新值会立即同步到主内存中,其他线程读取这个变量时也会立即从主内存中刷新,而不是使用线程工作内存中的旧副本。

    • 保证多个线程看到的是同一个值
    • ❌ 但是 不能保证原子性
  • 禁止进行指令重排序

    用volatile修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果

    • Java 编译器 & CPU 为了提高性能可能会对指令进行优化,造成代码执行顺序书写顺序不一致。

    • volatile 能在变量读写操作前插入内存屏障(Memory Barrier):

      • 写屏障:防止写操作后面的指令重排到前面

      • 读屏障:防止读操作前面的指令被排到后面

    JMM 是理论模型,volatile 是其具体体现。

    🔄 volatile 是 JMM(Java内存模型)在语义上的一个重要实现。

    JMM 内容 volatile 实现作用
    主内存 & 工作内存 强制将值立即刷新到主内存
    可见性保证 ✅ volatile 提供
    原子性保证 ❌ volatile 不提供
    禁止指令重排 ✅ volatile 提供(内存屏障)
    这几个进行一下比较
    特性 volatile synchronized / Lock CAS (AtomicXXX)
    可见性
    原子性 ✅(通过硬件指令)
    重排序控制 ✅(内存屏障) ✅(通过锁的语义) ✅(内存屏障)
    是否加锁 否,轻量,性能高 是,重量级,性能相对低 否,自旋CAS
    适用场景 状态标志、单例双检锁等 临界区互斥、大块同步场景 高并发下原子计数/计量等操作

什么是AQS?

  • 是多线程中的队列同步器。是一种锁机制,它是做为一个基础框架使用的,像ReentrantLock、Semaphore都是基于AQS实现的
  • AQS内部维护了一个**先进先出的双向队列**,队列中存储的排队的线程
  • 在AQS内部还有一个属性state,这个state就相当于是一个资源,默认是0(无所状态),如果队列中有一个线程修改成功了state为1,则当前线程就相当于获取了资源。
  • 在对state修改的时候使用CAS(compare and swap)操作,保证多个线程修改的情况下原子性

AQS(AbstractQueuedSynchronizer),即抽象队列同步器。它是构建锁或者其他同步组件的基础框架

AQS与Synchronized的区别
AQS synchronized
java语言实现 关键字,C++语言实现
悲观锁,手动开启和关闭 悲观锁,自动释放锁
锁竞争激烈的情况下,提供了多种解决方案 锁竞争激励都会升级为重量级锁,性能差

CAS和AQS的区别

CAS(Compare And Swap)乐观锁:一种无锁的原子操作机制,用于实现数据层面的原子性,是底层原语。

AQS(AbstractQueuedSynchronizer)悲观锁:一种同步器框架,用于构建锁和同步器(如 ReentrantLock、Semaphore 等),是结构设计。

表格对比:CAS vs AQS

对比点 CAS AQS
全称 Compare And Swap AbstractQueuedSynchronizer
概念类型 原子操作机制(CPU指令级别) 同步器框架(Java并发包核心)
功能目的 保证共享变量原子性更新 实现线程同步控制(排队、阻塞、唤醒等)
属于哪一层 底层原子操作 高层并发框架
是否加锁 否(无锁) 是(加锁或排队等待)
底层依赖 CPU的 CAS 指令(如 cmpxchg CAS、LockSupport、队列、模板方法
应用场景 AtomicXXX、线程安全计数器、乐观锁等 ReentrantLock、Semaphore、CountDownLatch、FutureTask 等
是否自带阻塞/唤醒 ❌ 不具备阻塞机制 ✅ 自带阻塞/唤醒机制(如 condition.await/signal)
实现原理 通过比较内存值 + 原子更新 模板方法 + 状态位 state + FIFO 等待队列
失败机制 自旋重试(乐观锁) 阻塞挂起,进入等待队列

各自常见应用场景

场景 用的是谁? 举例
实现原子操作 CAS AtomicInteger.incrementAndGet()
实现线程排队获取锁 AQS ReentrantLock.lock()
信号量控制 AQS Semaphore.acquire()
倒计时器 AQS CountDownLatch.await()
实现 Future 机制 AQS FutureTask.run()
高并发无锁计数器 CAS LongAdder.add()(改进版

CAS 是 AQS 的底层基础之一:AQS 内部更新同步状态(state)时就用的是 CAS。
AQS 是基于 CAS + FIFO 队列实现的线程同步框架,比 CAS 更复杂、能力更强。

// AQS 内部设置状态的关键方法
protected final boolean compareAndSetState(int expect, int update) {
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update); // 使用CAS
}

举个例子:ReentrantLock

lock.lock();
  • 内部结构:
    • 使用 AQS 实现公平/非公平锁的排队机制
    • 使用 CAS 来设置 state = 1(加锁)

CAS 是一种基于硬件的原子操作指令,用于在无锁环境下保证共享变量的线程安全,常用于 AtomicInteger 等类。而 AQS 是 Java 并发包中用于构建同步器(如锁、信号量等)的框架,它通过 CAS 操作来维护内部状态 state,并通过一个基于 FIFO 的等待队列来实现线程的阻塞与唤醒。因此,CAS 是底层原语,而 AQS 是上层的并发框架,AQS 内部正是基于 CAS 实现的

AQS常见的实现类
  • ReentrantLock 阻塞式锁
  • Semaphore 信号量
  • CountDownLatch 倒计时锁

ReentrantLock [rɪ’entrənt]lock 的实现原理?[关联HashMap线程不安全需加锁(synchronized或ReentrantLock)]

ReentrantLock主要利用CAS+AQS队列CompareAndSwap+AbstractQueuedSynchronized来实现。**它支持公平锁和非公平锁,两者的实现类似构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁**。公平锁的效率往往没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。

ReentrantLock翻译过来是可重入锁,相对于synchronized它具备以下特点:

  • 可中断synchronized不可中断
  • 可设置超时时间没有获得锁时只能进入等待[没有获取锁可以放弃锁]
  • 可以设置公平锁synchronized只有非公平锁[也支持非公平锁]
  • 支持多个条件变量
  • 与synchronized一样,都支持重入

synchronized和Lock有什么区别?

  • 语法层面

synchronized是关键字,源码在jvm中,用c++语言实现
Lock是接口,源码由jdk提供,用
java
语言实现
使用synchronized时,退出同步代码块锁会自动释放,而使用Lock时,需要手动调用unlock方法释放锁

  • 功能层面

二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
Lock提供了许多synchronized不具备的功能,例如公平锁、可打断、可超时、多条件变量
Lock有适合不同场景的实现,如ReentrantLock、ReentrantReadWeiteLock(读写锁)

死锁产生的条件是什么

死锁:一个线程需要同时获取多把锁,这时就容易发生死锁

如何进行死锁诊断 ?

当程序出现了死锁现象,我们可以使用jdk自带的工具:jpsjstack

  • jps:输出JVM中运行的进程状态信息
  • jstack:查看java进程内线程的堆栈信息

JVM中也有死锁,jvm没有超时机制不会解决 可以查看命令打印堆栈信息可以查看哪里产生死锁

你可以使用jstack命令来打印指定进程ID的Java堆栈跟踪信息。这个命令可以帮助你分析线程的状态

  1. 首先,找到你的Java进程ID(PID)。你可以使用jps命令来列出所有正在运行的Java进程及其PID。

    jps
    
  2. 使用jstack命令打印出该Java进程的堆栈跟踪。

    jstack -l <PID>
    

    <PID>替换为实际的进程ID。

  3. 查找堆栈跟踪中的”DEADLOCK”关键字。jstack会自动检测死锁并在输出中报告。

其他解决工具,可视化工具
  • jconsole

用于对jvm的 内存,线程,类 的监控,是一个基于jmx的GUI性能监控工具
打开方式:java安装目录 bin目录下 直接启动 jconsole.exe就行

  • VisualVM:故障处理工具

能够监控线程,内存情况,查看方法的cpu时间和内存中的对象,已被GC的对象,反向查看分配的堆栈
打开方式:java安装目录 bin目录下 直接启动 jvisualvm.exe就行

死锁:两个线程争夺两个资源的时候 1线程拿到a 想拿b 2线程拿到了b 想拿a
四个原因互斥条件 请求保持 不可剥夺 循环等待
产生死锁的四个因素 同时满足才会死锁 想要解决死锁 需要打破其中一个原因就行

  1. 互斥条件(Mutual Exclusion):资源不能被多个线程同时使用。即某个资源在一段时间内只能由一个线程占用,其他线程必须等待该资源被释放后才能使用。
  2. 持有和等待条件(Hold and Wait):线程至少持有一个资源,并且正在等待获取额外的资源,而该资源又被其他线程持有。
  3. 非抢占条件(No Preemption):已经分配给某个线程的资源在该线程完成任务前不能被抢占,即只能由线程自己释放。
  4. 循环等待条件(Circular Wait):存在一种线程资源的循环等待链,每个线程都在等待下一个线程所持有的资源。

在实际操作中,以下是一些打破死锁的具体方法:银行家算法可以避免死锁

  • 资源分配图:使用资源分配图来检测循环等待条件,并在检测到循环时采取措施。
  • 锁排序:确保所有线程以相同的顺序获取锁,从而避免循环等待。
  • 超时机制:线程在请求资源时设置超时时间,如果超过时间未获得资源,则放弃当前任务并释放已持有的资源。
  • 死锁检测算法:运行死锁检测算法,如银行家算法,来检测系统中的死锁,并在必要时采取措施。
  • 线程中断:允许系统或其他线程中断正在等待资源的线程。
  • 回滚操作:如果检测到死锁,可以让某些线程回滚它们的工作,并释放资源,从而打破死锁。

MySQL是不会有死锁的 自身会检测 [让后面的超时释放回滚]
在分布式事务 线程1拿着资源a是数据库1 线程2拿着资源b是数据库2
JVM中也有死锁,jvm没有超时机制不会解决 可以查看命令打印堆栈信息可以查看哪里产生死锁

你可以使用jstack命令来打印指定进程ID的Java堆栈跟踪信息。这个命令可以帮助你分析线程的状态

  1. 首先,找到你的Java进程ID(PID)。你可以使用jps命令来列出所有正在运行的Java进程及其PID。

    jps
    
  2. 使用jstack命令打印出该Java进程的堆栈跟踪。

    jstack <PID>
    

    <PID>替换为实际的进程ID。

  3. 查找堆栈跟踪中的”DEADLOCK”关键字。jstack会自动检测死锁并在输出中报告。

聊一下ConcurrentHashMap

ConcurrentHashMap是一种线程安全的高效Map集合
底层数据结构

  • JDK1.7底层采用分段的数组+链表实现

  • JDK1.8采用的数数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树

    在JDK1.8中,放弃了Segment臃肿的设计,数据结构跟HashMap的数据结构是一样的:数组+红黑树+链表,采用CAS + Synchronized来保证并发安全进行实现

    • CAS控制数组节点的添加
    • synchronized只锁定当前链表或红黑二叉树的首节点,只要hash不冲突,就不会产生并发的问题,效率得到提升

加锁的方式

  • JDK1.7采用Segment分段锁,底层使用的是ReentrantLock
  • JDK1.8采用CAS自旋锁添加新节点,采用synchronized锁定链表或红黑二叉树的首节点,相对Segment分段锁粒度更细,性能更好

JDK1.7ConcurrentHashMap 实现中:

  • ConcurrentHashMap 底层被分成了多个 Segment(段)。
  • 每个 Segment 本质上就是一个小型的 HashMap + 一把锁(ReentrantLock)。
  • 整个 Map 是由多个 Segment 组成的数组:Segment<K, V>[] segments;
  • 每个 Segment 管理自己那部分的数据,互不干扰,从而实现高并发。

提高并发性,减小锁竞争:

  • 将一个大的 HashMap 拆成多个 Segment(默认16个),每个 Segment 单独加锁。
  • 这样多个线程并发访问不同 Segment 的数据时,就不会互相阻塞,从而提升性能。
  • 线程只会锁定自己需要访问的那个 Segment,不会锁全表。
【下列图中针对于整体和put的解释】

📌 1. 整体结构

  • 外部是一个 Segment 数组:每个 Segment 是独立加锁的。
  • 每个 Segment 内部又是一个 HashEntry 数组(就像 HashMap 的结构)。

📌 2. put 操作流程(以 JDK1.7 为例):

  1. 根据 key 的 hash 计算出 Segment 的下标(如 Segment[5])。
  2. 进入对应的 Segment,获取其锁ReentrantLock.lock())。
  3. 再在该 Segment 中,查找对应的桶位(HashEntry 数组)。
  4. 找到位置后:
    • 如果该位置已有数据,做链表遍历、替换或追加。
    • 如果链表过长,在 JDK1.7 仍然是链表(没有红黑树)。
  5. 插入完成后释放锁。
✅ JDK1.8 为什么放弃 Segment?

JDK1.8 里,取消了 Segment 分段锁结构,改为节点粒度的同步控制

  • 使用 CAS + synchronized 替代了 Segment + ReentrantLock。
  • 好处:
    • 不再有 Segment 的内存占用与操作复杂度。
    • 粒度更细,性能更好。
    • 数据结构与 HashMap 接轨,统一维护。

在 JDK1.7 中,ConcurrentHashMap 使用 Segment 分段锁机制 提高并发性能,将 Map 拆成多个小的 Segment,每个 Segment 内部结构类似 HashMap,通过加锁控制并发。而在 JDK1.8 中,放弃 Segment,采用 CAS + synchronized 锁节点的方式,结构变为数组 + 链表 + 红黑树,性能与简洁性双双提升。

导致并发程序出现问题的根本原因是什么 (Java程序中怎么保证多线程的执行安全)

Java并发编程三大特性
  • 原子性synchronized、lock:一个线程在CPU中操作不可暂停,也不可中断,要么执行完成,要么不执行
int ticketNum = 10;
public void getTicket(){
    if(ticketNum <= 0){
        return;
    }
    sout(Thread.currentThread().getName() + "抢到一张票,剩余:" + ticketNum);
    // 非原子性操作
    ticketNum--;
}
main{
    TicketDemo demo = new TicketDemo();
    for(int i = 0; i < 20; i++){
        new Thread(demo::getTicket).start();
    }
}
不是原子操作,怎么保证原子操作呢?
  1. synchronized:同步加锁
  2. JUC里面的lock:加锁

  • 可见性volatile、synchronized、lock
内存可见性:让一个线程对共享变量的修改对另一个线程可见
public class VolatileDemo{
    private static boolean flag = false;
    public static void main(String[] args) throws InterruptedException{
        new Thread(()->{
            while(!flag){
                sout("第一个线程执行完毕...");
            }
        }).start();
        Thread.sleep(100);
        new Thread(()->{
            flag = true;
            sout("第二个线程执行完毕...");
        }).start();
    }
}

解决方案:synchronized、volatile、LOCK

volatile:加在共享变量上面即可 → private static volatile boolean flag = false;

  • 有序性volatile

指令重排:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的

int x;
int y;
@Actor
public void actor1(){
    x = 1;
    y = 1;
}
@Actor
public void actor2(II_Result r){
    r.r1 = y;
    r.r2 = x;
}

解决办法:在前面加上volatile

说一下线程池的核心参数

为什么要创建线程池 因为每次创建线程的时候就要占用一定的内存空间 无限创建线程会浪费内存严重会导致内存溢出
CPU有限的同一时刻只能同时处理一个线程 大量线程来的话就没有线程权 会造成线程等待 造成大量线程在之间切换也会导致性能变慢

在这个例子中,我们创建了一个线程池,核心线程数为5,最大线程数为10,如果线程池中的线程数大于核心线程数,则空闲线程在60秒后会被终止。工作队列使用ArrayBlockingQueue,其容量为100。

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class ThreadPoolExample {
    public static void main(String[] args) {
        // 核心线程数
        int corePoolSize = 5;
        // 最大线程数 = (核心线程 + 救急线程的最大数目)
        int maximumPoolSize = 10;
        // 线程池中超过 corePoolSize 数量的空闲线程最大存活时间
        long keepAliveTime = 60L;
        // 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
        TimeUnit unit = TimeUnit.SECONDS;
        // 工作队列,用于存放提交的任务 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
        ArrayBlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(100);
        // 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
        ThreadFactory threadFactory = new ThreadFactory;
        // 拒绝策略 - 当所有线程都繁忙,workQueue也繁忙时,会触发拒绝策略
        RejectedExecutionHandler handler = new RejectedExecutionHandler;
        
        // 创建线程池
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
                corePoolSize,
                maximumPoolSize,
                keepAliveTime,
                unit,
                workQueue
        );

        // 示例:向线程池提交任务  threadPoolExecutor.submit()/.execute()
        for (int i = 0; i < 20; i++) {
            int taskNumber = i;
            threadPoolExecutor.execute(() -> {
                System.out.println("Executing task " + taskNumber);
                // 模拟任务执行时间
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }

        // 关闭线程池
        threadPoolExecutor.shutdown();
    }
}

一开始new的时候没有是空的。先当一个任务提交给线程池时,线程池首先检查当前运行的线程数是否达到核心线程数。如果没有达到核心线程数,线程池会创建一个新的线程来执行任务。如果已经达到核心线程数,线程池会将任务放入工作队列中等待执行。如果工作队列满了,并且当前运行的线程数小于最大线程数,线程池会创建新的线程来执行任务。如果工作队列满了,并且当前运行的线程数等于最大线程数,线程池会根据拒绝策略

拒绝策略:
  • 丢弃任务抛出异常
  • 丢弃任务不抛弃异常
  • 丢弃队列最前面的任务,然后重新提交被拒绝的任务、
  • 由主线程处理该任务来处理无法执行的任务。【线程池无法起到异步问题】
    • 问题:想继续异步且不丢弃任务怎么办?
    • 把这个业务先存到别的地方 ↓↓↓
  • 自定义拒绝策略 自己写实现类实现拒绝策略 可以先存到mysql到时候再慢慢搞

线程池中有哪些常见的阻塞队列

线程工厂可以设置创建的属性
守护线程:主线程(main)一天不死 守护线程不死 [同生共死]
非守护线程:new一个就是 [不是同生共死]

workQueue - 阻塞队列常用的队列:当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务

  1. ArrayBlockingQueue: 基于数组结构的有界阻塞队列,此队列按照先进先出(FIFO)的原则对元素进行排序。创建时需要指定容量。【底层是数组 随机读写的 **时间复杂度O(1)**】
    • 开辟新空间创建新数组 把旧数组的数据迁移过去 new ArrayList为空 需要add才可以 扩容是+10 取1.5倍
    • 高并发不会超过某个值 数组不会涉及到扩容 性能会好一些【比较稳定能预估】
    • new的时候不用指定长度
  2. LinkedBlockingQueue: 基于链表结构的有界阻塞队列(如果不指定容量,则默认为Integer.MAX_VALUE,即视为无界)。按照先进先出的原则排序元素。【随机读写的 时间复杂度O(n) 随机读写快 查询慢 是通过二分查找定位到下标元素(通过下标访问数组和链表) 只会走一次二分查找】
    • 读中间的慢 读头尾快
    • 新增元素不涉及到数组的迁移
    • 一般情况下高并发推荐使用,因为队列高级数据结构(可以用数组和链表的实现 由于底层数据结构不同)的特性是先进先出,链表不涉及到数组的扩容 末尾的最快是O(1)【不稳定】
    • new的时候可指定长度是最大链表的长度
    • 不可指定长度 [有界队列&无界队列] → 可能产生JVM的OOM
  3. DelayedWorkQueue:是一个优先级队列,它可以保证每次出队的任务都是当前队列中时间最靠前的
  4. SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作
ArrayBlockingQueue LinkedBlockingQueue不给值默认最大值
强制有界 默认无界,支持有界
底层是数组 底层是链表
提前初始化Node数组 是懒惰的,创建节点的时候添加数据
Node需要是提前创建好的 入队会生成新Node
一把锁 两把锁(头尾)可以一边入队,一边出队

ArrayBlockingQueue(数组有界队列)

特性 描述
类型 有界队列(必须指定容量)
底层结构 数组(先进先出 FIFO)
线程安全 内部使用一把锁(ReentrantLock)实现
性能 读写快,结构稳定
应用场景 可以准确预估任务数量的场景,推荐用于生产环境保障系统稳定
特点 不支持扩容,满了会阻塞或抛异常

适用于:生产环境中任务量可控,保证内存稳定,不希望触发OOM。

LinkedBlockingQueue(链表无界队列)

特性 描述
类型 默认无界队列(最大为 Integer.MAX_VALUE,可设置为有界)
底层结构 链表(FIFO)
线程安全 使用两把锁(put锁 + take锁),可以同时入队出队
性能 插入删除性能好,随机访问性能差(O(n))
应用场景 任务流量大、不可预估任务量的场景
特点 不容易触发拒绝策略,但容易造成内存溢出(OOM)

适用于:高并发日志、事件处理等消费速度快但生产不确定的情况。

队列类型 有界性 底层结构 锁机制 特点描述
ArrayBlockingQueue 有界 数组 一把锁 性能稳定,适合任务量可控场景
LinkedBlockingQueue 默认无界 链表 两把锁(头尾) 插入删除效率高,容易堆积任务造成内存压力

线程池中选择哪个阻塞队列?

使用场景 推荐队列类型 说明
普通后台异步任务 ArrayBlockingQueue 稳定、安全,可预估任务量
高并发任务,消费快但产量不可控 LinkedBlockingQueue 适合吞吐量大场景,注意内存风险

守护线程补充

你提到的这段也非常好,总结如下:

  • 守护线程(daemon):依附主线程存在,主线程结束,守护线程也自动终止。如:GC线程。
  • 非守护线程(user thread):默认类型,主线程结束后仍会继续运行。

可通过:

Thread thread = new Thread(...);
thread.setDaemon(true); // 设置为守护线程

如何确定核心线程数

① 高并发、任务执行时间短 → (CPU核数 + 1),减少线程上下文的切换
② 并发不高、任务执行时间长

  • IO密集型任务 → (CPU核数 * 2 + 1)
  • 计算密集型任务 → (CPU核数 + 1)

并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,增加服务器是第二步,至于线程池的设置参考②

  • IO密集型任务:文件读写、DB读写、网络请求等 核心线程数大小设置为2N+1
  • CPU密集型任务:计算型代码、Bitmap转换、Gson转换等 核心线程数大小设置为N+1
// 查看机器的CPU核数
public static void main(String[] args){
    // 查看机器的CPU核数
    System.out.println(Runtime.getRuntime().avaliableProcessors());
}

线程池的种类有哪些

java.util.concurrent.Executors类中提供了大量创建线程池的静态方法,常见的有四种

① 创建使用固定线程数的线程池

适用于任务已知,相对耗时的任务

public static ExecutorService newFixedThreadPool(int nThreads){
    return new ThreadPoolExecutor(nThreads, nThreads,0L,TimeUnit.MILLISECONDS.new LinkedBlockingQueue<Runnable>)
}
  • 核心线程数与最大线程数一样,没有救急线程 = 最大线程数 - 核心线程数
  • 阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE
② 单线程化的线程池它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO)执行→[先进先出]

适用于按照顺序执行的任务

public static ExecutorService newSingleThreadExecutor(){
    return new FinalizableDelegatedExecutorService(new ThreadPoolExecutor(1,1,0L,TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>()));
}
  • 核心线程数和最大线程数都是1
  • 阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE
③ 可缓存线程池
public static ExecutorService newCachedThreadPool(){
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,60L,TimeUnit.SECONDS,new SynchronousQueue<Runnable>());
}
  • 核心线程数为0
  • 最大线程数是Integer.MAX_VALUE
  • 阻塞队列是SynchronousQueue: 不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作
④ 提供了 延迟周期执行 功能的ThreadPoolExecutor
public ScheduledThreadPoolExecutor(int corePoolSize){
    super(corePoolSize, Integer.MAX_VALUE,0,NANOSECONDS,new DelayedWorkQueue());
}

为什么不建议使用Executors创建线程池?

参考阿里开发手册

【强制】 线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
说明:Executors返回的线程池对象的弊端如下:
1. FixedThreadPool 和 SingleThreadPool:
允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致OOM
2. CachedThreadPool:
允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致OOM
    
    
在实际开发中,不建议使用 Executors 创建线程池,因为其底层默认参数具有潜在的 OOM 风险。例如 FixedThreadPool 使用无界队列、CachedThreadPool 最大线程数为 Integer.MAX_VALUE,容易在高并发场景下造成内存溢出。因此建议通过 ThreadPoolExecutor 显式指定核心参数,做到资源可控,避免系统风险。

线程池的使用场景①:ES数据批量导入

CountDownLatch

CountDownLatch(闭锁/倒计时锁)用来进行线程同步协作,等待所有线程完成倒计时时(一个或多个线程,等待其他多个线程完成某件事情之后才能执行)

  • 其中构造参数用来初始化等待计数值
  • await()用来等待计数归零
  • countDown()用来让计数减一
多线程使用场景一 (es数据批量导入)

在我们项目上线之前,我们需要把数据库中的数据一次性的同步到es索引库中,但是当时的数据好像是1000万左右一次性读取数据肯定不行(oom异常),当时我就想到可以使用线程池的方式导入,利用CountDownLatch来控制就能避免一次性加载过多,防止内存溢出

在我们项目中,曾有一次需要把MySQL中的一千万条历史文章同步到ES。为了避免一次性加载引发OOM,我将数据分页为每页2000条,使用线程池批量提交导入任务,同时使用 CountDownLatch 控制主线程阻塞等待所有子任务完成,再统一执行收尾逻辑。这样做极大优化了内存占用和同步效率。

DB(一千万) → 线程池(CountDownLatch) → Elasticearch

       批量导入  →  查询总条数   →       DB
                      ↓               ↑          批量导入到ES中     →   ES
(固定每页2000条)        计算总页数            ↑  (countDownLatch.countDown())
                        ↓               ↑                ↑
(总页数)         CountDownLatch        ↑                ↑
                        ↓               ↑                ↑
                分页查询文章数据 → [查询当前页的文章 → 创建任务批量导入ES → 提交到线程池执行]循环
                                             (文章列表, countDownLatch)
                                                          ↓
                                                countDownLatch.await()

线程池的使用场景②:数据汇总

  • 在一个电商网站中,用户下单之后,需要查询数据,数据包含了三部分:订单信息、包含的商品、物流信息;这三块信息都在不同的微服务中进行实现的,我们如何完成这个业务呢?
    • 在实际开发的过程中,难免需要调用多个接口来汇总数据,如果所有接口(或部分接口)的没有依赖关系,就可以使用线程池+future来提升性能
      [统计的图文发布量、点赞数量、收藏数量、评论数量若不在同一台微服务下 或者 部分没有依赖关系]

并发数据汇总(如订单数据聚合)

关键词:接口无依赖、加速响应、Future并发调用

📌 适用场景

  • 一个接口需要聚合多个来源服务(如订单服务、商品服务、物流服务)
  • 服务之间无强依赖,可并发发起请求提升响应速度

✅ 技术点

  • 线程池 + Future + Callable
  • 三个子任务并发发起 → .get()阻塞获取返回值
  • 总响应时间 ≈ 最慢的那个接口,而不是三个之和

线程池的使用场景③:异步调用

为了避免下一级方法影响上一级方法(性能考虑),可使用异步线程调用下一个方法(不需要下一级方法返回值),可以提升方法相应时间

如何控制某个方法允许并发访问线程的数量

Semaphore信号量,是JUC包下的一个工具类,底层是AQS,我们可以通过其限制执行的线程数量
适用场景
通常用于那些资源有明确访问数量限制的场景,常用于限流

Semaphore使用步骤
  • 创建Semaphore对象,可以给一个容器
  • semaphore.acquire():请求一个信号量,这时候的信号量个数 -1 (一旦没有可使用的信号量,也即信号量个数变为负数时,再次请求的时候就会阻塞,直到其他线程释放了信号量)
  • semaphore.release():释放一个信号量,此时信号量个数 +1

使用 Semaphore 信号量

  • Semaphore 是 JUC 包下的并发工具类,用于控制同时访问某个资源的线程数量
  • 底层基于 AQS(AbstractQueuedSynchronizer)实现
  • 常用于限流、并发资源控制、连接池管理、接口控制等场景
Semaphore 就像操作系统中的“通行证/信号灯”,只有拿到令牌(acquire)才能进入方法执行,执行完后必须释放令牌(release),否则其他线程会一直阻塞在那等令牌释放。
  • new Semaphore(n):设置可同时访问的线程数量为 n
  • semaphore.acquire():申请令牌,获取不到则阻塞
  • semaphore.release():释放令牌,通知其他线程

Semaphore 可以限制方法的并发访问线程数,常用于限流或控制资源并发度。通过 acquire() 获取访问许可、release() 释放许可,从而确保同时最多只有固定数量的线程能访问目标方法或资源。

谈一谈你对ThreadLocal的理解

ThreadLocal 是 Java 提供的线程本地变量工具类,用于实现线程间的数据隔离,也可理解为线程级别的“共享变量”。

🌟 一句话总结(记住这个)

ThreadLocal 实现线程间变量隔离,让每个线程拥有一份自己的变量副本,常用于解决共享变量的线程安全问题。

📌 附加:ThreadLocal ≠ 线程安全

ThreadLocal 并不是让对象变“线程安全”,只是让每个线程用自己那份数据,避免共享导致的问题。

🚀 作用与优势

  • 线程隔离:每个线程持有一份独立变量,互不干扰,解决并发线程对共享资源读写冲突的问题。
  • 线程内共享:同一线程中可以在不同方法、组件间共享数据(如事务控制、用户上下文等)。

🧠 原理说明(核心)

每个线程内部都维护一个 ThreadLocalMap(它是 Thread 类的成员变量):

  • 当调用 threadLocal.set(value) 时:
    • 当前线程的 ThreadLocalMap 中以 threadLocal 实例作为 key,value 作为值进行存储。
  • 当调用 threadLocal.get() 时:
    • 会从当前线程中以 threadLocal 为 key 查找对应的值。
  • remove() 用于手动移除,防止内存泄漏。

✅ 本质上:ThreadLocal 并不是把值保存在自己内部,而是保存在当前线程的 ThreadLocalMap 中。

☢️ 内存泄漏问题

  • ThreadLocalMap 中的 key 是 ThreadLocal弱引用,但 value 是强引用
  • 当 ThreadLocal 实例被 GC 回收后,key 变成 null,**value 仍存在,若不手动 remove,就会造成内存泄漏**
  • 最佳实践:用完一定要调用 remove() 方法清除数据
  • ThreadLocal 可以实现【资源对象】的线程隔离,让每个线程各用各的【资源对象】避免争用引发的线程安全问题
  • ThreadLocal 同时实现了线程内的资源共享
  • 每个线程内有一个 ThreadLocalMap 类型的成员变量,用来存储资源对象
    • 调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线
      程的 ThreadLocalMap 集合中
    • 调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中査找关联的资源值
    • 调用remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值
  • ThreadLocal内存泄漏问题ThreadLocalMap 中的key是弱引用,值为强引用; key会被Gc释放内存,关联 value的内存并不会释放。建议主动remove 释放 key,value
ThreadLocal概述

ThreadLocal是多线程中对于解决线程安全的一个操作类,它会为每个线程都分配一个独立的线程副本从而解决了变量并发访问冲突的问题。ThreadLocal同时实现了线程内的资源共享

案例:使用JDBC操作数据库时,会将每一个线程的Connection放入各自的ThreadLocal中,从而保证每个线程都在各自的 Connection 上进行数据库的操作,避免A线程关闭了B线程的连接。

ThreadLocal基本使用

  • set(value) 设置值
  • get() 获取值
  • remove() 清除值
ThreadLocal的实现原理 & 源码解析

ThreadLocal本质来说就是一个线程内部存储类,从而让多个线程只操作自己内部的值,从而实现线程数据隔离

JVM相关面试题

什么是JVM?

JVM = Java Virtual Machine 是java程序的运行环境
JVM是运行在操作系统中的 屏蔽了操作系统的差异

好处

  • 一次编码,到处运行
  • 自动内存管理,垃圾回收机制

什么是程序计数器?

程序计数器:线程私有的,内部保存的字节码的行号。用于记录正在执行的字节码指令的地址

javap -v xx.class:打印堆栈大小,局部变量的数量和方法的参数

找到Application的class文件后 → Build → Rebuild Project编译一下 → 找到该Application的class文件黄色的 → Open in → Terminal → javap -v Application.class

你能给我详细介绍Java堆吗?

线程共享的区域:主要用来保存对象实例、数组等,当堆中没有内存空间可分配给实例,也无法再扩展,则抛出OutOfMemoryError异常

  • 组成:年轻代 + 老年代
    • 年轻代被划分为三部分,Eden区和两个大小严格相同的Survivor区幸存者区
    • 老年代主要保存生命周期长的对象,一般是一些老的对象
  • jdk1.7和1.8的区别
    • 1.7中有一个永久代,存储的是类信息、静态变量、常量、编译后的代码
    • 1.8移除了永久代,把数据存储到了本地内存的元空间中,防止内存溢出

什么是虚拟机栈?

Java Virtual machine Stacks(Java虚拟机栈)

  • 每个线程运行时所需要的内存,称为虚拟机栈,先进后出
  • 每个栈由多个栈帧(frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

垃圾回收是否涉及栈内存?

不涉及,因为垃圾回收主要指的是堆内存
这里当栈帧弹栈后,内存就会释放

栈内存分配越大越好吗?

未必,默认的栈内存通常为1024k
栈帧过大会导致线程数变少,例如,机器总内存为512m,目前能活动的线程数则为512个,如果把栈内存改为2048k,那么能活动的栈帧就会减半

方法内的局部变量是否线程安全?

如果方法内局部变量没有逃离方法的作用范围,它是线程安全的
如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全

什么情况下会导致栈内存溢出?

栈帧过多导致栈内存溢出,经典问题:递归调用
栈帧过大导致栈内存溢出

堆栈的区别是什么?

栈内存一般会用来存储局部变量和方法调用,但堆内存是用来存储Java对象和数组的的。堆会GC垃圾回收,而栈不会
栈内存是线程私有的,而堆内存是线程共有的。
两者异常错误不同,但如果栈内存或者堆内存不足都会抛出异常
栈空间不足:java.lang.StackOverFlowError。
堆空间不足:java.ang.OutOfMemoryError。

能不能解释一下方法区

  • 方法区(Method Area)是各个线程 共享的内存区域
  • 主要存储类的信息、运行时常量池
  • 虚拟机启动的时候创建,关闭虚拟机时释放
  • 如果方法区域中的内存无法满足分配请求,则会抛出OutOfMemoryError: Metaspace
常量池

可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
Terminal中执行:javap -v Application.class
可以查看字节码结构 (类的基本信息、常量池、方法定义)
当类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址

你听过直接内存吗?

直接内存:并不属于JVM中的内存结构,不由JVM进行管理。是虚拟机的系统内存,常见于NIO操作时,用于数据缓冲区,它分配回收成本较高,但读写能力高。[平时的是BIO]

直接内存并不属于JVM中的内存结构,不由VM进行管理。是虚拟机的系统内存常见于 NIO 操作时,用于数据缓冲区,分配回收成本较高,但读写性能高,不受 JVM 内存回收管理

什么是类加载器,类加载器有哪些?

类加载器

JVM只会运行二进制文件,类加载器的作用就是将字节码文件加载到JVM中,从而让Java程序能够启动起来

  1. 引导类加载器(Bootstrap ClassLoader)加载JAVA_HOME/jre/lib目录下的库
    • 这是最顶层的类加载器,它用于加载Java的核心库,这些库位于<JAVA_HOME>/jre/lib目录(比如rt.jarresources.jar等),或者被-Xbootclasspath参数指定的路径中。
    • 引导类加载器是用原生代码(如C/C++)实现的,它属于JVM的一部分。
    • 它并不继承自java.lang.ClassLoader,而是由JVM自身实现。
  2. 扩展类加载器(Extension ClassLoader)加载JAVA_HOME/jre/lib/ext目录中的类
    • 它负责加载<JAVA_HOME>/lib/ext目录中,或者由系统属性java.ext.dirs指定的路径中的类库。
    • 它是sun.misc.Launcher$ExtClassLoader类的实例。
  3. 系统类加载器(System ClassLoader)用于加载classPath下的类
    • 也称为应用类加载器(Application ClassLoader),它负责加载用户类路径(Classpath)上的所有类库。
    • 系统类加载器是sun.misc.Launcher$AppClassLoader类的实例。
    • 它是程序中默认的类加载器,可以通过ClassLoader.getSystemClassLoader()方法获取。
  4. 自定义加载器(CustomizeClassLoader)自定义继承ClassLoader,实现自定义类加载规则
    • 用户还可以自定义类加载器。自定义类加载器通过继承java.lang.ClassLoader类并重写相应的方法来实现。自定义类加载器可以用于特定的需求,例如在Web容器中加载类,或者在运行时从网络或其他地方动态加载类。

什么是双亲委派模型?

加载某一个类,先委托上一级的加载器进行加载,如果上级加载器也有上级,则会继续向上委托,如果该类委托上级没有被加载,子加载器尝试加载该类

JVM为什么采用双亲委派机制?
  • 通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性
  • 为了安全,保证类库API不会被修改

说一下类装载的执行过程?

加载:查找和导入class文件
验证:保证加载类的准确性
准备:为类变量分配内存并设置类变量初始值
解析:把类中的符号引用转换为直接引用
初始化:对类的静态变量,静态代码块执行初始化操作
使用:JVM 开始从入口方法开始执行用户的程序代码
卸载:当用户程序代码执行完毕后,JVM便开始销毁创建的Class对象

类从加载到虚拟机中开始,直到卸载为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用卸载这7个阶段。其中,验证、准备和解析这三个部分统称为连接(linking)

  • 通过类的全名,获得类的二进制数据流
  • 解析类的二进制数据流为方法区内的数据结构(Java类模型)
  • 创建java.lang.Class类的实例,表示该类型。作为方法区这个类的各种数据的访问入口

对象什么时候可以被垃圾器回收

如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾,如果定位了垃圾,则有可能会被垃圾回收器回收

怎么确定什么是垃圾?
  • 引用计数法

    一个对象被引用了一次,在当前的对象头上递增一次引用次数,如果这个对象的引用次数为0,代表这个对象可回收

  • 可达性分析算法

    采用的都是通过可达性分析算法来确定哪些内容是垃圾

JVM垃圾回收算法有哪些?

  • 标记清除算法

    是将垃圾回收分为2个阶段,分别为标记清除

    • 根据可达性分析算法得出的垃圾进行标记
    • 对这些标记为可回收的内容进行垃圾回收
  • 复制算法

    将原有的内存空间一分为二,每次只用其中的一块,正在使用的对象复制到另一个内存空间中,然后将该内存空间清空,交换两个内存的角色,完成垃圾的回收;无碎片,内存使用率低

  • 标记清理算法一般用于老年代

    标记清除算法一样,将存活对象都向内存另一端移动,然后清理边界以外的垃圾,无碎片,对象需要移动,效率低

JVM的分代回收是什么?

分代收集算法

在java8时,堆被分为了两份:新生代和老年代[1:2]
对于新生代,内部又分为了三个区域,Eden区,幸存者区survivor(分成from和to)【8:1:1】

MinorGC、MixedGC、FullGC的区别是什么
  • MinorGC(youngGC)发生在新生代的垃圾回收,暂停时间短(STW)
  • MixedGC:新生代 + 老年代 部分区域的垃圾回收,G1收集器特有
  • FullGC:新生代 + 老年代 完整垃圾回收,暂停时间长(STW),应尽力避免

STW(Stop-The-World)暂停所有应用程序线程,等待垃圾回收的完成

JVM有哪些垃圾回收器?

在jvm中,实现了多种垃圾收集器,包括:

  • 串行垃圾收集器

    SerialSerial Old串行垃圾收集器,是指使用单线程进行垃圾回收,堆内存较小,适合个人电脑

    • Serial 作用于新生代,采用复制算法
    • Serial Old 作用于老年代,采用标记-整理算法垃圾回收时,只有一个线程在工作,并且java应用中的所有线程都要暂停(STW),等待垃圾回收的完成
  • 并行垃圾收集器

    Parallel New和Parallel Old是一个并行垃圾回收器,JDK8默认使用此垃圾回收器

    • Parallel New作用于新生代,采用复制算法

    • Parallel Old作用于老年代,采用标记-整理算法

      垃圾回收时,多个线程在工作,并且java应用中的所有线程都要暂停(STW),等待垃圾回收的完成。

  • CMS(并发)垃圾收集器

    CMS全称 Concurrent Mark Sweep,是一款并发的、使用标记-清除算法的垃圾回收器,该回收器是针对老年代垃圾回收的,是一款以获取最短回收停顿时间为目标的收集器,停顿时间短,用户体验就好。其最大特点是在进行垃圾回收时,应用仍然能正常运行

  • G1垃圾收集器

    作用在新生代和老年代

详细聊一下G1垃圾回收器

  • 应用于新生代和老年代,在JDK9之后默认使用G1
  • 划分成多个区域,每个区域都可以充当eden,survivor,old,humongous,其中humongous专为大对象准备
  • 采用复制算法
  • 响应时间与吞吐量兼顾
  • 分成三个阶段:新生代回收(STW)、并发标记(重新标记STW)、混合收集
  • 如果并发失败(即回收速度赶不上创建新对象速度),就会触发Full GC

强引用、软引用、弱引用、虚引用的区别

强引用:只要所有 GC Roots 能找到,就不会被回收
软引用:需要配合SoftReference使用,当垃圾多次回收,内存依然不够时候会回收软引用对象
弱引用:需要配合WeakReference使用,只要进行了垃圾回收,就会把引用对象回收
虚引用:必须配合引用队列使用,被引用对象回收时,会将虚引用入队由 Reference Handler 线程调用虚引用相关方法释放直接内存

  • 强引用:只有所有 GCRoots 对象都不通过【强引用】 引用该对象,该对象才能被垃圾回收
User user = new User();

GC Root → User对象

  • 软引用:仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次触发垃圾回收
User user = new User();
SoftReference softReference = new SoftReference(user);

GC Root → SoftReference对象 →→虚线 User对象
一开始并不会对User对象进行回收 此时User对象就是软引用 如果内存还是不够 马上又再次进行了垃 圾回收 此时软引用的User就会被回收

  • 弱引用:仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象
User user = new User();
WeakReference weakReference = new WeakReference(user)

GC Root → WeakReference对象 →→虚线 User对象

延申话题:ThreadLocal内存泄露问题

static class Entry extends WeakReference<ThreadLocal<?>>{
    Object value;
 Entry(ThreadLocal<?>k, Object v){
     super(k); // k是弱引用
     value = v; // 强引用,不会被回收
 }
}
  • 虚引用:必须配合引用队列使用,被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存
User user = new User();
ReferenceQueue referenceQueue = new ReferenceQueue();
PhantomReference phantomReference = new PhantomReference(user, queue);

JVM调优的参数可以在哪里设置?

  • war包部署在tomcat中设置

    修改 TOMCAT_HOME/bin/catalina.sh 文件
    D:\apache-tomcat-8.5.93\bin\catalina.sh【卡特琳娜】

    # OS specific support.  $var _must_ be set to either true or false.
    JAVA_OPTS="-Xms512m -Xmx1024m"
    cygwin=false
    darwin=false
    os400=false
    hpux=false
    
  • jar包部署在启动参数设置

    通常在linux系统下直接加参数启动SpringBoot项目—VM

    nohup java -Xms512m -Xmx1024n -jar xxxx.jar --spring.profiles.active=prod &

nohup:用于在系统后台不挂断地运行命令,退出终端不会影响程序的运行
**参数&**:让命令在后台执行,终端退出后命令仍然执行

JVM调优的参数都有哪些?

对于JVM调优,主要就是调整 年轻代、老年代、元空间 的内存大小及使用的垃圾回收器类型

  • 设置堆空间大小

    设置堆的初始大小和最大大小,为了防止垃圾收集器在初始大小、最大大小之间收缩堆而产生额外的时间,通常把最大、初始大小设置为相同的值

    -Xms: 设置堆的初始化大小
    -Xmx: 设置堆的最大大小
    // 不指定单位默认为字节
    -Xms:1024
    -Xms:1024k
    

    堆内存设置多少合适?

    • 最大大小的默认值是物理内存的1/4,初始大小是物理内存的1/64【不设置的情况下】
    • 堆太小,可能会频繁的导致年轻代和老年代的垃圾回收,会产生STW,暂停用户线程
    • 堆内存大肯定是好的,存在风险,假如发生了fullgc,它会扫描整个堆空间,暂停用户线程的时间长
  • 虚拟机栈的设置

    虚拟机栈的设置:每个线程默认会开启1M的内存,用于存放栈帧、调用参数、局部变量等,但一般256K就够用。通常减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统。

    -Xss 对每个线程stack大小的调整,-Xss128k

  • 年轻代中Eden区和两个Survivor区的大小比例

    设置年轻代中Eden区和两个Survivor区的大小比例。该值如果不设置,则默认比例为8:1:1。通过增大Eden区的大小来减少YGC发生的次数,但有时我们发现,虽然次数减少了,但Eden区满的时候,由于占用的空间较大,导致释放缓慢,此时STW的时间较长,因此需要按照程序情况去调优。

    -XXSurvivorRatio=8,表示年轻代中的分配比率:survivor:eden = 2:8

  • 年前代晋升老年代阈值【默认值为15,取值范围0-15】

    -XX:MaxTenuringThreshold=threshold

  • 设置垃圾回收收集器

    通过增大吞吐量提高系统性能,可以通过设置并行垃圾回收收集器

    -XX:+UseParallelGC
    -XX:+UseParallelOldGC

    -XX:+UserG1GC

JVM调优的参数都有哪些?

  • 命令工具

    • jps 进程状态信息

    • jstack 查看进程内线程的堆栈信息产生死锁可以查看

    • jmap 查看堆栈信息[生成堆转内存快照,内存使用信息]

      jmap -head pid 显示Java堆的信息
      jmap -dump:format=b,file=heap.hprof pid
      
      • format=b 表示以hprof二进制格式存储Java堆的内存

      • file=< filename > 用于指定快照dump文件的文件名

        dump:它是我们都可以通过工个进程或系统在某一给定的时间的快照。比如在进程崩溃时,甚至是任何时候,具将系统或某进程的内存备份出来供调试分析用,dump文件中包含了程序运行的模块信息、线程信息、堆调用信息、异常信息等数据,方便系统技术人品进行错误排查

    • jhat 堆转储快照分析工具

    • jstat JVM统计监测工具[可以用来显示垃圾回收信息、类加载信息、新生代统计信息等]

      • 总结垃圾回收统计:jstat -gcutil pid
      • 垃圾回收统计:jstat -gc pid
  • 可视化工具

    • jconsole 用于对jvm的内存,线程,类的监控, 是一个可视化工具
      D:\java\jdk-11.0.20\bin\jconsole.exe
    • VisualVM 能够监控线程,内存情况只有jdk1.8有
      D:\java\jdk1.8.0_181\bin\jvisualvm.exe

Java内存泄露的排查思路?

内存泄漏通常是指堆内存,通常是指一些大对象不被回收的情况
1、通过jmap或设置jvm参数获取堆内存快照dump
2、通过工具,VisualVM去分析dump文件,VisualVM可以加载离线的dump文件
3、通过查看堆信息的情况,可以大概定位内存溢出是哪行代码出了问题
4、找到对应的代码,通过阅读上下文的情况,进行修复即可

JVM Stacks 虚拟机栈StackOverFlowError
Heap OutOfMemoryError:java heap space
Method Are/ MateSpace 方法区/元空间OutOfMemoryError: Metaspace

模拟堆空间溢出场景:-VM设置参数 → -Xmx10m

List<String> list = new ArrayList<>();
while(true){
    list.add("北京");
}
-------------------------------------------
// OutOfMemoryError:java heap space
如何排查启动闪退、运行一段时间宕机
  • 获取堆内存快照dump

    • 使用jmap命令获取运行中程序的dump文件【只有在项目运行时候才可以用】
    jmap -head pid 显示Java堆的信息
    jmap -dump:format=b,file=heap.hprof pid 【只有在项目运行时候才可以用】
    
    • 使用vm参数获取dump文件

      有的情况是内存溢出之后程序则会直接中断,而jmap只能打印在运行中的程序,所以建议通过参数的方式生成dump文件

    -XX:+HeapDumpOnOutOfMemoryError
    -XX:HeapDumpPath=/home/app/dumps/
    
  • VisualVM区分析dump文件

  • 通过查看堆内存的信息,定位内存溢出问题

CPU飙高排查方案与思路?

1.使用top命令查看占用cpu的情况
2.通过top命令查看后,可以查看是哪一个进程占用cpu较高
3.使用ps命令查看进程中的线程信息
4.使用jstack命令查看进程中哪些线程出现了问题,最终定位问题

  • 使用top命令查看占用cpu的情况哪个进程占用的cpu最高

    finalShell中输入 top

  • 查看进程中的线程信息 ps H -eo pid,tid,%cpu | gerp pid

  • jstack 查看进程内线程的堆栈信息产生死锁可以查看

    因为是十六进程所以要十进程转换十六进程
    直接linux输入 printf "%x\n" Pid
    然后就可以根据十六进制的去找哪个线程cpu占用
    之后查看文件是cat xxx

设计模式

框架中的设计模式 + 项目中的设计模式

简单工厂模式

简单工厂包含如下角色

  • 抽象产品:定义了产品的规范,描述了产品的主要特性和功能。
  • 具体产品 :实现或者继承抽象产品的子类
  • 具体工厂:提供了创建产品的方法,调用者通过该方法来获取产品。

需求:设计一个咖啡店点餐系统。
设计一个咖啡类(Coffee),并定义其两个子类(美式咖啡【AmericanCofee】和拿铁咖啡【LatteCoffee】); 再设计一个咖啡店类(CoffeeStore),咖啡店具有点咖啡的功能。

工厂方法模式完全遵循开闭原则

方法模式的主要角色:
抽象工厂(Abstract Factory):提供了创建产品的接口,调用者通过它访问具体工厂的工厂方法来创建产品。
具体工厂(ConcreteFactory):主要是实现抽象工厂中的抽象方法,完成具体产品的创建。
抽象产品(Product):定义了产品的规范,描述了产品的主要特性和功能。
具体产品(ConcreteProduct):实现了抽象产品角色所定义的接口,由具体工厂来创建,它同具体工厂之间一 一对应。

抽象工厂模式

工厂方法模式只考虑生产同等级的产品,抽象工厂可以处理等级产品的生产
抽象工厂模式是工厂方法模式的升级版本,工厂方法模式只生产–个等级的产品,而抽象工厂模式可生产多个等级的产品。一个超级工厂创建其他工厂。该超级工厂又称为其他工厂的工厂

策略模式

  • 该模式定义了一系列算法,并将每个算法封装起来,使它们可以相互替换,且算法的变化不会影响使用算法的客户
  • 它通过对算法进行封装,把使用算法的责任和算法的实现分割开来,并委派给不同的对象对这些算法进行管理

策略模式的主要角色如下:
抽象策略(Strategy)类:这是一个抽象角色,通常由一个接口或抽象类实现。此角色给出所有的具体策略类所需的接口
具体策略(Concrete Strategy)类:实现了抽象策略定义的接口,提供具体的算法实现或行为。
环境(Context)类:持有一个策略类的引用,最终给客户端调用。

策略模式—登录案例 (工厂模式 + 策略模式)

  • 什么是策略模式

    • 策略模式定义了一系列算法,并将每个算法封装起来,使它们可以相互替换,且算法的变化不会影响使用算法的客户

    • 一个系统需要动态地在几种算法中选择一种时,可将每个算法封装到策略类中

  • 案例(工厂方法+策略)

    • 介绍业务(登录、支付、解析excel、优惠等级…)
    • 提供了很多种策略,都让spring容器管理
    • 提供一个工厂:准备策略对象,根据参数提供对象

一句话总结:只要代码中有冗长的if-else 或switch 分支判断都可以采用策略模式优化

举一反三

  • 订单的支付策略(支付宝、微信、银行卡..)
  • 解析不同类型excel(xls格式、xlsx格式)
  • 打折促销(满300元9折、满500元8折、满1000元7折..)
  • 物流运费阶梯计算(5kg以下、5-10kg、10-20kg、20kg以上)

策略模式和工厂方法模拟.png

责任链模式—概述及案例

责任链模式:为了避免请求发送者与多个请求处理者耦合在一起,将所有请求的处理者通过前一对象记住其下一个对象的引用而连成一条链;当有请求发生时,可将请求沿着这条链传递,直到有对象处理它为止。

  • 抽象处理者(Handler)角色:定义一个处理请求的接口,包含抽象处理方法和一个后继连接。
  • 具体处理者(Concreate Handler)角色:实现抽象处理者的处理方法,判断能否处理本次请求,如果可以处理请求则处理,否则将该请求转给它的后继者。
  • 客户类(Cient)角色:创建处理链,并向链头的具体处理者对象提交请求,它不关心处理细节和请求的传递过程。

举一反三

  • 内容审核(视频、文章、课程)
  • 订单创建
  • 简易流程审批

区分责任链模式策略模式 在if的情况下

✅ 一句话区分

模式 一句话理解
策略模式 多种方案中选一个执行(if 就是“选择谁”)
责任链模式 多个处理器依次尝试处理(if 是“要不要接着传下去”)

👀 从 if 角度看

模式 if 的含义 结果
策略模式 选择哪个处理逻辑(只执行一个 一旦匹配执行完毕,不再走其他
责任链模式 是否要处理或继续传递(可能多个都执行 可以处理多个,也可以提前终止

🎯 场景类比(超级形象)

📦 策略模式:

像点菜:你从多个菜中选择一个最合适的吃
🔁 选择一个策略就完事

if (type == "微信") {
   payWithWeChat();
} else if (type == "支付宝") {
   payWithAliPay();
}
  • 策略模式核心:同一个接口,不同实现类,运行时选择哪个

🛠️ 责任链模式:

像审批流程:你写了个请假单,先经理签字 → 主管签字 → HR签字
🔁 每个人看一下自己要不要处理,处理完能不能往下传

if (金额 <= 1000) {
   主管处理
   return
}
if (金额 <= 5000) {
   经理处理
   return
}
HR处理
  • 责任链核心:链式传递,一个接一个处理(可中断)

✅ 示例代码对比

🧪 策略模式(支付选择)

public interface PayStrategy {
    void pay();
}

public class WeChatPay implements PayStrategy {
    public void pay() { System.out.println("微信支付"); }
}

public class AliPay implements PayStrategy {
    public void pay() { System.out.println("支付宝支付"); }
}

// 使用
PayStrategy strategy;
if ("wechat".equals(type)) {
    strategy = new WeChatPay();
} else {
    strategy = new AliPay();
}
strategy.pay();

✔️ 选择一个策略类并执行,if 只是为了选哪一个


🧪 责任链模式(审批流程)

public abstract class Approver {
    protected Approver next;

    public void setNext(Approver next) {
        this.next = next;
    }

    public abstract void process(int amount);
}

public class Manager extends Approver {
    public void process(int amount) {
        if (amount <= 1000) {
            System.out.println("Manager approved");
        } else if (next != null) {
            next.process(amount);
        }
    }
}

public class Director extends Approver {
    public void process(int amount) {
        if (amount <= 5000) {
            System.out.println("Director approved");
        } else if (next != null) {
            next.process(amount);
        }
    }
}

// 链式构建
Manager m = new Manager();
Director d = new Director();
m.setNext(d);

// 发起请求
m.process(3000);

✔️ 多个处理器按顺序尝试,if 是为了决定自己是否处理,并可能往下传递

if 语句长得一样,但目的不同:

  • 策略 if 是“选哪一个”
  • 责任链 if 是“要不要传下去”

常见技术场景题

单点登录这块怎么实现的?

单点登录的英文名:Single Sign On (SSO),只需要登录一次,就可以访问所有信任的应用系统

① 先解释什么是单点登录:单点登录的英文名叫做:Single SignOn(简称SSO)
② 介绍自己项目中涉及到的单点登录(即使没涉及过,也可以说实现的思路)
③ 介绍单点登录的解决方案,以JWT为例
用户访问其他系统,会在网关判断token是否有效
如果token无效则会返回401(认证失败)前端跳转到登录页面
用户发送登录请求,返回浏览器一个token,浏览器把token保存到cookie
再去访问其他服务的时候,都需要携带token,由网关统一验证后路由到目标服务

权限认证是如何实现的?

后台的管理系统,更注重权限控制,最常见的就是RBAC模型来指导实现权限
RBAC(Role-Based Access Control)基于角色的访问控制

  • 3个基础部分组成:用户、角色、权限
  • 具体实现:
    • 5张表:用户表、角色表、权限表、用户角色中间表、角色权限中间表
    • 7张表:用户表、角色表、权限表、菜单表、用户角色中间表、角色权限中间表、权限菜单中间表

张三具有什么权限呢?
流程:张三登录系统 → 查询张三拥有的角色列表 → 再根据角色查询拥有的权限

权限框架:Apache shiroSpring Security(推荐)

上传数据的安全性你们怎么控制?

主要说的是数据在网络上传输如何保证安全

使用**非对称加密(或对称加密)**,给前端一个公钥让他把数据加密后传到后台,后台负责解密后处理数据

对称加密

文件加密和解密使用相同的密钥,即加密密钥也可以用作解密密钥

你负责项目的时候遇到了哪些比较棘手的问题?怎么解决的?1+3

其次你也可以说说aop的实现,比如你们操作日志记录等,利用aop切面思想,通过环绕通知等但需封装出出个切面工具类。建议你们说说sql调优,比如商品列表页需要分页查询,但是几百万商品导致查询慢,如何优化的,这是一个

① 设计模式在项目中的应用

是为了遵循一系列的开发原则【工厂、策略、责任链】

  • 什么背景[技术问题] → 登录的例子
  • 过程[解决问题的过程]
  • 最终落地方案
② 线上BUGJVM+多线程
  • CPU飙高
  • 内存泄露
  • 线程死锁
③ 调优
  • 慢接口
  • 慢SQL
  • 缓存方案

④ 组件封装

  • 分布式锁
  • 接口幂等
  • 分布式事务
  • 支付通用
你们项目中日志怎么采集的?

我们搭建了ELK日志采集系统
介绍**ELK**的三个组件:
Elasticsearch是全文搜索分析引擎,可以对数据存储、搜索、分析
Logstash是一个数据收集引擎,可以动态收集数据,可以对数据进行过滤、分析,将数据存储到指定的位置
Kibana是一个数据分析和可视化平台,配合Elasticsearch对数据进行搜索,分析,图表化展示

  • 为什么要采集日志

日志是定位系统问题的重要手段,可以根据日志信息快速定位系统中的问题

  • 采集日志的方式有哪些
    • ELK:即ElasticSearch、LogStash、Kibanna三个软件的首字母
    • 常规采集:按天保存到一个日志文件

查看日志的命令?查看是否在线查看过日志
  • 实时监控日志的变化
    实时监控某一个日志文件的变化:tail -f xx.log
    实时监控日志文件最后100行的变化:tail -n 100 -f xx.log

  • 按照行号查询
    查询日志尾部最后100行日志:tail -n 100 xx.log
    查询日志头部开始100行日志:head -n 100 xx.log
    查询某一个日志行号区间:cat -n xx.log | tail -n +100 | head -n 100(查询100行至200行的日志)

  • 按照关键字找日志的信息
    查询日志文件中包含debug的日志行号:cat -n xx.log | grep "debug"

  • 按照日期查询日期必须在日志中出现过

    sed -n '/2025-01-14 14:22:31.070/,/ 2025-01-14 14:27:18.158/p' xx.log

  • 日志太多,处理方式

    • 分页查询日志信息:cat -n xx.log | grep "debug" | more
    • 筛选过滤后,输出到一个文件:cat -n xx.log | grep "debug" > debug.txt

上线的项目远程Debug —— 生产问题怎么排查?本地调试远程代码

已经上线的bug排查的思路:

  • 先分析日志,通常在业务中都会有日志的记录,或者查看系统日志,或者查看日志文件,然后定位问题
  • 远程debug(通常公司的正式环境(生产环境)是不允许远程debug的。一般远程debug都是公司的测试环
    境,方便调试代码)

远程debug

前提条件:远程的代码和本地的代码要保持一致

远程代码需要配置启动参数,把项目打包放到服务器后启动项目的参数:

java -jar -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 project-1.0-SNAPSHOT.jar

idea中设置远程debug,找到idea中的Edit Configurations... → 添加一个Remote JVM debug → 右侧要配置Configuration的Host → 添加上面的代码…

在项目中点debug(绿色小虫子)

访问远程服务器,在本地代码中打断点即可调试远程

怎么快速定位系统的瓶颈?

  • 压测(性能测试),项目上线之前测评系统的压力

    • 压测目的:给出系统当前的性能状况;定位系统性能瓶颈或潜在性能瓶颈
    • 指标:响应时间、QPS、并发数、吞吐量、CPU利用率、内存使用率、磁盘IO、错误率
    • 压测工具:LoadRunner、Apache Jmeter …
    • 后端工程师:根据压测的结果进行解决或调优(接口、代码报错、并发达不到要求.)
  • 监控工具、链路追踪工具,项目上线之后监控

    • 监控工具:Prometheus+Grafana
    • 链路追踪工具:skywalking、Zipkin
  • 线上诊断工具Arthas(阿尔萨斯),项目上线之后监控、排查

    • 官网:https://arthas.aliyun.com/

    • 核心功能:Arthas 是 Alibaba 开源的 Java 诊断工具,深受开发者喜爱。
      当你遇到以下类似问题而束手无策时,Arthas 可以帮助你解决:

      • 这个类从哪个jar 包加载的?为什么会报各种类相关的 Exception?

      • 我改的代码为什么没有执行到?难道是我没 commit?分支搞错了?

      • 遇到问题无法在线上 debug,难道只能通过加日志再重新发布吗?

      • 线上遇到某个用户的数据处理有问题,但线上同样无法 debug,线下无法重现!

      • 是否有一个全局视角来查看系统的运行状况?

      • 有什么办法可以监控到 JVM 的实时运行状态?

      • 怎么快速定位应用的热点,生成火焰图?

      • 怎样直接从 JVM 内查找某个类的实例?

怎么解决cpu飙高?

使用top命令查看占用cpu的情况
通过top命令查看后,可以查看是哪一个进程占用cpu较高
使用ps命令查看进程中的线程信息 使用top -H -p 进程Id [找线程哪个使用多]
记住要打印%X十六进制
使用jstack命令查看进程中哪些线程出现了问题,最终定位问题
jstack 进程PID | grep 16进制线程PID -A 20

  • 使用top命令查看占用cpu的情况哪个进程占用的cpu最高

    finalShell中输入 top

  • 查看进程中的线程信息 ps H -eo pid,tid,%cpu | gerp pid

  • jstack 查看进程内线程的堆栈信息产生死锁可以查看

    因为是十六进程所以要十进程转换十六进程
    直接linux输入 printf "%x\n" Pid
    然后就可以根据十六进制的去找哪个线程cpu占用
    之后查看文件是cat xxx

2025/1/14 20:35 地点广州 完结撒花

项目难点?四方保险——day11-数据中心:时序数据库、看板展示【实战】

技术上的难点:时序数据库看板展示

12306技术难点

这份是12306 真实 / 同源技术栈 + 你能直接学、直接用的替代方案,按「从前端到库存核心」整理好,照着学就能吃透高并发。

12306 核心技术栈(官方 + 业界对标)

1. 接入层 / 网关 / 限流

  • 核心作用:抗流量、防刷、路由
  • 12306 在用:
    • Nginx + 自研网关
    • 自研限流、排队、防刷系统
  • 你可学 / 可用:
    • Nginx、OpenResty
    • Spring Cloud Gateway / Zuul
    • Sentinel、Hystrix(限流、熔断、降级)

2. 高并发读:多级缓存(最关键)

  • 12306 70%+ 流量都是查票,全靠缓存顶住
  • 技术栈:
    • 本地缓存:Caffeine、Guava Cache
    • 分布式缓存Redis 集群(主从 + 哨兵)
    • 缓存策略:
      • 余票查询:缓存 1~5 秒
      • 读多写少:先缓存后 DB

3. 消息队列:削峰填谷

  • 作用:下单、支付、退票、通知异步化
  • 12306 类方案:
    • Kafka(高吞吐)
    • RocketMQ(金融级可靠,阿里系)
  • 你必学:
    • 异步削峰
    • 最终一致性
    • 幂等性(防重复下单)

4. 微服务 & 业务层

  • 拆分:用户、票务、订单、支付、风控
  • 技术:
    • Java(12306 主体语言)
    • Spring Boot / Spring Cloud
    • Dubbo(国内高并发主流)
  • 思想:服务隔离、独立扩容、故障不扩散
  1. 数据库 & 分库分表

  • 读多写少、并发极高
  • 技术栈:
    • MySQL / Oracle(12306 核心用 Oracle)
    • 读写分离
    • 分库分表:ShardingSphere、MyCat
  • 关键点:
    • 订单按用户 ID / 日期分片
    • 库存绝对不能随便分片

6. 世界级难点:席位库存 & 内存计算

12306 最牛、最难、最值得学的部分。

  • 核心问题:一段座位可以卖多段行程(北京→济南→上海)
  • 12306 方案:
    • 内存分布式数据库:GemFire / 国产类产品
    • 位图(BitMap) 表示座位
    • 位运算判断余票(毫秒级)
  • 你能学的替代:
    • Redis Bitmap
    • 自定义内存网格
    • 席位分段算法(核心业务模型)

7. 风控 & 反黄牛(12306 第二硬核)

  • 设备指纹、IP 画像、行为风控
  • 技术:
    • Lua + Nginx 拦截
    • 机器学习异常识别
  • 你可学:
    • 滑动窗口限流
    • 接口防刷
    • 黑名单、频控、人机验证

8. 云原生 & 高可用

  • Docker + Kubernetes
  • 两地三中心、异地多活
  • 监控:Prometheus + Grafana(你刚记住那个)

你按这个路线学,就能吃透 12306 级高并发
  1. 先学:Redis + 缓存设计
  2. 再学:消息队列削峰
  3. 再学:分库分表 + 读写分离
  4. 攻坚:位图、库存算法、高一致性
  5. 最后:网关、限流、风控、监控

秒杀系统如何优化?

痛点描述:
  • 瞬时并发量大
    • 大量用户会在同一时间进行抢购
    • 网站瞬时访问流量激增
  • 库存少
    • 访问请求数量远远大于库存数量
    • 只有少部分用户能够秒杀成功

Ⅰ. 访问层 — 商品页

  • 可以将静态秒杀页面放在cdn上[用户访问速度↑ 减轻服务器压力++]

Ⅱ. 访问层 — 秒杀按钮

  • 活动前禁用按钮、点击后禁用按钮、滑动验证码[防羊毛党]、排队体验[提升用户体验]

Ⅲ. 中间转换层 — 多级负载均衡 & 限流 & 自动伸缩

  • 通常会通过Nginx来进行负载均衡【单台Ng处理的并发量是两三万左右】
  • 在它上层要做到硬件级别的隔离器 【F5/LVS】
  • 通过Ng负载均衡到网关之后 通过客户端的负载均衡器Ribbon
  • 4级的负载均衡 可以处理每秒上10W以上的QPS并发量
  • 通过DockerK8S来进行云服务器的动态伸缩的部署[秒杀开始自动扩容 秒杀结束自动缩减]
  • 注意要在Ng上做好限流 防止一些绕过了我们前端的DDOS攻击 还需要在网关层通过Sentinel对不同的服务节点去设置限流以及熔断的机制
  • 可以在秒杀中通过MQ做削锋填股 通过MQ可以减轻下游的压力 防止激增流量打垮下游数据库

Ⅳ. 服务端 — 用Redis做缓存减轻数据库压力

  • 秒杀商品信息预热到Redis中 防止Redis被击穿我们的数据库
  • 通过Redis的Lua脚本[保证多个操作的原子性]操作库存
  • 防重 可以通过 redis的SETNX → 用 Token + 商品URL // IP + 商品URL 只能有一个有效
  • 分布式锁保证请求的原子性 → Redisson的分布式锁

**Ⅴ. 数据库 — 读写分离 **

  • 数据量很大就分库分表

✅ 一张图理清:秒杀系统全链路优化流程

[用户点击秒杀按钮]
     ↓
【前端防刷】
      - 限制频繁点击
      - 滑动验证码
      - 倒计时、按钮控制
        ↓
   【网关 & Nginx】
      - 黑名单拦截(IP、UA)
      - Sentinel 限流 + 降级 + 熔断
        ↓
   【秒杀服务】
      - 判断秒杀状态、时间、库存是否存在
      - 生成秒杀Token(防重)
      - Redis 原子性扣减库存(Lua脚本)
      - 发送下单消息至 MQ 异步处理
        ↓
   【MQ异步削峰】
      - 持久化队列(RocketMQ / Kafka)
      - 消费者异步落库
        ↓
   【数据库层】
      - MySQL最终扣减库存 + 创建订单(事务)
      - 数据库读写分离 / 分库分表

Sentiel

Sentinel = 阿里开源的”流量防卫兵”,像小区门口的保安,控制多少人能同时进。

核心作用

// 没有限流时:
10000人同时抢10个商品 → 服务器瞬间被打爆 → 所有人都抢不到

// 有限流时:
Sentinel:每秒只放行1000个请求
        多余的请求直接返回"人多拥挤,请稍后"
        服务器稳稳的,1000个人公平抢10个商品

三种核心能力

1. 限流(控制流量)

// 规则:/seckill 接口每秒最多1000个请求
if 当前QPS > 1000 {
    return "当前人数过多,请重试"
}

2. 降级(有损服务)

// 监控接口响应时间
if 接口P95 > 500ms {  // 95%的请求变慢了
    // 自动降级:返回缓存数据或友好提示
    return "活动太火爆,稍后再试"
}

3. 熔断(自动断电)

// 像保险丝:发现后端数据库出问题了
if 错误率 > 50% {
    // 熔断器打开,10秒内不再请求
    return "服务维护中"
    // 10秒后尝试恢复
}

秒杀场景示例

# Sentinel配置
限流规则:
  - 资源名: /seckill/buy
    阈值: 1000   # 每秒最多1000请求
    限流效果: 直接拒绝

熔断规则:
  - 资源名: /seckill/buy  
    阈值: 20%    # 错误率超过20%熔断
    熔断时长: 10秒

降级规则:
  - 资源名: /seckill/query
    阈值: 500ms  # 响应时间超过500ms降级

一句话:Sentinel就是给系统装了个”智能限流阀”,防止秒杀时系统被冲垮。

🧩 各模块详细优化方案

① 前端层(第一道防线)

  • 活动页静态化:部署在 CDN,秒开页面,减少服务器并发压力。

    CDN域名是指通过内容分发网络(CDN)技术加速访问的域名。CDN的全称是Content Delivery Network,即内容分发网络。它通过将源站内容分发到分布在全球各地的加速节点,使用户可以从离自己最近的节点获取内容,从而提升访问速度和体验。【可以提前把一些静态资源 图片视频等放入,但不可以放接口那种】

    CDN域名的工作原理是将用户的访问请求通过DNS解析,指向最优的CDN节点。如果节点上已有缓存内容,则直接返回给用户;如果没有缓存,则从源站拉取内容并缓存到节点,供后续用户访问。

  • JS 控制按钮状态:倒计时期间按钮禁用;点击后立即禁用防止重复提交。

  • 防刷机制

    • 滑动验证码(极验、腾讯滑动等)
    • 限制频繁请求(客户端节流 + 后端拦截)
    • 秒杀路径动态化(通过接口获取临时随机URL)

② 网关层(第二道防线)

  • Nginx限流 + F5/LVS 硬件负载均衡
  • Sentinel限流
    • QPS限流、线程数限制
    • 降级策略(服务不稳定时快速失败)
  • 灰度发布 + 金丝雀策略防雪崩

③ 服务层(核心逻辑)

  • Redis预热商品库存
    • key: seckill:stock:123 => 10
  • Lua脚本保证扣减原子性
if redis.call("get", KEYS[1]) >= tonumber(ARGV[1]) then
   return redis.call("decrby", KEYS[1], ARGV[1])
else
   return -1
end
  • Token校验防重(防止同一用户多次提交)
    • 用户下发秒杀Token
    • 下单时校验 token 是否存在
  • 幂等性处理:幂等令牌、Redis标记等手段防止重复下单
  • Redisson分布式锁(用于控制某些全局状态,如每秒限量)

④ MQ 消息队列层(削峰填谷)

  • ✅ 典型架构:RocketMQ / RabbitMQ / Kafka
  • ✅ 一进一出,异步下单逻辑
    • 消息格式:包含userId, productId, token
  • ✅ 消息失败怎么办?
    • 死信队列 + 重试机制 + 日志报警

⑤ 数据库层(最终一致性)

  • 分库分表
    • 订单表按用户ID或时间范围分表
    • 库存表按商品类型分表
  • 读写分离
    • MySQL主从复制
    • 下单写入主库,查询走从库
  • 事务处理
    • 扣库存 + 创建订单需要事务包裹
  • 补偿机制
    • MQ失败回滚机制 + 自动重试 or 人工介入

🎯 秒杀系统关键点总结(重点记忆)

优化维度 关键点
防刷防作弊 滑动验证码、动态路径、限流、IP黑名单
限流削峰 Sentinel、MQ异步下单、排队
高性能扣减 Redis + Lua 脚本,原子扣减库存
数据一致性 MQ消息可靠投递、事务补偿机制
分布式扩展 分库分表、读写分离、动态扩容
安全性 Token校验、防重、防止超卖

我将为你详细讲解和设计一个真实的秒杀系统完整优化方案,从 Redis 预热、限流、库存扣减、异步下单、订单状态回写等关键步骤一一展开说明,并配上示意代码。


🔧 一、整体秒杀流程概览图

用户请求 → 接入层限流 → Redis预扣库存(Lua脚本) → 发送MQ消息 → 异步下单 → 数据库落库 → 回写订单状态

🧱 二、Redis缓存预热(秒杀前的准备工作)

提前将商品库存加载到 Redis,避免高并发时频繁访问数据库。

// Redis结构设计
// key: seckill:stock:<skuId>
// val: 商品库存数量

public void preloadSeckillStock(Long skuId, Integer stock) {
    String key = "seckill:stock:" + skuId;
    redisTemplate.opsForValue().set(key, stock);
}

🛡 三、限流 + 防刷 + 验签(接入层)

使用网关 Sentinel 限流,前端限制点击频率,后端防刷接口做风控。

@GetMapping("/doSeckill")
public ResponseEntity<?> doSeckill(@RequestParam Long skuId) {
    // 判断是否登录
    Long userId = getLoginUserId();

    // 判断是否重复请求(防重)
    String repeatKey = "seckill:user:" + userId + ":sku:" + skuId;
    Boolean hasBought = redisTemplate.opsForValue().setIfAbsent(repeatKey, "1", 5, TimeUnit.MINUTES);
    if (!hasBought) return ResponseEntity.status(429).body("请勿重复抢购");

    // 执行扣库存的 Lua 脚本
    Long result = redisTemplate.execute(luaScript, Collections.singletonList("seckill:stock:" + skuId), "1");
    if (result == 0L) return ResponseEntity.status(410).body("库存不足");

    // 发送消息至 MQ 进行异步处理
    SeckillMessage msg = new SeckillMessage(userId, skuId);
    mqTemplate.convertAndSend("seckill.queue", msg);

    return ResponseEntity.ok("下单请求已提交");
}

📜 四、Lua脚本操作 Redis(保证原子性)

-- KEYS[1]: 库存key
-- ARGV[1]: 扣减数量
local stock = redis.call("get", KEYS[1])
if tonumber(stock) >= tonumber(ARGV[1]) then
    return redis.call("decrby", KEYS[1], ARGV[1])
end
return 0

📦 五、MQ异步消费 + 创建订单(核心业务)

用 RabbitMQ、RocketMQ、Kafka 等异步落库下单,减轻主线程压力。

@RabbitListener(queues = "seckill.queue")
public void handleSeckill(SeckillMessage msg) {
    Long userId = msg.getUserId();
    Long skuId = msg.getSkuId();

    // 检查数据库是否已下单(防止重复下单)
    boolean exists = orderMapper.existsByUserAndSku(userId, skuId);
    if (exists) return;

    // 创建订单
    Order order = new Order();
    order.setUserId(userId);
    order.setSkuId(skuId);
    order.setStatus("WAIT_PAY");
    orderMapper.insert(order);
}

🧨 六、下单后定时关闭未支付订单(延迟消息)

利用 RabbitMQ 的延迟队列(或用 Redis 的 ZSet+定时任务轮询)。

// 下单后发送延迟消息
rabbitTemplate.convertAndSend("order.ttl.exchange", "order.ttl", orderId);

// 死信队列处理超时未支付订单
@RabbitListener(queues = "order.dlx.queue")
public void closeOrder(String orderId) {
    Order order = orderMapper.selectById(orderId);
    if ("WAIT_PAY".equals(order.getStatus())) {
        order.setStatus("CLOSED");
        orderMapper.updateById(order);

        // 回滚库存
        redisTemplate.opsForValue().increment("seckill:stock:" + order.getSkuId());
    }
}

📊 七、数据一致性考虑

场景 解决方案
Redis库存扣减成功但消息发送失败 使用本地事务 + MQ事务消息机制
订单超时未支付但库存没回滚 MQ死信队列 + 回滚库存
秒杀重复下单 Redis防重 + DB唯一约束

✅ 八、技术选型建议

模块 技术
缓存 Redis
消息队列 RabbitMQ/RocketMQ
限流防刷 Sentinel、滑动窗口
延迟任务 MQ延迟队列、定时任务轮询
Redisson 分布式锁
脚本原子性 Lua脚本


你说的很好 我想更进一步了解一下 你说的 Nginx限流+F5/LVS硬件负载均衡;网关&nginx黑名单拦截(IP、UA);生成秒杀Token;灰度发布+金丝雀策略

✅ 一、Nginx 限流 + F5/LVS 硬件负载均衡

1. Nginx 限流

✨目的:

  • 限制单位时间的请求数,防止恶意刷接口、瞬间高并发导致服务崩溃。

✨配置方式:

http {
    limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=1r/s;
    ...
    server {
        location /seckill {
            limit_req zone=req_limit_per_ip burst=5 nodelay;
            proxy_pass http://seckill-server;
        }
    }
}

参数解释:

  • rate=1r/s:每秒允许一个请求。
  • burst=5:允许瞬间突发5个请求。
  • nodelay:立即处理突发请求,不排队。

✅应用场景:

  • 秒杀接口、登录接口、验证码接口等敏感接口的访问频控。

2. F5 / LVS 硬件负载均衡

✨作用:

  • F5/LVS 作为物理网络设备层面的高性能负载均衡系统,位于企业最外层(防火墙之后,Nginx之前),进行四层(TCP/UDP)转发,性能远高于 Nginx。

✅典型架构:

               用户请求
                 ↓
       ┌────────────────┐
       │  F5 / LVS  (L4)│
       └────────────────┘
                 ↓
       ┌────────────────┐
       │   Nginx (L7)    │
       └────────────────┘
                 ↓
            应用服务层

✅优势:

  • 性能高、吞吐量大(每秒几十万 QPS)。
  • 更稳定,更适合企业级大并发业务。
  • 通常和 Nginx 搭配使用,分别处理 L4 / L7 流量调度。

✅ 二、网关 & Nginx 黑名单拦截(IP/UA)

✅ 目标:

防止恶意攻击、秒杀脚本、爬虫工具参与秒杀。

1. 拦截策略

  • IP 黑白名单:将恶意访问源加入黑名单。
  • UA 校验:不允许 curl、httpclient、python 等工具模拟请求。
  • 访问频率统计:单位时间内访问频率超过阈值即封禁。

2. 网关层实现(如 SpringCloud Gateway)

// 伪代码:过滤器中处理
if (blackList.contains(ip) || suspiciousUA(ua)) {
    return 403 Forbidden;
}

✅ 三、生成秒杀Token(防黄牛、刷请求)

✅ 核心思想:

用户先请求一个随机 Token,再带着这个 Token 才能参与秒杀,且只能用一次

✅ 实现流程:

🔁 第一步:获取秒杀Token

GET /seckill/token?productId=123
Header: Authorization: Bearer 用户登录Token

返回:

{ "token": "a8729f03-6a22-4cdb-8ad4-e19932cf" }

Redis 存储 token → seckill:token:uid:productId -> token

🏃 第二步:用户秒杀请求带上Token

POST /seckill/submit
Body: { productId: 123, token: "a8729f03-6a22-4cdb-8ad4-e19932cf" }

✅ 服务端校验:

  • token是否存在;
  • token是否属于当前用户;
  • 校验通过后删除 token(一次性);
  • 进入库存扣减逻辑。

✅ 四、灰度发布 + 金丝雀策略(保障系统稳定性)

✅ 区别:

策略 说明
灰度发布 指在小范围(如某些用户/机器)上逐步发布新功能
金丝雀发布 类似灰度,但更强调监控与回滚能力

✅ 示例:

1. 灰度规则:

  • 仅对北京地区、特定用户ID段、Android设备开放新功能;
  • 灰度用户看到的是新接口,其他人访问旧接口。

2. 实现方式:

  • 在网关中增加灰度判断逻辑,如基于用户ID、设备类型、地理位置等维度;
  • 配合 Nacos / Apollo 实现灰度配置动态下发。
if (grayUser(userId)) {
    routeTo(newServiceV2);
} else {
    routeTo(oldServiceV1);
}

3. 金丝雀监控:

  • 接入 Prometheus + Grafana
  • 实时监控新版本的接口成功率、响应时间、异常率;
  • 若指标异常,立即自动或手动回滚。

🧠 总结:秒杀防护层层递进

层级 技术 / 策略 目的
网络层 F5/LVS、Nginx限流 硬抗高并发
网关层 黑名单拦截、UA过滤、灰度/金丝雀 防刷 + 风控
应用层 秒杀Token、验证码、人机校验 精准控制用户行为
缓存层 Redis库存控制、Lua原子操作、布隆过滤器 高性能、数据一致
后端层 MQ异步削峰、数据库分库分表 解耦 + 弹性架构

订单超时自动取消是怎么实现的?

① JDK自带的延时队列

优点:简单,不需要借助其他第三方组件,成本低。
缺点:所有超时处理订单都要加入到DelayQueue中,占用内存大,没办法做到分布式处理,之恶能在集群中挑选一台leader专门处理,效率低
不适合订单量比较大的

② 基于RocketMQ的定时消息 — 延时消息

优点:使用简单,和使用普通消息一样,支持分布式。精度高,支持任意时刻

缺点使用限制:定时时长最大值24小时。
成本高:每个订单需要新增一个定时消息,且不会马上消费,给MQ带来很大的存储成本。
同一个时刻大量消息会导致消息延迟:定时消息的实现逻辑需要先经过定时存储等待触发,定时时间到达后才会被投递给消费者。因此,如果将大量定时消息的定时时间设置为同一时刻,则到达该时刻后会有大量消息同时需要被处理,会造成系统压力过大,导致消息分发延迟,影响定时精度。

③ 基于Redis的过期监听

设置过期时间:24小时内没有支付就会自动取消
缺点:(也是所有中间件的缺点)

  • 不可靠 Redis在过期通知的时候,如果应用正好重启了,那么就有可能通知事件就丢了,会导致订单一直无法关闭,有稳定性问题。如果一定要使用Redis过期监听方案,建议再通过定时任务做补偿机制。
  • 如果订单量大需要占用中间件大量的存储空间,需要额外维护成本。
④ 定时任务分布式处理【要按照成本思维的思考方式】

通过定时任务(任务调度)的批量处理 → 一次性把所有超时的订单全部捞出来 处理完再全部执行更新
如果使用中间件都要单独存储那些数据,如果存储压力大就要涉及到集群

如果对于超时精度比较高,超时时间在24小时内,且不会有峰值压力的场景下,推荐使用RocketMQ的定时消息解决方案
在电商业务下,许多订单超时场景都在24小时以上,对于超时精度没那么敏感,并且有海量订单需要批处理,推荐使用基于定时任务的跑批解决方案。

✅ 最佳实践对比表

方案 分布式支持 精度 可靠性 适用场景 优缺点总结
DelayQueue ❌ 否 秒级 ❌ 低 单体项目/小订单量 简单、无需中间件,但不支持分布式和高可用
RocketMQ延时消息 ✅ 是 秒级 ✅ 高 秒杀、限时抢购、延迟关闭等 精度高、支持分布式,但时长限制 & 消息堆积
Redis过期监听 ✅ 是 秒级 ❌ 较低 轻量业务、有兜底机制的场景 实时、方便,但事件容易丢失,不适合重要任务
定时任务跑批 ✅ 是 分钟级 ✅ 高 电商订单系统、大量订单处理 稳定、灵活、适合大业务,容忍分钟级延迟

🔄 常见混合策略推荐

  • 秒杀业务 / 限时订单:RocketMQ 延时消息为主 + 补偿机制(定时任务兜底)
  • 电商平台:定时任务跑批为主 + MQ 异步通知用户(取消成功推送)
  • 轻量小应用:Redis 过期监听 + 手动补偿兜底
  • 单体项目或demo:DelayQueue 简单可用

🧠 思考:为什么不用 cron 来做?

cron 固定执行时间点,而订单创建是动态的,无法精确知道每个订单的30分钟是哪一刻。

举例:

  • cron表达式只能写 每隔5分钟扫描每天0点执行
  • 订单创建时间是不确定的 → 用cron不能实时取消30分钟后的每个订单

所以,动态调度任务(MQ/DelayQueue)或带参数处理(定时扫描数据库)更合适。

如何防止重复下单?

方案一:提交订单按钮置灰 [防止用户无意点击多次]
方案二:后端采用redis的setnx 来保证它的唯一幂等性

同一个用户、同一个请求,在极短时间内只能成功创建 1 次订单。
利用 Redis SETNXSET if Not Exists)的原子性,同一时间只有一个请求能加锁成功,其他请求直接拒绝。

用户点击提交 ↓ 生成唯一锁key ↓ Redis SETNX 尝试加锁 ↓ 成功?→ 是 → 创建订单 ↓ 订单完成 → 释放锁 失败?→ 直接返回:请勿重复提交

setnx:当我们调用setnx来去保存一个key和value的时候,如果这个value没有值的话,那么就会返回true保存成功;如果有值就会返回false → 保证多次存储只能存储一个值

业务幂等号(如唯一Token机制)

  • 用户点击下单前,后端下发一个token(存 Redis),用户下单时带上这个 token。
  • 后端验证 token 是否存在,使用后即删除。
// 伪代码
if (redisToken == null || !redisToken.equals(requestToken)) {
    throw new RuntimeException("重复请求或非法请求");
}
redisTemplate.delete(redisToken);

✅ 优点:

  • 通用幂等机制,不局限订单;
  • 可防止表单重复提交、支付回调重复通知等场景;

✅ 使用场景:

  • 秒杀下单、提交表单、支付回调。

🔐 方案四:消息队列去重(异步场景)

  • 使用 RocketMQ 的幂等机制,确保同一消息只消费一次(消费端做去重处理),适用于下单流程是异步的情况。

三、多手段组合更安全

层级 技术手段 是否强制
前端 按钮置灰/节流
应用层 Redis + setnx 或 Token
数据层 唯一约束字段
异步处理 MQ消费幂等处理

📌 典型实践示意图:

  • 用户点击下单获取唯一Token/Redis锁请求成功后释放写入订单表时校验唯一性

💡 小贴士:

  • 并发高推荐:Redis方案(+Redisson分布式锁)
  • 最后兜底:数据库唯一索引
  • 支付类、接口幂等推荐:Token机制

怎么防止刷单?【人肉机刷单!!】

业务风控

提高羊毛门槛:实名认证、消费门槛、随机优惠
限制用户参与、中奖、奖励次数
根据用户的历史行为和忠诚度,提供不同层次的优惠,优待忠实用户
奖池(优惠券数量)限制上限

分布式集群架构下怎么保证并发安全?

✅ 一、为什么分布式架构下更容易出现并发问题?

在单体应用中,所有请求在同一个进程内处理,天然可以用synchronized等方式控制并发。

而在分布式集群架构中:

  • 请求会打到多个节点 → 本地锁失效
  • 数据可能分库分表 → 数据不在一个数据库
  • 多线程 + 多机器 + 多服务 → 并发成倍放大

所以需要一整套分布式并发安全解决方案。

✅ 二、并发安全常见场景

  • 秒杀/抢购:多个用户同时抢一件商品
  • 下单:防止重复下单、超卖
  • 支付:防止重复支付
  • 分布式调度:防止定时任务重复执行
  • 分布式ID生成:避免重复ID

✅ 三、总结:解决并发的“组合拳”

组件/策略 主要作用
Redis分布式锁 跨节点并发控制,控制共享资源
乐观锁(version) 控制数据库并发更新冲突
消息队列MQ 异步削峰,提高系统吞吐
唯一Token机制 防止重复提交
限流 & 黑名单机制 拦截恶意请求,保护系统
本地 + 分布式缓存 缓解数据库压力,提高响应速度
灰度发布 降低风险,平稳上线

一、先看背景:为什么要用 Redisson?

同步锁(比如 Java 的 synchronizedReentrantLock)是 JVM 级别 的,只能锁住同一个进程里的线程。

在分布式部署(多台服务器)场景下,不同服务器的线程之间完全感知不到彼此的锁,会出现并发安全问题(比如超卖、重复下单)。

所以需要 分布式锁 来跨机器、跨进程保证原子性,Redisson 就是基于 Redis 实现的成熟分布式锁框架。


二、Redisson 核心流程拆解

  1. 生成唯一锁 Key

    锁的 Key 由 UUID + 线程ID 组成,用来精确标识:谁(哪个线程 + 哪个客户端)持有这把锁,避免误删别人的锁。

  2. 尝试获取锁

    • 多个线程(甚至跨服务器)同时尝试获取同一把锁。
    • Redisson 会通过 hash 算法 选择一个 Redis 节点,执行 Lua 脚本 完成加锁操作(Lua 脚本保证原子性)。
    • 加锁成功:进入下一步;加锁失败:进入 while 循环,不断重试获取锁(自旋等待)。
  3. 看门狗(Watch Dog)自动续期

    • 加锁成功后,Redisson 会启动一个后台线程(看门狗),每隔 10 秒 检查锁是否还被当前线程持有。
    • 如果业务还没执行完,看门狗会自动延长锁的过期时间(默认 30 秒),防止锁提前过期导致并发问题。
    • 这解决了 “业务执行时间过长,锁过期释放后被其他线程抢到” 的经典问题。
  4. Redis 主从架构保证高可用

    • 锁数据会写入 Redis Master 节点,并同步到 Slave 节点,实现数据冗余。
    • 如果 Master 宕机,Slave 会自动提升为新 Master,保证锁服务不中断,实现 高可用 + 高性能
    • 若需要更强一致性,可使用 RedLock(RLock):要求所有 Slave 都存储完成后才返回成功,牺牲部分性能换取更高一致性。
  5. 释放锁

    • 业务执行完毕后,Redisson 会通过 Lua 脚本原子性地删除锁 Key。
    • 只有持有锁的线程(匹配 UUID + 线程 ID)才能释放锁,避免误删其他线程的锁。
    • 看门狗也会在锁释放后自动停止。

三、核心优势 ✨

  • 自动续期:看门狗机制避免锁过期导致的并发问题。
  • 原子操作:基于 Lua 脚本保证加锁 / 解锁的原子性。
  • 可重入:同一个线程可以多次获取同一把锁,不会自己阻塞自己。
  • 高可用:依托 Redis 主从 / 集群架构,锁服务不会单点故障。
  • 防误删:用 UUID+线程ID 做锁值,只有持有者才能释放锁。

四、和原生 setnx 的区别

特性 原生 Redis setnx Redisson 分布式锁
自动续期 无,需手动处理 看门狗自动续期
可重入 不支持 天然支持
主从一致性 依赖 Redis 主从同步 支持 RedLock 强一致模式
易用性 需自己写代码处理各种边界 封装完善,开箱即用

五、一句话总结

Redisson 是在 Redis 基础上封装的企业级分布式锁,通过 Lua 原子脚本 + 看门狗续期 + 主从高可用,解决了分布式环境下多线程 / 多服务的并发安全问题,比原生 setnx 更可靠、更易用。

让你设计一个扫码登录怎么实现?

生成二维码

请求登录页生成二维码,PC端请求后端生成一个二维码,此时在后端就会生成一个全局唯一的二维码ID,主要保存二维码的状态[二维码ID, NEW],状态设置到Redis设置过期时间,然后把当前的二维码ID返回给前端,然后生成二维码 【前后端都可以生成 → 返回Base64的编码给前端】此时的二维码就绑定了用户的ID让用户扫描。

扫码

PC端和后端会建立一个轮询的请求,不断的根据二维码ID去查询二维码状态,一旦状态改变页面也会改变。也可以通过长连接WebSocket获取状态 淘宝用的轮询、抖音用的长连接,此时就可以扫码。
扫码前保证手机是登录状态 没有登录肯定是不能扫码的,登录后进行扫码就会携带手机端的用户token以及二维码的ID在后端去校验请求Token,如果校验成功就代表手机可以登录,此时可以变更二维码状态为扫描。前端就可以根据这个把页面变为待确认状态

✅ 一、整体流程概述

目标: 用户在 PC 端扫码并登录系统,安全、高效、用户体验好。

参与者:

  • PC 浏览器(Web)
  • 手机 App(用户已登录)
  • 后端服务(Web + API)
  • Redis(状态存储)
  • 前端轮询/长连接

🧩 二、关键技术点拆解

1️⃣ 二维码生成(PC端发起)

  • 用户打开 PC 登录页面,请求后端接口 /api/qr/generate
  • 后端逻辑:
    • 生成唯一的二维码ID(一般用 UUID、Snowflake 或 Redis Incr)
    • 创建二维码状态:[qrCodeId: {status: NEW, userId: null}] 存入 Redis,设置过期时间(如:3分钟)
    • qrCodeId 返回给前端(前端将其转成二维码图像)

二维码状态定义:

状态值 含义
NEW 二维码已生成,待扫码
SCANNED 手机已扫码,待确认
CONFIRMED 用户已确认登录
EXPIRED 二维码过期

2️⃣ 轮询 or WebSocket 监听状态(PC端)

  • 前端定时调用 /api/qr/status?qrCodeId=xxx 或使用 WebSocket 长连接订阅状态变更。
  • 后端通过 Redis 获取二维码状态,响应当前状态值给前端;
  • 前端根据状态更新 UI:
    • NEW:显示二维码提示扫码
    • SCANNED:显示“请确认登录”
    • CONFIRMED:跳转系统首页
    • EXPIRED:提示“二维码已失效”

3️⃣ 手机扫码(App 端发起)

  • 用户打开手机 App,扫码得到 qrCodeId
  • App 发起请求 /api/qr/scan,携带:
    • qrCodeId
    • 当前用户登录 Token(说明谁在扫码)
  • 后端校验 Token 合法性 + 校验二维码状态是否是 NEW
  • 如果校验通过:
    • 更新 Redis 状态为 SCANNED
    • 保存扫码用户ID(用于确认登录)

4️⃣ 手机端确认登录(App 端)

  • 用户点击“确认登录”按钮,App 发起请求 /api/qr/confirm
  • 后端再次校验 Token、状态、qrCodeId
  • 如果合法:
    • 更新 Redis 状态为 CONFIRMED
    • 后端为 PC 端生成登录凭证(JWT 或 Session ID)
    • 可以将 token 写入 Redis,让 PC 端后续使用

5️⃣ PC端轮询到 CONFIRMED 状态后

  • 前端收到 CONFIRMED 状态
  • 发起请求 /api/qr/login?qrCodeId=xxx
  • 后端从 Redis 中取出对应用户信息
  • 为 PC 创建 Session 或返回 JWT Token
  • 登录成功,跳转首页

🔐 三、安全要点

  • 二维码应设置过期时间,防止被反复使用
  • Token 校验要严谨,确保扫码者是本人
  • Redis 里状态更新使用 Lua 脚本或事务 CAS 保证一致性
  • 后端二维码状态需加密传输或限制频繁请求(防刷)

🚀 四、技术选型小结

功能 技术 说明
二维码生成 UUID + Redis 可快速唯一生成并记录状态
状态存储 Redis(带 TTL) 快速响应,高并发,易过期处理
实时通知 轮询 / WebSocket 淘宝用轮询、抖音用 WS
登录授权 JWT / Session 生成 PC 端登录凭证
防刷限流 接口限流 + 签名校验 避免恶意轮询/伪造请求

✅ 五、流程图(配合讲解)

text复制编辑[PC端]        [后端]                   [App端]
  |                      |                         |
  |--> 请求生成二维码 -->|                         |
  |                      |-- 生成qrCodeId + Redis存储
  |<-- 返回二维码Base64--|                         |
  |                      |                         |
  |==轮询/WS监听状态====>|                         |
  |                      |                         |
  |                      |<--扫码携带Token + qrCodeId
  |                      |--校验后标记为SCANNED     |
  |<==收到SCANNED状态== |                         |
  |                      |<--确认登录               |
  |                      |--更新为CONFIRMED +登录信息
  |<==收到CONFIRMED==   |                         |
  |-- 请求登录状态凭证 -->|                         |
  |<-- 返回JWT/Session--|                         |

如何设计分布式日志存储架构?

使用redis出现缓存三兄弟如何解决?减轻数据库的压力

你在项目中用到了Redis对吧 介绍一下有没有遇到关于redis的什么问题?

暂时还没看!
12.使用redis出现缓存击穿雪崩穿透怎么解决_哔哩哔哩_bilibili

如何使用Redis记录用户连续登录了多少天?放在数据库里不合适

放在数据库不合适因为你要创建一个表 记录用户哪一天进行了签到 如果用户量很多就会很大的量

这个问题其实就是一个连续签到/登录统计问题,数据库不适合是因为:

  • 每签到一天就插一行 → 数据量巨大,I/O压力大。
  • 查询连续签到天数复杂,SQL不好写,效率低。

所以使用 Redis 的 位图(bitmap) 来解决,是一种低存储+高性能的方案。


🎯 场景目标

统计用户连续登录天数本月第几天登录过,实现类似:

复制编辑用户A 7月签到状态 = 01111100010001000(1表示登录,0表示没登录)
查询当前用户是否今天登录过?
查询用户本月连续登录天数?

🧠 技术选型:使用 Redis Bitmap

什么是 Bitmap?

Redis 的 Bitmap 本质上是字符串(String 类型),但你可以对它的每一位 bit 操作:

# 设置偏移量为 5 的位置为 1(签到)
SETBIT user:sign:1001:202507 5 1

# 查询偏移量为 5 的位置是否为 1(是否登录)
GETBIT user:sign:1001:202507 5

✅ 实现思路

1. 登录/签到时

int offset = today - 1; // 7月19日 → offset = 18,从0开始
String key = "user:sign:" + userId + ":" + yyyyMM;
redisTemplate.opsForValue().setBit(key, offset, true);

2. 查询某天是否登录

Boolean isLogin = redisTemplate.opsForValue().getBit(key, offset);

3. 查询本月累计登录多少天

BitCountOptions options = BitCountOptions.defaults();
Long total = redisTemplate.execute((RedisCallback<Long>) conn -> 
    conn.bitCount(key.getBytes(), options)
);

4. 查询连续登录天数(重点)

假设今天是第19天,从 offset = 18 开始向前看:

int count = 0;
for (int i = offset; i >= 0; i--) {
    if (redisTemplate.opsForValue().getBit(key, i)) {
        count++;
    } else {
        break; // 一旦中断,退出循环
    }
}

🚀 优势

  • Redis Bitmap 单月只占用 31 bit,超省空间(一个用户一年只用 372 bit ≈ 47 字节)
  • 查询效率高,O(1)
  • 写入也快,支持并发

🔐 注意点

  • 设置过期时间:避免内存占用过大

    redisTemplate.expire(key, Duration.ofDays(60));
    
  • 如果需要“补签”功能,操作相应的 bit 位即可

📌 示例 Redis 数据结构(7月)

Key: user:sign:1001:202507

Value (bit位):0111110000000000000000000000000
日期:1 2 3 4 5 6 7 ... 31
含义:从左往右,第i位是第i+1天

12306 用BitMap

所以 12306 用 Bitmap

  • 极省内存:一列火车 2000 座 = 2000 bit = 250 字节
  • 极快:扣减座位 1 毫秒内完成
  • 原子操作:高并发下绝对不超卖

二、12306 怎么用 Bitmap 存座位?

核心规则

  1. 一列火车 = 一个 Redis Bitmap
  2. 一个座位 = 一个 bit 位
    • 0 = 空座(可卖)
    • 1 = 已售(不可卖)

例子

一列火车有 5 个座位

座位号:  01A  01B  01C  02A  02B
bit 位:    0    1    0    0    1

含义:

  • 01B、02B 已卖掉(1)
  • 其他是空座(0)

三、最难的地方:区间票怎么存?

这是 12306 独有的世界级难题

比如:

  • 北京 → 济南 → 南京 → 上海
  • 你买 北京→南京
  • 会占用:北京 - 济南、济南 - 南京 两段区间

普通系统根本算不明白,但 Bitmap 可以轻松搞定

12306 的超级方案:

一趟车,每两个站之间,都做一个独立 Bitmap

比如 4 个站,会产生 6 个区间

  1. 北京→济南
  2. 北京→南京
  3. 北京→上海
  4. 济南→南京
  5. 济南→上海
  6. 南京→上海

每个区间 = 一个 Bitmap,存这个区间的座位占用情况

北京→南京 会经过两段:

  1. 北京→济南
  2. 济南→南京

所以系统会同时把 3 个 Bitmap 的第 3 位设为 1

  1. 北京→济南 bit3 = 1
  2. 济南→南京 bit3 = 1
  3. 北京→南京 bit3 = 1

比如你买 北京→上海

系统要保证:

  • 北京→济南 有座
  • 济南→南京 有座
  • 南京→上海 有座

怎么快速判断?

用位运算:AND 运算

把三个小段的 Bitmap 做 AND

结果中 = 0 的位置,就是全程都空的座位

bj-jn:   0 0 1 0 0
jn-nj:   0 0 1 0 0
nj-sh:   0 0 1 0 0
AND 后: 0 0 1 0 0

只有 bit=0 的位置,全程都有座

四、用户买票时,怎么毫秒级判断?

流程(超级关键)

  1. 用户选:北京→南京

  2. 后端找到对应的区间 Bitmap

  3. BITOP 或 位运算

    计算:

    • 哪些座位在 整个区间都是 0(空座)
  4. 找到一个空座,把所有经过的区间 bit 都设为 1

  5. 下单成功

一句话总结

用多个 Bitmap 叠加运算,毫秒级算出 “全程都有空” 的座位。

五、为什么这是世界最难?

因为要同时满足:

  1. 不超卖(绝对不能卖重座位)
  2. 速度极快(百万并发不卡)
  3. 区间计算极复杂(一站一卖,交叉占用)
  4. 空间极小(存得下全国几万辆车)

只有 Bitmap 能同时做到。

和你刚才学的 “签到” 对比

你会瞬间彻底懂:

功能 Bitmap 含义
用户签到 1 = 签到,0 = 未签
12306 座位 1 = 已售,0 = 空座
签到统计 数有多少个 1
查余票 找连续为 0 的位置

原理一模一样,只是业务场景换了!

给你一亿个Redis keys统计双方的共同好友?

🧩 题目解析

假设:

  • 每个用户的好友列表存储在 Redis 的 Set 结构中,例如:
    SADD friends:user1 A B C D
    SADD friends:user2 B C E F

目标:

  • 快速统计两个人共同的好友,即集合交集数量。

✅ 常规解法:Redis SINTER 命令

Redis 支持对多个集合求交集:

SINTER friends:user1 friends:user2

返回结果即为双方的共同好友。

如果只要交集数量,可以使用:

SINTERCARD 2 friends:user1 friends:user2

这是 Redis 7.0 新增的命令,效率更高

🧠 问题难点:一亿个 keys 怎么办?

一亿个 keys 说明用户量巨大或好友数量极多,可能涉及以下挑战:

问题 描述
Redis 内存压力 如果所有好友关系都存在 Redis Set 中,消耗大量内存
网络 IO 开销 获取或计算时大量命令交互
集合元素巨大 每个 Set 元素多(例如几千好友),单次 SINTER 代价大
频繁交集操作 如果这是一个高频功能(如社交推荐),需要高效方案

🧠 实战建议

场景 推荐方案
精确共同好友、数量不大 SINTER / SINTERCARD
只需估算交集数量 PFCOUNT(HyperLogLog)
只需交集数量 + 数据量很大 Bitmap + BITOP + BITCOUNT
数据量超大,追求压缩极致 Roaring Bitmap、Redis Module

如何做一亿用户实时积分排行榜?

一亿用户真的放得下吗?

放得下,而且非常小!

  • 一个用户在 ZSet 中占 约 30 字节
  • 1 亿用户 = 300MB 左右内存

一台普通 Redis 就能轻松扛住!

做“一亿用户实时积分排行榜”时,面临的挑战是:高并发写入、高频读、排序性能、排行榜分页、内存管理等。使用传统数据库难以胜任,我们通常结合 Redis 的 ZSet(有序集合)结构 + 分布式架构 来高效实现。

💡 核心思路

  • 使用 Redis 的 ZSet 存储用户积分(ZSet天然支持有序集合)。
  • 使用 用户ID为 member,积分为 score,自动排序。
  • 按业务场景设置多个排行榜(总榜、日榜、周榜等)。
  • 使用分片/分区 + 多 Redis 实例缓解内存压力。

🚀 性能优化方案

场景 方案
高并发写入 异步批处理写入积分变化(Kafka / MQ)
高并发读榜 热榜分页结果缓存(Redis 二级缓存)
大数据量 分片存储排行榜(按地域/业务线)
数据持久化 后台定期将 ZSet 持久化至 MySQL(定时备份)
用户查自己排名 排名反查缓存 + 异步修正(定时刷新)

⚙️ 分布式架构下的挑战与解决

问题 解决方案
单机内存不足 Redis 分布式集群 + 按 key 做水平分区
网络波动 Redis 哨兵或主从架构容灾
跨机房 使用 Kafka MQ 异步同步数据
并发冲突 ZINCRBY 原子操作,确保并发安全

内存200M读取1G文件并统计内容重复次数?内存受限时

一次性读取肯定会OOM
可以根据缓冲区分块读取

📌 方案核心:“分治 + 磁盘中间结果 + 再归并统计”


📍第一步:分片预处理(Hash 分桶)

  • 遍历文件,每条记录用 hash 函数映射到 N 个临时小文件中(如 100 个文件)。

  • 例如:

    hash(line) % 100 -> 选择第 i 个 bucket_i.txt 写入
    
  • 每个桶的数据量 << 1GB,避免某一个桶数据过大(可以动态调节桶数)。

📝 实现要点

  • 不能直接把 key 存在内存中,而是用 BufferedWriter 把行写入不同的中间文件。
  • 临时文件名如 bucket_0.txt, bucket_1.txt, …, bucket_99.txt

📍第二步:小文件内统计(Map 阶段)

  • 每个小文件都可以用内存加载(一般几十 MB),然后用 Map<String, Integer> 来做频次统计。

  • 统计完毕后,结果写入新的临时文件,如:

    result_bucket_0.txt:
    word1 -> 5
    word2 -> 3
    ...
    

📍第三步:归并所有桶(Reduce 阶段)

  • 如果需要所有数据的总频率(跨桶汇总),则可以:
    • 对所有结果文件做归并统计(Map 合并)。
    • 比如 word1result_bucket_0.txt 是 5,result_bucket_1.txt 是 2,总共就是 7。
    • 这一步可再次用 hashmap 缓存 + 写磁盘防止内存爆掉。

📘 总结一句话

“把大象装进冰箱,需要分步来——分桶写临时文件 → 每桶局部统计 → 全局归并或提取 Top-N。”

查询200条数据耗时200ms,怎么在500ms内查询1000条数据?

SpringBoot如果有百万数据插入怎么优化?

SpringBoot 百万数据插入优化:

使用 MyBatis-Plus 批量插入 + 每 1000 条一批 + 手动事务 + MySQL rewriteBatchedStatements=true 开启真正批处理,避免循环单条插入,将性能提升 50~100 倍。

SpringBoot可以同时处理多少请求?

volatile的应用场景?

为了保证我们并发编程的可见性有序性

SQL的执行流程

单表最多数据量需要多大才涉及到分表?

Mysql引擎层BufferPool工作过程原理?

什么是聚集索引和非聚集索引?

count(*)、count(1)、count(字段) 谁更快?有什么区别?

tb_user
id name
1 潘春尧
2 NULL
3 张三
Ⅰ. SELECT count(*) FROM tb_user;                → 3
Ⅱ. SELECT count(1) FROM tb_user;                → 3
Ⅲ. SELECT count(name) FROM tb_user;             → 2

在功能上没有区别 Ⅲ.如果你统计的数据需要排除NULL 就可以用count(指定字段)
在性能上没有任何区别 非要比较就是count(1)更胜一筹 因为不需要mysql在底层做任何的sql优化

SQL语句中使用了前模糊会导致索引失效?

分库分表id冲突解决方案?

✅ 解决方案详解

1. 数据库主键自增(不推荐)

  • 每个分库或分表自己用数据库的自增主键生成 ID。
  • 缺点:跨库表合并数据时会重复,不具备全局唯一性

2. UUID 作为主键

  • 使用 Java 的 UUID.randomUUID().toString() 等方式。
  • 优点:天然全局唯一
  • 缺点:
    • 不适合做主键(无序、冗长,影响数据库索引性能)。
    • 可读性差,调试困难。

3. 数据库主键段(Segment)模式(推荐)

  • 思路:中心服务维护一张 ID 号段表,为每个业务系统分配一个号段。
  • 实现:
    • 表中记录:biz_tag, max_id, step, version
    • 应用请求号段:SELECT max_id, step FROM id_segment WHERE biz_tag = 'order'
    • 然后更新 max_id 为 max_id + step
  • 优点:
    • 性能高(本地生成,无需每次访问数据库)
    • 避免重复(由号段控制)
  • 缺点:需要一个中心服务(如美团 Leaf)

4. 雪花算法(Snowflake)

  • Twitter 出品,用于生成 64 位整数 ID。

  • 格式如下:

    0 - 41位时间戳 - 10位机器ID - 12位自增序列
    
  • 优点:

    • 单机高性能、趋势递增、可分布式部署
  • 缺点:

    • 依赖机器时钟,系统时间回拨可能导致重复 ID 或服务挂掉
    • 需要保证机器 ID 唯一(通常通过配置或 ZooKeeper 分配)

5. Redis 生成自增 ID

  • 利用 Redis 的 INCR 命令,生成全局递增 ID:

    INCR order:id
    
  • 可配合时间戳、业务前缀等拼接成全局唯一 ID。

  • 优点:

    • 简单、轻量、分布式支持
  • 缺点:

    • Redis 挂掉或主从切换期间可能丢失或重复

🏁 总结推荐

方案 唯一性 性能 实现复杂度 推荐使用场景
UUID 简单 临时标识、测试用途
自增主键 简单 单库表内可用
Segment 中等 企业级 ID 服务(如 Leaf)
雪花算法 中等 分布式高并发业务
Redis INCR 简单 轻量级全局 ID 需求

如果你是在高并发、微服务、分布式环境下,强烈推荐使用:

  • 雪花算法 + Redis 搭配
  • 或引入一个中心 ID 生成服务(如 Leaf、TinyId、美团 UID Generator)

超直白对比(不用记术语)

1. Eureka(最简单、最纯粹)

定位:只做服务注册发现

  • 就像一个简单电话本
  • 只干一件事:服务注册、服务发现
  • 不做主从选举、不做分布式锁
  • 适合:Spring Cloud 微服务
  • 特点:可用性优先,集群挂一部分还能跑

缺点:功能少,现在慢慢被淘汰了


2. ZooKeeper(老大哥、全能但重)

定位:分布式协调工具(元老级)

  • 什么都能干:配置、注册、锁、选主
  • 像一个全能管家
  • 很多中间件(Kafka、HBase)依赖它
  • 特点:强一致性,数据绝对准确
  • 缺点:重、难维护、不专门给微服务设计

3. Nacos(现在的主流、最香)

定位:专为微服务而生的一站式平台

  • 对标:Eureka + ZooKeeper 的简化版
  • 像一个现代化管理平台
  • 能做:服务注册发现 + 配置中心
  • 开箱即用、简单、轻量、性能强
  • 现在国内微服务首选 Nacos

最关键区别(面试必问)

产品 主要功能 特点 现在地位
Eureka 服务注册发现 简单、AP、老技术 慢慢淘汰
ZooKeeper 协调服务(配置、锁、选主) 全能、CP、重、老架构 中间件用得多,微服务少用
Nacos 服务发现 + 配置中心 简单、好用、主流 现在最火,企业首选

你可以这么理解

  • Eureka:老式电话本
  • ZooKeeper:老式全能管家
  • Nacos:现代化智能管理平台

它们都是让服务之间能互相找到、配置能统一管理的工具。Nacos 是现在的王者,最简单最好用;ZooKeeper 是老大哥,功能多但笨重;Eureka 是前任,快退休了。

深分页为什么慢,怎么优化?

❓ 问题背景:什么是深分页?

深分页 = 当前页数很大,比如 page=100000 & size=10
对应 SQL:

SELECT * FROM table LIMIT 1000000, 10;
  • LIMIT offset, size 这种分页方式,在 offset 特别大时非常慢
  • 根因:数据库在处理时,仍然会扫描前面 offset 条记录,然后丢弃它们,仅返回后面的 size 条

🐌 为什么慢?

数据库执行过程(如MySQL):

LIMIT 1000000, 10

数据库内部会:

  1. 先从磁盘/缓冲中 取出前 1000000 条记录
  2. 然后只返回第 1000001 ~ 1000010 条;
  3. 前面的全丢了,但依然耗费 CPU、IO 和内存资源。

当数据量大时:

  • IO开销大(全表扫描)
  • CPU开销大(排序、过滤)
  • 数据库响应延迟高

🚀 如何优化深分页?

✅ 方案一:使用 覆盖索引

SELECT id FROM table ORDER BY id LIMIT 1000000, 10;

如果 id 是索引字段,数据库可以直接从索引树上读取,无需回表。

但这个优化能力有限,适合某些查询字段很少、又刚好有索引的情况。


✅ 方案二:记录上次的游标(Keyset Pagination

又叫 基于条件的分页,避免使用 offset。

例如:

SELECT * FROM table 
WHERE id > 上一页最后一条记录id 
ORDER BY id 
LIMIT 10;

优点:

  • 快!数据库利用索引跳过前面的数据
  • 没有 offset,性能非常稳定

适用场景:

  • id 或时间戳等字段是自增或顺序的
  • 不要求用户可以跳到任意页,只支持“向前/向后翻页”

✅ 方案三:缓存 + 异步预处理

对于排行榜、热点数据等:

  • 查询结果提前生成并缓存(Redis 等)
  • 用户点页数 → 直接读缓存,避免实时查询

✅ 方案四:使用临时表或中间结果表

  • 查询结果太大 → 先异步存入临时表
  • 分页从临时表中读取数据(配合游标分页)

适用于复杂 SQL 查询 + 多表连接


✅ 总结对比

方案 优点 缺点 适用场景
offset 分页 简单 深分页慢 小数据量
覆盖索引 限制大 查询字段少
游标分页 性能高 不支持跳页 流式阅读/时间线
缓存分页 一致性差 热点排行榜等
临时表分页 灵活 复杂 大查询分页导出

MySQL的隔离级别实现原理MVCC ?

核心:隐藏字段 + Undo Log + ReadView

🔸 1. 每行记录都有两个隐藏字段:

字段 含义
trx_id(创建事务ID) 表示创建该版本的事务 ID
roll_pointer(回滚指针) 指向 Undo Log(历史版本)的位置

🔸 2. Undo Log(回滚日志)

  • 用于记录数据修改前的旧版本数据
  • 当有读请求(快照读)时,就通过 roll_pointer 访问历史版本
  • 写操作失败或回滚,也依赖 Undo Log 恢复数据

🔸 3. ReadView(读视图)

  • 当事务开启时,会创建一个 ReadView(可见性判断工具)
  • 包含当前活跃事务 ID 列表、最小事务ID、最大事务ID
  • 判断规则:
    • 如果行的创建事务ID < ReadView 中最小事务ID → 可见
    • 如果在活跃事务列表中 → 不可见
    • 如果创建事务ID > 当前事务ID → 不可见(未来数据)
  • **MVCC ** (Multiversion Concurrency Control) 多版本并发控制器

    它是事务隔离级别的无锁的实现方式,用于提高事务的并发性能

事务隔离级别 (isolation)

用来解决并发事务所产生一些问题:
并发:同一个时间,多个线程同时进行请求。
什么时候会发生并发问题:在并发情况下,对同一个数据(变量、对象)进行读写操作才会产生并发问题
并发会产生什么问题?
1.脏读一读已提交(行锁,读不会加锁)
2.不可重复度–重复读(行锁,读和写都会上锁)
3.幻影读–串行化(表锁)概念: 通过设置隔离级别可解决在并发过程中产生的那些问题

用户忘记密码,系统为什么不直接提供原密码,而让改密码

因为系统它也不知道我们的原密码是什么

服务端在保存密码的时候绝不会明文存到数据库,怕有数据库权限的人或者黑客恶意利用
必须用不可逆的加密算法

MD5
  • 只能加密不可解密 但是它是hash算法 可能会有哈希冲突 至少加密2^128次方才有可能发生哈希碰撞
  • 每次生成的密文都一样,不管加密多少次生成的密文都是一样的 可以通过暴力破解
    • 解决暴力破解 就要在里面加 每次加密 解密 都要加入
HS256
  • 增加加密字符长度 目前没有碰撞性
  • 最好加入随机盐
BCrypt → 加入spring-security-core依赖
  • 盐是随机的
  • 无法通过暴力破解

Git怎么修复线上的突发BUG线上突发Bug要修复,本地正在开发新需求

在git里我们通常会用一个单独的分支来进行管理
本地开发也会有一个单独的分支
可以将线上代码的分支签出来单独进行修复

  • 正在开发的代码 → 暂存dev分支
  • 严重故障:回滚上一个版本
    非严重故障:在fix分支修复紧急Bug
  • 非常紧急:直接合并master分支上线
  • 一般急:合并release分支,走测试、上线流程
  • 非紧急:合并dev分支,走测试、线上流程

RestTemplate如何优化连接池

默认是没有连接池的
要用框架HTTPClientOKHTTP

RestTemplate 是Spring框架中用于简化HTTP请求的一个类,它提供了多种方法来处理HTTP请求和响应。RestTemplate可以用于发送GET、POST、PUT、DELETE等HTTP请求,并且可以处理请求头、请求体、URL参数等。

Synchronized怎么提升性能

🧠 实际开发中如何用好 synchronized?

✅ 尽量减少锁的粒度

// ❌ 锁了整个方法
public synchronized void update() { ... }

// ✅ 只锁关键代码段
public void update() {
    // 非关键代码
    synchronized (this) {
        // 只锁关键部分
    }
}

✅ 使用局部锁对象代替类锁

private final Object lock = new Object();

public void doTask() {
    synchronized (lock) {
        // 更细粒度的锁,避免不必要的争用
    }
}

✅ 使用并发类替代锁(性能更高)

  • ConcurrentHashMap
  • ReadWriteLock
  • ReentrantLock
  • AtomicInteger

开发中有没有用到设计模式?怎么用的

策略模式 + 简单工厂 + 模板方法

SpringBoot如何防止反编译

有没有出现过Spring正常SpringBoot报错的情况?

SpringBoot配置文件敏感信息如何加密?

一个需求来了怎么办
首先看这个需求 进行一个分析 分析这个需求跟哪些功能有关联 比如说在我做过的xxx里面 和什么关联 要思考怎么去做这个关联 数据库 代码层面 思考好之后 再去ER画图 写接口文档 再去开始写代码

阅读全文

SpringBoot趣味实战课

2024/10/21

Swagger + Mariadb + Hibernate 实现极简CRUD

application.yaml
spring:
  application:
    name: Pluminary
  datasource:
    driver-class-name: org.mariadb.jdbc.Driver
    url: jdbc:mariadb://localhost:3306/pcy?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&nullCatalogMeansCurrent=true
    username: root
    password: root
  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MariaDB103Dialect
  springdoc:
    api-docs:
      path: /v3/api-docs
    swagger-ui:
      path: /swagger-ui.html

  server:
    port: 8080
    servlet:
      context-path: /springboot
      session:
        timeout: 60
  debug: true
com/pcy/Swagger/SwaggerConfig.java
package com.pcy.Swagger;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SwaggerConfig {

    @Bean
    public GroupedOpenApi createRestApi() {
        return GroupedOpenApi.builder()
                .group("Spring Boot 实战")
                .pathsToMatch("/users/**") //这里是扫描包
                .build();
    }

    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title("Spring Boot 实战")
                        .version("1.0")
                        .description("Spring Boot 实战的 RESTFul 接口文档说明")
                        .contact(new Contact()
                                .name("Pluminary")
                                .url("https://github.com/P-luminary")
                                .email("390415030@qq.com")));
    }
}
com/pcy/service/UserRepository.java //【这个是持久化接口 实现CRUD】
package com.pcy.service;

import com.pcy.dao.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User,Integer> {
}
com/pcy/controller/UserController.java
package com.pcy.controller;

import com.pcy.dao.User;
import com.pcy.service.UserRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/users")
@Tag(name = "User Controller", description = "用户相关操作")
public class UserController {

    @Autowired
    private UserRepository userRepository;

    @Operation(summary = "根据ID获取用户信息", description = "通过用户ID获取用户详细信息")
    @ApiResponses(value = {
            @ApiResponse(responseCode = "200", description = "成功获取用户信息"),
            @ApiResponse(responseCode = "404", description = "未找到用户")
    })
    @GetMapping("/{id}")
    public User get(@PathVariable int id) {
        return userRepository.findById(id).orElse(null);
    }

    @Operation(summary = "创建用户", description = "创建一个新的用户")
    @ApiResponses(value = {
            @ApiResponse(responseCode = "200", description = "成功创建用户"),
            @ApiResponse(responseCode = "400", description = "无效的输入")
    })
    @PostMapping
    public User create(@RequestBody User user) {
        return userRepository.save(user);
    }

    @Operation(summary = "更新用户", description = "更新用户信息")
    @ApiResponses(value = {
            @ApiResponse(responseCode = "200", description = "成功更新用户信息"),
            @ApiResponse(responseCode = "404", description = "未找到用户")
    })
    @PutMapping
    public User update(@RequestBody User user) {
        return userRepository.save(user);
    }

    @Operation(summary = "删除用户", description = "根据ID删除用户")
    @ApiResponses(value = {
            @ApiResponse(responseCode = "200", description = "成功删除用户"),
            @ApiResponse(responseCode = "404", description = "未找到用户")
    })
    @DeleteMapping("/{id}")
    public void delete(@PathVariable int id) {
        userRepository.deleteById(id);
    }
}
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.2</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.pcy</groupId>
    <artifactId>Pluminary</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>Pluminary</name>
    <description>Pluminary</description>
    <properties>
        <java.version>17</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
            <version>2.2.5.RELEASE</version>
        </dependency>

        <dependency>
            <groupId>io.swagger.core.v3</groupId>
            <artifactId>swagger-annotations</artifactId>
            <version>2.2.15</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
<!--        <dependency>-->
<!--            <groupId>mysql</groupId>-->
<!--            <artifactId>mysql-connector-java</artifactId>-->
<!--            <version>8.0.33</version>-->
<!--        </dependency>-->
        <dependency>
            <groupId>org.mariadb.jdbc</groupId>
            <artifactId>mariadb-java-client</artifactId>
            <version>3.0.3</version>
        </dependency>

        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>2.1.0</version>
        </dependency>
        <dependency>
            <groupId>org.mariadb.jdbc</groupId>
            <artifactId>mariadb-java-client</artifactId>
            <version>2.7.4</version>
        </dependency>
        <dependency>
            <groupId>org.hibernate</groupId>
            <artifactId>hibernate-core</artifactId>
            <version>6.1.7.Final</version> <!-- 选择与 Spring Boot 3.3.2 兼容的版本 -->
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

增加分页、排序

com/pcy/controller/UserController.java
@Operation(summary = "获取用户列表", description = "获取用户列表")
    @GetMapping
    public Page<User> list(@RequestParam(defaultValue = "id") String property,
 @RequestParam(defaultValue = "ASC")Sort.Direction direction,
 @RequestParam(defaultValue = "0") Integer page,
 @RequestParam(defaultValue = "10") Integer pageSize) {
    Pageable pageable = PageRequest.of(page, pageSize, direction, property);
        return userRepository.findAll(pageable);
    }

根据姓名查用户

com/pcy/controller/UserController.java    
    @Operation(summary = "根据姓名查用户",description = "根据姓名查用户")
    @GetMapping("/name")
    public List<User> getByName(String name){
        return userRepository.findByNameContaining(name);
    }
com/pcy/service/UserRepository.java
package com.pcy.service;

import com.pcy.dao.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface UserRepository extends JpaRepository<User,Integer> {
    List<User> findByNameContaining(String name);
}

根据生日查用户、删除User表

com/pcy/controller/UserController.java
@Operation(summary = "根据生日获取用户信息①",description = "根据生日获取用户信息①")
    @GetMapping("/birthdayOne")
    public List<User> getBirthDayOne(LocalDate birthDay){
        return userRepository.findByBirthDay(birthDay);
    }

    @Operation(summary = "根据生日获取用户信息②",description = "根据生日获取用户信息②")
    @GetMapping("/birthdayTwo")
    public List<User> getBirthDayTwo(LocalDate birthDay){
        return userRepository.findByBirthDayNative(birthDay);
    }

    @Operation(summary = "删除User",description = "删除User")
    @GetMapping("/delete")
    public void delete(){
        userRepository.delete();
    }
com/pcy/service/UserRepository.java
@Query("SELECT u FROM User u WHERE u.birthday=?1")
    List<User> findByBirthDay(LocalDate birthDay);

    @Query(value = "SELECT * FROM user WHERE birth_day =:birthDay",nativeQuery = true)
    List<User> findByBirthDayNative(LocalDate birthDay);

    @Modifying
    @Transactional
    @Query(value = "DELETE FROM User")
    int delete();

增加审计

com/pcy/MallApplication.java //【增加@EnableJpaAuditing】
package com.pcy;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@EnableJpaAuditing
@SpringBootApplication
public class MallApplication {
    public static void main(String[] args) {
        SpringApplication.run(MallApplication.class, args);
    }
}
com/pcy/dao/BaseEntity.java //【没有必要为每个实体类都编写 直接封装导一个类 User去继承】
package com.pcy.dao;

import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Data;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@Data
@MappedSuperclass
//该注解用于监听实体类,在save、update之后的状态
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
    @CreatedBy
    @Column(updatable = false)
    private String creator;

    @LastModifiedBy
    private String modifier;

    @CreatedDate
    @Column(updatable = false) //不可修改的
    private LocalDateTime createTime;

    @LastModifiedDate
    private LocalDateTime updateTime;
}
com/pcy/dao/User.java //【增加@EqualsAndHashCode 与 extends BaseEntity】
@Data
@Entity
@EqualsAndHashCode(callSuper = true)
//@Schema(name="用户信息")
@Table(indexes = {@Index(name = "uk_email",columnList = "email",unique = true)})
public class User extends BaseEntity{
    @Id
//    @Schema(description = "用户ID")
//    @NotBlank(message = "Id不能为空")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    ...
}
com/pcy/service/impl/AuditorAwareImpl.java
package com.pcy.service.impl;

import org.springframework.data.domain.AuditorAware;
import org.springframework.stereotype.Component;

import java.util.Optional;

@Component
public class AuditorAwareImpl implements AuditorAware<String> {

    @Override
    public Optional<String> getCurrentAuditor() {
        // 添加一个随机数
        return Optional.of("管理员"+(int)(Math.random()));
    }
}

引入Mybatis-Plus + FreeMarker

pom.xml
<dependency>
  <groupId>com.baomidou</groupId>
  <artifactId>mybatis-plus-boot-starter</artifactId>
  <version>3.4.2</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
//根据你提供的实体类BaseEntity和User,我为你设计了一个基于MyBatis-Plus 3.5.x版本的代码生成器MysqlGenerator,它将自动生成与这些实体类相关的代码,如Mapper、Service、Controller等。以下是生成器的代码示例
【仅供查看学习 实际代码爆红无法导入】
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.core.toolkit.StringPool;
import com.baomidou.mybatisplus.generator.AutoGenerator;
import com.baomidou.mybatisplus.generator.config.*;
import com.baomidou.mybatisplus.generator.config.builder.*;
import com.baomidou.mybatisplus.generator.config.po.TableInfo;
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;
import com.baomidou.mybatisplus.generator.fill.Property;
import com.baomidou.mybatisplus.generator.keywords.MySqlKeyWordsHandler;

import java.util.Collections;

public class MysqlGenerator {

    // 项目路径
    private static final String PROJECT_PATH = System.getProperty("user.dir");
    // 输出路径
    private static final String OUTPUT_DIR = PROJECT_PATH + "/src/main/java";
    // 作者
    private static final String AUTHOR = "YourName";
    // 包名
    private static final String BASE_PACKAGE = "com.pcy";
    // 数据源配置
    private static final String DATABASE_URL = "jdbc:mysql://localhost:3306/your_database";
    private static final String DATABASE_USERNAME = "root";
    private static final String DATABASE_PASSWORD = "password";
    private static final String DATABASE_DRIVER = "com.mysql.cj.jdbc.Driver";

    public static void main(String[] args) {
        // 1. 全局配置
        GlobalConfig.Builder globalConfig = new GlobalConfig.Builder()
            .outputDir(OUTPUT_DIR)
            .author(AUTHOR)
            .enableSwagger()
            .fileOverride()
            .disableOpenDir(); // 不自动打开输出目录

        // 2. 数据源配置
        DataSourceConfig.Builder dataSourceConfig = new DataSourceConfig.Builder(DATABASE_URL, DATABASE_USERNAME, DATABASE_PASSWORD)
            .dbQuery(new MySqlQuery())
            .schema("public")
            .dbType(DbType.MYSQL)
            .keyWordsHandler(new MySqlKeyWordsHandler())
            .driverName(DATABASE_DRIVER);

        // 3. 包配置
        PackageConfig.Builder packageConfig = new PackageConfig.Builder()
            .parent(BASE_PACKAGE)
            .entity("dao")
            .mapper("mapper")
            .service("service")
            .controller("controller");

        // 4. 策略配置
        StrategyConfig.Builder strategyConfig = new StrategyConfig.Builder()
            .addInclude("user") // 生成指定表
            .addTablePrefix("t_") // 去掉表前缀
            .entityBuilder()
                .superClass(BaseEntity.class)
                .enableLombok()
                .addSuperEntityColumns("id", "creator", "modifier", "create_time", "update_time")
                .logicDeleteColumnName("deleted")
                .addTableFills(new Property("create_time", FieldFill.INSERT))
                .addTableFills(new Property("update_time", FieldFill.INSERT_UPDATE))
                .enableActiveRecord()
                .naming(NamingStrategy.underline_to_camel)
                .columnNaming(NamingStrategy.underline_to_camel)
            .controllerBuilder()
                .enableRestStyle()
                .enableHyphenStyle()
            .serviceBuilder()
                .formatServiceFileName("%sService")
                .formatServiceImplFileName("%sServiceImpl")
            .mapperBuilder()
                .enableBaseResultMap()
                .enableBaseColumnList();

        // 5. 模板配置
        TemplateConfig.Builder templateConfig = new TemplateConfig.Builder();

        // 6. 自定义配置
        InjectionConfig.Builder injectionConfig = new InjectionConfig.Builder()
            .beforeOutputFile((tableInfo, objectMap) -> objectMap.put("parent", BASE_PACKAGE));

        // 7. 整合配置
        AutoGenerator autoGenerator = new AutoGenerator(dataSourceConfig.build())
            .global(globalConfig.build())
            .packageInfo(packageConfig.build())
            .strategy(strategyConfig.build())
            .template(templateConfig.build())
            .injection(injectionConfig.build())
            .templateEngine(new FreemarkerTemplateEngine()); // 选择模板引擎

        // 8. 执行
        autoGenerator.execute();
    }
}
/*
关键配置说明:
GlobalConfig:设置代码生成的全局配置,包括作者、输出目录、是否覆盖已有文件等。
DataSourceConfig:配置数据库连接信息,使用MySQL数据库。
PackageConfig:指定生成的代码所在的包路径。
StrategyConfig:配置生成策略,包括实体类的继承关系、使用Lombok、Rest风格的控制器等。
TemplateConfig:模板配置,可定制生成的模板。
InjectionConfig:自定义配置,用于在生成文件前注入自定义的变量或逻辑。
AutoGenerator:整合所有配置并执行代码生成。

生成的文件包括:
实体类:根据数据库表生成实体类,并继承BaseEntity。
Mapper接口:生成Mapper接口用于数据库操作。
Service接口和实现类:生成Service接口及其实现类。
Controller类:生成Rest风格的控制器类。

使用方法:
修改数据库连接信息(DATABASE_URL、DATABASE_USERNAME、DATABASE_PASSWORD)。
配置需要生成代码的表名(addInclude("user"))。
运行MysqlGenerator.java的main方法,代码将会生成在指定的输出目录中。
*
//【以下都是自动生成的代码】
com/pcy/mapper/UserMapper.java
package com.pcy.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.pcy.entity.User;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface UserMapper extends BaseMapper<User> {
}
com/pcy/service/UserService.java
package com.pcy.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.pcy.entity.User;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

/**
 * <p>
 * 用户表 服务类
 * </p>
 */
public interface UserService extends IService<User> {
// 在Spring中使用事务
    @Transactional(propagation = Propagation.REQUIRED)
    void addWithRequired(User user);

    @Transactional(propagation = Propagation.REQUIRED)
    void addWithRequiredAndException(User user);

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    void addWithRequiredNew(User user);

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    void addWithRequiredNewAndException(User user);

    @Transactional(propagation = Propagation.NESTED)
    void addWithNested(User user);

    @Transactional(propagation = Propagation.NESTED)
    void addWithNestedAndException(User user);
}
com/pcy/service/impl/UserServiceImpl.java
package com.pcy.service.impl;


import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.pcy.entity.User;
import com.pcy.mapper.UserMapper;
import com.pcy.service.UserService;
import com.pcy.mapper.UserMapper;
import com.pcy.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

/**
 * <p>
 * 用户表 服务实现类
 * </p>
 */
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {

    @Autowired
    private UserMapper mapper;
    
    @Override
    @Transactional(propagation = Propagation.REQUIRED)
    public void addWithRequired(User user) {
        mapper.insert(user);
    }

    @Override
    @Transactional(propagation = Propagation.REQUIRED)
    public void addWithRequiredAndException(User user) {
        mapper.insert(user);
        throw new RuntimeException();
    }

    @Override
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void addWithRequiredNew(User user) {
        mapper.insert(user);
    }

    @Override
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void addWithRequiredNewAndException(User user) {
        mapper.insert(user);
        throw new RuntimeException();
    }

    @Override
    @Transactional(propagation = Propagation.NESTED)
    public void addWithNested(User user) {
        mapper.insert(user);
    }

    @Override
    @Transactional(propagation = Propagation.NESTED)
    public void addWithNestedAndException(User user) {
        mapper.insert(user);
        throw new RuntimeException();
    }
}
resources/mapper/UserMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.pcy.mapper.UserMapper">

    <!-- 通用查询映射结果 -->
    <resultMap id="BaseResultMap" type="com.pcy.entity.User">
    <result column="id" property="id" />
    <result column="creator" property="creator" />
    <result column="modifier" property="modifier" />
    <result column="create_time" property="createTime" />
    <result column="update_time" property="updateTime" />
        <result column="name" property="name" />
        <result column="email" property="email" />
        <result column="birth_day" property="birthDay" />
    </resultMap>

    <!-- 通用查询结果列 -->
    <sql id="Base_Column_List">
        id,
        creator,
        modifier,
        create_time,
        update_time,
        name, email, birth_day
    </sql>

</mapper>
//【提问:爆bug  "Could not autowire. No beans of 'UserMapper' type found"】 深度解析
1. @MapperScan 注解的原理 //启动类里面的 @MapperScan("com.pcy.mapper")
@MapperScan 是 MyBatis-Spring 提供的一个注解,用于指定要扫描的 Mapper 接口所在的包路径。它的作用是告诉 Spring 框架应该在哪些包路径下寻找 Mapper 接口,并将它们注册为 Spring 的 Bean。
扫描 Mapper 接口:Spring Boot 在启动时,会扫描你指定的包路径下的所有接口,并检测这些接口是否包含 MyBatis 的 Mapper 注解或者继承了 BaseMapper 等相关接口。
注册为 Bean:一旦找到这些接口,Spring 会自动为这些接口生成一个实现类,并将它们注册为 Spring 容器中的 Bean,这样你就可以通过 @Autowired 注入这些 Mapper。

2. @Mapper 注解的原理
@Mapper 是 MyBatis 提供的一个注解,用于标记一个接口为 MyBatis 的 Mapper 接口。被标记为 @Mapper 的接口会被 MyBatis-Spring 扫描到,并且 MyBatis 会为该接口生成一个实现类,负责执行 SQL 语句。
当你在 UserMapper 接口上添加 @Mapper 注解时,即使没有使用 @MapperScan,MyBatis 也会知道这个接口是一个 Mapper 接口,并将其注册为一个 Bean。这使得你可以在 UserServiceImpl 中通过 @Autowired 注入它。

3. 为什么使用 @MapperScan 和 @Mapper 不会报错
自动注册 Bean:@MapperScan 会自动扫描指定包路径下的所有 Mapper 接口,并将它们注册为 Spring 容器中的 Bean。这意味着在 UserServiceImpl 中,当你使用 @Autowired 注入 UserMapper 时,Spring 可以找到对应的 Bean,从而避免 Could not autowire 错误。
手动注册 Bean:当你在 Mapper 接口上直接使用 @Mapper 注解时,Spring 也会将该接口注册为一个 Bean,这样你同样可以通过 @Autowired 进行注入,而不会出现 Bean 找不到的问题。
//【提问:MysqlGenerator 逆向生成那些包的原理】
MyBatis-Plus 提供的 MyBatis-Plus Generator 是一个非常强大的代码生成工具,可以通过数据库表结构生成对应的 Java 代码,包括实体类、Mapper 接口、Mapper XML 文件、Service 类、Controller 类等。这个过程通常被称为“逆向工程”或“代码生成”。
1. MyBatis-Plus Generator 的工作原理
 1.1 读取数据库表结构
数据源配置:首先,MyBatis-Plus Generator 通过配置的数据源连接到指定的数据库。它会读取数据库中的表结构信息,包括表名、字段名、数据类型、主键、外键、索引等信息。
> DataSourceConfig dsc = new DataSourceConfig.Builder(DATABASE_URL, DATABASE_USERNAME, DATABASE_PASSWORD)
    .driverName(DATABASE_DRIVER)
    .build();

元数据解析:MyBatis-Plus Generator 通过 JDBC 获取数据库的元数据 (Metadata),并解析每个表的结构,将其转换为可以用于代码生成的数据结构。

 1.2 生成代码
代码生成器:AutoGenerator 是核心的代码生成器类。它根据从数据库中获取的表结构信息,生成相应的 Java 类文件。
> AutoGenerator generator = new AutoGenerator(dsc);

模板引擎:MyBatis-Plus Generator 使用模板引擎(例如 Freemarker)来渲染代码模板。通过模板和解析后的元数据,生成代码文件。每个生成的 Java 类文件都对应着一个模板文件,模板文件中包含了如何生成特定类型文件的逻辑。
> generator.templateEngine(new FreemarkerTemplateEngine());

 1.3 生成的包和文件
实体类 (entity):根据表结构生成对应的 Java 实体类。每个实体类与数据库表一一对应,包含表中字段的定义。
> strategyConfig.entityBuilder().enableLombok().naming(NamingStrategy.underline_to_camel);

Mapper 接口 (mapper):生成的 Mapper 接口用于与数据库交互,执行基本的增删改查操作。Mapper 接口通常继承自 BaseMapper,提供基本的 CRUD 操作。
> strategyConfig.mapperBuilder().enableBaseResultMap().enableBaseColumnList();
Mapper XML 文件 (mapper.xml):生成的 Mapper XML 文件包含了 Mapper 接口中对应的方法的 SQL 语句。这些 XML 文件用于定义复杂的查询、更新语句等。

Service 接口和实现类 (service, service.impl):Service 层是业务逻辑层。生成的 Service 接口提供了业务操作的定义,Service 实现类则实现这些业务操作。
> strategyConfig.serviceBuilder().formatServiceFileName("%sService");

Controller 类 (controller):生成的 Controller 类用于处理 HTTP 请求,调用 Service 层的方法进行业务处理,然后返回结果。Controller 通常与前端交互,处理用户请求。
> strategyConfig.controllerBuilder().enableRestStyle().enableHyphenStyle();


2. MyBatis-Plus Generator 如何生成这些包和文件
 2.1 代码生成策略 (StrategyConfig)
StrategyConfig 类用于配置代码生成的策略,如生成哪些表,生成哪些类,类的命名规则,是否使用 Lombok 等。
StrategyConfig strategyConfig = new StrategyConfig.Builder()
    .addInclude("user") // 生成指定表
    .entityBuilder().enableLombok() // 实体类配置
    .mapperBuilder().enableBaseResultMap() // Mapper 配置
    .serviceBuilder().formatServiceFileName("%sService") // Service 配置
    .controllerBuilder().enableRestStyle() // Controller 配置
    .build();

 2.2 模板文件
MyBatis-Plus Generator 使用的模板文件可以自定义,通常位于 resources/templates 目录下。每个模板文件对应一个需要生成的 Java 文件类型,例如 entity.java.ftl 对应实体类,mapper.java.ftl 对应 Mapper 接口。
     
模板文件中可以使用变量和逻辑来决定生成的代码内容。例如,${className} 会被替换为实际的类名,<#if useLombok> @Data </#if> 会根据条件生成代码。
     
 2.3 文件输出配置 (InjectionConfig 和 FileOutConfig)
通过 InjectionConfig 和 FileOutConfig,可以控制生成文件的路径、名称、以及自定义生成的文件内容。例如,可以指定某个表的实体类生成到特定的包下,或者将 XML 文件输出到特定的路径。
InjectionConfig cfg = new InjectionConfig.Builder()
    .beforeOutputFile((tableInfo, objectMap) -> {
        // 自定义处理逻辑
    })
    .build();

用MyBatis Plus的分页

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.2</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.pcy</groupId>
    <artifactId>Pluminary</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>Pluminary</name>
    <description>Pluminary</description>
    <properties>
        <java.version>17</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
            <version>2.2.5.RELEASE</version>
        </dependency>

        <dependency>
            <groupId>io.swagger.core.v3</groupId>
            <artifactId>swagger-annotations</artifactId>
            <version>2.2.15</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
<!--        <dependency>-->
<!--            <groupId>mysql</groupId>-->
<!--            <artifactId>mysql-connector-java</artifactId>-->
<!--            <version>8.0.33</version>-->
<!--        </dependency>-->
        <dependency>
            <groupId>org.mariadb.jdbc</groupId>
            <artifactId>mariadb-java-client</artifactId>
            <version>3.0.3</version>
        </dependency>

        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>2.1.0</version>
        </dependency>
        <dependency>
            <groupId>org.hibernate</groupId>
            <artifactId>hibernate-core</artifactId>
            <version>6.1.7.Final</version> <!-- 选择与 Spring Boot 3.3.2 兼容的版本 -->
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.5</version>
            <exclusions>
                <exclusion>
                    <artifactId>mybatis-spring</artifactId>
                    <groupId>org.mybatis</groupId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.mybatis</groupId>
            <artifactId>mybatis-spring</artifactId>
            <version>3.0.3</version>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-generator</artifactId>
            <version>3.5.5</version> <!-- 版本对齐 -->
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-freemarker</artifactId>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>
//【添加MyBatis-Plus的分页插件】
com/pcy/utils/MyBatisPlusConfig.java
package com.pcy.utils;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
//@Configuration 用于定义配置类,被注解的类内部包含有一个或多个被@Bean注解的方法
// 用于构建bean定义,初始化Spring容器
@Configuration
public class MyBatisPlusConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor(){
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MARIADB));
        return interceptor;
    }
}
com/pcy/controller/UserController.java //【增加listPage】
package com.pcy.controller;

import com.pcy.entity.User;
import com.pcy.service.UserRepository;
import com.pcy.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDate;
import java.util.List;

@RestController
@RequestMapping("/users")
@Tag(name = "User Controller", description = "用户相关操作")
public class UserController {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private UserService userService;

    @Operation(summary = "根据ID获取用户信息", description = "通过用户ID获取用户详细信息")
    @GetMapping("/{id}")
    public User get(@PathVariable int id) {
        return userRepository.findById(id).orElse(null);
    }

    @Operation(summary = "创建用户", description = "创建一个新的用户")
    @PostMapping
    public User create(@RequestBody User user) {
        return userRepository.save(user);
    }

    @Operation(summary = "更新用户", description = "更新用户信息")
    @PutMapping
    public User update(@RequestBody User user) {
        return userRepository.save(user);
    }

    @Operation(summary = "删除用户", description = "根据ID删除用户")
    @DeleteMapping("/{id}")
    public void delete(@PathVariable int id) {
        userRepository.deleteById(id);
    }

    @Operation(summary = "获取用户列表", description = "获取用户列表")
    @GetMapping("/list")
    public org.springframework.data.domain.Page<User> list(@RequestParam(defaultValue = "id") String property,
                                                           @RequestParam(defaultValue = "ASC") Sort.Direction direction,
                                                           @RequestParam(defaultValue = "0") Integer page,
                                                           @RequestParam(defaultValue = "10") Integer pageSize) {
        Pageable pageable = PageRequest.of(page, pageSize, direction, property);
        return userRepository.findAll(pageable);
    }

    @Operation(summary = "根据生日获取用户信息①", description = "根据生日获取用户信息①")
    @GetMapping("/birthdayOne")
    public List<User> getBirthDayOne(@RequestParam LocalDate birthDay) {
        return userRepository.findByBirthDay(birthDay);
    }

    @Operation(summary = "根据生日获取用户信息②", description = "根据生日获取用户信息②")
    @GetMapping("/birthdayTwo")
    public List<User> getBirthDayTwo(@RequestParam LocalDate birthDay) {
        return userRepository.findByBirthDayNative(birthDay);
    }

    @Operation(summary = "删除所有用户", description = "删除所有用户")
    @DeleteMapping("/deleteAll")
    public void deleteAll() {
        userRepository.deleteAll();
    }

    @Operation(summary = "分页查询用户列表", description = "分页查询用户列表")
    @GetMapping("/page")
    public Page<User> listPage(@RequestParam(defaultValue = "1") Integer page,
                               @RequestParam(defaultValue = "10") Integer pageSize) {
        return userService.page(new Page<>(page, pageSize));
    }
}

高级SQL语句(Lambda)

wrapper.lambda().like(user -> user.getName(), "p");
/*
Lambda 表达式:

user -> user.getName() 是一个 Lambda 表达式。
user 是 User 类的一个实例,作为 Lambda 表达式的输入参数。
user.getName() 是对 user 对象的 getName() 方法的调用,返回 name 字段的值。
作用:

这行代码告诉 MyBatis-Plus:在生成的 SQL 查询中,查找 name 字段值中包含 "p" 的所有记录。
wrapper.lambda() 返回一个 LambdaQueryWrapper<User> 对象,支持使用 Lambda 表达式进行条件构建。
.like() 方法添加了一个 LIKE 条件,表示在 SQL 查询中进行模糊匹配。
*/


wrapper.lambda().like(User::getName, "p");
/*
方法引用:

User::getName 是一种方法引用,它引用了 User 类的 getName() 方法。
方法引用是对 Lambda 表达式的一种简写。它表示将某个方法作为函数式接口的实现。
作用:

这行代码与第一行代码的作用相同,都是在生成的 SQL 查询中查找 name 字段值中包含 "p" 的所有记录。
User::getName 告诉 MyBatis-Plus:使用 User 类中的 getName() 方法来获取要参与条件判断的字段。
*/
com/pcy/controller/UserController.java
@Operation(summary = "自定义查询", description = "自定义查询")
    @GetMapping("/Dingyi")
    public List<User> getWrapper() { //类型List<User> 可以返回数据库列表
        QueryWrapper<User> wrapper = new QueryWrapper<>();
//        wrapper.eq("name", "潘春尧");
//        wrapper.lambda().ge(User::getBirthDay, LocalDate.parse("2011-01-01"));
//        wrapper.between(User::getBirthDay, "2011-01-01", "2011-12-31");
        wrapper.lambda().like(User::getName, "string");

//      wrapper.lambda().like(user -> user.getName(), "p");
//        wrapper.select("name,count(*)").groupBy("name");
//        return (QueryWrapper<User>) userMapper.selectList(wrapper);
//        wrapper.in(CollectionUtils.isNotEmpty(nameList), User::getName, nameList);
        return userMapper.selectList(wrapper);
    }

自动填充、填充实现策略

com/pcy/utils/MyMetaObjectHandler.java
package com.pcy.utils;

import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;

import java.time.LocalDateTime;

public class MyMetaObjectHandler implements MetaObjectHandler {
    @Override
    public void insertFill(MetaObject metaObject) {
        this.strictInsertFill(metaObject, "createTime", LocalDateTime::now, LocalDateTime.class);
        this.strictInsertFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class);
        this.strictInsertFill(metaObject, "creator", this::getCurrentUser, String.class);
        this.strictInsertFill(metaObject, "modifier", this::getCurrentUser, String.class);
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        this.strictUpdateFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class);
        this.strictUpdateFill(metaObject, "modifier", this::getCurrentUser, String.class);
    }
    
    // 模拟获取当前用户
    private String getCurrentUser(){
        return "管理员" + (int) (Math.random() * 10);
    }
}
// 这是自动填充的原理
default MetaObjectHandler strictFillStrategy(MetaObject metaObject, String fieldName, Supplier<?> fieldVal) {
        if (metaObject.getValue(fieldName) == null) {
            Object obj = fieldVal.get();
            if (Objects.nonNull(obj)) {
                metaObject.setValue(fieldName, obj);
            }
        }
        return this;
    }

强大的Druid

pom.xml
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.2.5</version>
        </dependency>
// Druid和MariaDB是两种不同类型的数据库系统
1、类型和用途:
Druid:Druid是一种分布式的实时分析数据库,主要用于处理高吞吐量的时间序列数据或事件数据。它专为快速查询和分析大规模数据而设计,常用于数据仓库、在线分析处理(OLAP)以及实时数据分析等场景。
MariaDB:MariaDB是一种关系型数据库管理系统(RDBMS),它是MySQL的一个分支,广泛用于常规的事务处理、数据存储和管理。MariaDB通常用于传统的OLTP(在线事务处理)场景,如web应用、内容管理系统等。

2、适用场景:
Druid:适合用于实时数据分析、日志分析、时间序列分析、用户行为分析等需要快速响应的场景。
MariaDB:适合传统的数据库应用,如电子商务系统、内容管理系统、ERP、CRM等需要强事务处理能力的场景。
    
总结来说,Druid和MariaDB各自适用于不同的数据处理需求,Druid更侧重于实时分析和大规模数据处理,而MariaDB更侧重于事务处理和关系型数据管理

Spring Data JPA与MyBatis-Plus的区别并且简单举例说明

Spring Data JPA: //【实现接口】

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    // Other fields, getters, and setters
}

public interface UserRepository extends JpaRepository<User, Long> {
    List<User> findByName(String name);
}
Spring Data JPA: //【实现控制类】
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/jpa/users")
public class UserJpaController {

    @Autowired
    private UserRepository userRepository;

    @GetMapping
    public List<User> getAllUsers() {
        return userRepository.findAll();
    }

    @GetMapping("/{id}")
    public User getUserById(@PathVariable Long id) {
        return userRepository.findById(id)
                             .orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id));
    }

    @PostMapping
    public User createUser(@RequestBody User user) {
        return userRepository.save(user);
    }
    @PutMapping("/{id}")
    public User updateUser(@PathVariable Long id, @RequestBody User userDetails) {
        User user = userRepository.findById(id)
                                  .orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id));

        user.setName(userDetails.getName());
        // Update other fields here
        return userRepository.save(user);
    }

    @DeleteMapping("/{id}")
    public void deleteUser(@PathVariable Long id) {
        User user = userRepository.findById(id)
                                  .orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + id));

        userRepository.delete(user);
    }

    @GetMapping("/search")
    public List<User> searchUsersByName(@RequestParam String name) {
        return userRepository.findByName(name);
    }
}


MyBatis Plus: //【实现接口】

@TableName("user")
public class User {
    private Long id;
    private String name;
    // Other fields, getters, and setters
}

public interface UserMapper extends BaseMapper<User> {
    // Custom SQL
    @Select("SELECT * FROM user WHERE name = #{name}")
    List<User> selectByName(@Param("name") String name);
}
MyBatis-Plus: //【实现控制类】
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/mybatis/users")
public class UserMyBatisController {

    @Autowired
    private UserMapper userMapper;

    @GetMapping
    public List<User> getAllUsers() {
        return userMapper.selectList(null);
    }

    @GetMapping("/{id}")
    public User getUserById(@PathVariable Long id) {
        return userMapper.selectById(id);
    }

    @PostMapping
    public void createUser(@RequestBody User user) {
        userMapper.insert(user);
    }

    @PutMapping("/{id}")
    public void updateUser(@PathVariable Long id, @RequestBody User userDetails) {
        User user = userMapper.selectById(id);
        if (user == null) {
            throw new ResourceNotFoundException("User not found with id: " + id);
        }

        user.setName(userDetails.getName());
        // Update other fields here
        userMapper.updateById(user);
    }

    @DeleteMapping("/{id}")
    public void deleteUser(@PathVariable Long id) {
        User user = userMapper.selectById(id);
        if (user == null) {
            throw new ResourceNotFoundException("User not found with id: " + id);
        }

        userMapper.deleteById(id);
    }

    @GetMapping("/search")
    public List<User> searchUsersByName(@RequestParam String name) {
        return userMapper.selectByName(name);
    }
}

////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

// 构建查询条件的包装类,它使用 Lambda 表达式避免了手写字符串可能导致的字段错误。
// 这种方式非常适合需要根据多个条件动态生成SQL查询的场景,使用LambdaQueryWrapper不仅能提高代码的可读性,还能减少由于硬编码字符串导致的错误。
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/mybatis/users")
public class UserMyBatisController {

    @Autowired
    private UserMapper userMapper;

    @GetMapping("/search")
    public List<User> searchUsersByName(@RequestParam String name) {
        // 使用 LambdaQueryWrapper 构建模糊查询条件
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.like(User::getName, name); // 类似于 SQL 中的 "WHERE name LIKE '%name%'"

        // 执行查询并返回结果
        return userMapper.selectList(queryWrapper);
    }
    // 其他CRUD方法与前面的示例相同
}

Junit

经过单元测试,观察日志输出,就会发现没有进行数据库查询,对数据库的交互逻辑不是Service层的单元测试需要关心的事情,而是Dao层的单元测试需要考虑的。Service层的单元测试是假定Dao层全部正确的基础上写的,我们只需要关注Service层是正确即可。
pom.xml
          <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
com/pcy/service/impl/UserServiceImpl.java
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
    private static final Logger logger = LoggerFactory.getLogger(UserServiceImpl.class);
    @Autowired
    private UserMapper mapper;

    public User getById(int id) {
        logger.info("id为:",id);
        return mapper.selectById(id);
    }
......
}
这是测试Service
test/java  com/pcy/service/impl/UserServiceImplTest.java //【用Mock改造 + log4j】
// 检查 UserServiceImpl 是否在测试中被 @MockBean 或其他方式替换为Mock对象。如果使用了Mock对象,测试时不会真正访问数据库,而是使用模拟数据。
package com.pcy.service.impl;

import com.pcy.entity.User;
import com.pcy.mapper.UserMapper;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class UserServiceImplTest {
    @InjectMocks
    UserServiceImpl userService;

    @Mock
    UserMapper userMapper;

    @Test
    @DisplayName("Test Service getById")
    void getById() {
        // 模拟userMapper的selectById方法返回一个User对象
        User mockUser = new User().setId(1).setName("qwe").setEmail("1234@qq.com");
        Mockito.when(userMapper.selectById(1)).thenReturn(mockUser);

        // 调用userService的getById方法,并验证返回结果
        User user = userService.getById(1);

        System.out.println(user);
        Assertions.assertEquals("qwe", user.getName());
    }
}
=====================================================================
Java HotSpot(TM) 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
2024-08-12T21:18:07.805+08:00  INFO 31512 --- [Pluminary] [           main] com.pcy.service.impl.UserServiceImpl     : id为:
User(id=1, name=qwe, age=0, email=1234@qq.com, birthDay=null)
com/pcy/entity/User.java
//你的 User 类同时使用了 Lombok 注解 (@Data, @Accessors(chain = true)) 和手动定义的 getter/setter 方法。由于 Lombok 已经生成了这些方法,手动定义的 getter/setter 方法会覆盖 Lombok 自动生成的方法,这可能导致链式调用的 setEmail 和其他类似方法无法正确解析。
package com.pcy.entity;

import jakarta.persistence.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;

import java.time.LocalDate;

@Data
@Entity
@EqualsAndHashCode(callSuper = true)
//@Schema(name="用户信息")
@Table(indexes = {@Index(name = "uk_email",columnList = "email",unique = true)})
@Accessors(chain = true) // 允许链式调用
public class User extends BaseEntity{
    @Id
//    @Schema(description = "用户ID")
//    @NotBlank(message = "Id不能为空")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

//    @Schema(description = "用字")
//    @NotBlank(message = "名字不能为空")
    @Column(nullable = false, columnDefinition = "varchar(20) comment '姓名'")
    private String name;

//    @Transient //注解修饰
//    @Schema(description = "年龄")
//    @Min(value = 1, message = "年龄不能小于1")
    private int age;

//    @Schema(description = "邮箱")
//    @Email(message = "E-mail格式不正确")
    @Column(nullable = false, length = 50)
    private String email;

//    @Schema(description = "生日")
//    @Past(message = "生日必须为过去的时间")
    private LocalDate birthDay;
}
这是测试Controller
test/java  com/pcy/controller/UserControllerTest.java
// Controller层的单元测试需要用到一个特定的类——MockMvc 专门为SpringMVC提供支持的
package com.pcy.controller;

import com.pcy.entity.User;
import com.pcy.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.Before;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.BDDMockito;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static org.junit.jupiter.api.Assertions.*;
@Slf4j
@SpringBootTest
class UserControllerTest {
    MockMvc mockMvc;

    @Mock
    UserService userService;

    @InjectMocks
    UserController userController;

    @BeforeEach
    void setUp(){
        mockMvc = MockMvcBuilders.standaloneSetup(userController).build();
    }

    @Test
    @DisplayName("Test Controller get")
    void get() throws Exception {
        Mockito.when(userService.getById(1)).thenReturn(new User().setName("刘水镜").setEmail("liushuijing@mail.com"));
        BDDMockito.given(userService.getById(1)).willReturn(new User().setName("刘水镜").setEmail("liushuijing@mail.com"));
        mockMvc.perform(MockMvcRequestBuilders.get("/user/{id}", 1)
                        .accept("application/json;charset=UTF-8")
                        .contentType("application/json;charset=UTF-8"))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.jsonPath("$.name").value("刘水镜"))
                .andDo(MockMvcResultHandlers.print())
                .andReturn();
        log.info("Test Controller get");
    }
}

全局异常处理

/*
一、@RestControllerAdvice 注解的作用
@RestControllerAdvice 是 Spring Framework 为我们提供的一个复合注解,它是 @ControllerAdvice 和 @ResponseBody 的结合体。

@ControllerAdvice:该注解标志着一个类可以为所有的 @RequestMapping 处理方法提供通用的异常处理和数据绑定等增强功能。当应用到一个类上时,该类中定义的方法将在所有控制器类的请求处理链中生效。

@ResponseBody:表示方法的返回值将被直接写入 HTTP 响应体中,通常配合 Jackson 或 Gson 等 JSON 库将对象转换为 JSON 格式的响应。

因此,@RestControllerAdvice 就是专门为 RESTful 控制器设计的全局异常处理器,它的方法返回值将自动转换为响应体。
*/
“全球”异常
com/pcy/controller/UserController.java
@Operation(summary = "异常查询", description = "异常查询")
    @GetMapping(value = "/{id}")
    public Result<User> get(@PathVariable Integer id) {
        User user = userService.getById(id);
        if (user == null){
            throw new RuntimeException("找不到id信息" + id);
        }
        return Result.success(userService.getById(id));
    }
/*
当输入id信息错误的时候
{
  "code": 200,
  "message": "操作成功",
  "data": {
    "creator": null,
    "modifier": null,
    "createTime": null,
    "updateTime": null,
    "id": 1,
    "name": "潘春尧",
    "age": 1,
    "email": "390@qq.com",
    "birthDay": "2024-08-10"
  }
}


当输入id信息错误的时候
{
  "code": 500,
  "message": "找不到id信息3323",
  "data": null
}
*/
com/pcy/utils/GlobalExceptionHandler.java
package com.pcy.utils;

import com.pcy.entity.MessageEnum;
import com.pcy.entity.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
//@ExceptionHandler注解用于在Spring MVC控制器中处理特定类型的异常。它可以应用于方法上
//当控制器方法抛出指定类型的异常时,@ExceptionHandler注解的方法将被调用来处理该异常
    @ExceptionHandler(Exception.class)
    public Result<Boolean> globalException(Exception e){
        Result<Boolean> result = new Result<>();
        result.setCode(MessageEnum.ERROR.getCode());
        result.setMessage(e.getMessage() == null ? MessageEnum.ERROR.getMessage() : e.getMessage());
        log.error(e.getMessage(), e);
        return result;
    }
}
com/pcy/entity/MessageEnum.java
package com.pcy.entity;

import lombok.Getter;

@Getter
public enum MessageEnum {
    SUCCESS(200, "操作成功"),
    ERROR(500, "操作失败");

    private final Integer code;
    private final String message;
    MessageEnum(Integer code, String message){
        this.code = code;
        this.message = message;
    }
}
com/pcy/entity/Result.java
package com.pcy.entity;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> {
   private Integer code;
   private String message;
   private T data;
   // 用于生成一个没有具体数据内容的成功响应
   public static <T> Result<T> success(){
       return success(null);
   }
   // 用于生成包含数据的成功响应
    public static <T> Result<T> success(T data){
        return new Result<>(MessageEnum.SUCCESS.getCode(), MessageEnum.SUCCESS.getMessage(), data);
    }
    // 用于生成一个没有具体错误信息的默认错误响应
    public static<T> Result<T> error(){
        return error(MessageEnum.ERROR);
    }
    // 用于生成带有特定错误信息的错误响应,MessageEnum 是一个枚举类型,包含了不同的错误信息和代码。
    public static<T> Result<T> error(MessageEnum messageEnum){
        return new Result<>(messageEnum.ERROR.getCode(), messageEnum.getMessage(), null);
    }
    // 用于生成包含自定义错误信息的错误响应
    public static <T> Result<T> error(String message) {
        return error(message, MessageEnum.ERROR.getCode());
    }
    // 用于生成包含自定义错误信息和自定义状态码的错误响应
    protected static <T> Result<T> error(String message, Integer code) {
        return new Result<>(code, message, null);
    }
}
写个小异常
com/pcy/controller/ExceptionController.java
package com.pcy.controller;

import com.pcy.entity.Result;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/exception")
@Tag(name = "Exception", description = "异常操作")
public class ExceptionController {
    @GetMapping("/runtimeexception")
    public Result<Boolean> runtimeException(){
        throw new RuntimeException();
    }
}
/*
开启全局异常处理的返回值
{
  "code": 500,
  "message": "操作失败",
  "data": null
}

没有全局异常处理的错误返回值
{
  "timestamp": "2024-08-13T08:21:43.192+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "path": "/exception/runtimeexception"
}
*/
//在SwaggerConfig中添加扫描路径 "/exception/**"  不然接口无法获取
package com.pcy.Swagger;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
// http://localhost:8080/swagger-ui/index.html
@Configuration
public class SwaggerConfig {

    @Bean
    public GroupedOpenApi createRestApi() {
        return GroupedOpenApi.builder()
                .group("Spring Boot 实战")
                .pathsToMatch("/users/**", "/exception/**")
                // .addPathsToMatch("/exception/**")
                .build();
    }

    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title("Spring Boot 实战")
                        .version("1.0")
                        .description("Spring Boot 实战的 RESTFul 接口文档说明")
                        .contact(new Contact()
                                .name("Pluminary")
                                .url("https://github.com/P-luminary")
                                .email("390415030@qq.com")));
    }
}
你提到的 GlobalExceptionHandler 和 ExceptionController 是用于统一处理 Spring MVC 控制器中的异常。让我逐步分析它们的作用,以及为什么在某些情况下它返回错误值。

//1. GlobalExceptionHandler 的作用
@RestControllerAdvice:这个注解用来全局处理控制器层的异常。它会拦截所有抛出的异常,并根据异常类型调用相应的 @ExceptionHandler 方法。

@ExceptionHandler(Exception.class):这个注解标注的方法会在控制器抛出 Exception 或其子类时执行。它用来捕获并处理全局的异常,比如你代码中的 RuntimeException。

globalException(Exception e):这是一个全局异常处理方法。当控制器中出现 Exception 时,这个方法会被调用。它将返回一个带有错误状态码的 Result<Boolean> 对象,并且会将错误信息记录到日志中。

//2. ExceptionController 的作用
@RestController:声明这个类是一个 Spring MVC 控制器,处理 Web 请求并返回数据。

runtimeException() 方法:在这个方法中,你手动抛出了一个 RuntimeException,这会触发 GlobalExceptionHandler 中的 globalException 方法,并返回一个包含错误信息的 Result<Boolean> 对象。

//3. 为什么只有引用 runtimeException() 才返回错误值
runtimeException() 方法直接抛出了一个 RuntimeException,因此会被 GlobalExceptionHandler 捕获并处理。这就是为什么在访问 /exception/runtimeexception 时,你会看到返回的是错误信息。

//4. 在 get() 方法中返回 200 状态码的原因
在 get() 方法中,如果你传入的 id 是无效的,返回的 Result<User> 仍然会是 Result.success(userService.getById(id)),即使 userService.getById(id) 返回的是 null。这种情况下,你的 Result.success(null) 仍然会返回状态码 200,因为 Result.success() 的设计是用于表示成功状态的,且你没有抛出任何异常。

//5. 如何让 get() 方法在出错时返回错误信息
你可以通过以下方法来确保在 get() 方法中传入无效的 id 时,抛出异常并触发全局异常处理器:

手动抛出异常:

java
复制代码
@GetMapping(value = "/{id}")
public Result<User> get(@PathVariable Integer id) {
    User user = userService.getById(id);
    if (user == null) {
        throw new RuntimeException("User not found with id: " + id);
    }
    return Result.success(user);
}
在 userService.getById(id) 方法中抛出异常:如果你的业务逻辑要求在找不到用户时抛出异常,那么可以在 userService.getById(id) 方法中实现这个逻辑。

//6. 总结
GlobalExceptionHandler 用于捕获和处理全局异常。
当你手动抛出 RuntimeException 或其他异常时,它会捕获并返回带有错误信息的 Result。
在 get() 方法中,如果你想要在找不到用户时返回错误信息,需要手动抛出异常,这样才能触发 GlobalExceptionHandler。

日志级别

方法一:直接编写使用
// [配置方法:一种是直接在application.yml文件中配置、另一种是在外置logback-spring.xml文件中配置]
logging:
  pattern:
    console: "%d - %m%n"
方法二:引用外置xml文件
resources/pom.xml               <引用外部的配置>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-logging</artifactId>
</dependency>

resources/application.yaml
logging:
  config: classpath:logback-spring.xml
               <如果你有更多样的配置需求,就需要使用外置XML文件的配置方式>
<?xml version="1.0" encoding="UTF-8" ?>

<configuration>

    <!--    日志文件存放路径-->
    <property name="PATH" value="C:/Users/Pluminary/Desktop/log"/>

    <!-- 彩色日志依赖的渲染类 -->
    <conversionRule conversionWord="clr" converterClass="org.springframework.boot.logging.logback.ColorConverter"/>
    <conversionRule conversionWord="wex"
                    converterClass="org.springframework.boot.logging.logback.WhitespaceThrowableProxyConverter"/>
    <conversionRule conversionWord="wEx"
                    converterClass="org.springframework.boot.logging.logback.ExtendedWhitespaceThrowableProxyConverter"/>
    <!-- 彩色日志格式 -->
    <property name="CONSOLE_LOG_PATTERN"
              value="${CONSOLE_LOG_PATTERN:-%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"/>
    <!-- 文件日志格式 -->
    <property name="FILE_LOG_PATTERN"
              value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} -%msg%n"/>

    <!-- 控制台输出配置-->
    <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
        <!--日志输出格式-->
        <layout class="ch.qos.logback.classic.PatternLayout">
            <pattern>
                ${CONSOLE_LOG_PATTERN}
            </pattern>
        </layout>
    </appender>

    <!-- INFO 级别日志文件输出配置-->
    <appender name="info" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <!--按级别过滤日志,只输出 INFO 级别-->
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>INFO</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>
        <!--当天日志文件名-->
        <File>${PATH}/info.log</File>
        <!--按天分割日志文件-->
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <!--历史日志文件名规则-->
            <fileNamePattern>${PATH}/info.log.%d{yyyy-MM-dd}.%i</fileNamePattern>
            <!--按大小分割同一天的日志-->
            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <maxFileSize>100MB</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
            <!--日志文件保留天数-->
            <maxHistory>30</maxHistory>
        </rollingPolicy>
        <!--日志输出格式-->
        <layout class="ch.qos.logback.classic.PatternLayout">
            <Pattern>${FILE_LOG_PATTERN}</Pattern>
        </layout>
    </appender>

    <!-- ERROR 级别日志文件输出配置-->
    <appender name="error" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <!--按级别过滤日志,只输出 ERROR 及以上级别-->
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>ERROR</level>
        </filter>
        <!--当天日志文件名-->
        <File>${PATH}/error.log</File>
        <!--按天分割日志文件-->
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <!--历史日志文件名规则-->
            <fileNamePattern>${PATH}/error.log.%d{yyyy-MM-dd}.%i</fileNamePattern>
            <!--按大小分割同一天的日志-->
            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <maxFileSize>100MB</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
            <!--日志文件保留天数-->
            <maxHistory>30</maxHistory>
        </rollingPolicy>
        <!--日志输出格式-->
        <layout class="ch.qos.logback.classic.PatternLayout">
            <Pattern>${FILE_LOG_PATTERN}</Pattern>
        </layout>
    </appender>

    <!--日志级别-->
    <root level="info">
        <appender-ref ref="console"/>
        <appender-ref ref="info"/>
        <appender-ref ref="error"/>
    </root>
</configuration>
                   
                   
<
Logback 能够精确区分并输出特定日志级别的错误,是通过 Appender 配置中的 Filter 机制实现的。在你的 Logback 配置文件中,RollingFileAppender 使用了不同的 Filter 来确保只有指定级别的日志信息会被记录到特定的日志文件中。

工作原理
LevelFilter 和 ThresholdFilter:

LevelFilter: 这个过滤器允许你指定只接受特定日志级别的日志。例如,LevelFilter 被配置为只接受 INFO 级别的日志,而拒绝其他级别的日志。<level>INFO</level> 表示只记录 INFO 级别的日志。
ThresholdFilter: 这个过滤器允许你指定一个日志级别的下限,只有高于或等于这个级别的日志才会被记录。例如,ThresholdFilter 被配置为只接受 ERROR 级别及以上的日志(例如 ERROR 和 FATAL)。
日志级别的传递:

日志框架从最底层(比如 TRACE)开始逐级向上检查日志的级别,直到它与 Appender 中配置的 Filter 级别匹配。例如,如果一个 ERROR 级别的日志被触发,RollingFileAppender 的 ThresholdFilter 将检测到这个日志并允许它通过,然后将日志写入指定的 error.log 文件。
日志级别匹配:

当应用程序运行时,它会生成不同级别的日志信息(如 DEBUG、INFO、WARN、ERROR 等)。每个 Appender 都会根据它的 Filter 规则检查这些日志条目。只有符合条件的日志条目才会被记录到相应的日志文件中。
>

AOP切面

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
com/pcy/controller/AspectController.java
package com.pcy.controller;

import com.pcy.entity.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequestMapping("/aspect")
public class AspectController {
    @GetMapping
    public Result aspect(String message){
        log.info("aspect controller");
        return Result.success(message);
    }
}
com/pcy/Swagger/SwaggerConfig.java //【增加"/aspect/**"】
package com.pcy.Swagger;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
// http://localhost:8080/swagger-ui/index.html
@Configuration
public class SwaggerConfig {

    @Bean
    public GroupedOpenApi createRestApi() {
        return GroupedOpenApi.builder()
                .group("Spring Boot 实战")
                .pathsToMatch("/users/**", "/exception/**","/aspect/**")
                // .addPathsToMatch("/exception/**")
                .build();
    }

    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title("Spring Boot 实战")
                        .version("1.0")
                        .description("Spring Boot 实战的 RESTFul 接口文档说明")
                        .contact(new Contact()
                                .name("Pluminary")
                                .url("https://github.com/P-luminary")
                                .email("390415030@qq.com")));
    }
}
com/pcy/utils/WebAspect.java
package com.pcy.utils;

import com.pcy.entity.Result;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import java.util.HashMap;
import java.util.Map;

@Slf4j
@Aspect
@Component
public class WebAspect {
// ★★★★★★★★★★★ 一定要注意这个AOP切面扫描的包 ★★★★★★★★★★★
    @Pointcut("execution(public * com.pcy.controller.*.*(..))")
    public void pointCut() {
    }

    @Before(value = "pointCut()")
    public void before(JoinPoint joinPoint) {
        System.out.println("======================================== 这是@Before ========================================");
        String methodName = joinPoint.getSignature().getName();
        String className = joinPoint.getTarget().getClass().getName();
        Object[] args = joinPoint.getArgs();
        String[] parameterNames = ((MethodSignature) joinPoint.getSignature()).getParameterNames();

        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();

        Map<String, Object> paramMap = new HashMap<>();
        for (int i = 0; i < parameterNames.length; i++) {
            paramMap.put(parameterNames[i], args[i]);
        }

        log.info("before path:{}",request.getServletPath());
        log.info("before class name:{}",className);
        log.info("before method name:{}",methodName);
        log.info("before args:{}",paramMap.toString());
    }

    @After(value = "pointCut()")
    public void after(JoinPoint joinPoint) {
        System.out.println("======================================== 这是@After =========================================");
        log.info("{} after", joinPoint.getSignature().getName());
    }

    @AfterReturning(value = "pointCut()", returning = "returnVal")
    public void afterReturning(JoinPoint  joinPoint, Object returnVal) {
        System.out.println("==================================== 这是@AfterReturning ====================================");
        log.info("{} after return, returnVal: {}", joinPoint.getSignature().getName(), returnVal);
    }
}

/*
2024-08-14 18:28:20.249  INFO 3296 --- [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring DispatcherServlet 'dispatcherServlet'
2024-08-14 18:28:20.249  INFO 3296 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
2024-08-14 18:28:20.250  INFO 3296 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 1 ms
2024-08-14 18:28:20.261  INFO 3296 --- [nio-8080-exec-1] c.pcy.HandlerInterceptor.LogInterceptor  : preHandle开始时间:18:28:20:261 毫秒
======================================== 这是@Before ========================================
2024-08-14 18:28:20.278  INFO 3296 --- [nio-8080-exec-1] com.pcy.utils.WebAspect                  : before path:/aspect
2024-08-14 18:28:20.278  INFO 3296 --- [nio-8080-exec-1] com.pcy.utils.WebAspect                  : before class name:com.pcy.controller.AspectController
2024-08-14 18:28:20.278  INFO 3296 --- [nio-8080-exec-1] com.pcy.utils.WebAspect                  : before method name:aspect
2024-08-14 18:28:20.278  INFO 3296 --- [nio-8080-exec-1] com.pcy.utils.WebAspect                  : before args:{message=www}
2024-08-14 18:28:20.278  INFO 3296 --- [nio-8080-exec-1] com.pcy.controller.AspectController      : aspect controller
==================================== 这是@AfterReturning ====================================
2024-08-14 18:28:20.279  INFO 3296 --- [nio-8080-exec-1] com.pcy.utils.WebAspect                  : aspect after return, returnVal: Result(code=200, message=操作成功, data=www)
======================================== 这是@After =========================================
2024-08-14 18:28:20.280  INFO 3296 --- [nio-8080-exec-1] com.pcy.utils.WebAspect                  : aspect after
2024-08-14 18:28:20.308  INFO 3296 --- [nio-8080-exec-1] c.pcy.HandlerInterceptor.LogInterceptor  : postHandle结束时间:18:28:20:308 毫秒
2024-08-14 18:28:20.308  INFO 3296 --- [nio-8080-exec-1] c.pcy.HandlerInterceptor.LogInterceptor  : afterCompletion
2024-08-14 18:28:20.309  INFO 3296 --- [nio-8080-exec-1] c.pcy.HandlerInterceptor.LogInterceptor  : 接口运行时间:47 毫秒

*/
若是调用UserController的get接口
com/pcy/controller/UserController.java
...
    @Operation(summary = "根据ID获取用户信息", description = "通过用户ID获取用户详细信息")
    @GetMapping("/user/{id}")
    public User get(@PathVariable int id) {
        return userRepository.findById(id).orElse(null);
    }
...

Console控制台的报错信息:
/*
2024-08-14 18:33:22.383  INFO 3296 --- [nio-8080-exec-9] c.pcy.HandlerInterceptor.LogInterceptor  : preHandle开始时间:18:33:22:383 毫秒
======================================== 这是@Before ========================================
2024-08-14 18:33:22.385  INFO 3296 --- [nio-8080-exec-9] com.pcy.utils.WebAspect                  : before path:/users/user/2
2024-08-14 18:33:22.385  INFO 3296 --- [nio-8080-exec-9] com.pcy.utils.WebAspect                  : before class name:com.pcy.controller.UserController
2024-08-14 18:33:22.385  INFO 3296 --- [nio-8080-exec-9] com.pcy.utils.WebAspect                  : before method name:get
2024-08-14 18:33:22.385  INFO 3296 --- [nio-8080-exec-9] com.pcy.utils.WebAspect                  : before args:{id=2}
==================================== 这是@AfterReturning ====================================
2024-08-14 18:33:22.425  INFO 3296 --- [nio-8080-exec-9] com.pcy.utils.WebAspect                  : get after return, returnVal: User(id=2, name=we2, age=2, email=2, birthDay=2024-08-10)
======================================== 这是@After =========================================
2024-08-14 18:33:22.426  INFO 3296 --- [nio-8080-exec-9] com.pcy.utils.WebAspect                  : get after
2024-08-14 18:33:22.428  INFO 3296 --- [nio-8080-exec-9] c.pcy.HandlerInterceptor.LogInterceptor  : postHandle结束时间:18:33:22:428 毫秒
2024-08-14 18:33:22.428  INFO 3296 --- [nio-8080-exec-9] c.pcy.HandlerInterceptor.LogInterceptor  : afterCompletion
2024-08-14 18:33:22.428  INFO 3296 --- [nio-8080-exec-9] c.pcy.HandlerInterceptor.LogInterceptor  : 接口运行时间:45 毫秒
*/
异常善后处理
com/pcy/controller/AspectController.java //【浏览exception接口的时候会报错】
package com.pcy.controller;

import com.pcy.entity.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequestMapping("/aspect")
public class AspectController {
    @GetMapping
    public Result aspect(String message){
        log.info("aspect controller");
        return Result.success(message);
    }
    @GetMapping("/exception")
    public Result exception(){//抛出异常
        throw new RuntimeException("runtime exception");
    }
}


/*
======================================== 这是@Before ========================================
2024-08-15 15:09:20.586  INFO 4200 --- [nio-8080-exec-4] com.pcy.utils.WebAspect                  : before path:/aspect/exception
2024-08-15 15:09:20.586  INFO 4200 --- [nio-8080-exec-4] com.pcy.utils.WebAspect                  : before class name:com.pcy.controller.AspectController
2024-08-15 15:09:20.586  INFO 4200 --- [nio-8080-exec-4] com.pcy.utils.WebAspect                  : before method name:exception
2024-08-15 15:09:20.587  INFO 4200 --- [nio-8080-exec-4] com.pcy.utils.WebAspect                  : before args:{}
2024-08-15 15:09:20.587  INFO 4200 --- [nio-8080-exec-4] com.pcy.utils.WebAspect                  : exception after throwing, message: runtime exception
======================================== 这是@After =========================================
2024-08-15 15:09:20.587  INFO 4200 --- [nio-8080-exec-4] com.pcy.utils.WebAspect                  : exception after
2024-08-15 15:09:20.588 ERROR 4200 --- [nio-8080-exec-4] com.pcy.utils.GlobalExceptionHandler     : runtime exception
*/
com/pcy/utils/WebAspect.java
@AfterThrowing(value = "pointCut()", throwing = "e")
    public void afterThrowing(JoinPoint  joinPoint, Exception e) {
        log.info("{} after throwing, message: {}", joinPoint.getSignature().getName(), e.getMessage());
    }
综上所述:after方法不关心方法是否成功,当方法执行完成之后就会被执行;afterReturning方法必须在目标方法成果return之后才会被执行;afterThrowing方法则会在目标方法抛出异常后被执行
性能统计

Around可以囊括以上所有能力

com/pcy/controller/AspectController.java
@Slf4j
@RestController
@RequestMapping("/aspect")
public class AspectController {
  @GetMapping("/sleep/{time}")
    public Result sleep(@PathVariable("time") long time) {
        log.info("sleep");
        try {
            Thread.sleep(time);
        } catch (InterruptedException e) {
           log.error("error", e);
        }
        if (time == 1000) {
            throw new RuntimeException("runtime exception");
        }
        log.info("wake up");
        return Result.success("wake up");
    }
}
@Around("pointCut()")
    public Object around(ProceedingJoinPoint joinPoint) {
        log.info("around start");
        long startTime = System.currentTimeMillis();
        Object result = null;
        try {
            result = joinPoint.proceed();
        } catch (Throwable e) {
            log.error("around error",e);
        }
        long endTime = System.currentTimeMillis();
        log.info("execute time:{} ms",endTime - startTime);
        return result;
    }
//【当输入time值为2004时】
2024-08-15 15:27:21.987  INFO 10844 --- [nio-8080-exec-6] c.pcy.HandlerInterceptor.LogInterceptor  : preHandle开始时间:15:27:21:987 毫秒
2024-08-15 15:27:21.990  INFO 10844 --- [nio-8080-exec-6] com.pcy.utils.WebAspect                  : around start
======================================== 这是@Before ========================================
2024-08-15 15:27:21.991  INFO 10844 --- [nio-8080-exec-6] com.pcy.utils.WebAspect                  : before path:/aspect/sleep/2004
2024-08-15 15:27:21.991  INFO 10844 --- [nio-8080-exec-6] com.pcy.utils.WebAspect                  : before class name:com.pcy.controller.AspectController
2024-08-15 15:27:21.991  INFO 10844 --- [nio-8080-exec-6] com.pcy.utils.WebAspect                  : before method name:sleep
2024-08-15 15:27:21.991  INFO 10844 --- [nio-8080-exec-6] com.pcy.utils.WebAspect                  : before args:{time=2004}
2024-08-15 15:27:21.991  INFO 10844 --- [nio-8080-exec-6] com.pcy.controller.AspectController      : sleep
2024-08-15 15:27:23.996  INFO 10844 --- [nio-8080-exec-6] com.pcy.controller.AspectController      : wake up
==================================== 这是@AfterReturning ====================================
2024-08-15 15:27:23.997  INFO 10844 --- [nio-8080-exec-6] com.pcy.utils.WebAspect                  : sleep after return, returnVal: Result(code=200, message=操作成功, data=wake up)
======================================== 这是@After =========================================
2024-08-15 15:27:23.997  INFO 10844 --- [nio-8080-exec-6] com.pcy.utils.WebAspect                  : sleep after
2024-08-15 15:27:23.997  INFO 10844 --- [nio-8080-exec-6] com.pcy.utils.WebAspect                  : execute time:2007 ms
2024-08-15 15:27:23.999  INFO 10844 --- [nio-8080-exec-6] c.pcy.HandlerInterceptor.LogInterceptor  : postHandle结束时间:15:27:23:999 毫秒
2024-08-15 15:27:23.999  INFO 10844 --- [nio-8080-exec-6] c.pcy.HandlerInterceptor.LogInterceptor  : afterCompletion
2024-08-15 15:27:23.999  INFO 10844 --- [nio-8080-exec-6] c.pcy.HandlerInterceptor.LogInterceptor  : 接口运行时间:12 毫秒


//【当输入time值为1000时】
2024-08-15 15:28:19.596  INFO 10844 --- [nio-8080-exec-9] c.pcy.HandlerInterceptor.LogInterceptor  : preHandle开始时间:15:28:19:596 毫秒
2024-08-15 15:28:19.597  INFO 10844 --- [nio-8080-exec-9] com.pcy.utils.WebAspect                  : around start
======================================== 这是@Before ========================================
2024-08-15 15:28:19.597  INFO 10844 --- [nio-8080-exec-9] com.pcy.utils.WebAspect                  : before path:/aspect/sleep/1000
2024-08-15 15:28:19.597  INFO 10844 --- [nio-8080-exec-9] com.pcy.utils.WebAspect                  : before class name:com.pcy.controller.AspectController
2024-08-15 15:28:19.597  INFO 10844 --- [nio-8080-exec-9] com.pcy.utils.WebAspect                  : before method name:sleep
2024-08-15 15:28:19.597  INFO 10844 --- [nio-8080-exec-9] com.pcy.utils.WebAspect                  : before args:{time=1000}
2024-08-15 15:28:19.597  INFO 10844 --- [nio-8080-exec-9] com.pcy.controller.AspectController      : sleep
2024-08-15 15:28:20.607  INFO 10844 --- [nio-8080-exec-9] com.pcy.utils.WebAspect                  : sleep after throwing, message: runtime exception
======================================== 这是@After =========================================
2024-08-15 15:28:20.607  INFO 10844 --- [nio-8080-exec-9] com.pcy.utils.WebAspect                  : sleep after
2024-08-15 15:28:20.607 ERROR 10844 --- [nio-8080-exec-9] com.pcy.utils.WebAspect                  : around error

java.lang.RuntimeException: runtime exception
    at com.pcy.controller.AspectController.sleep(AspectController.java:32)
    at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
    at java.base/java.lang.reflect.Method.invoke(Method.java:578)
......
同一切面内的执行顺序

先执行before方法,再执行afterReturning / afterThrowing方法,最后执行after方法
要验证的关键点是around方法和它们之间的先后关系

around方法早于before方法开始执行,并且晚于after方法结束执行,刚好将其他同志完全包裹了起来

//【注释掉WebAspect.java里面的代码不然会叠叠乐累加】
com/pcy/utils/AspectOne.java
package com.pcy.utils;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;

@Slf4j
@Aspect
@Component
public class AspectOne {
    @Pointcut("execution(public * com.pcy.controller.*.*(..))")
    public void pointCut(){}
    @Before(value = "pointCut()")
    public void before(){
        log.info("before one");
    }
    @After(value = "pointCut()")
    public void after(){
        log.info("after one");
    }
    @AfterReturning(value = "pointCut()")
    public void afterReturning(){
        log.info("afterReturning one");
    }

    @Around(value = "pointCut()")
    public Object around(ProceedingJoinPoint joinPoint) {
        log.info("around one start");
        Object result = null;
        try {
            result = joinPoint.proceed();
        } catch (Throwable e) {
            log.error("around error", e);
        }
        log.info("around one end");
        return result;
    }
}

/*
2024-08-15 16:12:12.819  INFO 28788 --- [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring DispatcherServlet 'dispatcherServlet'
2024-08-15 16:12:12.819  INFO 28788 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
2024-08-15 16:12:12.820  INFO 28788 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 0 ms
2024-08-15 16:12:12.839  INFO 28788 --- [nio-8080-exec-1] c.pcy.HandlerInterceptor.LogInterceptor  : preHandle开始时间:16:12:12:839 毫秒
2024-08-15 16:12:12.864  INFO 28788 --- [nio-8080-exec-1] com.pcy.utils.AspectOne                  : around one start
2024-08-15 16:12:12.864  INFO 28788 --- [nio-8080-exec-1] com.pcy.utils.AspectOne                  : before one
2024-08-15 16:12:12.864  INFO 28788 --- [nio-8080-exec-1] com.pcy.controller.AspectController      : aspect controller
2024-08-15 16:12:12.865  INFO 28788 --- [nio-8080-exec-1] com.pcy.utils.AspectOne                  : afterReturning one
2024-08-15 16:12:12.865  INFO 28788 --- [nio-8080-exec-1] com.pcy.utils.AspectOne                  : after one
2024-08-15 16:12:12.865  INFO 28788 --- [nio-8080-exec-1] com.pcy.utils.AspectOne                  : around one end
2024-08-15 16:12:12.908  INFO 28788 --- [nio-8080-exec-1] c.pcy.HandlerInterceptor.LogInterceptor  : postHandle结束时间:16:12:12:908 毫秒
2024-08-15 16:12:12.909  INFO 28788 --- [nio-8080-exec-1] c.pcy.HandlerInterceptor.LogInterceptor  : afterCompletion
2024-08-15 16:12:12.909  INFO 28788 --- [nio-8080-exec-1] c.pcy.HandlerInterceptor.LogInterceptor  : 接口运行时间:69 毫秒
*/
不同切面间的执行顺序

将AspectOne复制两份命名AspectTwo和AspectThree [执行后是One→Three→Two]
在Spring中的加载顺序是根据类名升序排列的,Three字母排序排在Two前面
那如何指定执行顺序按照One Two Three?
分别为AspectOne/Two/Three加上@Order(1),@Order(2),@Order(3)

Redis

集成
pom.xml
       <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>io.lettuce</groupId>
                    <artifactId>lettuce-core</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
        </dependency>
spring:
  application:
    name: Pluminary
  datasource:
    driver-class-name: org.mariadb.jdbc.Driver
    url: jdbc:mariadb://localhost:3306/pcy?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&nullCatalogMeansCurrent=true
    username: root
    password: root

    redis:
      host: localhost port:6379
      connect-timeout: 1000
      jedis:
        pool:
          min-idle: 5
          max-active: 10
          max-idle: 10
          max-wait: 2000
com/pcy/controller/HelloController.java
@Slf4j
@RestController
@RequestMapping("/test")
public class HelloController {
 @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @GetMapping("/hello")
    public String hello(){
        stringRedisTemplate.opsForValue().set("hello","world");
        return stringRedisTemplate.opsForValue().get("hello");
    }
}

//先访问hello接口 再去redis-cli中尝试访问自己定义的内容
http://localhost:8080/swagger-ui/index.html#/hello-controller/hello

/*
127.0.0.1:6379> get hello
"world"
*/

Spring Security

pom.xml
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
com/pcy/controller/HelloController.java
package com.pcy.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@RequestMapping("/test")
public class HelloController {
    @GetMapping("/hi")
//  http://localhost:8080/hi
    public String hi(){
        log.info("hi");
        return "ok!";
    }

    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @GetMapping("/hello")
    public String hello(){
        stringRedisTemplate.opsForValue().set("hello","world");
        return stringRedisTemplate.opsForValue().get("hello");
    }
}

/* Console:
Using generated security password: 4147707e-58d6-46d9-b5cc-19865a2c523f
*/

账号:user
密码:4147707e-58d6-46d9-b5cc-19865a2c523f
com/pcy/config/SecurityConfig.java
package com.pcy.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // 配置HTTP安全性
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorizeRequests ->
                        authorizeRequests
//          .antMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() // 允许访问Swagger UI和API文档
                                .anyRequest().authenticated() // 所有请求都需要认证
                )
                .httpBasic(withDefaults()); // 使用HTTP Basic认证

        return http.build();
    }

    // 配置认证管理器
    @Bean
    public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
        AuthenticationManagerBuilder authenticationManagerBuilder =
                http.getSharedObject(AuthenticationManagerBuilder.class);

        authenticationManagerBuilder
                .inMemoryAuthentication()
                .withUser("pcy")
                .password(passwordEncoder().encode("123456"))
                .roles("admin");

        return authenticationManagerBuilder.build();
    }

    // 配置密码编码器
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

/*
你可能无法访问 http://localhost:8080/swagger-ui/index.html 的原因可能与 Spring Security 配置有关。由于你启用了 Spring Security,默认情况下,所有请求都需要经过身份认证,这可能会阻止你访问 Swagger UI。

为了确保你能够访问 Swagger UI,你需要在 Spring Security 的配置中添加一个例外规则,允许对 /swagger-ui/** 和相关的 Swagger 资源进行无认证访问。

添加代码:.antMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()


账号:pcy
密码:123456
*/

从数据库中获取用户信息

com/pcy/config/SecurityConfig.java
package com.pcy.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Autowired
    private UserDetailsService userDetailsService; // 使用 Spring Security 的 UserDetailsService

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(authorizeRequests ->
                        authorizeRequests
                                .anyRequest().authenticated()
                )
                .userDetailsService(userDetailsService) // 设置 UserDetailsService
                .httpBasic(withDefaults())
                .csrf(csrf -> csrf.disable());

        return http.build();
    }

    // 配置认证管理器
    @Bean
    public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
        AuthenticationManagerBuilder authenticationManagerBuilder =
                http.getSharedObject(AuthenticationManagerBuilder.class);

        authenticationManagerBuilder
                .userDetailsService(userDetailsService)  // 使用数据库中的用户信息
                .passwordEncoder(passwordEncoder());

        return authenticationManagerBuilder.build();
    }

    // 配置密码编码器
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
com/pcy/service/impl/UserDetailsServiceImpl.java
package com.pcy.service.impl;

import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.pcy.entity.SysUser;
import com.pcy.service.SysUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
//确保你的 UserDetailsServiceImpl 类被 Spring 管理,且实现了 Spring Security 的 UserDetailsService 接口
@Service
public class UserDetailsServiceImpl implements UserDetailsService  {

    @Autowired
    private SysUserService sysUserService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser sysUser = sysUserService.getOne(Wrappers.<SysUser>lambdaQuery().eq(SysUser::getUsername, username));
        if (sysUser == null) {
            throw new UsernameNotFoundException("User not found with username: " + username);
        }
        return User.builder()
                .username(sysUser.getUsername())
                .password(sysUser.getPassword())
                .authorities(AuthorityUtils.commaSeparatedStringToAuthorityList(sysUser.getRole()))
                .build();
    }
}
com/pcy/entity/SysUser.java
package com.pcy.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.extension.activerecord.Model;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;

import java.io.Serializable;

@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@Schema(name = "SysUser对象", description = "系统用户表")
public class SysUser extends Model<SysUser> {

    private static final long serialVersionUID = 1L;

    @Schema(description = "主键 id")
    @TableId(value = "id", type = IdType.AUTO)
    private Integer id;

    @Schema(description = "用户名")
    private String username;

    @Schema(description = "密码")
    private String password;

    @Schema(description = "角色")
    private String role;


    @Override
    public Serializable pkVal() {
        return this.id;
    }
}
com/pcy/service/SysUserService.java
package com.pcy.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.pcy.entity.SysUser;

public interface SysUserService extends IService<SysUser> {
    String getCurrentUser();
}
com/pcy/service/impl/SysUserServiceImpl.java
package com.pcy.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.pcy.common.ApiException;
import com.pcy.entity.SysUser;
import com.pcy.mapper.SysUserMapper;
import com.pcy.service.SysUserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements SysUserService {
    @Override
    public String getCurrentUser() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        // 非匿名用户访问才能获得用户信息
        if (!(authentication instanceof AnonymousAuthenticationToken)) {
            String userName = authentication.getName();
            log.info("userName by SecurityContextHolder: {}", userName);
            return userName;
        }
        throw new ApiException("用户不存在!");
    }
}
com/pcy/mapper/SysUserMapper.java
package com.pcy.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.pcy.entity.SysUser;

/**
 * <p>
 * 系统用户表 Mapper 接口
 * </p>
 */
public interface SysUserMapper extends BaseMapper<SysUser> {

}
com/pcy/common/ApiException.java
package com.pcy.common;

import com.pcy.entity.MessageEnum;
import lombok.Data;

@Data
public class ApiException extends RuntimeException {

    private Integer code;

    public ApiException(MessageEnum messageEnum) {
        super(messageEnum.getMessage());
        this.code = messageEnum.getCode();
    }

    public ApiException(String message) {
        super(message);
        this.code = 500;
    }
}
//【由于数据库的密码要被加密后的形式保存到数据中】
com/pcy/common/test.java
package com.pcy.common;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

public class test {
    public static void main(String[] args) {
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
        String encodedPassword = encoder.encode("123456");
        System.out.println(encodedPassword);
    }
}

//$2a$10$GzDPdLyrzC9NudmE937AAetR2bef2VQzuSbP6KM6Y.I3045OuT/xC
修改创建SysUser用户的时候用Spring Security [登录的时候就可以用自己创建的了]
com/pcy/controller/UserController.java
/* 对比User数据
    @Operation(summary = "创建User用户", description = "创建一个新的User用户")
    @PostMapping("/create/")
    public User create(@RequestBody User User) {
        return userRepository.save(User);
    }
*/
    @Autowired
    private SysUserService sysUserService;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Operation(summary = "创建SysUser用户", description = "创建一个新的SysUser用户")
    @PostMapping("/create/test")
    public SysUser create(@RequestBody SysUser sysUser) {
        sysUser.setPassword(passwordEncoder.encode(sysUser.getPassword()));
        sysUserService.save(sysUser);
        return sysUser;
    }
package com.pcy.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.pcy.entity.SysUser;
public interface SysUserService extends IService<SysUser>{

    String getCurrentUser();
}
@Slf4j
@Service
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements SysUserService {

 @Override
    public boolean save(SysUser sysUser) {
        return SqlHelper.retBool(this.baseMapper.insert(sysUser));
    }
}
com/pcy/mapper/SysUserMapper.java
package com.pcy.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.pcy.entity.SysUser;

/**
 * <p>
 * 系统用户表 Mapper 接口
 * </p>
 */
public interface SysUserMapper extends BaseMapper<SysUser> {

}
权限控制
com/pcy/config/SecurityConfig.java
package com.pcy.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Autowired
    private UserDetailsService userDetailsService; // 使用 Spring Security 的 UserDetailsService

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(authorizeRequests -> authorizeRequests
                        .requestMatchers("/security/permitall").permitAll() // 允许所有人访问
                        .requestMatchers("/security/anonymous").anonymous() // 仅允许匿名用户访问
                        .requestMatchers("/security/config").hasAuthority("ROLE_config") // 仅拥有 ROLE_config 权限的用户可以访问
                        .requestMatchers("/security/Secured").hasRole("Secured") // 仅拥有 ROLE_Secured 的用户可以访问
                        .requestMatchers("/security/preAuthorize").hasAuthority("PreAuthorize") // 仅拥有 PreAuthorize 权限的用户可以访问
                        .anyRequest().authenticated() // 其他所有请求需要认证
                )
                .userDetailsService(userDetailsService) // 设置 UserDetailsService
                .httpBasic(withDefaults()) // 使用 HTTP Basic 认证
                .csrf(csrf -> csrf.disable()); // 禁用 CSRF

        return http.build();
    }

    // 配置认证管理器
    @Bean
    public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
        AuthenticationManagerBuilder authenticationManagerBuilder =
                http.getSharedObject(AuthenticationManagerBuilder.class);

        authenticationManagerBuilder
                .userDetailsService(userDetailsService)  // 使用数据库中的用户信息
                .passwordEncoder(passwordEncoder());

        return authenticationManagerBuilder.build();
    }

    // 配置密码编码器
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
com/pcy/controller/SecurityController.java
package com.pcy.controller;

import com.pcy.entity.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.annotation.Secured;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/security")
@Tag(name = "权限控制", description = "权限控制")
public class SecurityController {
    // Anyone
    @Operation(summary = "permitAll 权限")
    @GetMapping(value = "/permitall")
    public Result<String> permitAll(){
        return Result.success("permitAll");
    }
    // 未登录时可以访问
    @Operation(summary = "anonymous 权限")
    @GetMapping(value = "/anonymous")
    public Result<String> anonymous(){
        return Result.success("anonymous");
    }
    // xiaopan可以访问
    @Operation(summary = "config 权限")
    @GetMapping(value = "/config")
    public Result<String> config(){
        return Result.success("permitAll");
    }
    // xiaochun可以访问
    @Operation(summary = "Secured 权限")
    @GetMapping(value = "/Secured")
    @Secured({"ROLE_Secured"})
    public Result<String> Secured(){
        return Result.success("Secured");
    }
    // panchunyao可以访问
    @Operation(summary = "PreAuthorize 权限")
    @GetMapping(value = "/preAuthorize")
    @PreAuthorize("hasAnyAuthority('PreAuthorize')")
    public Result<String> PreAuthorize(){
        return Result.success("PreAuthorize");
    }
}

/*
首先,确保在数据库中创建几个测试用户,并为每个用户分配不同的角色或权限。假设你有以下几个用户:
User 1: Username: xiaopan, Password: 123456, Role: ROLE_config
User 2: Username: xiaochun, Password: 123456, Role: ROLE_Secured
User 3: Username: panchun, Password: 123456, Authority: PreAuthorize

尝试使用不同用户登录:
使用 xiaopan 登录后,尝试访问 /security/config。
使用 xiaochun 登录后,尝试访问 /security/Secured。
使用 panchunyao 登录后,尝试访问 /security/preAuthorize。

检查响应:
/security/config: 只有 xiaopan 能访问,其他用户会被拒绝访问。
/security/Secured: 只有 xiaochun 能访问,其他用户会被拒绝访问。
/security/preAuthorize: 只有 panchunyao 能访问,其他用户会被拒绝访问。
/security/permitall: 所有用户都可以访问。
/security/anonymous: 只有未登录的用户可以访问,登录的用户会被拒绝。

验证权限控制
每个请求的响应应该反映你在 SecurityConfig 中配置的权限。
如果用户没有适当的角色或权限,应该会返回 403 Forbidden 或其他错误响应。
*/
@Configuration
public class SwaggerConfig {

    @Bean
    public GroupedOpenApi createRestApi() {
        return GroupedOpenApi.builder()
                .group("Spring Boot 实战")
                .pathsToMatch("/users/**", "/exception/**","/aspect/**","/test/**","/security/**")
                // .addPathsToMatch("/exception/**")
                .build();
    }
}
// 【问答环节】
我的数据库创建的是Role字段但是为什么 下面这些有.hasAuthority 有.hasRole 还有其他的 这是怎么匹配到我数据库 按照你的方式设置的数据的

/*
1. hasRole() 和 hasAuthority() 的区别

hasRole(String role):
hasRole 方法通常用于检查用户是否拥有特定的角色。
Spring Security 会在你传递的角色名称前自动加上 "ROLE_" 前缀。因此,当你使用 hasRole("Secured") 时,实际上它会检查用户是否有 "ROLE_Secured" 这个权限。

hasAuthority(String authority):
hasAuthority 方法用于检查用户是否拥有特定的权限(或授权)。
hasAuthority 不会自动添加任何前缀。所以当你使用 hasAuthority("ROLE_config") 时,它会直接匹配 "ROLE_config",而不会添加任何前缀。


2. 匹配数据库中的角色和权限
hasRole("Secured"):
代码中的 hasRole("Secured") 实际上会匹配数据库中的 ROLE_Secured,因为 hasRole 方法会自动加上 "ROLE_" 前缀。

hasAuthority("ROLE_config"):
代码中的 hasAuthority("ROLE_config") 会直接匹配数据库中的 "ROLE_config",没有任何前缀变化。

hasAuthority("PreAuthorize"):
代码中的 hasAuthority("PreAuthorize") 会直接匹配数据库中的 "PreAuthorize",因为没有添加任何前缀。
*/
    
它为什么能查到我数据库的role字段里面的数据 如果我把这个字段换成test这个名字 它又是怎么去匹配到的呢
/*
Spring Security 默认会使用 UserDetails 接口中的 getAuthorities() 方法来获取用户的权限或角色信息。这些权限或角色信息通常是通过你在 UserDetailsService 实现类中定义的逻辑从数据库中获取的。
而在SecurityConfig中有代码:
@Autowired // 使用 Spring Security 的UserDetailsService
private UserDetailsService userDetailsService; 
回顾securityFilterChain代码
下面会有 .userDetailsService(userDetailsService) // 设置 UserDetailsService

Spring Security 本身并不直接访问你的数据库表或字段。它依赖于你在 UserDetailsService 中提供的 UserDetails 对象的 getAuthorities() 方法的返回值。因此,当你在 SecurityConfig 中使用 hasRole() 或 hasAuthority() 方法时,它实际上是在检查用户的权限信息,即 UserDetails 对象中的 authorities。
===========================================================================
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    SysUser sysUser = sysUserService.getOne(Wrappers.<SysUser>lambdaQuery().eq(SysUser::getUsername, username));
    if (sysUser == null) {
        throw new UsernameNotFoundException("User not found with username: " + username);
    }
    return User.builder()
            .username(sysUser.getUsername())
            .password(sysUser.getPassword())
            .authorities(AuthorityUtils.commaSeparatedStringToAuthorityList(sysUser.getTest()))  // 修改为使用 'test' 字段
            .build();
}

*/

记住我 √ Remember Me

基于SpringSession的方式
pom.xml
        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-redis</artifactId>
        </dependency>
//【要新搞个登录界面 .ftl】
application.yaml

spring:
  freemarker:
    template-loader-path: /templates/
    suffix: .ftl
resources/templates/loginPage.ftl
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Login</title>
</head>
<body>
<h1>Login</h1>
<form action="/login" method="post">
    <div>
        <label for="username">Username:</label>
        <input type="text" id="username" name="username" required>
    </div>
    <div>
        <label for="password">Password:</label>
        <input type="password" id="password" name="password" required>
    </div>
    <div>
        <input type="checkbox" id="remember-me" name="remember-me">
        <label for="remember-me">Remember me</label>
    </div>
    <div>
        <button type="submit">Login</button>
    </div>
</form>
</body>
</html>
com/pcy/controller/LoginController.java
package com.pcy.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class LoginController {
    @GetMapping("/login") // 修改为 "/custom-login"
    public String login() {
        return "loginPage"; // 返回的视图名仍然是 "loginPage"
    }
}

/*
http://localhost:8080/login

Please sign in
Username
    panchunyao
Password
    •••••••••••••
√ Remember me on this computer.


127.0.0.1:6379> keys spring*
1) "spring:session:sessions:96c83240-f939-4fd1-ac2c-93542f883aef"
2) "spring:session:sessions:56baf3c6-7a5c-483b-b04a-422b8a2be1b7"
*/
com/pcy/config/SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Autowired
    private UserDetailsService userDetailsService; // 使用 Spring Security 的 UserDetailsService

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(authorizeRequests -> authorizeRequests
                        .requestMatchers("/security/permitall").permitAll() // 允许所有人访问
                        .requestMatchers("/security/anonymous").anonymous() // 仅允许匿名用户访问
                        .requestMatchers("/security/config").hasAuthority("ROLE_config") // 仅拥有 ROLE_config 权限的用户可以访问
                        .requestMatchers("/security/Secured").hasRole("Secured") // 仅拥有 ROLE_Secured 的用户可以访问
                        .requestMatchers("/security/preAuthorize").hasAuthority("PreAuthorize") // 仅拥有 PreAuthorize 权限的用户可以访问
                        .anyRequest().authenticated() // 其他所有请求需要认证
                )
                .formLogin(form -> form
//                        .loginPage("/custom-login") // 将登录页面的路径改为 "/custom-login" 打开会循环重定向
                        .defaultSuccessUrl("http://localhost:8080/swagger-ui/index.html", true) // 登录成功后的跳转路径
                        .permitAll()
                )
                .rememberMe(rememberMe -> rememberMe
                        .rememberMeServices(rememberMeServices()) // 配置 Remember Me 服务
                )
                .userDetailsService(userDetailsService) // 设置 UserDetailsService
                .httpBasic(withDefaults()) // 使用 HTTP Basic 认证
                .csrf(csrf -> csrf.disable()); // 禁用 CSRF

        return http.build();
    }
}
......
//【问答环节】
分析一下freemarker这个配置的含义
    freemarker:
      template-loader-path: /templates/
      suffix: .ftl
/*
这些配置项的含义如下:
template-loader-path: /templates/: 这个配置指定了 Freemarker 模板文件的加载路径,也就是 /templates/ 目录。Spring Boot 会在这个目录下寻找所有的 .ftl 模板文件。

suffix: .ftl: 这个配置指定了 Freemarker 模板文件的文件后缀,也就是 .ftl。在控制器中返回视图名称时,Spring 会自动添加这个后缀来查找相应的模板文件。


配置的工作原理
@Controller
public class LoginController {
    @GetMapping("/login")
    public String login() {
        return "loginPage"; // 返回的视图名
    }
}
在 LoginController 中的 login() 方法中,你返回的是 "loginPage":
因为在 application.yaml 中已经配置了 template-loader-path 和 suffix,Spring Boot 会根据这些配置来查找模板文件:
它会在 template-loader-path 配置的 /templates/ 目录下寻找文件。
它会在视图名称 "loginPage" 后面自动添加 .ftl 后缀。
因此,最终 Spring Boot 会查找路径 /templates/loginPage.ftl,并使用这个模板文件来渲染登录页面。这就是为什么当你在控制器中返回 "loginPage" 时,Freemarker 能正确地找到并渲染 loginPage.ftl 模板。

如果你想修改 Freemarker 模板文件的目录或后缀,可以调整 application.yaml 中的相应配置。
视图名称在 return 中不需要包含目录或后缀,Spring 会根据配置自动处理。
*/

SpringBoot + Vue企业级狐狸

@RequestMapping 注解指定控制器类中的方法可以处理哪些格式的URL请求
@RequestMapping("/hello") 说明该方法将接收并处理格式为/hello的HTTP请求
@RestController 注解指定本类承担着SpringBoot项目的'控制器'效果
包名 所放置的业务代码类型
common 放置了通用的参数和业务方法
controller 放置了针对各业务请求的控制类
domain 放置了各种业务实体类
mapper 放置了针对MyBatis框架的映射关系类
service 放置了诸多实现业务逻辑的类
阅读全文

苍穹外卖

2024/9/28

苍穹外卖

软件开发整体介绍

软件开发流程

需求分析
  • 需求规格说明书(word)、产品原型
设计
  • UI设计、数据库设计、接口设计
编码
  • 项目代码、单元测试
测试
  • 测试用例、测试报告
上线运维
  • 软件环境安装、配置
角色分工
  • 项目经理:对整个项目负责,任务分配、把控进度
  • 产品经理:进行需求调研,输出需求调研文档、产品原型等
  • UI设计师:根据产品原型输出界面效果图
  • 架构师:项目整体架构设计、技术选型等
  • 开发工程师:代码实现
  • 测试工程师:编写测试用例,输出测试报告
  • 运维工程师:软件环境搭建、项目上线
软件环境
  • 开发环境:开发人员在开发阶段使用的环境

  • 测试环境:专门给测试人员使用的环境,用于项目测试

  • 生产环境:线上环境

第二轮补充知识点复习 会以橙色标注

苍穹外卖项目介绍

项目介绍
  • 定位:专门为餐饮制定的一款软件产品[管理端用户端]
功能架构 (体现项目中的业务功能模块)
  • 管理端:员工、分类、菜品、套餐、订单管理、工作台、数据统计、来单提醒
  • 用户端:微信登录、商品浏览、购物车、用户下单、微信支付、历史订单、地址管理、用户催单
产品原型“在文件里有用户端和管理端” (用于展示项目的业务功能 一般由产品经理进行设计)
技术选型 (展示项目中使用到的技术框架和中间件)
  • 用户层:node.js、VUE.js、ElementUI、微信小程序、apache echarts

  • 网关层:Nginx

  • 应用层:SpringBoot、SpringMVC、SpringTask、httpclient、SpringCache、JWT、阿里云OSS、Swagger、POI(操作excel表格)、WebSocket(网络协议<催单…>)

  • 数据层:MySQL、Redis、MyBatis、PageHelper、Spring Data Redis

  • 工具:Git、Maven、Junit、PostMan

开发环境搭建

前端:管理端(Web基于Nginx)、用户端(小程序)

前端环境位置:
E:\Java实例项目1-20套\第24套【项目实战】Java外卖项目实战《苍穹外卖》SpringBoot+SpringMVC+Vue+Swagger+Lombok+Mybatis+SpringSession+Redis+Nginx+小程序\0-0 源码资料\资料\day01\前端运行环境\nginx-1.20.2\html\sky

D:\nginx-1.20.2 [放在英文目录下 双击 nginx.exe] 默认端口号80

[苍穹外卖] (http://localhost/#/login) 如果被其他占用(比如RAGFlow)就把 localhost 换成 127.0.0.1

后端:后端服务(Java)

后端环境位置:
E:\Java实例项目1-20套\第24套【项目实战】Java外卖项目实战《苍穹外卖》SpringBoot+SpringMVC+Vue+Swagger+Lombok+Mybatis+SpringSession+Redis+Nginx+小程序\0-0 源码资料\资料\day01\后端初始工程\sky-take-out

把sky-take-out导入到idea

  • sky-take-out [maven父工程,统一管理依赖版本聚合其他子模块]
    • sky-common [子模块,存放公共类(工具类、常量类、异常类)]
    • sky-pojo [子模块,存放实体类、VO、DTO等]
    • sky-server [子模块,后端服务,存放配置文件、Controller、Service、Mapper等]
名称 说明
Entity 实体,通常和数据库中的表对应
DTO 数据传输对象,通常用程序中各层之间传递数据
VO 视图对象,为前端展示数据提供的对象
POJO 普通Java对象,只有属性和对应的Getter和Setter

深刻理解POJO

POJO的内在含义是指:那些没有继承任何类、也没有实现任何接口[可以实现],更没有被其它框架侵入的java对象。
POJO是一个简单的、普通Java对象,它包含业务逻辑处理或持久化逻辑等,但不是JavaBean、EntityBean等不具有任何特殊角色,不继承或不实现任何其它Java框架的类或接口。 可以包含类似与JavaBean属性和对属性访问的setter和getter方法的
一般在web应用程序中建立一个数据库的映射对象时,我们只能称它为POJO。

  • POJO持久化之后==〉PO(在运行期,由Hibernate中的cglib动态把POJO转换为PO,PO相对于POJO会增加一些用来管理数据库entity状态的属性和方法。PO对于programmer来说完全透明,由于是运行期生成PO,所以可以支持增量编译,增量调试。)
  • POJO传输过程中==> DTO
  • POJO用作表示层==> VO

深刻理解PO、DTO、VO

PO(persistent object):就是将对象与关系数据库绑定,用对象来表示关系数据,
最简单的PO就是对应数据库中某个表中的一条记录,多个记录可以用PO的集合。PO中应该不包含任何对数据库的操作。

  • 有时也被称为Data对象,对应数据库的entity,简单认为一个PO对应数据库中的一条记录
  • PO中不应该包含任何对数据库的操作
  • PO的属性是跟数据表的字段一一对应的
  • PO对象需要实现序列化接口

DTO(Data Transfer Object): → 数据传输对象
主要用于远程调用需要大量传输对象的地方
我们可以将PO中的部分属性抽取出来,就形成了DTO
举例说明
比如我们有一张表有100个字段,那么对应的PO就有100个属性
但是我们界面上需要显示10个字段,客户端用WEB service来获取数据,没必要把整个PO对象传递到客户端,这时我们就可以用只有这10个属性的DTO来传递结果到客户端,这样就不会暴露服务端表结构,到达客户端后,如果用这个对象来对应界面显示,那么此时它的身份就转为了VO(View Object)


VO
VO(value object) 是值对象,精确点讲它是业务对象,是存活在业务层的,是业务逻辑使用的,它存活的目的就是为数据提供一个生存的地方。VO的属性是根据当前业务的不同而不同的,也就是说,它的每一个属性都一一对应当前业务逻辑所需要的数据的名称。 VO是什么?它是值对象,准确地讲,它是业务对象,是生活在业务层的,是业务逻辑需要了解,需要使用的,再简单地讲,它是概念模型转换得到的。
重点
一个VO可以只是PO的一部分,也可以是多个PO构成,同样也等同于一个PO(指的是属性)。正因为这样,PO独立出来,数据持久层也就独立出来了,它不会受到任何业务的影响和干涉。又因为这样,业务逻辑层也独立开来,它不会受到数据持久层的影响,业务层只关心业务逻辑的处理,怎么存和读都交给别人。

深刻理解什么是DAO

DAO(Data Access Object):数据访问对象
主要用来封装对数据库的访问。通过它可以把POJO持久化为PO,用PO组装出来VO、DTO。
是一个sun的一个标准j2ee设计模式,这个模式中有个接口就是DAO,它负持久层的操作。为业务层提供接口。此对象用于访问数据库。通常和PO结合使用,DAO中包含了各种数据库的操作方法。通过它的方法,结合PO对数据库进行相关的操作。夹在业务逻辑与数据库资源中间。配合VO,提供数据库的CRUD操作…

  • 主要用来封装对DB(数据库)的访问(CRUD操作)。

  • 通过接收业务层的数据,把POJO持久化为PO。

深刻理解JavaBean

JavaBean是一个遵循特定写法的Java类,是一种Java语言编写的可重用组件,它的方法命名,构造及行为必须符合特定的约定:

1、这个类必须具有一个公共的(public)无参构造函数;
2、所有属性私有化(private);
3、私有化的属性必须通过public类型的方法(getter和setter)暴露给其他程序,并且方法的命名也必须遵循一定的命名规范。
4、这个类应是可序列化的。(比如可以实现Serializable 接口,用于实现bean的持久性)
JavaBean在JavaEE开发中,通常用于封装数据
许多开发者会把JavaBean看作村从特定命名约定的POJOPOJO按照JavaBean的规则来就可以变成JavaBean
当一个POJO可序列化,有一个无参的构造函数,使用getter和setter方法来访问属性时,他就是一个JavaBean
JavaBean是一种组件技术,就好像你做了一个扳手,而这个扳手会在很多地方被拿去用,这个扳子也提供多种功能(你可以拿这个扳手扳、锤、撬等等),而这个扳手就是一个组件。

common里的constant、context、properties代表什么意思

constant:
用于存放常量类。这些常量可能是项目中频繁使用的固定值,如状态码、错误码、系统配置项等。
常量类中的变量一般使用public static final修饰,确保其不可变性。
    
context:
用于存放上下文类。上下文类通常用来保存和传递运行时环境信息或状态。
在Spring框架中,ApplicationContext就是一个典型的上下文对象,它提供了对Bean的访问以及配置信息的管理。
    
properties:
用于存放属性文件。这些文件通常以.properties为扩展名,用于存储配置信息,如数据库连接字符串、系统参数等。
属性文件可以通过Properties类来读取和写入,方便在运行时动态调整系统行为。

Final的巩固

问:对于引用类型(如String、Object等),final变量的引用不能被改变,但引用的对象内部状态可以改变。 这句话是什么意思?

答:当一个引用类型的变量被声明为final时,这个变量的引用(即指向的对象)不能被改变,但该对象的内部状态是可以改变的。我们可以通过具体的例子来理解这一点。
public class Example {
    public static final String EMP_ID = "empId";
    public static void main(String[] args){
        // 下面这行代码会编译失败,因为EMP_ID是final的
        // EMP_ID = "newEmpId"; // 编译错误
        
// 但是可以创建一个新的String对象并使用EMP_ID内容
    String anotherId = EMP_ID + "123";
    sout(anotherId) => empId123;
    }
}

解析context (实现上下类的逻辑原理) 内的代码

package com.sky.context;

public class BaseContext {

    public static ThreadLocal<Long> threadLocal = new ThreadLocal<>();
//ThreadLocal 是一个线程局部变量,每个线程都有自己的独立副本。这意味着不同线程之间不会共享同一个 ThreadLocal实例的数据,从而避免了多线程环境下的数据竞争问题。
    public static void setCurrentId(Long id) {
        threadLocal.set(id);
    }

    public static Long getCurrentId() {
        return threadLocal.get();
    }

    public static void removeCurrentId() {
        threadLocal.remove();
    }
}
/*
这个类通常用于需要在多线程环境中传递和管理线程上下文信息的场景。例如:

Web应用:在处理HTTP请求时,可能需要将用户ID或其他上下文信息绑定到当前线程,以便在整个请求处理过程中都能访问到这些信息。

日志记录:在日志记录中,可能需要记录每个操作的执行者ID,通过 ThreadLocal 可以方便地在日志记录器中获取当前操作者的ID。

事务管理:在分布式事务中,可能需要将事务ID绑定到当前线程,以便在事务的各个阶段都能访问到这个ID。

内存泄漏:如果 ThreadLocal 中存储的对象没有及时释放,可能会导致内存泄漏。因此,建议在不再需要 ThreadLocal 中的数据时,调用 remove 方法将其移除。

线程池:在使用线程池时,特别需要注意 ThreadLocal 的管理。线程池中的线程是复用的,如果不及时清理 ThreadLocal 中的数据,可能会导致数据混淆或内存泄漏。
*/

静态变量解析

//静态变量 (static)
静态变量:在 Java 中,静态变量属于类而不是类的实例。这意味着无论创建多少个类的实例,静态变量都只有一份拷贝,并且所有实例共享这份拷贝。
作用域:静态变量在类加载时初始化,并且在类卸载时销毁。它们存在于类的生命周期内,而不是实例的生命周期内。

//结合 static 和 ThreadLocal
在 BaseContext 类中,threadLocal 被声明为 static,这意味着所有 BaseContext 实例共享同一个 ThreadLocal 实例。但这并不意味着所有线程共享同一个 ThreadLocal 实例的数据。相反,每个线程都有自己独立的 ThreadLocal 数据副本。
    
静态变量:
public static ThreadLocal<Long> threadLocal = new ThreadLocal<>();
这行代码声明了一个静态的 ThreadLocal 变量 threadLocal,所有 BaseContext 实例共享这个 ThreadLocal 实例。

使用Git进行版本控制

  • 创建Git本地仓库
  • 创建Git远程仓库[GitHub、Gitee]
  • 将本地文件推送到Git远程仓库
.gitignore[文件设置]
    
//忽略git管理的文件
**/target/
.idea
*.iml
*.class
*Test.java
**/test/

创建远程仓库流程:【提交到本地】
VCS → Create Git Repository → sky-take-out → √ → Unversinoed Files(All) → Commit

去创建一个仓库:[Pluminary/sky-take-out (gitee.com)] (https://gitee.com/Pluminary/sky-take-out)

推送代码到Gitee远程仓库:Idea右上角的↗ → 定义一下本地和远程仓库关联 点击Define remote → Name: origin
URL: https://gitee.com/Pluminary/sky-take-out.git (这个是在Gitee上创建仓库后复制的代码)

推送成功:[Pluminary/sky-take-out (gitee.com)] (https://gitee.com/Pluminary/sky-take-out)

后端环境搭建

数据库环境搭建

Unknown collation: ‘utf8mb4_0900_ai_ci‘的解决方法_unknown collation utf8mb4-CSDN博客

前后端联调
浏览器

↓
Controller:
1.接收并封装参数
2.调用service方法查询数据库
3.封装结果并相应

↓
Service:
1.调用mapper查询数据库
2.密码对比
3.返回结果

↓
Mapper:
1.select * from employee where username = ? 

↓
数据库

IDEA中导入多module的Maven项目无法识别module的解决办法_idea modules太多 mvn clean 对某个module不起作用-CSDN博客

Maven → compile(编译聚合模块 )

[INFO] ————————————————————————
[INFO] Reactor Summary for sky-take-out 1.0-SNAPSHOT:
[INFO]
[INFO] sky-take-out ………………………………… SUCCESS [ 0.003 s]
[INFO] sky-common ………………………………….. SUCCESS [ 2.761 s]
[INFO] sky-pojo ……………………………………. SUCCESS [ 2.227 s]
[INFO] sky-server ………………………………….. SUCCESS [ 1.294 s]
[INFO] ————————————————————————
[INFO] BUILD SUCCESS

在数据库中 新建查询  SELECT VERSION(); 引擎是8.0.33的是正规操作mysql此时对应的任务管理器服务里搜索mysql(名称:MySQL80)开启这个  如果开启了服务里的MySQL那SELECT VERSION()查询就是11.0.5-MariaDB

handler:全局异常处理器

右侧Maven的具体用途

  1. clean
    功能:清除项目构建过程中生成的所有文件,通常包括 target 目录下的内容
    命令:mvn clean
    使用场景:
    在每次构建之前,确保没有旧的构建产物干扰新构建。
    清理项目目录,准备进行新的构建。

  2. validate
    功能:验证项目的正确性,确保所有必要的信息都已就绪
    命令:mvn validate
    使用场景:
    在构建过程的早期阶段,检查项目配置是否正确。
    **确保所有依赖项和资源都可用**。

  3. compile
    功能:编译项目的源代码
    命令:mvn compile
    使用场景:
    编译项目源代码,生成 .class 文件。
    通常在开发过程中频繁使用确保代码可以成功编译

  4. test
    功能:运行项目的单元测试。
    命令:mvn test
    使用场景:
    在代码提交前,确保所有单元测试通过。
    持续集成(CI)过程中,自动运行测试以确保代码质量。

  5. package
    功能:将编译后的代码打包成可分发的格式,如 JAR、WAR 等
    命令:mvn package
    使用场景:
    构建项目并生成可部署的包。
    通常在开发和部署过程中使用,生成最终的可发布版本。

  6. verify
    功能:运行任何检查以验证包的完整性和有效性。
    命令:mvn verify
    使用场景:
    在发布前,进行更严格的验证,确保包的质量。
    运行集成测试、性能测试等。

  7. install
    功能:将包安装到本地 Maven 仓库,供其他项目使用
    命令:mvn install
    使用场景:
    将项目依赖安装到本地仓库,以便其他项目可以引用。
    通常在开发和测试环境中使用,确保依赖项可用。

  8. site
    功能:生成项目的站点文档,包括项目报告、测试覆盖率等。
    命令:mvn site
    使用场景:
    生成项目文档,供团队成员和外部用户查阅。
    文档生成和发布,提高项目的透明度和可维护性。

  9. deploy
    功能:将最终的包部署到远程仓库,如 Nexus、Artifactory 等。
    命令:mvn deploy
    使用场景:
    将项目发布到远程仓库,供其他团队或项目使用。
    通常在持续集成和持续部署(CI/CD)流程中使用,确保发布的版本可用。

总结
clean:清理构建产物。
validate:验证项目配置。
compile:编译源代码。
test:运行单元测试。
package:打包项目。
verify:验证包的完整性和有效性。
install:安装到本地仓库。
site:生成项目文档。
deploy:部署到远程仓库。

思考:前端发送的请求,是如何请求到后端服务的?
前端请求地址:http://localhost/api/employee/login
后端接口地址:http://localhost:8080/admin/employee/login
nginx 反向代理的配置方式
nginx.conf

server{
    listen 80;
    server_name localhost;
    location /api/{
        proxy_pass http://localhost:8080/admin/;  #反向代理
    }
}
nginx 负载均衡的配置方法(平均转发到多台后端服务器)
nginx.conf

upstream webservers{
    server 192.168.100.128:8080;
    server 192.168.100.129:8080;
}

server{
    listen 80;
    server_name localhost;
    location /api/{
        proxy_pass http://webservers/admin/;  #反向代理
    }
}
nginx 负载均衡策略:
名称 说明
轮询 默认方式
weight 权重方式,默认为1,权重越高,被分配的客户端请求就越多
ip_hash 依据ip分配方式,这样每个访客可以固定访问一个后端服务
least_conn 依据最少连接方式,把请求优先分配给连接数少的后端服务
url_hash 依据url分配方式,这样相同的url会被分配到同一个后端服务
fair 依据相应时间方式,响应时间短的服务将会被优先分配

完善登录功能

问题:员工表中的密码是明文存储,安全性太低
  • 将密码加密后存储,提高安全性

  • 使用MD5加密方式对明文密码加密 [不可逆]

  • 修改数据库中的明文代码,改为MD5加密后的密文

  • 修改Java代码,前端提交的代码进行MD5加密后再跟数据库中密码比对

在Idea中有 “//TODO” 这代表着标记处 此处还未完成一些操作 标记后可以在idea的下面快速定位到TODO

MD5密码加密后 也区分大小写 如果相同的密文但是大小写不同 结果还是不同的

修改密码
com/sky/controller/admin/EmployeeController.java
 @PutMapping("/editPassword")
    @ApiOperation("修改密码")
    public Result editPassword(@RequestBody PasswordEditDTO passwordEditDTO) {
        log.info("修改密码:{}", passwordEditDTO);
        employeeService.updatePassword(passwordEditDTO);
        return Result.success();
    }
com/sky/service/EmployeeService.java
/**
     * 更改密码
     * @param passwordEditDTO
     */
    void updatePassword(PasswordEditDTO passwordEditDTO);
com/sky/service/impl/EmployeeServiceImpl.java
 /**
     * 更改密码
     * @param passwordEditDTO
     */
    @Override
    public void updatePassword(PasswordEditDTO passwordEditDTO) {
    //getCurrentId 方法:public static Long getCurrentId() 方法用于获取当前线程的用户ID。

        Long empId = BaseContext.getCurrentId();
    //select * from employee where id = #{id}   根据id查员工的所有
        Employee employee = employeeMapper.getById(empId);
    //用md根据从前端传来的oldpassword 去判断employee的原始代码是否相同
        if (!employee.getPassword().equals(DigestUtils.md5DigestAsHex(passwordEditDTO.getOldPassword().getBytes()))) { 
            throw new PasswordErrorException(MessageConstant.PASSWORD_ERROR);
        }
        String newPassword = DigestUtils.md5DigestAsHex(passwordEditDTO.getNewPassword().getBytes());
        employee.setPassword(newPassword);
        employeeMapper.update(employee);
    }

导入接口文档

前后端分离开发流程
  • 定制接口(定义规范) → 前端开发(mock数据) + 后端开发(后端自测) → 连调(校验格式) → 提测(自动化测试)
操作步骤 YApi Pro-高效、易用、功能强大的可视化接口管理平台

将课程资料中提供的项目接口导入YApi
苍穹外卖-管理端接口.json
苍穹外卖-用户端接口.json
苍穹外卖-管理端+用户端接口 → 数据管理 → 数据导入(json 随后把json文件拖入) → 点击接口可查看

Swagger介绍和使用方式

Knife4j是为Java MVC框架集成Swagger生成Api文档的增强解决方案

<dependency>
   <groupId>com.github.xiaoymin</groupId>
   <artifactId>knife4j-spring-boot-starter</artifactId>
   <version>3.0.2</version>
</dependency>
使用方式
  • 导入knife4j的maven坐标
  • 在配置类中加入knife4j相关配置
sky-server  com/sky/config/WebMvcConfiguration.java
/**
 * 通过knife4j生成接口文档
 * @return
*/ 
@Bean
    public Docket docket() {
        ApiInfo apiInfo = new ApiInfoBuilder()
                .title("苍穹外卖项目接口文档")
                .version("2.0")
                .description("苍穹外卖项目接口文档")
                .build();
        Docket docket = new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(apiInfo)
                .select()
                    //指定生成接口需要扫描的包
                .apis(RequestHandlerSelectors.basePackage("com.sky.controller"))
                .paths(PathSelectors.any())
                .build();
        return docket;
    }
  • 设置静态资源映射,否则接口文档页面无法访问
/**
 * 设置静态资源映射
 * @param registry
*/
    protected void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/doc.html").addResourceLocations("classpath:/META-INF/resources/");
        registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
    }

[苍穹外卖项目接口文档] (http://localhost:8080/doc.html#/home) 这个文档是解析EmployeeController来的

通过Swagger就可以生成接口文档,那么我们不需要Yapi了?
  • Yapi是设计阶段使用的工具,管理和维护接口
  • Swagger在开发阶段使用的框架,帮助后端开发人员做后端的接口测试

编写接口文档 在企业中需要注意:

测试:
为每个API编写单元测试和集成测试,确保API的正确性和稳定性。
使用自动化测试工具(如Postman, JUnit等)来定期验证API的行为。

Swagger常用注解

注解 说明
@Api 用在类上,例如Controller,表明对类的说明
@ApiModel 用在类上,例如entity、DTO、VO
@ApiModelProperty 用在属性上,描述属性信息
@ApiOperation 用在方法上,例如Controller的方法,说明方法的用途、作用
sky-server  com/sky/controller/admin/EmployeeController.java
package com.sky.controller.admin;

import com.sky.constant.JwtClaimsConstant;
import com.sky.dto.EmployeeLoginDTO;
import com.sky.entity.Employee;
import com.sky.properties.JwtProperties;
import com.sky.result.Result;
import com.sky.service.EmployeeService;
import com.sky.utils.JwtUtil;
import com.sky.vo.EmployeeLoginVO;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

/**
 * 员工管理
 */
@RestController
@RequestMapping("/admin/employee")
@Slf4j
@Api(tags = "员工相关接口")
public class EmployeeController {

    @Autowired
    private EmployeeService employeeService;
    @Autowired
    private JwtProperties jwtProperties;

    /**
     * 登录
     *
     * @param employeeLoginDTO
     * @return
     */
    @PostMapping("/login")
    @ApiOperation(value = "员工登录")
    public Result<EmployeeLoginVO> login(@RequestBody EmployeeLoginDTO employeeLoginDTO) {
        log.info("员工登录:{}", employeeLoginDTO);

        Employee employee = employeeService.login(employeeLoginDTO);

        //登录成功后,生成jwt令牌
        Map<String, Object> claims = new HashMap<>();
        claims.put(JwtClaimsConstant.EMP_ID, employee.getId());
        String token = JwtUtil.createJWT(
                jwtProperties.getAdminSecretKey(),
                jwtProperties.getAdminTtl(),
                claims);

        EmployeeLoginVO employeeLoginVO = EmployeeLoginVO.builder()
                .id(employee.getId())
                .userName(employee.getUsername())
                .name(employee.getName())
                .token(token)
                .build();

        return Result.success(employeeLoginVO);
    }

    /**
     * 退出
     *
     * @return
     */
    @PostMapping("/logout")
    @ApiOperation(value = "员工退出")
    public Result<String> logout() {
        return Result.success();
    }
}
sky-pojo  com/sky/vo/EmployeeLoginVO.java
// 这里是最后返回的数据vo [已经经历过由po→DTO→vo的过程] 这里的po应该就是Employee
package com.sky.vo;

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ApiModel(description = "员工登录返回的数据格式")
public class EmployeeLoginVO implements Serializable {

    @ApiModelProperty("主键值")
    private Long id;

    @ApiModelProperty("用户名")
    private String userName;

    @ApiModelProperty("姓名")
    private String name;

    @ApiModelProperty("jwt令牌")
    private String token;
}
sky-pojo  com/sky/dto/EmployeeLoginDTO.java
// 这里的DTO是传输中的数据
package com.sky.dto;

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

import java.io.Serializable;

@Data
@ApiModel(description = "员工登录时传递的数据模型")
public class EmployeeLoginDTO implements Serializable {

    @ApiModelProperty("用户名")
    private String username;

    @ApiModelProperty("密码")
    private String password;

}

Getter与Setter无中生有??    以及快速创建对象builder

在上述VO和DTO代码中很显然没有看到常见的Getter和Setter
这是因为代码使用了 Lombok 注解,Lombok 是一个 Java 库,可以通过注解自动生成常见的样板代码,如 getter、setter、toString、equals 和 hashCode 等方法。

Lombok 注解解释
@Data
作用:这是一个组合注解,包含了 @ToString、@EqualsAndHashCode、@Getter、@Setter 和 @RequiredArgsConstructor。
效果:自动生成所有字段的 getter 和 setter 方法,toString 方法,equals 和 hashCode 方法,以及一个包含所有 final 字段和 @NonNull 字段的构造函数。

@Builder
作用:生成一个构建器模式的类,使得对象的创建更加灵活和可读。
效果:自动生成一个静态内部类 EmployeeLoginVO.EmployeeLoginVOBuilder,并提供构建方法。

  • 生成的构建器类包含所有字段的设置方法,并提供一个 build 方法来最终构建对象

  • 使用构建器模式可以让你在创建对象时更清晰地指定各个字段的值,特别是在对象有很多字段时。
    构建器模式允许你按需设置字段,而不需要为每个字段组合创建多个构造函数。

  • 生成的构建器类:
    Lombok 会自动生成一个静态内部类 EmployeeLoginVOBuilder,包含所有字段的设置方法和一个 build 方法。

    • 设置字段:
      你可以按需调用构建器的设置方法来设置字段值,例如 id(1L)、userName(“john_doe”) 等。
    • 构建对象:
      最后调用 build 方法来创建 EmployeeLoginVO 对象。
 // 使用构建器创建 EmployeeLoginVO 对象
        EmployeeLoginVO employeeLoginVO = EmployeeLoginVO.builder()
                .id(1L)
                .userName("john_doe")
                .name("John Doe")
                .token("eyJhbGciOiJIUzI1NiJ9...")
                .build();

        System.out.println(employeeLoginVO);

@NoArgsConstructor
作用:生成一个无参构造函数。
效果:自动生成一个不带任何参数的构造函数。

@AllArgsConstructor
作用:生成一个全参构造函数。
效果:自动生成一个包含所有字段的构造函数。

详细解析@GetMapping 与 @PostMapping

选择使用 @GetMapping 还是 @PostMapping 主要取决于Http请求的性质和用途
@GetMapping

作用:

  • @GetMapping专门用于处理HTTP GET请求

  • GET请求通常用于从服务器获取资源,不会对服务器上的数据进行修改

特点:

  • 请求参数通常附加在URL中 @GetMapping("/xxx/{id}") 底下会跟 @PathVariable
  • 请求是安全的不会修改服务器状态

适用场景:

  • 查询数据:获取用户列表、搜索结果
  • 获取静态资源:图片、css文件
  • 获取单个资源:获取某个用户的详细信息

@PostMapping

作用:

  • @PostMapping专门用于处理HTTP POST请求
  • POST请求通常用于向服务器发送数据,可能会对服务器上的数据进行修改

特点:

  • 请求参数放在请求体中,不会显示在URL这种 @PostMapping("/users")底下会跟@RequestBody
  • 请求体的大小没有固定限制,可以发送大量数据
  • 多次相同的POST请求可能会产生不同的结果,可能会修改服务器状态
  • 请求是不安全的,可能会修改服务器状态

适用场景:

  • 创建新资源:注册新用户、添加新订单
  • 更新现有资源:修改用户信息、更新订单状态
  • 发送表单数据:登录、提交评论
  • 上传文件:图片、文档

以下是一些可能的原因,解释了为什么您看到的前端项目代码不是“正经的网页代码”:

  • 构建过程:现代前端项目通常有一个构建过程,使用工具如Webpack、Rollup或Parcel等将源代码转换和打包。原始的代码可能在src目录中,而您看到的是构建后的dist目录,其中包含了转换和压缩后的文件。
  • 模块化和组件化:前端框架和库鼓励开发者将UI拆分成可复用的模块和组件。这意味着不再有传统的HTML、CSS和JavaScript混合在一起的代码,而是分别在不同的文件中定义。
  • 框架和库的使用:现代前端开发往往依赖于各种框架和库,这些框架和库有自己的文件结构和编写方式,可能和您熟悉的传统网页代码写法有很大不同。

根据@PutMapping(“/{Id}”)提问

我为什么@PutMapping(“/{id}”)是id呢 是不是因为我后面需要数据库where id = #{id} 查询才这样传输 如果 我想以name去查询呢

在RESTful API设计中,资源通常通过其唯一标识符(如ID)来访问和操作。@PutMapping("/{id}")使用{id}作为路径变量是因为ID是数据库中记录的唯一标识,这使得通过ID查找和更新特定的记录变得直接和高效。

如果你想要通过name来查询和更新记录,这在理论上是可行的,但需要注意以下几点:

  1. name字段可能不是唯一的,这意味着可能有多条记录具有相同的名称。
  2. 使用非唯一字段作为更新依据可能会导致数据不一致或错误更新。

更新员工 前后端联调原理

★ 在前端使用Ajax与后端URL对应时,你需要确保Ajax请求的URL与后端控制器中定义的映射相匹配。在你提供的例子中,后端使用@PutMapping("/{id}")来定义更新员工信息的端点,因此前端的Ajax URL应该包含员工的ID

// 绑定更新按钮的点击事件
$('#updateBtn').click(function() {
    var employee = {
        id: $('#id').val(), // 假设这是员工的唯一标识符
        name: $('#name').val(),
        email: $('#email').val(),
        department: $('#department').val()
    };

    // 发起Ajax PUT请求,URL中包含员工的ID
    $.ajax({
        url: `/api/employees/${employee.id}`, // 注意这里的URL与后端的@PutMapping("/{id}")对应
        type: 'PUT',
        contentType: 'application/json', // 指定发送给服务器的数据类型
        data: JSON.stringify(employee), // 将JavaScript对象转换为JSON字符串
        success: function(response) {
            // 请求成功,可以在这里处理响应数据
            alert('Employee information updated successfully!');
            // 如果需要,可以在这里更新页面上的表单数据
        },
        error: function(xhr, status, error) {
            // 请求失败,可以在这里处理错误信息
            alert('Error updating employee information: ' + xhr.responseText);
        }
    });
});

//在这个例子中,employee.id是从表单中获取的员工ID,它被拼接到URL字符串中,以形成完整的请求URL。这个URL应该与后端控制器中定义的@PutMapping("/{id}")相对应。当点击更新按钮时,Ajax请求会被发送到后端,后端会根据提供的ID找到对应的员工记录并进行更新。
 @PutMapping("/{id}")
    public ResponseEntity<Employee> updateEmployee(@PathVariable Long id, @RequestBody Employee employeeDetails) {
        Employee employee = employeeService.getEmployeeById(id);
        if (employee != null) {
            employee.setName(employeeDetails.getName());
            employee.setEmail(employeeDetails.getEmail());
            employee.setDepartment(employeeDetails.getDepartment());
            Employee updatedEmployee = employeeService.updateEmployee(employee);
            return ResponseEntity.ok(updatedEmployee);
        } else {
            return ResponseEntity.notFound().build();
        }
    }
}
//这里{id}是路径变量,它会匹配Ajax请求URL中的employee.id。这样,前后端的URL就正确对应起来了。

新增员工(Post+Json提交格式)

需求分析和设计

账号必须是唯一的、手机号校验合法11位、性别单选男女、身份证合法18位号码、新增密码默认为123456

本项目约定

  • 管理端发出的请求,统一使用 /admin 作为前缀
  • 用户端发出的请求,统一使用 /user 作为前缀

代码开发

根据新增员工接口设计对应的DTO
注意:当前提交的数据和实体类中对应的属性差别比较大时,建议使用DTO(数据传输)来封装数据

sky-pojo  com/sky/dto/EmployeeDTO.java
package com.sky.dto;

import lombok.Data;

import java.io.Serializable;

@Data
public class EmployeeDTO implements Serializable {

    private Long id;

    private String username;

    private String name;

    private String phone;

    private String sex;

    private String idNumber;

}
sky-server  com/sky/controller/admin/EmployeeController.java
/**
     * 新增员工
     * @param employeeDTO
     * @return
     */
    @PostMapping
    @ApiOperation("新增员工")
    public Result save(@RequestBody  EmployeeDTO employeeDTO){
    // 因为是JSON格式 要加@RequestBody
        log.info("新增员工:{}",employeeDTO);
        employeeService.save(employeeDTO);
        return Result.success();
    }
sky-server  com/sky/service/EmployeeService.java
package com.sky.service;

import com.sky.dto.EmployeeDTO;
import com.sky.dto.EmployeeLoginDTO;
import com.sky.entity.Employee;

public interface EmployeeService {

    /**
     * 员工登录
     * @param employeeLoginDTO
     * @return
     */
    Employee login(EmployeeLoginDTO employeeLoginDTO);

    /**
     * 新增员工
     * @param employeeDTO
     */
    void save(EmployeeDTO employeeDTO);
}
sky-server  com/sky/service/impl/EmployeeServiceImpl.java
 /**
     * 新增员工
     * @param employeeDTO
     */
    @Override
    public void save(EmployeeDTO employeeDTO) {
        Employee employee = new Employee();
        //employee.setName(employeeDTO.getName()); 太多了 用对象属性拷贝
        BeanUtils.copyProperties(employeeDTO,employee); //其余的要手动设置
        //设置账号状态,默认正常状态 1正常 0锁定  规范封装
        employee.setStatus(StatusConstant.ENABLE);
        //设置密码,默认密码123456
        employee.setPassword(DigestUtils.md5DigestAsHex(PasswordConstant.DEFAULT_PASSWORD.getBytes()));
        //设置当前记录的创建时间和修改时间
        employee.setCreateTime(LocalDateTime.now());
        employee.setUpdateTime(LocalDateTime.now());
        //设置当前记录创建人id和修改人id
        //TODO 后期需要改为当前登录用户的id
        employee.setCreateUser(10L);
        employee.setUpdateUser(10L);

        employeeMapper.insert(employee);
    }
sky-pojo  com/sky/entity/Employee.java
package com.sky.entity;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.time.LocalDateTime;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Employee implements Serializable {

    private static final long serialVersionUID = 1L;

    private Long id;

    private String username;

    private String name;

    private String password;

    private String phone;

    private String sex;

    private String idNumber;

    private Integer status;

    //@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createTime;

    //@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime updateTime;

    private Long createUser;

    private Long updateUser;

}
sky-server  com/sky/mapper/EmployeeMapper.java
/**
 * 插入员工数据
*/
    @Insert("insert into employee (name,username,password,phone,sex,id_number,create_time,update_time,create_user,update_user))" +
            "values" +
            "(#{name},#{username},#{password},#{phone},#{sex},#{idNumber},#{createTime},#{updateTime},#{createUser},#{updateUser},#{status})")
    void insert(Employee employee);

@Builder 和 @JsonFormat

@Builder 是 Lombok 提供的一个注解,用于自动生成构建器模式的代码。
它会在编译时生成一个静态的 Builder 类和相关的方法,使得对象的创建更加灵活和可读。
需要创建不可变对象时,可以使用 @Builder 结合 @Value 注解。
需要创建复杂的对象时,可以通过构建器模式逐步设置属性,提高代码的可读性和可维护性

@JsonFormat 是 Jackson 库提供的注解,用于指定日期时间字段在 JSON 序列化和反序列化时的格式。
通过设置 pattern 属性,可以控制日期时间字段的格式化方式。
当需要将 LocalDateTime、Date 等日期时间类型的字段转换为特定格式的字符串时。
在 RESTful API 中,返回的 JSON 数据需要符合特定的日期时间格式要求。

使用 @Builder 的场景
public class Main {
    public static void main(String[] args) {
        // 使用 @Builder 创建 Employee 对象
        Employee employee = Employee.builder()
                .id(1L)
                .username("user123")
                .name("张三")
                .password("password123")
                .phone("12345678901")
                .sex("男")
                .idNumber("123456789012345678")
                .status(1)
                .createTime(LocalDateTime.now())
                .updateTime(LocalDateTime.now())
                .createUser(1L)
                .updateUser(1L)
                .build();

        System.out.println(employee);
    }
}
使用 @JsonFormat 的场景
//创建 RESTful API
Employee里面的pojo就不详细写了
    //@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createTime;

    //@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime updateTime;
==========================================================================

@RestController
public class EmployeeController {

    @GetMapping("/employee")
    public ResponseEntity<String> getEmployee() throws Exception {
        // 创建 Employee 对象
        Employee employee = Employee.builder()
                .id(1L)
                .username("user123")
                .name("张三")
                .password("password123")
                .phone("12345678901")
                .sex("男")
                .idNumber("123456789012345678")
                .status(1)
                .createTime(LocalDateTime.now())
                .updateTime(LocalDateTime.now())
                .createUser(1L)
                .updateUser(1L)
                .build();

        // 使用 ObjectMapper 将 Employee 对象转换为 JSON 字符串
        ObjectMapper objectMapper = new ObjectMapper();
        String json = objectMapper.writeValueAsString(employee);

        return ResponseEntity.ok(json);
    }
}
===========================================================================
// 除了 @JsonFormat 注解,还有其他方式可以指定日期时间格式,具体取决于你的需求和使用的库。
 public static void main(String[] args) {
        LocalDateTime now = LocalDateTime.now();
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
        String formattedDateTime = now.format(formatter);

        Employee employee = Employee.builder()
                .id(1L)
                .username("user123")
                .name("张三")
                .password("password123")
                .phone("12345678901")
                .sex("男")
                .idNumber("123456789012345678")
                .status(1)
                .createTime(now)
                .updateTime(now)
                .createUser(1L)
                .updateUser(1L)
                .build();

        System.out.println("Formatted Create Time: " + formattedDateTime);
        System.out.println("Formatted Update Time: " + formattedDateTime);
    }

@Builder
通过 Employee.builder() 创建了一个构建器对象。
使用链式调用设置各个属性,最后调用 build() 方法生成 Employee 实例。
这种方式使得创建对象的代码更加简洁和易读,特别是当对象属性较多时。

@JsonFormat
在 createTime 和 updateTime 字段上使用了 @JsonFormat 注解,指定了日期时间的格式为 “yyyy-MM-dd HH:mm:ss”。
当 Employee 对象被转换为 JSON 字符串时,这两个字段会被格式化为指定的日期时间格式。
这样可以确保返回的 JSON 数据符合预期的格式要求。

RESTful风

可缓存性:
RESTful API 可以利用 HTTP 缓存机制,减少网络请求,提高性能。
客户端可以缓存响应,减少服务器的负载。

易于集成:
RESTful API 使用标准的 HTTP 协议,几乎所有的编程语言和框架都支持 HTTP 请求。
这使得不同系统之间的集成变得更加容易。

可读性强:
RESTful API 的 URL 设计通常非常直观,易于理解和记忆。
例如,/users/123 表示用户 ID 为 123 的资源,/users/123/orders 表示该用户的订单资源。

灵活性:
RESTful API 支持多种数据格式(如 JSON、XML 等),可以根据需要选择合适的格式。
客户端和服务器可以通过协商确定数据格式,提高了灵活性。

GET /users
GET /users/{id}
POST /users
PUT /users/{id}

功能测试

功能测试方式:
  • 通过接口文档测试
  • 通过前后端联调测试

注意:由于开发阶段前后端是并行开发的,后端完成某个功能后,此时前端对应的功能可能还没有开发完成,导致无法进行前后端联调测试。所以在开发阶段,后端测试主要以接口文档测试为主

[苍穹外卖项目接口文档] (http://localhost:8080/doc.html#/documentManager/GlobalParameters-default)

首先要拿到JWT令牌(去接口进行一次登录测试后会有) → 全局参数设置 → 添加参数
注意:这个jwt→json是有有效期的(2小时=7200000秒)

sky:
  jwt:
    # 设置jwt签名加密时使用的秘钥
    admin-secret-key: itcast
    # 设置jwt过期时间
    admin-ttl: 7200000
    # 设置前端传递过来的令牌名称
    admin-token-name: token

{
“code”: 1,
“msg”: null,
“data”: {
“id”: 1,
“userName”: “admin”,
“name”: “管理员”,
“token”: “eyJhbGciOiJIUzI1NiJ9.eyJlbXBJZCI6MSwiZXhwIjoxNzI3NjAxMTAxfQ.rnxaRc7fjPzMYwGHk3VzKA4EOxRFrYkKzesxEQsCQUc”
}
}


新增参数:
参数名称:token
参数值:eyJhbGciOiJIUzI1NiJ9.eyJlbXBJZCI6MSwiZXhwIjoxNzI3NjAxMTAxfQ.rnxaRc7fjPzMYwGHk3VzKA4EOxRFrYkKzesxEQsCQUc
参数类型:header

新增员工接口
{
“idNumber”: “1321321312”,
“name”: “张三”,
“phone”: “11111111111”,
“sex”: “1”,
“username”: “zhangsan”
}

响应内容:
{
“code”: 1,
“msg”: null,
“data”: null
}

sky-server  com/sky/interceptor/JwtTokenAdminInterceptor.java
package com.sky.interceptor;

import com.sky.constant.JwtClaimsConstant;
import com.sky.properties.JwtProperties;
import com.sky.utils.JwtUtil;
import io.jsonwebtoken.Claims;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * jwt令牌校验的拦截器
 */
@Component  //将该类注册为 Spring 管理的 Bean。
@Slf4j //使用 Lombok 自动生成日志记录器
public class JwtTokenAdminInterceptor implements HandlerInterceptor {
// 包含 JWT 相关的配置属性,如令牌名称和密钥
    @Autowired
    private JwtProperties jwtProperties;

    /**
     * 校验jwt
     *
     * @param request
     * @param response
     * @param handler
     * @return
     * @throws Exception
     */
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //判断当前拦截到的是Controller的方法还是其他资源
//检查当前拦截到的是否是 Controller 的方法。如果不是,直接放行
        if (!(handler instanceof HandlerMethod)) {
            //当前拦截到的不是动态方法,直接放行
            return true;
        }

        //1、从请求头中获取令牌
        String token = request.getHeader(jwtProperties.getAdminTokenName());

        //2、校验令牌
        try {
            log.info("jwt校验:{}", token);
            Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
            Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());
            log.info("当前员工id:", empId);
            //3、通过,放行
            return true;
        } catch (Exception ex) {
            //4、不通过,响应401状态码
            response.setStatus(401);
            return false;
        }
    }
}
com/sky/properties/JwtProperties.java
package com.sky.properties;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties(prefix = "sky.jwt")
@Data
public class JwtProperties {

    /**
     * 管理端员工生成jwt令牌相关配置
     */
    private String adminSecretKey;
    private long adminTtl;
    private String adminTokenName;

    /**
     * 用户端微信用户生成jwt令牌相关配置
     */
    private String userSecretKey;
    private long userTtl;
    private String userTokenName;
}

代码完善

程序存在的问题:
  • 录入的用户名已存在,抛出异常后没有处理
  • 新增员工时,创建人id和修改人id设置了固定值

当你在 Maven 中执行 compile 命令时,它会强制 Maven 重新编译整个项目,包括所有的类和资源。这一过程会清除任何旧的编译结果,确保所有的依赖和代码都是最新的。这可能导致以下几种情况,从而解决了你的问题:

**重新编译:**Maven 会重新编译所有的源代码,包括你修改或新增的类,这样就能解决因为旧的编译缓存而引起的引用问题。
**更新依赖:**如果你在项目中添加或修改了依赖,执行 compile 可以确保这些依赖被正确加载和引用。
清理旧缓存在编译过程中,Maven 会清理旧的缓存和临时文件,避免由于这些文件造成的潜在冲突。
**IDE 同步:**有时候,IDE 的状态可能与 Maven 项目状态不一致,执行 Maven 命令可以帮助 IDE 重新同步项目的状态。

问题①
sky-server  com/sky/handler/GlobalExceptionHandler.java
package com.sky.handler;

import com.sky.constant.MessageConstant;
import com.sky.exception.BaseException;
import com.sky.result.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.sql.SQLIntegrityConstraintViolationException;

/**
 * 全局异常处理器,处理项目中抛出的业务异常
 */
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    /**
     * 捕获业务异常
     * @param ex
     * @return
     */
    @ExceptionHandler
    public Result exceptionHandler(BaseException ex){
        log.error("异常信息:{}", ex.getMessage());
        return Result.error(ex.getMessage());
    }

    @ExceptionHandler
    public Result exceptionHandler(SQLIntegrityConstraintViolationException ex) {
        String message = ex.getMessage();
        if (message.contains("Duplicate entry")) {
            // Duplicate entry 'zhangsan' for key 'employee.idx_username'
// 在这里,我们使用 split("'") 将字符串分割为多个部分。这样,parts[1] 将得到 zhangsan,因为它位于单引号之间。这种方式可以正确提取用户名。
            String[] split = message.split("'");
            String username = split[1];
//            String msg = username + "已存在";
            String msg = username + MessageConstant.ALREADY_EXISTS;
            return Result.error(msg);
        }else {
            return Result.error(MessageConstant.UNKNOWN_ERROR);
        }
    }
}

Split的深入学习

  • 正则表达式

split 方法接受一个正则表达式作为参数,因此分隔符可以是复杂的模式,而不仅仅是单个字符。
例如,split(“\s+”) 可以用来按一个或多个空白字符(包括空格、制表符、换行符等)进行分割。

  • 限制分割次数

split 方法还有一个重载版本 split(String regex, int limit),可以限制分割的次数。
例如,split(“‘“, 3) 只会进行两次分割,结果数组最多包含三个元素。

问题② 解析出员工登录id后,如何转递给Service的save方法?ThreadLocal

前端会携带JWT令牌,通过JWT令牌可以解析出当前登录员工id:
        //1、从请求头中获取令牌
        String token = request.getHeader(jwtProperties.getAdminTokenName());

        //2、校验令牌
        try {
            log.info("jwt校验:{}", token);
            Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
            Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());
            log.info("当前员工id:", empId);
            //3、通过,放行
            return true;
        } catch (Exception ex) {
            //4、不通过,响应401状态码
            response.setStatus(401);
            return false;
        }
ThreadLocal

ThreadLocal并不是一个Thread,Thread的局部变量
ThreadLocal为每个线程提供单独一份存储空间,具有线程隔离的效果,只有在线程内才能获得到对应的值,线程外则不能访问

sky-common  com/sky/context/BaseContext.java
package com.sky.context;

public class BaseContext {

    public static ThreadLocal<Long> threadLocal = new ThreadLocal<>();

    public static void setCurrentId(Long id) {
        threadLocal.set(id);
    }

    public static Long getCurrentId() {
        return threadLocal.get();
    }

    public static void removeCurrentId() {
        threadLocal.remove();
    }
}
sky-server  com/sky/interceptor/JwtTokenAdminInterceptor.java
package com.sky.interceptor;

import com.sky.constant.JwtClaimsConstant;
import com.sky.context.BaseContext;
import com.sky.properties.JwtProperties;
import com.sky.utils.JwtUtil;
import io.jsonwebtoken.Claims;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * jwt令牌校验的拦截器
 */
@Component
@Slf4j
public class JwtTokenAdminInterceptor implements HandlerInterceptor {

    @Autowired
    private JwtProperties jwtProperties;

    /**
     * 校验jwt
     *
     * @param request
     * @param response
     * @param handler
     * @return
     * @throws Exception
     */
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //判断当前拦截到的是Controller的方法还是其他资源
        if (!(handler instanceof HandlerMethod)) {
            //当前拦截到的不是动态方法,直接放行
            return true;
        }

        //1、从请求头中获取令牌
        String token = request.getHeader(jwtProperties.getAdminTokenName());

        //2、校验令牌
        try {
            log.info("jwt校验:{}", token);
            Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
            Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());
            log.info("当前员工id:", empId);
// ★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★
            BaseContext.setCurrentId(empId);
            //3、通过,放行
            return true;
        } catch (Exception ex) {
            //4、不通过,响应401状态码
            response.setStatus(401);
            return false;
        }
    }
}
sky-server  com/sky/service/impl/EmployeeServiceImpl.java
/**
     * 新增员工
     * @param employeeDTO
     */
    @Override
    public void save(EmployeeDTO employeeDTO) {
        Employee employee = new Employee();
//        employee.setName(employeeDTO.getName()); 太多了 用对象属性拷贝
        BeanUtils.copyProperties(employeeDTO,employee); //其余的要手动设置
        //设置账号状态,默认正常状态 1正常 0锁定  规范封装
        employee.setStatus(StatusConstant.ENABLE);
        //设置密码,默认密码123456
        employee.setPassword(DigestUtils.md5DigestAsHex(PasswordConstant.DEFAULT_PASSWORD.getBytes()));
        //设置当前记录的创建时间和修改时间
        employee.setCreateTime(LocalDateTime.now());
        employee.setUpdateTime(LocalDateTime.now());
        //设置当前记录创建人id和修改人id
        //TODO 后期需要改为当前登录用户的id
//        employee.setCreateUser(10L);
// ★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★
        employee.setCreateUser(BaseContext.getCurrentId());
        employee.setUpdateUser(BaseContext.getCurrentId());
    
        employeeMapper.insert(employee);
    }

// ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ 如果想单独针对22行代码 测试部分的值是多少 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
// 左键选中'BaseContext.getCurrentId()' 右键Evaluate Expression单独计算即可

将员工登录ID放在 ThreadLocal 中的原因

在多线程环境中,使用 ThreadLocal 来存储和传递员工的完整信息是一个常见的做法。这样可以确保每个线程都有独立的变量副本,避免并发问题。以下是一些步骤和最佳实践,帮助你在 ThreadLocal 中安全地传递和存储员工的完整信息。
ThreadLocal 主要用于在同一个线程内传递和存储数据,确保每个线程都有独立的变量副本。在你的例子中,ThreadLocal 用于存储员工ID,确保在多线程环境下员工ID的安全传递。

  • 线程隔离:ThreadLocal 确保每个线程都有独立的员工ID副本,避免了多线程环境下的并发问题。
    防止篡改:只有当前线程可以访问和修改 ThreadLocal 中的员工ID,其他线程无法访问,确保了ID的安全性。
  • 员工其他信息的安全性
    数据库查询:员工的其他信息是从数据库中查询的,而不是从 ThreadLocal 中获取的。数据库查询本身是安全的,只要数据库连接和查询操作是安全的。
    权限控制:确保只有经过认证的用户才能执行查询操作,防止未授权访问。
    数据加密:敏感信息(如密码)在存储和传输过程中应进行加密,确保数据的安全性。

线程安全
ThreadLocal 为每个线程提供独立的变量副本,避免了多线程环境下的并发问题。每个线程都可以安全地读取和修改自己的 ThreadLocal 变量,而不会影响其他线程。

简化代码
在 Web 应用中,通常需要在多个方法或组件之间传递用户身份信息(如员工登录ID)。使用 ThreadLocal 可以避免在每个方法调用中显式传递这些信息,从而简化代码。

全局访问
在同一个线程内,任何地方都可以访问 ThreadLocal 中存储的值,这使得在复杂的业务逻辑中传递和使用员工登录ID变得非常方便。

避免传递参数
在多层调用中,如果需要传递员工登录ID,通常需要在每个方法签名中添加相应的参数。使用 ThreadLocal 可以避免这种繁琐的参数传递,提高代码的可读性和可维护性。

// 设置员工登录ID:
在用户登录成功后,将员工登录ID设置到 ThreadLocal 中。
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) {
    // 验证用户名和密码
    User user = userService.validateUser(loginRequest.getUsername(), loginRequest.getPassword());
    if (user != null) {
        // 设置当前线程的员工登录ID
        BaseContext.setCurrentId(user.getId());
        // 返回登录成功信息
        return ResponseEntity.ok("Login successful");
    } else {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid username or password");
    }
}
// 获取员工登录ID
在需要使用员工登录ID的地方,直接从 ThreadLocal 中获取
@Service
public class OrderService {

    public void createOrder(Order order) {
        Long currentUserId = BaseContext.getCurrentId();
        if (currentUserId != null) {
            order.setCreatedBy(currentUserId);
            orderRepository.save(order);
        } else {
            throw new RuntimeException("User ID not found in context");
        }
    }
}

将员工登录ID放在 ThreadLocal 中,可以确保每个线程都有独立的变量副本,避免多线程环境下的并发问题。同时,这种方式简化了代码,提供了全局访问的能力,避免了繁琐的参数传递,使得在复杂的业务逻辑中传递和使用员工登录ID变得非常方便。

员工分页查询

需求分析和设计
业务规则:(查询 → get)
  • 根据页码展示员工信息
  • 每页展示10条数据
  • 分页查询时可以根据需要,输入员工姓名进行查询
代码开发
根据分页查询接口设计对应的DTO:

Query

参数名称 是否必须 示例 备注
name 张三 员工姓名
page 1 页码
pageSize 10 每页记录数
@Data
public class EmployeePageQueryDTO implements Serializable{
    private String name;
    private int page;
    private int pageSize;
}
后面所有的分页查询,统一都封装成PageResult对象
/*封装分页查询结果*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PageResult implements Serializable{
    private long total; 
    private List records;
}
员工信息分页查询后端返回的对象类型为:Result < PageResult >
sky-server  com/sky/controller/admin/EmployeeController.java
/**
     * 员工分页查询
     * @param employeePageQueryDTO
     * @return
     */
    @GetMapping("/page")
    public Result<PageResult> page(EmployeePageQueryDTO employeePageQueryDTO){
        //格式不是JSON不用加 @RequestBody
        log.info("员工分页查询,参数为:{}", employeePageQueryDTO);
        PageResult pageResult = employeeService.pageQuery(employeePageQueryDTO);
        return Result.success(pageResult);
    }
sky-server  com/sky/service/EmployeeService.java
/**
     * 分页查询
     * @param employeePageQueryDTO
     * @return
     */
    PageResult pageQuery(EmployeePageQueryDTO employeePageQueryDTO);
sky-server  com/sky/service/impl/EmployeeServiceImpl.java
/**
     * 分页查询
     * @param employeePageQueryDTO
     * @return
     */
    @Override
    public PageResult pageQuery(EmployeePageQueryDTO employeePageQueryDTO) {
        // select * from employee limit 0,10
        // 开始分页查询 动态拼接
        PageHelper.startPage(employeePageQueryDTO.getPage(), employeePageQueryDTO.getPageSize());
        Page<Employee> page =  employeeMapper.pageQuery(employeePageQueryDTO);

        long total = page.getTotal();
        List<Employee> records = page.getResult();

        return new PageResult(total, records);
}

逐行研究分页查询

  • PageHelper.startPage 是 MyBatis 分页插件提供的方法,用于开启分页功能

    • employeePageQueryDTO.getPage() 获取当前页码。

    • employeePageQueryDTO.getPageSize() 获取每页显示的记录数

      这一行代码的作用是告诉 MyBatis 在接下来的查询中启用分页,并设置分页参数

  • employeeMapper.pageQuery(employeePageQueryDTO) 是调用 MyBatis 的 Mapper 接口方法,执行分页查询。

    • employeePageQueryDTO 包含了查询条件,如关键字、排序字段等。
    • 查询结果会被封装成 Page 对象,其中包含了分页数据和分页元数据。
  • page.getTotal() 获取分页查询的总记录数。

    • 总记录数用于计算总页数和其他分页相关的计算
  • List records = page.getResult();

    • page.getResult() 获取分页查询的实际数据列表。
    • 这个列表包含了当前页的员工记录
  • return new PageResult(total, records);

  • new PageResult(total, records) 创建一个新的 PageResult 对象,将总记录数和分页数据列表封装起来

  • PageResult 类通常包含 total 和 records 属性,用于返回给客户端

  • 假设 employeePageQueryDTO.getPage() 返回 2,employeePageQueryDTO.getPageSize() 返回 10,那么 MyBatis 生成的 SQL 可能类似于:

SELECT * FROM employee
WHERE ... -- 根据 employeePageQueryDTO 中的查询条件
LIMIT 10 OFFSET 10;

LIMIT 10:表示每页显示 10 条记录。
OFFSET 10:表示从第 11 条记录开始(因为页码从 1 开始,所以第 2 页的偏移量是 10)。

  • PageResult 类:用于封装分页查询的结果,包括总记录数和当前页的数据集合。
    使用场景:在分页查询服务中,将查询结果封装为 PageResult 对象,通过控制器返回给客户端。

  • Serializable 接口:是 Java 中的一个标记接口,没有定义任何方法。实现 Serializable 接口的类的对象可以被序列化,即将对象的状态转换为字节流,以便在网络上传输或持久化存储。反序列化则是将字节流恢复为对象的过程。

    • 序列化

    对象状态转换:将对象的状态(即对象的字段值)转换为字节流。
    默认序列化机制:Java 提供了默认的序列化机制,通过 ObjectOutputStream 类的 writeObject 方法实现。
    自定义序列化:可以通过实现 writeObject 和 readObject 方法来自定义序列化和反序列化过程。

    • 持久化

    持久化:序列化的主要目的是将对象的状态保存到存储介质中,或者通过网络传输对象。

查询结果会被封装到 PageResult 对象中,其中 total 表示总记录数,records 表示当前页的数据集合。

sky-server  com/sky/mapper/EmployeeMapper.java
 /**
     * 分页查询 [动态sql 不用注解了 写道 EmployeeMapper.xml]
     * @param employeePageQueryDTO
     * @return
     */
    Page<Employee> pageQuery(EmployeePageQueryDTO employeePageQueryDTO);
sky-server  mapper/EmployeeMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.EmployeeMapper">
    <select id="pageQuery" resultType="com.sky.entity.Employee">
        select * from employee
        <where>
            <if test="name != null and name != ''">
                and name like concat('%',#{name},'%')
            </if>
        </where>
        order by create_time desc
    </select>
</mapper>
<!--
and name like concat('%',#{name},'%'):
如果条件成立,生成的 SQL 条件为 AND name LIKE '%${name}%',实现名称的模糊匹配
-->

[员工管理] (http://localhost/#/employee)

代码完善
问题:创建/更新时间那边传入的数据不是想要的
// 2024929214237
"createTime": [
          2024,
          9,
          29,
          22,
          10,
          37
        ],
        "updateTime": [
          2024,
          9,
          29,
          22,
          10,
          37
        ],
解决方式:
  • 方法一:在属性上加注解,对日期进行格式化(只能处理单独一个属性)

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime updateTime;
    ------------------------------------------------------------------------
     "createTime": "2024-09-29 22:10:37",
    
  • 方法二:在WebMvcConfiguration中扩展Spring MVC的消息转换器,统一对日期类型进行格式化处理

重写父类方法 去扩展 消息转换器

sky-server  com/sky/config/WebMvcConfiguration.java
 /**
     * 扩展Spring MVC框架的消息转化器
     * @param converters
     */
    protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
        log.info("扩展消息转换器...");
        //创建一个消息转换器对象
        MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
        //需要为消息转换器设置一个对象转换器,对象转换器可以将Java对象序列化为json数据
        converter.setObjectMapper(new JacksonObjectMapper());
        //将自己的消息转化器加入容器中
        converters.add(0,converter);
    }
package com.sky.json;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES;

/**
 * 对象映射器:基于jackson将Java对象转为json,或者将json转为Java对象
 * 将JSON解析为Java对象的过程称为 [从JSON反序列化Java对象]
 * 从Java对象生成JSON的过程称为 [序列化Java对象到JSON]
 */
public class JacksonObjectMapper extends ObjectMapper {

    public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
    //public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
    public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm";
    public static final String DEFAULT_TIME_FORMAT = "HH:mm:ss";

    public JacksonObjectMapper() {
        super();
        //收到未知属性时不报异常
        this.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);

        //反序列化时,属性不存在的兼容处理
        this.getDeserializationConfig().withoutFeatures(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);

        SimpleModule simpleModule = new SimpleModule()
                .addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
                .addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
                .addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)))
                .addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
                .addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
                .addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)));

        //注册功能模块 例如,可以添加自定义序列化器和反序列化器
        this.registerModule(simpleModule);
    }
}

启用禁用员工账号

需求分析和设计
业务规则:
  • 可以对状态为 “启用” 的员工账号进行 “禁用” 操作
  • 可以对状态为 “禁用” 的员工账号进行 “启用” 操作
  • 状态为 “禁用” 的员工账号不能登录系统
sky-server
/**
     * 启用禁用员工账号
     * @param status
     * @param id
     * @return
     */
    @PostMapping("/status/{status}")
    @ApiOperation("启用禁用员工账号")
    //因为上面的和下面的参数都是一致 不然需要@PathVariable("status")解释一下
    public Result startOrStop(@PathVariable("status") Integer status, Long id) {
        log.info("启用禁用员工账号: {},{}",status,id);
        employeeService.startOrStop(status, id);
        return Result.success();
    }
sky-server  com/sky/service/EmployeeService.java
 /**
     * 启用禁用员工账号
     * @param status
     * @param id
     * @return
     */
    void startOrStop(Integer status, Long id);
sky-server  com/sky/service/impl/EmployeeServiceImpl.java
/**
     * 启用禁用员工账号
     * @param status
     * @param id
     * @return
     */
    @Override
    public void startOrStop(Integer status, Long id) {
        // update employee set status = ? where id = ?
        Employee employee = new Employee();
        employee.setStatus(status);
        employee.setId(id);

/** 要在Employee.java中添加@Builder 才能使用这种风格
 *      Employee employee = Employee.builder()
 *              .status(status)
 *              .id(id)
 *              .build();
 */
        employeeMapper.update(employee);
    }
sky-server  com/sky/mapper/EmployeeMapper.java
/**
     * 根据主键动态修改属性
     * @param employee
     */
    void update(Employee employee);
sky-server  mapper/EmployeeMapper.xml
<update id="update" parameterType="Employee">
        update employee
        <set>
            <if test="name != null">name = #{name},</if>
            <if test="username != null">username = #{username},</if>
            <if test="password != null">password = #{password},</if>
            <if test="phone != null">phone = #{phone},</if>
            <if test="sex != null">sex = #{sex},</if>
            <if test="idNumber != null">id_Number = #{idNumber},</if>
            <if test="updateTime != null">update_Time = #{updateTime},</if>
            <if test="updateUser != null">update_User = #{updateUser},</if>
            <if test="status != null">status = #{status},</if>
        </set>
        where id = #{id}
    </update>

编辑员工

需求分析和设计[回写数据]

编辑员工功能涉及到两个接口:
  • 根据id查询员工信息
  • 编辑员工信息

代码开发

sky-server  com/sky/controller/admin/EmployeeController.java
    /**
     * 根据id查询员工信息
     * @param id
     * @return
     */
    @GetMapping("/{id}")
    @ApiOperation("根据id查询员工信息")
    public Result<Employee> getById(@PathVariable Long id){
        Employee employee = employeeService.getById(id);
        return Result.success(employee);
    }

    /**
     * 编辑员工信息
     * @param employeeDTO
     * @return
     */
    @PutMapping
    @ApiOperation("编辑员工信息")
    public Result update(@RequestBody EmployeeDTO employeeDTO){
        log.info("编辑员工信息:{}", employeeDTO);
        employeeService.update(employeeDTO);
        return Result.success();
    }
sky-server  com/sky/service/EmployeeService.java
/**
     * 根据id查询员工
     * @param id
     * @return
     */
    Employee getById(Long id);

    /**
     * 编辑员工信息
     * @param employeeDTO
     */
    void update(EmployeeDTO employeeDTO);
sky-server  com/sky/service/impl/EmployeeServiceImpl.java
/**
     * 根据id查询员工
     * @param id
     * @return
     */
    public Employee getById(Long id) {
        Employee employee = employeeMapper.getById(id);
        employee.setPassword("****");
        return employee;
    }

    /**
     * 编辑员工信息
     * @param employeeDTO
     */
    public void update(EmployeeDTO employeeDTO) {
        Employee employee = new Employee();
        BeanUtils.copyProperties(employeeDTO, employee);

        //employee.setUpdateTime(LocalDateTime.now());
        //employee.setUpdateUser(BaseContext.getCurrentId());

        employeeMapper.update(employee);
    }
sky-server  com/sky/mapper/EmployeeMapper.java
 /**
     * 根据主键动态修改属性
     * @param employee
     */
    void update(Employee employee);

    /**
     * 根据id查询员工信息
     * @param id
     * @return
     */
    @Select("select * from employee where id = #{id}")
    Employee getById(Long id);
  • 前端提交表单:
    用户在前端页面编辑员工信息并提交表单。
    表单数据被序列化为 JSON 格式,通过 HTTP PUT 请求发送到后端。
  • 后端接收数据:
    控制器方法 update 接收到 EmployeeDTO 对象。
    记录日志,输出接收到的员工信息。
    调用服务层的 update 方法,处理员工信息的更新。
  • 服务层处理:
    创建一个新的 Employee 对象。
    使用 BeanUtils.copyProperties 将 EmployeeDTO 的属性复制到 Employee 对象中。
    调用 MyBatis 的 employeeMapper,执行更新操作。
  • MyBatis 更新操作:
    生成动态 SQL 语句,只更新传入的非 null 属性。
    例如,如果 name 和 phone 不为 null,生成的 SQL 语句如下:

数据回写的具体过程

  • 前端请求获取员工信息 // 根据id查询员工信息
    当你点击编辑按钮时,前端会发起一个 HTTP GET 请求,从后端获取员工的详细信息。这些信息将被用来填充表单字段。
  • 后端处理 GET 请求
    后端需要提供一个接口来处理这个 GET 请求,并返回员工的详细信息。
  • 前端处理响应并填充表单
    前端接收到后端返回的员工信息后,将其填充到表单字段中
/**
     * 根据id查询员工信息
     * @param id
     * @return
     */
    @GetMapping("{/id}")
    @ApiOperation("根据id查询员工信息")
    public Result<Employee> getById(@PathVariable Long id) {
        log.info("根据id查询员工信息:{}", id);
        Employee employee = employeeService.getById(id);
        return Result.success(employee);
    }
<script>export default {
  data() {
    return {
      employee: {
        id: null,
        name: '',
        username: '',
        password: '',
        phone: '',
        sex: '',
        idNumber: ''
      }
    };
  },
  methods: {
    async fetchEmployee(id) {
      try {
        const response = await this.$axios.get(`/employees/${id}`);
        this.employee = response.data.data;
      } catch (error) {
        console.error('获取员工信息失败', error);
      }
    },
    async updateEmployee() {
      try {
        await this.$axios.put('/employees', this.employee);
        alert('员工信息更新成功');
      } catch (error) {
        console.error('更新员工信息失败', error);
      }
    }
  },
  mounted() {
    const id = this.$route.params.id; // 假设通过路由参数传递员工ID
    this.fetchEmployee(id);
  }
};
</script>
  • 提交表单
    当用户编辑完表单并点击保存按钮时,前端会发起一个 HTTP PUT 请求,将更新后的员工信息发送到后端进行处理

总结
前端请求获取员工信息:点击编辑按钮时,前端发起 GET 请求获取员工的详细信息。
后端处理 GET 请求:后端提供一个接口处理 GET 请求,返回员工的详细信息。
前端处理响应并填充表单:前端接收到员工信息后,将其填充到表单字段中。
提交表单:用户编辑完表单并点击保存按钮,前端发起 PUT 请求,将更新后的员工信息发送到后端进行处理。

[苍穹外卖项目接口文档] (http://localhost:8080/doc.html#/default/员工相关接口/updateUsingPUT)

导入分类管理功能代码

业务规则:
  • 分类名称必须是唯一
  • 分类按章类型可分为菜品分类套餐分类
  • 新添加的分类状态默认认为 “禁用
接口设计:
  • 新增分类
  • 分类分页查询
  • 根据id删除分类
  • 修改分类
  • 启用禁止分类
  • 根据类型调查分类

数据库设计(category表)

sky-server  com/sky/controller/admin/CategoryController.java
package com.sky.controller.admin;

import com.sky.dto.CategoryDTO;
import com.sky.dto.CategoryPageQueryDTO;
import com.sky.entity.Category;
import com.sky.result.PageResult;
import com.sky.result.Result;
import com.sky.service.CategoryService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;

/**
 * 分类管理
 */
@RestController
@RequestMapping("/admin/category")
@Api(tags = "分类相关接口")
@Slf4j
public class CategoryController {

    @Autowired
    private CategoryService categoryService;

    /**
     * 新增分类
     * @param categoryDTO
     * @return
     */
    @PostMapping
    @ApiOperation("新增分类")
    public Result<String> save(@RequestBody CategoryDTO categoryDTO){
        log.info("新增分类:{}", categoryDTO);
        categoryService.save(categoryDTO);
        return Result.success();
    }

    /**
     * 分类分页查询
     * @param categoryPageQueryDTO
     * @return
     */
    @GetMapping("/page")
    @ApiOperation("分类分页查询")
    public Result<PageResult> page(CategoryPageQueryDTO categoryPageQueryDTO){
        log.info("分页查询:{}", categoryPageQueryDTO);
        PageResult pageResult = categoryService.pageQuery(categoryPageQueryDTO);
        return Result.success(pageResult);
    }

    /**
     * 删除分类
     * @param id
     * @return
     */
    @DeleteMapping
    @ApiOperation("删除分类")
    public Result<String> deleteById(Long id){
        log.info("删除分类:{}", id);
        categoryService.deleteById(id);
        return Result.success();
    }

    /**
     * 修改分类
     * @param categoryDTO
     * @return
     */
    @PutMapping
    @ApiOperation("修改分类")
    public Result<String> update(@RequestBody CategoryDTO categoryDTO){
        categoryService.update(categoryDTO);
        return Result.success();
    }

    /**
     * 启用、禁用分类
     * @param status
     * @param id
     * @return
     */
    @PostMapping("/status/{status}")
    @ApiOperation("启用禁用分类")
    public Result<String> startOrStop(@PathVariable("status") Integer status, Long id){
        categoryService.startOrStop(status,id);
        return Result.success();
    }

    /**
     * 根据类型查询分类
     * @param type
     * @return
     */
    @GetMapping("/list")
    @ApiOperation("根据类型查询分类")
    public Result<List<Category>> list(Integer type){
        List<Category> list = categoryService.list(type);
        return Result.success(list);
    }
}
sky-server  com/sky/service/CategoryService.java
package com.sky.service;

import com.sky.dto.CategoryDTO;
import com.sky.dto.CategoryPageQueryDTO;
import com.sky.entity.Category;
import com.sky.result.PageResult;
import java.util.List;

public interface CategoryService {

    /**
     * 新增分类
     * @param categoryDTO
     */
    void save(CategoryDTO categoryDTO);

    /**
     * 分页查询
     * @param categoryPageQueryDTO
     * @return
     */
    PageResult pageQuery(CategoryPageQueryDTO categoryPageQueryDTO);

    /**
     * 根据id删除分类
     * @param id
     */
    void deleteById(Long id);

    /**
     * 修改分类
     * @param categoryDTO
     */
    void update(CategoryDTO categoryDTO);

    /**
     * 启用、禁用分类
     * @param status
     * @param id
     */
    void startOrStop(Integer status, Long id);

    /**
     * 根据类型查询分类
     * @param type
     * @return
     */
    List<Category> list(Integer type);
}
sky-server  com/sky/service/impl/CategoryServiceImpl.java
package com.sky.service.impl;

import com.github.pagehelper.Page;
import com.github.pagehelper.PageHelper;
import com.sky.constant.MessageConstant;
import com.sky.constant.StatusConstant;
import com.sky.context.BaseContext;
import com.sky.dto.CategoryDTO;
import com.sky.dto.CategoryPageQueryDTO;
import com.sky.entity.Category;
import com.sky.exception.DeletionNotAllowedException;
import com.sky.mapper.CategoryMapper;
import com.sky.mapper.DishMapper;
import com.sky.mapper.SetmealMapper;
import com.sky.result.PageResult;
import com.sky.service.CategoryService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;

/**
 * 分类业务层
 */
@Service
@Slf4j
public class CategoryServiceImpl implements CategoryService {

    @Autowired
    private CategoryMapper categoryMapper;
    @Autowired
    private DishMapper dishMapper;
    @Autowired
    private SetmealMapper setmealMapper;

    /**
     * 新增分类
     * @param categoryDTO
     */
    public void save(CategoryDTO categoryDTO) {
        Category category = new Category();
        //属性拷贝
        BeanUtils.copyProperties(categoryDTO, category);

        //分类状态默认为禁用状态0
        category.setStatus(StatusConstant.DISABLE);

        //设置创建时间、修改时间、创建人、修改人
        category.setCreateTime(LocalDateTime.now());
        category.setUpdateTime(LocalDateTime.now());
        category.setCreateUser(BaseContext.getCurrentId());
        category.setUpdateUser(BaseContext.getCurrentId());

        categoryMapper.insert(category);
    }

    /**
     * 分页查询
     * @param categoryPageQueryDTO
     * @return
     */
    public PageResult pageQuery(CategoryPageQueryDTO categoryPageQueryDTO) {
        PageHelper.startPage(categoryPageQueryDTO.getPage(),categoryPageQueryDTO.getPageSize());
        //下一条sql进行分页,自动加入limit关键字分页
        Page<Category> page = categoryMapper.pageQuery(categoryPageQueryDTO);
        return new PageResult(page.getTotal(), page.getResult());
    }

    /**
     * 根据id删除分类
     * @param id
     */
    public void deleteById(Long id) {
        //查询当前分类是否关联了菜品,如果关联了就抛出业务异常
        Integer count = dishMapper.countByCategoryId(id);
        if(count > 0){
            //当前分类下有菜品,不能删除
            throw new DeletionNotAllowedException(MessageConstant.CATEGORY_BE_RELATED_BY_DISH);
        }

        //查询当前分类是否关联了套餐,如果关联了就抛出业务异常
        count = setmealMapper.countByCategoryId(id);
        if(count > 0){
            //当前分类下有菜品,不能删除
            throw new DeletionNotAllowedException(MessageConstant.CATEGORY_BE_RELATED_BY_SETMEAL);
        }

        //删除分类数据
        categoryMapper.deleteById(id);
    }

    /**
     * 修改分类
     * @param categoryDTO
     */
    public void update(CategoryDTO categoryDTO) {
        Category category = new Category();
        BeanUtils.copyProperties(categoryDTO,category);

        //设置修改时间、修改人
        category.setUpdateTime(LocalDateTime.now());
        category.setUpdateUser(BaseContext.getCurrentId());

        categoryMapper.update(category);
    }

    /**
     * 启用、禁用分类
     * @param status
     * @param id
     */
    public void startOrStop(Integer status, Long id) {
        Category category = Category.builder()
                .id(id)
                .status(status)
                .updateTime(LocalDateTime.now())
                .updateUser(BaseContext.getCurrentId())
                .build();
        categoryMapper.update(category);
    }

    /**
     * 根据类型查询分类
     * @param type
     * @return
     */
    public List<Category> list(Integer type) {
        return categoryMapper.list(type);
    }
}
sky-server  com/sky/mapper/CategoryMapper.java
package com.sky.mapper;

import com.github.pagehelper.Page;
import com.sky.enumeration.OperationType;
import com.sky.dto.CategoryPageQueryDTO;
import com.sky.entity.Category;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;

@Mapper
public interface CategoryMapper {

    /**
     * 插入数据
     * @param category
     */
    @Insert("insert into category(type, name, sort, status, create_time, update_time, create_user, update_user)" +
            " VALUES" +
            " (#{type}, #{name}, #{sort}, #{status}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser})")
    void insert(Category category);

    /**
     * 分页查询
     * @param categoryPageQueryDTO
     * @return
     */
    Page<Category> pageQuery(CategoryPageQueryDTO categoryPageQueryDTO);

    /**
     * 根据id删除分类
     * @param id
     */
    @Delete("delete from category where id = #{id}")
    void deleteById(Long id);

    /**
     * 根据id修改分类
     * @param category
     */
    void update(Category category);

    /**
     * 根据类型查询分类
     * @param type
     * @return
     */
    List<Category> list(Integer type);
}
sky-server  mapper/CategoryMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.CategoryMapper">

    <select id="pageQuery" resultType="com.sky.entity.Category">
        select * from category
        <where>
            <if test="name != null and name != ''">
                and name like concat('%',#{name},'%')
            </if>
            <if test="type != null">
                and type = #{type}
            </if>
        </where>
        order by sort asc , create_time desc
    </select>

    <update id="update" parameterType="Category">
        update category
        <set>
            <if test="type != null">
                type = #{type},
            </if>
            <if test="name != null">
                name = #{name},
            </if>
            <if test="sort != null">
                sort = #{sort},
            </if>
            <if test="status != null">
                status = #{status},
            </if>
            <if test="updateTime != null">
                update_time = #{updateTime},
            </if>
            <if test="updateUser != null">
                update_user = #{updateUser}
            </if>
        </set>
        where id = #{id}
    </update>

    <select id="list" resultType="Category">
        select * from category
        where status = 1
        <if test="type != null">
            and type = #{type}
        </if>
        order by sort asc,create_time desc
    </select>
</mapper>
sky-server  com/sky/mapper/DishMapper.java
package com.sky.mapper;

import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;

@Mapper
public interface DishMapper {

    /**
     * 根据分类id查询菜品数量
     * @param categoryId
     * @return
     */
    @Select("select count(id) from dish where category_id = #{categoryId}")
    Integer countByCategoryId(Long categoryId);

}
sky-server  com/sky/mapper/SetmealMapper.java
package com.sky.mapper;

import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;

@Mapper
public interface SetmealMapper {

    /**
     * 根据分类id查询套餐的数量
     * @param id
     * @return
     */
    @Select("select count(id) from setmeal where category_id = #{categoryId}")
    Integer countByCategoryId(Long id);

}

菜品管理

公共字段自动填充

业务表中的公共字段:(后期会很多[菜品/套餐管理])

问题:代码冗余不利于后期维护
序号 字段名 含义 数据类型
1 create_time 创建时间 datetime
2 create_user 创建人id bigint
3 update_time 修改时间 datetime
4 update_user 修改人id bigint
解决:技术点 → 枚举、注解、AOP、反射
序号 字段名 含义 数据类型 操作类型
1 create_time 创建时间 datetime insert
2 create_user 创建人id bigint insert
3 update_time 修改时间 datetime insert、update
4 update_user 修改人id bigint insert、update
  • 自定义注解 AutoFill,用于标识需要进行公共字段自动填充的方法
  • 自定义切面 AutoFillAspect,统一拦截加入了 AutoFill 注解的方法,通过反射为公共字段赋值
  • Mapper 的方法上加入 AutoFill 注解

代码开发1

sky-server  com/sky/annotation/AutoFill.java
package com.sky.annotation;

import com.sky.enumeration.OperationType;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 自定义注解,用于标识某个方法需要进行功能字段自动填充处理
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFill {
// 枚举数据库操作类型:UPDATE INSERT [只要在这情况才有必要设置]
    OperationType value();
}
sky-server  com/sky/aspect/AutoFillAspect.java
package com.sky.aspect;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;

/**
 * 自定义切面,实现公共字段自动填充处理逻辑
 */
@Aspect
@Component
@Slf4j
public class AutoFillAspect {
    /**
     * 切入点
     */
    // 拦截类 + 注解的东西
    @Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
    public void autoFillPointCut(){}

    // 前置通知,在通知中进行公共字段的赋值
    @Before("autoFillPointCut()")
    public void autoFill(JoinPoint joinPoint) {
        log.info("开始公共字段自动填充...");
    }
}
sky-server  com/sky/mapper/EmployeeMapper.java
//只在update和insert里加
@Mapper
public interface EmployeeMapper {
 /**
     * 插入员工数据
     * @param employee
     */
    @Insert("insert into employee (name, username, password, phone, sex, id_number, create_time, update_time, create_user, update_user,status) " +
            "values " +
            "(#{name},#{username},#{password},#{phone},#{sex},#{idNumber},#{createTime},#{updateTime},#{createUser},#{updateUser},#{status})")
    @AutoFill(value = OperationType.INSERT)
    void insert(Employee employee);
/**
     * 分页查询 [动态sql 不用注解了 写道 EmployeeMapper.xml]
     * @param employeePageQueryDTO
     * @return
     */
    Page<Employee> pageQuery(EmployeePageQueryDTO employeePageQueryDTO);
    @AutoFill(value = OperationType.UPDATE)
}
sky-server  com/sky/mapper/CategoryMapper.java
package com.sky.mapper;

import com.github.pagehelper.Page;
import com.sky.annotation.AutoFill;
import com.sky.enumeration.OperationType;
import com.sky.dto.CategoryPageQueryDTO;
import com.sky.entity.Category;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface CategoryMapper {

    /**
     * 插入数据
     * @param category
     */
    @Insert("insert into category(type, name, sort, status, create_time, update_time, create_user, update_user)" +
            " VALUES" +
            " (#{type}, #{name}, #{sort}, #{status}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser})")
    void insert(Category category);

    /**
     * 根据id修改分类
     * @param category
     */
    void update(Category category);
}

详细讲解@AutoFill

@Target(ElementType.METHOD)
使用@Target注解指定自定义注解@AutoFill可以应用的目标元素类,这里指定了ElementType.METHOD,表示@AutoFill**只能应用于方法上**

@Retention(RetentionPolicy.RUNTIME)
使用 @Retention 注解指定自定义注解 @AutoFill 的保留策略。这里指定了 RetentionPolicy.RUNTIME,表示 @AutoFill 注解会在运行时保留,可以通过反射获取到。

public @interface AutoFill {…}
@interface:关键字,用于定义一个新的注解类型。
AutoFill:注解的名称,表示这个注解就叫做AutoFill

区分普通接口:@interface 与普通的 interface 不同,普通的interface用于定义接口,而@interface用于定义注解,@符号帮助编译器区分这两者

package com.sky.enumeration;

/**
 * 数据库操作类型
 */
public enum OperationType {
    /**
     * 更新操作
     */
    UPDATE,

    /**
     * 插入操作
     */
    INSERT
}

@Aspect
使用@Aspect注解将这个类标记为一个切面,切面是AOP(面向切面编程),用于定义切面关注点(日志记录、事务管理)

@Component
使用@Component注解将这个类标记为Spring管理的Bean,这样Spring容器会自动扫描并管理这个类的实例

@Slf4j
使用 @Slf4j 注解生成一个日志记录器(Logger)实例。这个注解来自 Lombok 库,可以简化日志记录器的创建

@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
    public void autoFillPointCut(){}

使用@Pointcut注解定义一个切入点autoFillPointCut

  • execution(* com.sky.mapper.*.*(..)):匹配com.sky.mapper包下所有类的所有方法
  • && @annotation(com.sky.annotation.AutoFill):并且这些方法必须带有@AutoFill注解
  • public void autoFillPointCut():定义一个空的方法,用于标识这个切入点
/**
 * 自定义切面,实现公共字段自动填充处理逻辑
 */
@Aspect
@Component
@Slf4j
public class AutoFillAspect {
    /**
     * 切入点
     */
    // 拦截类 + 注解的东西
    @Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
    public void autoFillPointCut(){}

    // 前置通知,在通知中进行公共字段的赋值
    @Before("autoFillPointCut()")
    public void autoFill(JoinPoint joinPoint) {
        log.info("开始公共字段自动填充...");
    }
}

使用@Before注解定义一个前置通知autoFill,这个通知会在切入点方法执行前被调用

  • @Before("autoFillPointCut()"):指定这个通知应用于autoFillPointCut切入点
  • public void autoFill(JoinPoint joinPoint):定义通知方法,接收一个JoinPoint参数,JoinPoint包含了连接点的信息,如被拦截的方法、参数等

代码开发2

公共属性赋值后 Service里的 save(Employee employee) → employee.setCreateUser(BaseContext.getCurrentId())就不用再去赋值了

这个写完后 就可以把Service里的一些employee.setXXX的删除了 因为公共属性只需要加@AutoFill
sky-server  com/sky/annotation/AutoFill.java 不变
sky-server  com/sky/aspect/AutoFillAspect.java
package com.sky.aspect;

import com.sky.annotation.AutoFill;
import com.sky.constant.AutoFillConstant;
import com.sky.context.BaseContext;
import com.sky.enumeration.OperationType;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.time.LocalDateTime;

/**
 * 自定义切面,实现公共字段自动填充处理逻辑
 */
@Aspect
@Component
@Slf4j
public class AutoFillAspect {
    /**
     * 切入点
     */
    // 拦截类 + 注解的东西
    @Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
    public void autoFillPointCut(){}

    // 前置通知,在通知中进行公共字段的赋值
    @Before("autoFillPointCut()")
    public void autoFill(JoinPoint joinPoint) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
        log.info("开始公共字段自动填充...");

        // 获取当前被拦截的方法上的数据库操作类型(Update/Insert)
        MethodSignature signature = (MethodSignature)joinPoint.getSignature(); //方法签名对象
        AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class); //获得方法上的注解对象
        OperationType operationType = autoFill.value();//获得数据库操作类型

        // 获取当当前被拦截的方法的参数--实体对象 (Employee employee)
        Object[] args = joinPoint.getArgs();
        if (args == null || args.length == 0) { //没有参数不执行
            return;
        }
        Object entity = args[0]; //获得第一个

        // 准备赋值数据
        LocalDateTime now = LocalDateTime.now();
        Long currentId = BaseContext.getCurrentId();

        // 根据当前不同的操作类型,对对应的属性通过反射来赋值
        if (operationType == OperationType.INSERT) {
            // 为4个公共字段赋值
            Method setCreateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME, LocalDateTime.class);
            Method setCreateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER, Long.class);
            Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
            Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);


            //通过反射对对象赋值属性
            setCreateTime.invoke(entity,now);
            setCreateUser.invoke(entity,currentId);
            setUpdateTime.invoke(entity,now);
            setUpdateUser.invoke(entity,currentId);
        } else if (operationType == OperationType.UPDATE) {
            // 为2个公共字段赋值
            Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
            Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);

            //通过反射对对象赋值属性
            setUpdateTime.invoke(entity,now);
            setUpdateUser.invoke(entity,currentId);
        }
    }
}

新增菜品

业务规则:
  • 菜品名称必须是唯一的
  • 菜品必须属于某个分类下,不能单独存在
  • 新增菜品时可以根据选择情况菜品的口味
  • 每个菜品必须对应一张图片
接口设计:
  • 根据类型查询分类(已完成) /admin/category/list GET

    这里要注意数据返回 因为它里面的口味算一个集合

  • 文件上传 /admin/common/upload POST

  • 新增菜品 /admin/dish POST

数据库设计:
  • dish菜品表 [一个菜品对应着多种口味]
  • dish_flavour口味表
开发文件上传接口:

浏览器 → 后端服务 → 阿里云OSS

sky-common  com/sky/utils/AliOssUtil.java
package com.sky.utils;

import com.aliyun.oss.ClientException;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.OSSException;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;


import java.io.ByteArrayInputStream;

@Data
@AllArgsConstructor
@Slf4j
public class AliOssUtil {
// 通过配置类初始化这些数据
    private String endpoint;
    private String accessKeyId;
    private String accessKeySecret;
    private String bucketName;

    /**
     * 文件上传
     *
     * @param bytes
     * @param objectName
     * @return
     */
    public String upload(byte[] bytes, String objectName) {

 // 创建OSSClient实例。 将字节数组转换为输入流,并将其上传到指定的bucket和objectName
        OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);

        try {
            // 创建PutObject请求。
            ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(bytes));
        } catch (OSSException oe) {
            System.out.println("Caught an OSSException, which means your request made it to OSS, "
                    + "but was rejected with an error response for some reason.");
            System.out.println("Error Message:" + oe.getErrorMessage());
            System.out.println("Error Code:" + oe.getErrorCode());
            System.out.println("Request ID:" + oe.getRequestId());
            System.out.println("Host ID:" + oe.getHostId());
        } catch (ClientException ce) {
            System.out.println("Caught an ClientException, which means the client encountered "
                    + "a serious internal problem while trying to communicate with OSS, "
                    + "such as not being able to access the network.");
            System.out.println("Error Message:" + ce.getMessage());
        } finally {
            if (ossClient != null) {
                ossClient.shutdown();
            }
        }

        //文件访问路径规则 https://BucketName.Endpoint/ObjectName
        StringBuilder stringBuilder = new StringBuilder("https://");
        stringBuilder
                .append(bucketName)
                .append(".")
                .append(endpoint)
                .append("/")
                .append(objectName);
        log.info("文件上传成功,访问路径:{}", stringBuilder);

        return stringBuilder.toString();
    }
}
sky-common  com/sky/constant/AutoFillConstant.java
package com.sky.constant;

/**
 * 公共字段自动填充相关常量
 */
public class AutoFillConstant {
    /**
     * 实体类中的方法名称
     */
    public static final String SET_CREATE_TIME = "setCreateTime";
    public static final String SET_UPDATE_TIME = "setUpdateTime";
    public static final String SET_CREATE_USER = "setCreateUser";
    public static final String SET_UPDATE_USER = "setUpdateUser";
}
application-dev.yml
sky:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    host: localhost
    port: 3306
    database: sky_take_out
    username: root
    password: root
  alioss:
    endpoint: XXXXXXXXX
    access-key-id: XXXXXXXXXXXX
    access-key-secret: XXXXXXXXXXX
    bucketName: XXXXXXXXX
sky-server  com/sky/controller/admin/CommonController.java
package com.sky.controller.admin;

import com.sky.result.Result;
import com.sky.utils.AliOssUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.util.UUID;

/**
 * 通用接口
 */
@RestController
@RequestMapping("/admin/common")
@Api(tags = "通用接口")
@Slf4j
public class CommonController {
    @Autowired
    private AliOssUtil aliOssUtil;
    @PostMapping("/upload")
    @ApiOperation("文件上传")
    // 如果要测试文件上传 只能用postman或者前后端联调
    public Result<String> upload(MultipartFile file) {
        log.info("文件上传:{}", file);
        // 防止重名覆盖
        try {
            // 原始文件名
            String filename = file.getOriginalFilename();
            // 截取原始文件名的后缀
            String extension = filename.substring(filename.lastIndexOf("."));
            // 构造新文件名UUID
            String objectName = UUID.randomUUID().toString() + extension;

            // 文件的请求路径
            String filepath = aliOssUtil.upload(file.getBytes(), objectName);
            log.info("文件上传成功,访问路径:{}", filepath);
            return Result.success(filepath);
        } catch (IOException e) {
            log.error("文件上传失败:{}", e);
        }
        return Result.error(MessageConstant.UPLOAD_FAILED);
    }
}

如何让application.yml识别到我在application-dev.yml里设置的值呢?

server:
  port: 8080

spring:
  profiles:
    active: dev

你已经在 application.yml 中指定了 spring.profiles.active: dev,这样在启动应用程序时,Spring Boot 会自动加载 application-dev.yml 中的配置

如果你使用的是 IDE(如 IntelliJ IDEA 或 Eclipse),你可以在运行配置中指定激活的环境配置文件。例如,在 IntelliJ IDEA 中:
打开 Run -> Edit Configurations。
选择你的应用程序配置。
在 VM options 中添加 -Dspring.profiles.active=dev。

# 在 VM options 中配置的原理:
在 VM options 中添加 -Dspring.profiles.active=dev 的原理是通过 Java 虚拟机(JVM)的系统属性来设置 Spring Boot 应用程序的活动配置文件。以下是详细的解释:
原理
JVM 系统属性:
JVM 提供了一种机制,允许你在启动时通过命令行参数传递系统属性。这些系统属性可以在应用程序中通过 System.getProperty 方法访问。
-D 前缀用于设置系统属性。例如,-Dkey=value 会将 key 设置为 value。
Spring Boot 配置:
Spring Boot 会读取 spring.profiles.active 系统属性来确定当前激活的配置文件。
当你通过 -Dspring.profiles.active=dev 设置系统属性时,Spring Boot 会在启动时读取这个属性,并根据其值加载相应的配置文件(如 application-dev.yml)。
新增菜品重要代码
sky-pojo  com/sky/dto/DishDTO.java
package com.sky.dto;

import com.sky.entity.DishFlavor;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

@Data
public class DishDTO implements Serializable {
    //dish属性封装成dto

    private Long id;
    //菜品名称
    private String name;
    //菜品分类id
    private Long categoryId;
    //菜品价格
    private BigDecimal price;
    //图片
    private String image;
    //描述信息
    private String description;
    //0 停售 1 起售
    private Integer status;
    //口味[因为有多种口味要区分]
    private List<DishFlavor> flavors = new ArrayList<>();

}
sky-pojo  com/sky/entity/DishFlavor.java
package com.sky.entity;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

/**
 * 菜品口味
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DishFlavor implements Serializable {

    private static final long serialVersionUID = 1L;

    private Long id;
    //菜品id
    private Long dishId;

    //口味名称
    private String name;

    //口味数据list
    private String value;

}
sky-server  com/sky/controller/admin/DishController.java
package com.sky.controller.admin;

import com.sky.dto.DishDTO;
import com.sky.result.Result;
import com.sky.service.DishService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * 菜品管理
 */
@RestController
@RequestMapping("/admin/dish")
@Api(tags = "菜品相关接口")
@Slf4j
public class DishController {
    @Autowired
    private DishService dishService;
    @PostMapping
    @ApiOperation("新增菜品")
    //@RequestBody 封装JSON格式的数据
    public Result save(@RequestBody DishDTO dishDTO) {
        log.info("新增菜品:{}", dishDTO);
        dishService.saveWithFlavour(dishDTO);
        return Result.success();
    }
}
com/sky/service/DishService.java
package com.sky.service;

import com.sky.dto.DishDTO;

public interface DishService {
    /**
     * 新增菜品和对应的口味
     * @param dishDTO
     */
    public void saveWithFlavour(DishDTO dishDTO);
}
com/sky/service/impl/DishServiceImpl.java
package com.sky.service.impl;

import com.sky.dto.DishDTO;
import com.sky.entity.Dish;
import com.sky.entity.DishFlavor;
import com.sky.mapper.DishFlavorMapper;
import com.sky.mapper.DishMapper;
import com.sky.service.DishService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@Slf4j

public class DishServiceImpl implements DishService {
    @Autowired
    private DishMapper dishMapper;
    @Autowired
    private DishFlavorMapper dishFlavorMapper;
    /**
     * 新增菜品和对应的口味
     * @param dishDTO
     */
    @Override
    @Transactional //保证事务一致性
    public void saveWithFlavour(DishDTO dishDTO) {
        Dish dish = new Dish();
        //直接new出来是空的需要先赋值 属性拷贝[属性命名要一致]
        BeanUtils.copyProperties(dishDTO,dish);

        // 向菜品表插入1条数据
        dishMapper.insert(dish);
        // 前端无法传 要获取dishId
// <insert id="insertBatch" useGeneratedKeys="true" keyProperty="id"> 获取主键值
        Long dishId = dish.getId();

        List<DishFlavor> flavors = dishDTO.getFlavors();
        if (flavors != null && flavors.size() > 0) {
            flavors.forEach(dishFlavor -> {
                dishFlavor.setDishId(dishId);
            });
            // 向口味表插入n条数据 集合对象批量传入不用集合
            dishFlavorMapper.insertBatch(flavors);
        }
    }
}
sky-server  com/sky/mapper/DishMapper.java
package com.sky.mapper;

import com.sky.annotation.AutoFill;
import com.sky.entity.Dish;
import com.sky.enumeration.OperationType;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;

@Mapper
public interface DishMapper {

    /**
     * 根据分类id查询菜品数量
     * @param categoryId
     * @return
     */
    @Select("select count(id) from dish where category_id = #{categoryId}")
    Integer countByCategoryId(Long categoryId);

    /**
     * 插入菜品数据
     */
    @AutoFill(value = OperationType.INSERT)
    void insert(Dish dish);
}
sky-server  mapper/DishMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.DishMapper">
    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
        insert into dish (name, category_id, price, image, description, create_time, update_time, create_user,
                          update_user, status)
        values (#{name}, #{categoryId}, #{price}, #{image}, #{description}, #{createTime}, #{updateTime}, #{createUser},
                #{updateUser}, #{status})
    </insert>
</mapper>
sky-server  com/sky/mapper/DishFlavorMapper.java
package com.sky.mapper;

import com.sky.entity.DishFlavor;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface DishFlavorMapper {
    /**
     * 批量插入口味数据
     */
    void insertBatch(List<DishFlavor> flavors);
}
sky-server  mapper/DishFlavorMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.DishFlavorMapper">

    <insert id="insertBatch" useGeneratedKeys="true" keyProperty="id">
        insert into dish_flavor (dish_id, name, value) values
        <foreach collection="flavors" item="df" separator=",">
            (#{df.dishId},#{df.name},#{df.value})
        </foreach>
    </insert>
</mapper>

菜品分页查询

菜品名称[ ] 菜品分类[ ] 售卖状态[ ] [搜索]
菜品名称、图片、菜品分类、售价、售卖状态、最后操作事件、操作[修改 删除 启售,停售]
右下角 分页操作

业务规则:
  • 根据页码展示菜品信息
  • 每页展示10条数据
  • 分页查询时可以根据需要输入菜品名称、菜品分类、菜品状态进行查询
接口设计:

Path:/admin/dish/page
Method:GET

代码开发:

根据菜品分页查询接口定义设计对应的DTO
根据菜品分页查询接口定义设计对应的VO[转成Json数据给前端]

sky-server  com/sky/controller/admin/DishController.java
@GetMapping("/page")
    @ApiOperation("菜品分页查询")
    public Result<PageResult> page(DishPageQueryDTO dishPageQueryDTO){
        log.info("菜品分页查询:{}", dishPageQueryDTO);
        PageResult pageResult = dishService.pageQuery(dishPageQueryDTO);
        return Result.success(pageResult);
    }
sky-server  com/sky/service/DishService.java
package com.sky.service;

import com.sky.dto.DishDTO;
import com.sky.dto.DishPageQueryDTO;
import com.sky.result.PageResult;

public interface DishService {
    /**
     * 新增菜品和对应的口味
     * @param dishDTO
     */
    public void saveWithFlavour(DishDTO dishDTO);

    /**
     * 菜品分页查询
     * @param dishPageQueryDTO
     * @return
     */
    PageResult pageQuery(DishPageQueryDTO dishPageQueryDTO);
}
sky-server  com/sky/service/impl/DishServiceImpl.java
@Service
@Slf4j
public class DishServiceImpl implements DishService {
    @Autowired
    private DishMapper dishMapper;
    @Autowired
    private DishFlavorMapper dishFlavorMapper;
 @Override
    public PageResult pageQuery(DishPageQueryDTO dishPageQueryDTO) {
  // 1. 开启分页功能,设置当前页和每页显示的数量
        PageHelper.startPage(dishPageQueryDTO.getPage(), dishPageQueryDTO.getPageSize());
 // 2. 调用 dishMapper 的 pageQuery 方法进行分页查询,返回一个 Page<DishVO> 对象
        Page<DishVO> page = dishMapper.pageQuery(dishPageQueryDTO);
     // 3. 创建并返回 PageResult 对象,包含总记录数和查询结果列表
        return new PageResult(page.getTotal(), page.getResult());
    }
}
sky-server  com/sky/mapper/DishMapper.java
/**
     * 菜品分页查询
     * @param dishPageQueryDTO
     * @return
     */
    Page<DishVO> pageQuery(DishPageQueryDTO dishPageQueryDTO);
sky-server  mapper/DishMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.DishMapper">
    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
        insert into dish (name, category_id, price, image, description, create_time, update_time, create_user,
                          update_user, status)
        values (#{name}, #{categoryId}, #{price}, #{image}, #{description}, #{createTime}, #{updateTime}, #{createUser},
                #{updateUser}, #{status})
    </insert>
    <select id="pageQuery" resultType="com.sky.vo.DishVO">
        select d.*, c.name as categoryName
        from dish d
            left join category c
                on d.category_id=c.id
        <where>
            <if test="name != null and name != ''">
                and d.name like concat('%',#{name},'%')
            </if>
            <if test="categoryId != null">
                and d.category_id = #{categoryId}
            </if>
            <if test="status != null">
                and d.status = #{status}
            </if>
        </where>
        order by d.update_time desc
    </select>
</mapper>

分页查询SQL语句分析

<select id="pageQuery" resultType="com.sky.vo.DishVO">
        select d.*, c.name as categoryName
        from dish d
            left join category c
                on d.category_id=c.id
        <where>
            <if test="name != null and name != ''">
                and d.name like concat('%',#{name},'%')
            </if>
            <if test="categoryId != null">
                and d.category_id = #{categoryId}
            </if>
            <if test="status != null">
                and d.status = #{status}
            </if>
        </where>
        order by d.update_time desc
    </select>

从 dish 表中选择所有列,并从 category 表中选择 name 列,别名为 categoryName。
使用左连接 (left join) 将 dish 表和 category 表连接起来,连接条件是 d.category_id = c.id。

动态生成WHERE子句,< where >标签会自动处理AND和OR关键字的添加,并且会忽略第一个条件前的ADN和OR

删除菜品

单个删除、批量删除、先停售后删除

业务规则:
  • 可以一次删除一个菜品,也可以批量删除菜品

Path: /admin/dish
Method: DELETE
数据库设计
dish表 → id 【菜品】
dish_flavor表 → dish_id 【口味】
setmeal_dish表 → dish_id

  • 起售中的菜品不能删除
  • 被套餐关联的菜品不能删除
  • 删除菜品后,关联的口味数据也需要删除
代码开发:
sky-server  com/sky/controller/admin/DishController.java
 /**
     * 菜品的批量删除
     * @param ids
     * @return
     */
    @DeleteMapping
    @ApiOperation("批量删除菜品")
    //@RequestParam MVC动态解析字符串 ids提取出来
    public Result delete(@RequestParam List<Long> ids) { //ids
        log.info("批量删除菜品:{}", ids);
        dishService.deleteBatch(ids);
        return Result.success();
    }
sky-server  com/sky/service/DishService.java
 /**
     * 菜品的批量删除
     * @param ids
     * @return
     */
    void deleteBatch(List<Long> ids);
sky-server  com/sky/service/impl/DishServiceImpl.java
/**
     * 菜品的批量删除
     * @param ids
     * @return
     */
    @Override
    public void deleteBatch(List<Long> ids) {
        // 判断当前菜品是否能够删除--是否存在起售中的菜品?? 取出id
        for (Long id : ids) {
            Dish dish = dishMapper.getById(id);
            if (dish.getStatus() == StatusConstant.ENABLE) {
                //当前菜品处于起售中,不能删除
                throw new DeletionNotAllowedException(MessageConstant.DISH_ON_SALE);
            }
        }

        // 判断当前菜品是否能够删除--是否被套餐关联了
        List<Long> setMealIds = setmealDishMapper.getSetmealIdsByDishId(ids);
        if (setMealIds != null && setMealIds.size() > 0) { //存在不允许删除
            // 当前菜品被套餐关联了,不能删除
            throw new DeletionNotAllowedException(MessageConstant.DISH_BE_RELATED_BY_SETMEAL);
        }
        // 删除菜品表中的菜品数据
        for (Long id : ids) {
            dishMapper.deleteById(id);
            // 删除菜品关联的口味数据
            dishFlavorMapper.deleteByDishId(id);
        }
    }
sky-server  com/sky/mapper/DishMapper.java
/**
     * 根据主键删除菜品数据
     */

    @Delete("delete from dish where id = #{id}")
    void deleteById(Long id);
sky-server  com/sky/mapper/DishFlavorMapper.java
/**
     * 根据菜品id删除对应的 口味数据
     * @param id
     */
    @Delete("delete from dish_flavor where dish_id = #{id}")
    void deleteByDishId(Long id);
sky-server  com/sky/mapper/SetmealDishMapper.java
package com.sky.mapper;

import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface SetmealDishMapper {
    /**
     * 根据菜品id查询对应的套餐id
     * @param dishIds
     * @return
     */
    // select setmeal_id from setmeal_dish where dish_id in (1,2,3)
    // 在mapper.xml中dishIds是形参  <foreach collection="dishIds">
    List<Long> getSetmealIdsByDishId(List<Long> dishIds);
}
sky-server  mapper/SetmealDishMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.SetmealDishMapper">

    <select id="getSetmealIdsByDishId" resultType="java.lang.Long">
        SELECT setmeal_id
        FROM setmeal_dish
        WHERE dish_id IN
        <foreach collection="dishIds" item="dishId" open="(" separator="," close=")">
           #{dishId}
        </foreach>
    </select>
</mapper>

<!--
★ foreach 标签用于遍历集合,并生成相应的 SQL 语句。
★ collection="dishIds":指定要遍历的集合名称,即传入的参数 dishIds。
★ item="dishId":指定集合中的每个元素的别名,即每次迭代时的变量名。
★ separator=",":指定每个元素之间的分隔符,这里是逗号 ,。
★ open="(" 和 close=")":指定生成的 SQL 语句的开始和结束符号,这里是括号 ( 和 )。
-->

@RequestParm详细分析

public Result delete(@RequestParam List<Long> ids)

@RequestParam:注解用于将请求参数绑定到方法参数上。具体来说,它可以从请求的查询参数中提取出指定的参数值,并将其转换为方法参数的类型;在这个例子中,@RequestParam List ids 表示从请求的查询参数中提取 ids 参数,并将其转换为 List 类型。

修改菜品

数据回显

接口设计:

  • 根据id查询菜品

口味也要回显
Path: /admin/dish/{id}
Method:GET

  • 根据类型查询分类(已实现)
  • 文件上传(已实现)
  • 修改菜品

根据ID修改
Path:/admin/dish
Method:PUT

代码开发:

根据id查询菜品进行信息回显

sky-server  com/sky/controller/admin/DishController.java
/**
     * 根据id查询菜品和对应的口味数据
     * @param id
     * @return
     */
    @GetMapping("/{id}")
    @ApiOperation("根据id查询菜品")
//  @PathVariable 注解用于从 URL 路径中的占位符参数中提取值,并将其绑定到方法参数上
    public Result<DishVO> getById(@PathVariable long id) {
        log.info("根据id查询菜品:{}", id);
        DishVO dishVO = dishService.getByIdWithFlavor(id);
        return Result.success(dishVO);
    }
sky-server  com/sky/service/DishService.java
 /**
     * 根据id查询菜品和对应的口味数据
     * @param id
     * @return
     */
    DishVO getByIdWithFlavor(long id);
sky-server  com/sky/service/impl/DishServiceImpl.java
/**
     * 根据id查询菜品和对应的口味数据
     * @param id
     * @return
     */
    @Override
    public DishVO getByIdWithFlavor(long id) {
        // 根据id查询菜品数据
        Dish dish = dishMapper.getById(id);
        // 根据菜品id查询口味数据
        List<DishFlavor> dishFlavors = dishFlavorMapper.getByDishId(id);

        // 将查询到的数据封装到VO
        DishVO dishVO = new DishVO();
            // 属性拷贝
        BeanUtils.copyProperties(dish,dishVO);
        dishVO.setFlavors(dishFlavors);

        return dishVO;
    }
sky-server  com/sky/mapper/DishFlavorMapper.java
/**
     * 根据菜品id查询对应的口味数据
     * @param id
     * @return
     */
    @Select("select * from dish_flavor where dish_id = #{id}")
    List<DishFlavor> getByDishId(long id);

修改菜品接口

sky-server  com/sky/controller/admin/DishController.java
/**
     * 根据id修改菜品和对应的口味数据
     * @param dishDTO
     * @return
     */
    @PutMapping
    @ApiOperation("修改菜品")
    public Result update(@RequestBody DishDTO dishDTO) {
        log.info("修改菜品:{}", dishDTO);
        dishService.updateWithFlavor(dishDTO);
        return Result.success();
    }
sky-server  com/sky/service/DishService.java
 /**
     * 根据id修改菜品和对应的口味数据
     * @param dishDTO
     * @return
     */
    void updateWithFlavor(DishDTO dishDTO);
sky-server  com/sky/service/impl/DishServiceImpl.java
/**
     * 根据id修改菜品和对应的口味数据
     * @param dishDTO
     * @return
     */
    @Override
    public void updateWithFlavor(DishDTO dishDTO) {
        Dish dish = new Dish();
        BeanUtils.copyProperties(dishDTO,dish);

        // 修改菜品表基本信息 只是基础信息噢
        dishMapper.update(dish);
        // 先删掉原先的
        dishFlavorMapper.deleteByDishId(dishDTO.getId());
        // 再重新插入新的
        List<DishFlavor> flavors = dishDTO.getFlavors();
        if (flavors != null && flavors.size() > 0) {
            flavors.forEach(dishFlavor -> {
                dishFlavor.setDishId(dishDTO.getId());
            });
            // 向口味表插入n条数据 集合对象批量传入不用集合
            dishFlavorMapper.insertBatch(flavors);
        }
    }
/*
这段代码中,将 dishDTO 的属性复制到 dish 对象的主要原因有以下几点:
数据模型分离:
dishDTO 通常用于数据传输,包含前端传来的所有数据。
dish 是数据库实体类,只包含数据库表中的字段。
安全性:
使用 BeanUtils.copyProperties 可以避免将不必要的字段(如前端传来的额外属性)写入数据库。
确保只有预期的字段被更新。
数据校验:
dishDTO 可以包含更多的验证逻辑或额外的属性,而 dish 对象则严格遵循数据库模型。
通过这种方式,可以在更新前对数据进行进一步校验。
事务管理:
添加 @Transactional 注解确保整个更新过程在一个事务中完成。
如果任何一步出错,整个事务都会回滚,保证数据一致性。
*/
sky-server  com/sky/mapper/DishMapper.java
 /**
     * 根据id修改菜品和对应的口味数据
     * @param dish
     */
    //有时间和修改人 不要忘记自动填充
    @AutoFill(value = OperationType.UPDATE)
    void update(Dish dish);
sky-server  mapper/DishMapper.xml
<update id="update">
        update dish
        <set>
            <if
                test="name != null and name != ''">
                name = #{name},
            </if>
        </set>
        where id = #{id}
    </update>
菜品起售停售
sky-server  com/sky/controller/admin/DishController.java
/**
     * 菜品起售停售
     *
     * @param status
     * @param id
     * @return
     */
    @PostMapping("/status/{status}")
    @ApiOperation("菜品起售停售")
    public Result<String> startOrStop(@PathVariable Integer status, Long id) {
        dishService.startOrStop(status, id);
        return Result.success();
    }
sky-server  com/sky/service/DishService.java
/**
     * 菜品起售停售
     *
     * @param status
     * @param id
     * @return
     */
    void startOrStop(Integer status, Long id);
sky-server  com/sky/service/impl/DishServiceImpl.java
/**
     * 菜品起售停售
     * @param status
     * @param id
     */
@Override
    public void startOrStop(Integer status, Long id) {
        Dish dish = Dish.builder()
                .id(id)
                .status(status)
                .build();
        dishMapper.update(dish);

        if (status == StatusConstant.DISABLE) {
            // 如果是停售操作,还需要将包含当前菜品的套餐也停售
            List<Long> dishIds = new ArrayList<>();
            dishIds.add(id);
            // select setmeal_id from setmeal_dish where dish_id in (?,?,?)
            List<Long> setmealIds = setmealDishMapper.getSetmealIdsByDishIds(dishIds);
            if (setmealIds != null && setmealIds.size() > 0) {
                for (Long setmealId : setmealIds) {
                    Setmeal setmeal = Setmeal.builder()
                            .id(setmealId)
                            .status(StatusConstant.DISABLE)
                            .build();
                    setmealMapper.update(setmeal);
                }
            }

        }
    }
sky-server  com/sky/mapper/SetmealMapper.java
/**
     * 根据id修改套餐
     *
     * @param setmeal
     */
    @AutoFill(OperationType.UPDATE)
    void update(Setmeal setmeal);
sky-server  com/sky/mapper/DishMapper.java

    /**
     * 根据套餐id查询菜品
     * @param setmealId
     * @return
     */
    @Select("select a.* from dish a left join setmeal_dish b on a.id = b.dish_id where b.setmeal_id = #{setmealId}")
    List<Dish> getBySetmealId(Long setmealId);
/*
在 SQL 查询中添加筛选条件。
确保返回的结果集中,setmeal_dish 表中的 setmeal_id 字段值与传入的 setmealId 参数值相匹配,从而获取与指定套餐 ID 相关的菜品列表。
*/
sky-server  mapper/SetmealMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.SetmealMapper">
    <resultMap id="setmealAndDishMap" type="com.sky.vo.SetmealVO" autoMapping="true">
        <result column="id" property="id"/>
        <collection property="setmealDishes" ofType="SetmealDish">
            <result column="sd_id" property="id"/>
            <result column="setmeal_id" property="setmealId"/>
            <result column="dish_id" property="dishId"/>
            <result column="sd_name" property="name"/>
            <result column="sd_price" property="price"/>
            <result column="copies" property="copies"/>
        </collection>
    </resultMap>
    <update id="update" parameterType="Setmeal">
        update setmeal
        <set>
            <if test="name != null">
                name = #{name},
            </if>
            <if test="categoryId != null">
                category_id = #{categoryId},
            </if>
            <if test="price != null">
                price = #{price},
            </if>
            <if test="status != null">
                status = #{status},
            </if>
            <if test="description != null">
                description = #{description},
            </if>
            <if test="image != null">
                image = #{image},
            </if>
            <if test="updateTime != null">
                update_time = #{updateTime},
            </if>
            <if test="updateUser != null">
                update_user = #{updateUser}
            </if>
        </set>
        where id = #{id}
    </update>
</mapper>
@Mapper
public interface SetmealDishMapper {
    /**
     * 根据菜品id查询对应的套餐id
     * @param dishIds
     * @return
     */
    // select setmeal_id from setmeal_dish where dish_id in (1,2,3)
    // 在mapper.xml中dishIds是形参  <foreach collection="dishIds">
    List<Long> getSetmealIdsByDishIds(List<Long> dishIds);
SetmealDishMapper.xml
<select id="getSetmealIdsByDishIds" resultType="java.lang.Long">
        select setmeal_id from setmeal_dish where dish_id in
        <foreach collection="dishIds" item="dishId" separator="," open="(" close=")">
            #{dishId}
        </foreach>
    </select>

修改套餐那些事

sky-server  com/sky/controller/admin/SetmealController.java
package com.sky.controller.admin;

import com.sky.dto.SetmealDTO;
import com.sky.dto.SetmealPageQueryDTO;
import com.sky.result.PageResult;
import com.sky.result.Result;
import com.sky.service.SetmealService;
import com.sky.vo.SetmealVO;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * 套餐管理
 */
@RestController
@RequestMapping("/admin/setmeal")
@Api(tags = "套餐相关接口")
@Slf4j
public class SetmealController {

    @Autowired
    private SetmealService setmealService;

    /**
     * 新增套餐
     *
     * @param setmealDTO
     * @return
     */
    @PostMapping
    @ApiOperation("新增套餐")
    @CacheEvict(cacheNames = "setmealCache",key = "#setmealDTO.categoryId")//key: setmealCache::100
    public Result save(@RequestBody SetmealDTO setmealDTO) {
        setmealService.saveWithDish(setmealDTO);
        return Result.success();
    }

    /**
     * 分页查询
     *
     * @param setmealPageQueryDTO
     * @return
     */
    @GetMapping("/page")
    @ApiOperation("分页查询")
    public Result<PageResult> page(SetmealPageQueryDTO setmealPageQueryDTO) {
        PageResult pageResult = setmealService.pageQuery(setmealPageQueryDTO);
        return Result.success(pageResult);
    }

    /**
     * 批量删除套餐
     *
     * @param ids
     * @return
     */
    @DeleteMapping
    @ApiOperation("批量删除套餐")
    @CacheEvict(cacheNames = "setmealCache",allEntries = true)
    public Result delete(@RequestParam List<Long> ids) {
        setmealService.deleteBatch(ids);
        return Result.success();
    }

    /**
     * 根据id查询套餐,用于修改页面回显数据
     *
     * @param id
     * @return
     */
    @GetMapping("/{id}")
    @ApiOperation("根据id查询套餐")
    public Result<SetmealVO> getById(@PathVariable Long id) {
        SetmealVO setmealVO = setmealService.getByIdWithDish(id);
        return Result.success(setmealVO);
    }

    /**
     * 修改套餐
     *
     * @param setmealDTO
     * @return
     */
    @PutMapping
    @ApiOperation("修改套餐")
    @CacheEvict(cacheNames = "setmealCache",allEntries = true)
    public Result update(@RequestBody SetmealDTO setmealDTO) {
        setmealService.update(setmealDTO);
        return Result.success();
    }

    /**
     * 套餐起售停售
     *
     * @param status
     * @param id
     * @return
     */
    @PostMapping("/status/{status}")
    @ApiOperation("套餐起售停售")
    @CacheEvict(cacheNames = "setmealCache",allEntries = true)
    public Result startOrStop(@PathVariable Integer status, Long id) {
        setmealService.startOrStop(status, id);
        return Result.success();
    }
}
sky-server  com/sky/service/SetmealService.java
package com.sky.service;

import com.sky.dto.SetmealDTO;
import com.sky.dto.SetmealPageQueryDTO;
import com.sky.entity.Setmeal;
import com.sky.result.PageResult;
import com.sky.vo.DishItemVO;
import com.sky.vo.SetmealVO;

import java.util.List;

public interface SetmealService {

    /**
     * 新增套餐,同时需要保存套餐和菜品的关联关系
     *
     * @param setmealDTO
     */
    void saveWithDish(SetmealDTO setmealDTO);

    /**
     * 分页查询
     *
     * @param setmealPageQueryDTO
     * @return
     */
    PageResult pageQuery(SetmealPageQueryDTO setmealPageQueryDTO);

    /**
     * 批量删除套餐
     *
     * @param ids
     */
    void deleteBatch(List<Long> ids);

    /**
     * 根据id查询套餐和关联的菜品数据
     *
     * @param id
     * @return
     */
    SetmealVO getByIdWithDish(Long id);

    /**
     * 修改套餐
     *
     * @param setmealDTO
     */
    void update(SetmealDTO setmealDTO);

    /**
     * 套餐起售、停售
     *
     * @param status
     * @param id
     */
    void startOrStop(Integer status, Long id);

    /**
     * 条件查询
     * @param setmeal
     * @return
     */
    List<Setmeal> list(Setmeal setmeal);

    /**
     * 根据id查询菜品选项
     * @param id
     * @return
     */
    List<DishItemVO> getDishItemById(Long id);
}
sky-server  com/sky/service/impl/SetmealServiceImpl.java
package com.sky.service.impl;

import com.github.pagehelper.Page;
import com.github.pagehelper.PageHelper;
import com.sky.constant.MessageConstant;
import com.sky.constant.StatusConstant;
import com.sky.dto.SetmealDTO;
import com.sky.dto.SetmealPageQueryDTO;
import com.sky.entity.Dish;
import com.sky.entity.Setmeal;
import com.sky.entity.SetmealDish;
import com.sky.exception.DeletionNotAllowedException;
import com.sky.exception.SetmealEnableFailedException;
import com.sky.mapper.DishMapper;
import com.sky.mapper.SetmealDishMapper;
import com.sky.mapper.SetmealMapper;
import com.sky.result.PageResult;
import com.sky.service.SetmealService;
import com.sky.vo.DishItemVO;
import com.sky.vo.SetmealVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

/**
 * 套餐业务实现
 */
@Service
@Slf4j
public class SetmealServiceImpl implements SetmealService {

    @Autowired
    private SetmealMapper setmealMapper;
    @Autowired
    private SetmealDishMapper setmealDishMapper;
    @Autowired
    private DishMapper dishMapper;


    /**
     * 新增套餐,同时需要保存套餐和菜品的关联关系
     *
     * @param setmealDTO
     */
    @Transactional
    public void saveWithDish(SetmealDTO setmealDTO) {
        Setmeal setmeal = new Setmeal();
        BeanUtils.copyProperties(setmealDTO, setmeal);

        //向套餐表插入数据
        setmealMapper.insert(setmeal);

        //获取生成的套餐id
        Long setmealId = setmeal.getId();

        List<SetmealDish> setmealDishes = setmealDTO.getSetmealDishes();
        setmealDishes.forEach(setmealDish -> {
            setmealDish.setSetmealId(setmealId);
        });

        //保存套餐和菜品的关联关系
        setmealDishMapper.insertBatch(setmealDishes);
    }

    /**
     * 分页查询
     *
     * @param setmealPageQueryDTO
     * @return
     */
    public PageResult pageQuery(SetmealPageQueryDTO setmealPageQueryDTO) {
        int pageNum = setmealPageQueryDTO.getPage();
        int pageSize = setmealPageQueryDTO.getPageSize();

        PageHelper.startPage(pageNum, pageSize);
        Page<SetmealVO> page = setmealMapper.pageQuery(setmealPageQueryDTO);
        return new PageResult(page.getTotal(), page.getResult());
    }

    /**
     * 批量删除套餐
     *
     * @param ids
     */
    @Transactional
    public void deleteBatch(List<Long> ids) {
        ids.forEach(id -> {
            Setmeal setmeal = setmealMapper.getById(id);
            if (StatusConstant.ENABLE == setmeal.getStatus()) {
                //起售中的套餐不能删除
                throw new DeletionNotAllowedException(MessageConstant.SETMEAL_ON_SALE);
            }
        });

        ids.forEach(setmealId -> {
            //删除套餐表中的数据
            setmealMapper.deleteById(setmealId);
            //删除套餐菜品关系表中的数据
            setmealDishMapper.deleteBySetmealId(setmealId);
        });
    }
/**
ids.forEach(id -> { ... }):对ids集合中的每个元素id执行大括号内的操作。
id -> { ... }:定义了一个接受单个参数id的函数,并执行大括号内的逻辑。
在大括号内,根据id查询数据库获取套餐信息,并检查其状态,若状态符合启用条件,则抛出异常。
**/
    
    /**
     * 根据id查询套餐和套餐菜品关系
     *
     * @param id
     * @return
     */
    public SetmealVO getByIdWithDish(Long id) {
        SetmealVO setmealVO = setmealMapper.getByIdWithDish(id);
        return setmealVO;
    }

    /**
     * 修改套餐
     *
     * @param setmealDTO
     */
    @Transactional
    public void update(SetmealDTO setmealDTO) {
        Setmeal setmeal = new Setmeal();
        BeanUtils.copyProperties(setmealDTO, setmeal);

        //1、修改套餐表,执行update
        setmealMapper.update(setmeal);

        //套餐id
        Long setmealId = setmealDTO.getId();

        //2、删除套餐和菜品的关联关系,操作setmeal_dish表,执行delete
        setmealDishMapper.deleteBySetmealId(setmealId);

        List<SetmealDish> setmealDishes = setmealDTO.getSetmealDishes();
        setmealDishes.forEach(setmealDish -> {
            setmealDish.setSetmealId(setmealId);
        });
        //3、重新插入套餐和菜品的关联关系,操作setmeal_dish表,执行insert
        setmealDishMapper.insertBatch(setmealDishes);
    }

    /**
     * 套餐起售、停售
     *
     * @param status
     * @param id
     */
    public void startOrStop(Integer status, Long id) {
        //起售套餐时,判断套餐内是否有停售菜品,有停售菜品提示"套餐内包含未启售菜品,无法启售"
        if (status == StatusConstant.ENABLE) {
            //select a.* from dish a left join setmeal_dish b on a.id = b.dish_id where b.setmeal_id = ?
            List<Dish> dishList = dishMapper.getBySetmealId(id);
            if (dishList != null && dishList.size() > 0) {
                dishList.forEach(dish -> {
                    if (StatusConstant.DISABLE == dish.getStatus()) {
                        throw new SetmealEnableFailedException(MessageConstant.SETMEAL_ENABLE_FAILED);
                    }
                });
            }
        }

        Setmeal setmeal = Setmeal.builder()
                .id(id)
                .status(status)
                .build();
        setmealMapper.update(setmeal);
    }

    /**
     * 条件查询
     * @param setmeal
     * @return
     */
    public List<Setmeal> list(Setmeal setmeal) {
        List<Setmeal> list = setmealMapper.list(setmeal);
        return list;
    }

    /**
     * 根据id查询菜品选项
     * @param id
     * @return
     */
    public List<DishItemVO> getDishItemById(Long id) {
        return setmealMapper.getDishItemBySetmealId(id);
    }
}
sky-server  com/sky/mapper/SetmealMapper.java
package com.sky.mapper;

import com.github.pagehelper.Page;
import com.sky.annotation.AutoFill;
import com.sky.dto.SetmealPageQueryDTO;
import com.sky.entity.Setmeal;
import com.sky.enumeration.OperationType;
import com.sky.vo.DishItemVO;
import com.sky.vo.SetmealVO;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;

import java.util.List;
import java.util.Map;

@Mapper
public interface SetmealMapper {

    /**
     * 根据分类id查询套餐的数量
     *
     * @param id
     * @return
     */
    @Select("select count(id) from setmeal where category_id = #{categoryId}")
    Integer countByCategoryId(Long id);

    /**
     * 根据id修改套餐
     *
     * @param setmeal
     */
    @AutoFill(OperationType.UPDATE)
    void update(Setmeal setmeal);

    /**
     * 新增套餐
     *
     * @param setmeal
     */
    @AutoFill(OperationType.INSERT)
    void insert(Setmeal setmeal);

    /**
     * 分页查询
     * @param setmealPageQueryDTO
     * @return
     */
    Page<SetmealVO> pageQuery(SetmealPageQueryDTO setmealPageQueryDTO);

    /**
     * 根据id查询套餐
     * @param id
     * @return
     */
    @Select("select * from setmeal where id = #{id}")
    Setmeal getById(Long id);

    /**
     * 根据id删除套餐
     * @param setmealId
     */
    @Delete("delete from setmeal where id = #{id}")
    void deleteById(Long setmealId);

    /**
     * 根据id查询套餐和套餐菜品关系
     * @param id
     * @return
     */
    SetmealVO getByIdWithDish(Long id);

    /**
     * 动态条件查询套餐
     * @param setmeal
     * @return
     */
    List<Setmeal> list(Setmeal setmeal);

    /**
     * 根据套餐id查询菜品选项
     * @param setmealId
     * @return
     */
    @Select("select sd.name, sd.copies, d.image, d.description " +
            "from setmeal_dish sd left join dish d on sd.dish_id = d.id " +
            "where sd.setmeal_id = #{setmealId}")
    List<DishItemVO> getDishItemBySetmealId(Long setmealId);

    /**
     * 根据条件统计套餐数量
     * @param map
     * @return
     */
    Integer countByMap(Map map);
}
sky-server  mapper/SetmealMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.SetmealMapper">
    <resultMap id="setmealAndDishMap" type="com.sky.vo.SetmealVO" autoMapping="true">
        <result column="id" property="id"/>
        <collection property="setmealDishes" ofType="SetmealDish">
            <result column="sd_id" property="id"/>
            <result column="setmeal_id" property="setmealId"/>
            <result column="dish_id" property="dishId"/>
            <result column="sd_name" property="name"/>
            <result column="sd_price" property="price"/>
            <result column="copies" property="copies"/>
        </collection>
    </resultMap>
    <update id="update" parameterType="Setmeal">
        update setmeal
        <set>
            <if test="name != null">
                name = #{name},
            </if>
            <if test="categoryId != null">
                category_id = #{categoryId},
            </if>
            <if test="price != null">
                price = #{price},
            </if>
            <if test="status != null">
                status = #{status},
            </if>
            <if test="description != null">
                description = #{description},
            </if>
            <if test="image != null">
                image = #{image},
            </if>
            <if test="updateTime != null">
                update_time = #{updateTime},
            </if>
            <if test="updateUser != null">
                update_user = #{updateUser}
            </if>
        </set>
        where id = #{id}
    </update>

<!--
    <insert>:表示这是一个插入操作。
    id="insert":指定这个 SQL 语句的唯一标识符,通常用于在 MyBatis 映射文件中引用此 SQL 语句。
    parameterType="Setmeal":指定插入操作的参数类型为 Setmeal 类型。
    useGeneratedKeys="true":指示 MyBatis 在执行插入操作后自动获取自动生成的主键。
    keyProperty="id":指定将自动生成的主键值设置到对象的 id 属性上。
-->
    <insert id="insert" parameterType="Setmeal" useGeneratedKeys="true" keyProperty="id">
        insert into setmeal
        (category_id, name, price, status, description, image, create_time, update_time, create_user, update_user)
        values (#{categoryId}, #{name}, #{price}, #{status}, #{description}, #{image}, #{createTime}, #{updateTime},
                #{createUser}, #{updateUser})
    </insert>

    <select id="pageQuery" resultType="com.sky.vo.SetmealVO">
        select
        s.*,c.name categoryName
        from
        setmeal s
        left join
        category c
        on
        s.category_id = c.id
        <where>
            <if test="name != null">
                and s.name like concat('%',#{name},'%')
            </if>
            <if test="status != null">
                and s.status = #{status}
            </if>
            <if test="categoryId != null">
                and s.category_id = #{categoryId}
            </if>
        </where>
        order by s.create_time desc
    </select>

    <select id="getByIdWithDish" parameterType="long" resultMap="setmealAndDishMap">
        select a.*,
               b.id    sd_id,
               b.setmeal_id,
               b.dish_id,
               b.name  sd_name,
               b.price sd_price,
               b.copies
        from setmeal a
                 left join
             setmeal_dish b
             on
                 a.id = b.setmeal_id
        where a.id = #{id}
    </select>

    <select id="list" parameterType="Setmeal" resultType="Setmeal">
        select * from setmeal
        <where>
            <if test="name != null">
                and name like concat('%',#{name},'%')
            </if>
            <if test="categoryId != null">
                and category_id = #{categoryId}
            </if>
            <if test="status != null">
                and status = #{status}
            </if>
        </where>
    </select>

    <select id="countByMap" resultType="java.lang.Integer">
        select count(id) from setmeal
        <where>
            <if test="status != null">
                and status = #{status}
            </if>
            <if test="categoryId != null">
                and category_id = #{categoryId}
            </if>
        </where>
    </select>
</mapper>

Redis入门 [调整营业状态]

Redis是一个基于内存的 key-value 结构数据库

  • 基于内存存储,读写性能高
  • 适合存储热点数据 (热点商品、资讯、新闻) 访问量较大
  • 企业应用广泛

Redis常用数据类型

Redis存储的是key-value结构的数据,其中key是字符串类型,value有5种常用的数据类型:
  • 字符串 string:普通字符串
  • 哈希 hash:散列,类似于java中的HashMap结构
  • 列表 list:按照插入顺序排序,可以有重复元素,类似于java中的LinkedList
  • 集合 set:无序集合,没有重复元素,类似于java中的HashSet
  • 有序集合 sorted set / zset:集合中每个元素关联一个分数(score),根据分数升序排序,没有重复元素

Redis常用命令

  • 字符串操作命令

    ValueOperations valueOperations = redisTemplate.opsForValue();

    • SET key value 设置指定key的值
    • GET key 获取指定key的值
    • SETEX key seconds value 设置指定key的值,并将key的过期时间设为 seconds秒
    • SETNX key value 只有在key不存在时设置key的值
  • 哈希操作命令 [key → value(field1 value1, field2 value2)]

    HashOperations hashOperations = redisTemplate.opsForHash();

    Redis hash是一个string类型的field和value的映射表,hash特别适合用于存储对象

    • HSET key field value 将哈希表key中的字段field的值设为value
    • HGET key field 获取存储在哈希表中指定字段的值
    • HDEL key field 删除存储在哈希表中的指定字段
    • HKEYS key 获取哈希表中所有字段
    • HVALS key 获取哈希表中所有值
  • 列表操作命令

    ListOperations listOperations = redisTemplate.opsForList();

    • LPUSH key value1 [value2] 将一个或多个值插入到列表头部
    • LRANGE key start stop 获取列表指定范围内的元素
    • RPOP key 移除并获取列表最后一个元素
    • LLEN key 获取列表长度
  • 集合操作命令

    SetOperations setOperations = redisTemplate.opsForSet();

    Redis set是string类型的无序集合。集合成员是唯一的,集合中不能出现重复的数据

    • SADD key member1 [member2] 向集合添加一个或多个成员 [无序插入]
    • SMEMBERS key 返回集合中的所有成员
    • SCARD key 获取集合的成员数
    • SINTER key1 [key2] 返回给定所有集合的交集
    • SUNION key1 [key2] 返回所有给定集合的并集
    • SREM key member1 [member2] 删除集合中一个或多个成员
  • 有序列表操作命令

    ZSetOperations zSetOperations = redisTemplate.opsForZSet();

    Redis有序集合是string类型元素的集合,且不允许重复成员。每个元素都会关联一个double类型的分数

    • ZADD key score1 member1 [score2 member2] 向有序集合添加一个或多个成员
    • ZRANGE key start stop [WITHSCORES] 通过索引区间返回有序集合中指定区间内的成员
    • ZINCRBY key increment member 有序集合中对指定成员的分数加上增量increment
    • ZREM key member [member …] 移除有序集合中的一个或多个成员
  • 通用命令

    Redis的通用命令是不分数据类型的,都可以使用的命令

    • KEYS pattern 查找所有符合给定模式(pattern)的key
    • EXISTS key 检查给定key是否存在
    • TYPE key 返回key所存储的值的类型
    • DEL key 该命令用于在key存在是删除key

在java中操作Redis_SpringDataRedis

序列化器:redisTemplate.setKeySerializer(new StringRedisSerializer());

Redis的Java客户端很多
  • Jedis
  • Lettuce
  • Spring Data Redis

Spring Data Redis 是 Spring 的一部分,对Redis底层开发包进行了高度封装
在Spring项目中,可以使用Spring Data Redis来简化操作

Spring Data Redis
  • 导入Spring Data Redis的maven坐标

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
        <version>2.7.3</version>
    </dependency>
    
  • 配置Redis数据源

    spring:
     redis:
      host: localhost
      port: 6379
      password:
    
  • 编写配置类,创建RedisTemplate对象

    package com.sky.config;
    
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.data.redis.connection.RedisConnectionFactory;
    import org.springframework.data.redis.core.RedisTemplate;
    import org.springframework.data.redis.serializer.StringRedisSerializer;
    
    @Configuration
    @Slf4j
    public class RedisConfiguration {
    
        @Bean
        public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory){
            log.info("开始创建redis模板对象...");
            RedisTemplate redisTemplate = new RedisTemplate();
            //设置redis的连接工厂对象
            redisTemplate.setConnectionFactory(redisConnectionFactory);
            //设置redis key的序列化器
            redisTemplate.setKeySerializer(new StringRedisSerializer());
            return redisTemplate;
        }
    }
    
  • 通过RedisTemplate对象操作Redis

测试连接
application.yml
  redis:
    host: ${sky.redis.host}
    port: ${sky.redis.port}
    database: ${sky.redis.database}
application-dev.yml
  redis:
    host: localhost
    port: 6379
    database: 1
sky-server  com/sky/config/RedisConfiguration.java
package com.sky.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
@Slf4j
public class RedisConfiguration {
    @Bean
    public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory){
        log.info("开始创建redis模板对象...");
        RedisTemplate redisTemplate = new RedisTemplate();
        //设置redis的连接工厂对象
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        //设置redis key的序列化器 在图形化界面不出现乱码
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        return redisTemplate;
    }
}
sky-server【测试类】 com/sky/test/SpringDataRedisTest.java
package com.sky.test;

import com.mysql.cj.util.TimeUtil;
import net.sf.jsqlparser.statement.select.KSQLWindow;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.*;

import java.util.concurrent.TimeUnit;

@SpringBootTest //测试完记得注释 不然每次启动类就会运行这个测试类
public class SpringDataRedisTest {
    @Autowired
    private RedisTemplate redisTemplate;

    @Test
    public void testRedisTemplate(){
        System.out.println(redisTemplate);
        //创建根据字符串、哈希、列表、集合、有序列表、通用命令的代码
        ValueOperations valueOperations = redisTemplate.opsForValue();
        HashOperations hashOperations = redisTemplate.opsForHash();
        ListOperations listOperations = redisTemplate.opsForList();
        SetOperations setOperations = redisTemplate.opsForSet();
        ZSetOperations zSetOperations = redisTemplate.opsForZSet();
    }

    /**
     * 操作字符串类型的数据
     */
    @Test
    public void testString(){
        // set get setex setnx
        redisTemplate.opsForValue().set("city","北京");
        String city = (String) redisTemplate.opsForValue().get("city");
        System.out.println(city); // 北京

        redisTemplate.opsForValue().set("code", "1234", 3, TimeUnit.MINUTES);
        // 第一次调用可以设置成功
        redisTemplate.opsForValue().setIfAbsent("lock", "1");
        // 第二次不可以成功
        redisTemplate.opsForValue().setIfAbsent("lock", "2");
        Object lock = redisTemplate.opsForValue().get("lock");
        System.out.println(lock); // 1

        Object lock2 = redisTemplate.opsForValue().get("locwwk");
        System.out.println(lock2);// null
    }
    
    /**
     * 操作哈希类型的数据
     */
    @Test
    public void testHash(){
        //hset hget hdel hkeys havls
        HashOperations hashOperations = redisTemplate.opsForHash();
        hashOperations.put("100","name","tom");
        hashOperations.put("100","age","20");

        String name = (String) hashOperations.get("100","name");
        System.out.println(name);

        Set keys = hashOperations.keys("100");
        System.out.println(keys);

        List values = hashOperations.values("100");
        System.out.println(values);

        hashOperations.delete("100","age");
/**
     * 操作列表类型的数据
     */
    @Test
    public void testList(){
        //lpush lrange rpop llen
        ListOperations listOperations = redisTemplate.opsForList();

        listOperations.leftPushAll("mylist","a","b","c");
        listOperations.leftPush("mylist","d");

        List mylist = listOperations.range("mylist", 0, -1);
        System.out.println(mylist);

        listOperations.rightPop("mylist");

        Long size = listOperations.size("mylist");
        System.out.println(size);
    }

    /**
     * 操作集合类型的数据
     */
    @Test
    public void testSet(){
        //sadd smembers scard sinter sunion srem
        SetOperations setOperations = redisTemplate.opsForSet();

        setOperations.add("set1","a","b","c","d");
        setOperations.add("set2","a","b","x","y");

        Set members = setOperations.members("set1");
        System.out.println(members);

        Long size = setOperations.size("set1");
        System.out.println(size);

        Set intersect = setOperations.intersect("set1", "set2");
        System.out.println(intersect);

        Set union = setOperations.union("set1", "set2");
        System.out.println(union);

        setOperations.remove("set1","a","b");
    }

    /**
     * 操作有序集合类型的数据
     */
    @Test
    public void testZset(){
        //zadd zrange zincrby zrem
        ZSetOperations zSetOperations = redisTemplate.opsForZSet();

        zSetOperations.add("zset1","a",10);
        zSetOperations.add("zset1","b",12);
        zSetOperations.add("zset1","c",9);

        Set zset1 = zSetOperations.range("zset1", 0, -1);
        System.out.println(zset1);

        zSetOperations.incrementScore("zset1","c",10);

        zSetOperations.remove("zset1","a","b");
    }

    /**
     * 通用命令操作
     */
    @Test
    public void testCommon(){
        //keys exists type del
        Set keys = redisTemplate.keys("*");
        System.out.println(keys);

        Boolean name = redisTemplate.hasKey("name");
        Boolean set1 = redisTemplate.hasKey("set1");

        for (Object key : keys) {
            DataType type = redisTemplate.type(key);
            System.out.println(type.name());
        }

        redisTemplate.delete("mylist");
    }
}

店铺营业状态设置 【存入Redis】

接口设计:
  • 设置营业状态

    Path:/admin/shop/{status}
    Method:PUT
    status 1 店铺营业状态:1为营业,0为打样

  • 管理端查询营业状态

    Path:/admin/shop/status
    Method:GET

  • 用户端查询营业状态

    Path:/user/shop/status
    Method:GET

★ ★ 本项目约定 ★ ★

  • 管理端发出的请求,统一使用**/admin**作为前缀
  • 用户端发出的请求,统一使用**/user**作为前缀

营业状态数据存储方式:基于Redis的字符串来进行存储
key: SHOP_STATUS value: 1 1为营业,0为打样

代码开发:
sky-server  com/sky/controller/admin/ShopController.java
package com.sky.controller.admin;

import com.sky.result.Result;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.*;

@RestController("adminShopController")
@RequestMapping("/admin/shop")
@Api(tags = "店铺相关接口")
@Slf4j
public class ShopController {
    public static final String KEY = "SHOP_STATUS";

    @Autowired
    private RedisTemplate redisTemplate;
    /**
     * 设置店铺营业状态
     * @param status
     * @return
     */
    @PutMapping("/{status}") //动态取到status
    @ApiOperation("设置店铺营业状态")
    public Result setStatus(@PathVariable Integer status) {
        log.info("设置店铺的营业状态为:{}", status == 1 ? "营业中" : "打样中");
        redisTemplate.opsForValue().set(KEY, status);
        return Result.success();
    }

    /**
     * 获取店铺的营业状态
     * @return
     */
    @GetMapping("/status")
    @ApiOperation("获取店铺的营业状态")
    public Result<Integer> getStatus(){
        Integer status = (Integer) redisTemplate.opsForValue().get(KEY);
        log.info("获取到的店铺营业状态为:{}",status == 1 ? "营业中" : "打样中");
        return Result.success(status);
    }
}
sky-server  com/sky/controller/user/ShopController.java
package com.sky.controller.user;

import com.sky.result.Result;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.*;
//@RestController("userShopController") 指定了这个控制器的名称为 userShopController
//这有助于在应用中唯一标识这个控制器,便于管理和调用
@RestController("userShopController")
@RequestMapping("/user/shop")
@Api(tags = "店铺相关接口")
@Slf4j
public class ShopController {
    public static final String KEY = "SHOP_STATUS";

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 获取店铺的营业状态
     * @return
     */
    @GetMapping("/status")
    @ApiOperation("获取店铺的营业状态")
    public Result<Integer> getStatus(){
        Integer status = (Integer) redisTemplate.opsForValue().get(KEY);
        log.info("获取到的店铺营业状态为:{}",status == 1 ? "营业中" : "打样中");
        return Result.success(status);
    }
}
sky-server  com/sky/config/WebMvcConfiguration.java
// 设置两个接口文档方便在前端文档处调试【管理端+用户端】
package com.sky.config;

import com.sky.interceptor.JwtTokenAdminInterceptor;
import com.sky.interceptor.JwtTokenUserInterceptor;
import com.sky.json.JacksonObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;

import java.util.List;

/**
 * 配置类,注册web层相关组件
 */
@Configuration
@Slf4j
public class WebMvcConfiguration extends WebMvcConfigurationSupport {

    @Autowired
    private JwtTokenAdminInterceptor jwtTokenAdminInterceptor;
    @Autowired
    private JwtTokenUserInterceptor jwtTokenUserInterceptor;

    /**
     * 注册自定义拦截器
     * @param registry
     */
    protected void addInterceptors(InterceptorRegistry registry) {
        log.info("开始注册自定义拦截器...");
        registry.addInterceptor(jwtTokenAdminInterceptor)
                .addPathPatterns("/admin/**")
                .excludePathPatterns("/admin/employee/login");

        registry.addInterceptor(jwtTokenUserInterceptor)
                .addPathPatterns("/user/**")
                .excludePathPatterns("/user/user/login")
                .excludePathPatterns("/user/shop/status");
    }

    @Bean
    public Docket docket1(){
        log.info("准备生成接口文档...");
        ApiInfo apiInfo = new ApiInfoBuilder()
                .title("苍穹外卖项目接口文档")
                .version("2.0")
                .description("苍穹外卖项目接口文档")
                .build();

        Docket docket = new Docket(DocumentationType.SWAGGER_2)
                .groupName("管理端接口")
                .apiInfo(apiInfo)
                .select()
                //指定生成接口需要扫描的包
                .apis(RequestHandlerSelectors.basePackage("com.sky.controller.admin"))
                .paths(PathSelectors.any())
                .build();

        return docket;
    }

    @Bean
    public Docket docket2(){
        log.info("准备生成接口文档...");
        ApiInfo apiInfo = new ApiInfoBuilder()
                .title("苍穹外卖项目接口文档")
                .version("2.0")
                .description("苍穹外卖项目接口文档")
                .build();

        Docket docket = new Docket(DocumentationType.SWAGGER_2)
                .groupName("用户端接口")
                .apiInfo(apiInfo)
                .select()
                //指定生成接口需要扫描的包
                .apis(RequestHandlerSelectors.basePackage("com.sky.controller.user"))
                .paths(PathSelectors.any())
                .build();

        return docket;
    }

    /**
     * 设置静态资源映射,主要是访问接口文档(html、js、css)
     * @param registry
     */
    protected void addResourceHandlers(ResourceHandlerRegistry registry) {
        log.info("开始设置静态资源映射...");
        registry.addResourceHandler("/doc.html").addResourceLocations("classpath:/META-INF/resources/");
        registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
    }

    /**
     * 扩展Spring MVC框架的消息转化器
     * @param converters
     */
    protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
        log.info("扩展消息转换器...");
        //创建一个消息转换器对象
        MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
        //需要为消息转换器设置一个对象转换器,对象转换器可以将Java对象序列化为json数据
        converter.setObjectMapper(new JacksonObjectMapper());
        //将自己的消息转化器加入容器中
        converters.add(0,converter);
    }
}

回顾拦截器原理

HttpClient & 微信小程序开发

HttpClient

HttpClient 是 Apache Jakarta Common下的子项目,可以用来提供高效的、最新的、功能丰富的支持HTTP协议的客户端编程工具包,并且它支持HTTP协议最新的版本和建议

<dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpclient</artifactId>
    <version>4.5.13</version>
</dependency>

核心API:

  • HttpClient
  • HttpClients
  • CloseableHttpClient
  • HttpGet
  • HttpPost

发送请求步骤:

  • 创建HttpClient对象
  • 创建Http请求对象
  • 调用HttpClient的execute方法发送请求
发送GET方式请求 [要先把项目跑起来]
sky-server  com/sky/test/HttpClientTest.java
package com.sky.test;

import org.apache.http.HttpEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import java.io.IOException;

@SpringBootTest
public class HttpClientTest {
    /**
     * 测试通过httpclient发送GET方式的请求
     */

    @Test
    public void testGET() throws IOException {
        // 创建httpclient对象
        CloseableHttpClient httpClient = HttpClients.createDefault();

        // 创建请求对象接口 (GET请求方式+请求地址)
        HttpGet httpGet = new HttpGet("http://localhost:8080/user/shop/status");

        // 发送请求,接受响应结果
        CloseableHttpResponse response = httpClient.execute(httpGet);

        // 获取服务端返回的状态码
        int statusCode = response.getStatusLine().getStatusCode();
        System.out.println("服务端返回的状态码为:" + statusCode);


        HttpEntity entity = response.getEntity();// 获得请求体
        String body = EntityUtils.toString(entity);
        System.out.println("服务端返回的数据为:" + body);

        // 关闭资源
        response.close();
        httpClient.close();
    }

    /**
     * 测试通过httpclient发送POST方式的请求
     */
}
--------------------------------------------------------------------------------
服务端返回的状态码为:200
服务端返回的数据为:{"code":1,"msg":null,"data":0}
发送POST方式请求 [要先把项目跑起来]
sky-server   com/sky/test/HttpClientTest.java
/**
     * 测试通过httpclient发送POST方式的请求
     */
    @Test
    public void testPOST() throws Exception{
        // 创建httpclient对象
        CloseableHttpClient httpClient = HttpClients.createDefault();

        // 创建请求对象
        HttpPost httpPost = new HttpPost("http://localhost:8080/admin/employee/login");
        // 以json方式请求提交参数
        JSONObject jsonObject = new JSONObject();
        jsonObject.put("username","admin");
        jsonObject.put("password","123456");

        StringEntity entity = new StringEntity(jsonObject.toString());
        // 指定请求编码方式
        entity.setContentEncoding("utf-8");
        // 数据格式
        entity.setContentType("application/json");
        httpPost.setEntity(entity);

        // 发送请求
        CloseableHttpResponse response = httpClient.execute(httpPost);

        // 解析返回结果
        int statusCode = response.getStatusLine().getStatusCode();
        System.out.println("响应码为:" + statusCode);

        HttpEntity entity1 = response.getEntity();
        String body = EntityUtils.toString(entity1);
        System.out.println("响应数据为:" + body);

        // 关闭资源
        response.close();
        httpClient.close();
    }
--------------------------------------------------------------------------------
响应码为:200
响应数据为:{"code":1,"msg":null,"data":{"id":1,"userName":"admin","name":"管理员","token":"eyJhbGciOiJIUzI1NiJ9.eyJlbXBJZCI6MSwiZXhwIjoxNzI4MTMyMzczfQ.8M2nIkgtHx8wpORNfhKEWjbprBV6OwC82wgYjAMxe2I"}}
封装后的HttpClientUtil
package com.sky.utils;

import com.alibaba.fastjson.JSONObject;
import org.apache.http.NameValuePair;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.utils.URIBuilder;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;

import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
 * Http工具类
 */
public class HttpClientUtil {

    static final  int TIMEOUT_MSEC = 5 * 1000;

    /**
     * 发送GET方式请求
     * @param url
     * @param paramMap
     * @return
     */
    public static String doGet(String url,Map<String,String> paramMap){
        // 创建Httpclient对象
        CloseableHttpClient httpClient = HttpClients.createDefault();

        String result = "";
        CloseableHttpResponse response = null;

        try{
            URIBuilder builder = new URIBuilder(url);
            if(paramMap != null){
                for (String key : paramMap.keySet()) {
                    builder.addParameter(key,paramMap.get(key));
                }
            }
            URI uri = builder.build();

            //创建GET请求
            HttpGet httpGet = new HttpGet(uri);

            //发送请求
            response = httpClient.execute(httpGet);

            //判断响应状态
            if(response.getStatusLine().getStatusCode() == 200){
                result = EntityUtils.toString(response.getEntity(),"UTF-8");
            }
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            try {
                response.close();
                httpClient.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        return result;
    }

    /**
     * 发送POST方式请求
     * @param url
     * @param paramMap
     * @return
     * @throws IOException
     */
    public static String doPost(String url, Map<String, String> paramMap) throws IOException {
        // 创建Httpclient对象
        CloseableHttpClient httpClient = HttpClients.createDefault();
        CloseableHttpResponse response = null;
        String resultString = "";

        try {
            // 创建Http Post请求
            HttpPost httpPost = new HttpPost(url);

            // 创建参数列表
            if (paramMap != null) {
                List<NameValuePair> paramList = new ArrayList();
                for (Map.Entry<String, String> param : paramMap.entrySet()) {
                    paramList.add(new BasicNameValuePair(param.getKey(), param.getValue()));
                }
                // 模拟表单
                UrlEncodedFormEntity entity = new UrlEncodedFormEntity(paramList);
                httpPost.setEntity(entity);
            }

            httpPost.setConfig(builderRequestConfig());

            // 执行http请求
            response = httpClient.execute(httpPost);

            resultString = EntityUtils.toString(response.getEntity(), "UTF-8");
        } catch (Exception e) {
            throw e;
        } finally {
            try {
                response.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        return resultString;
    }

    /**
     * 发送POST方式请求
     * @param url
     * @param paramMap
     * @return
     * @throws IOException
     */
    public static String doPost4Json(String url, Map<String, String> paramMap) throws IOException {
        // 创建Httpclient对象
        CloseableHttpClient httpClient = HttpClients.createDefault();
        CloseableHttpResponse response = null;
        String resultString = "";

        try {
            // 创建Http Post请求
            HttpPost httpPost = new HttpPost(url);

            if (paramMap != null) {
                //构造json格式数据
                JSONObject jsonObject = new JSONObject();
                for (Map.Entry<String, String> param : paramMap.entrySet()) {
                    jsonObject.put(param.getKey(),param.getValue());
                }
                StringEntity entity = new StringEntity(jsonObject.toString(),"utf-8");
                //设置请求编码
                entity.setContentEncoding("utf-8");
                //设置数据类型
                entity.setContentType("application/json");
                httpPost.setEntity(entity);
            }

            httpPost.setConfig(builderRequestConfig());

            // 执行http请求
            response = httpClient.execute(httpPost);

            resultString = EntityUtils.toString(response.getEntity(), "UTF-8");
        } catch (Exception e) {
            throw e;
        } finally {
            try {
                response.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        return resultString;
    }
    private static RequestConfig builderRequestConfig() {
        return RequestConfig.custom()
                .setConnectTimeout(TIMEOUT_MSEC)
                .setConnectionRequestTimeout(TIMEOUT_MSEC)
                .setSocketTimeout(TIMEOUT_MSEC).build();
    }
}

微信小程序开发

小程序 (qq.com)

详情 → 本地设置 → [取消勾选]不校验合法域名…

操作步骤
  • 了解小程序目录结构

    小程序包含一个描述整体程序app和多个和描述各自页面page,一个小程序主体部分由三个文件组村,必须放在项目的根目录

    文件 必需 作用
    app.js 小程序逻辑
    app.json 小程序公共配置
    app.wxss 小程序公共样式表
    一个小程序页面由四个文件组成 [pages → index → …]
    文件类型 必需 作用
    js 页面逻辑
    wxml 页面结构
    json 页面配置
    wxss 页面样式表
  • 编写测试小程序代码

    微信getUserProfile不弹出授权框_wx.getuserprofile没有弹窗-CSDN博客

    app.json 【外面一层】
    {
      "pages": [
        "pages/index/index",
        "pages/logs/logs"
      ],
      "window": {
        "navigationBarTextStyle": "black",
        "navigationBarTitleText": "Sky-Delivery",
        "navigationBarBackgroundColor": "#ffffff"
      },
      "style": "v2",
      "componentFramework": "glass-easel",
      "sitemapLocation": "sitemap.json",
      "lazyCodeLoading": "requiredComponents"
    }
    
    pages/index/index.wxml
    <view class="container">
      <view>
        {{msg}}
      </view>
    
      <view>
        <button bindtap="getUserInfo" type="primary">获取用户信息</button>
        昵称:{{nickName}}
        <image src="{{url}}" style="width: 200px;height: 200px;"></image>
        <button bindtap="wxLogin" type="warn">微信登录</button>
        授权码:{{code}}
      </view>
    
      <view>
        <button bindtap="sendRequest" type="default">发送请求</button>
      </view>
    </view>
    
    pages/index/index.js
    Page({
      data: {
        msg: 'hello world',
        nickName: '',
        url:'',
        code:'',
      },
    
      // 获取微信用户的头像和昵称
      getUserInfo(e){
        wx.getUserProfile({
          desc: '获取用户信息',
          success: (res) => {
            console.log(res.userInfo);
            // 为数据赋值
            this.setData({
              nickName: res.userInfo.nickName,
              url: res.userInfo.avatarUrl
            })
          },
          fail:(err) => {
            console.error('获取用户信息失败', err);
          }
        });
      },
      
      //微信登录,获取微信用户的授权码 
      //拿到后可以去请求微信服务器获得openId
      //授权码提交到后端去调用服务器
      wxLogin(){
        wx.login({
          success: (res) => {
            console.log(res.code)
            this.setData({
              code: res.code
            })
          }
        })
      },
    
      //发送请求
      sendRequest(){
        wx.request({
          url: 'http://localhost:8080/user/shop/status',
          method: 'GET',
          success: (res)=>{
            // data是后端响应回来的整个数据
            console.log(res.data)
          }
        })
      }
    });
    
  • 编译小程序

微信登录

导入小程序代码

E:\Java实例项目1-20套\第24套【项目实战】Java外卖项目实战《苍穹外卖》SpringBoot+SpringMVC+Vue+Swagger+Lombok+Mybatis+SpringSession+Redis+Nginx+小程序\0-0 源码资料\资料\day06\微信小程序代码\mp-weixin
【注意:导入后有很多包名错误common、components】

微信登录流程

开放能力 / 用户信息 / 小程序登录 (qq.com)

属性 类型 默认值 必填 说明
appid string 小程序 appId
secret string 小程序 appSecret
js_code string 登录时获取的 code
grant_type string 授权类型,此处只需填写 authorization_code

PostMan测试 →
GET:https://api.weixin.qq.com/sns/jscode2session?appid=wxa33b4bae9165c5a5&secret=c2d6fc237953d711146c4ad5db3ef947&js_code=0f1hdA200TsYYS1ghD100c3GZJ1hdA2w&grant_type=authorization_code

返回:
{“session_key”:”HsYD32ryqarcnrCXbEyWhg==”,”openid”:”obaex5N3w1_oAP6a4h-c-CkQBsZQ”}

需求分析和设计

数据库设计(user表)

代码开发
sky-server  application.yml
sky:
  jwt:
    # 设置jwt签名加密时使用的秘钥
    admin-secret-key: itcast
    # 设置jwt过期时间
    admin-ttl: 7200000
    # 设置前端传递过来的令牌名称
    admin-token-name: token
    user-secret-key: itheima
    user-ttl: 7200000
    user-token-name: authentication
  alioss:
    endpoint: ${sky.alioss.endpoint}
    access-key-id: ${sky.alioss.access-key-id}
    access-key-secret: ${sky.alioss.access-key-secret}
    bucket: ${sky.alioss.bucket}
  wechat:
    appid: ${sky.wechat.appid}
    secret: ${sky.wechat.secret}
sky-server  application-dev.yml
  wechat:
    appid: xxxxxxx
    secret: xxxxxxx
sky-common  com/sky/properties/WeChatProperties.java
package com.sky.properties;

import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties(prefix = "sky.wechat")
@Data
public class WeChatProperties {

    private String appid; //小程序的appid
    private String secret; //小程序的秘钥
    private String mchid; //商户号
    private String mchSerialNo; //商户API证书的证书序列号
    private String privateKeyFilePath; //商户私钥文件
    private String apiV3Key; //证书解密的密钥
    private String weChatPayCertFilePath; //平台证书
    private String notifyUrl; //支付成功的回调地址
    private String refundNotifyUrl; //退款成功的回调地址
}
sky-server  com/sky/controller/user/UserController.java
package com.sky.controller.user;

import com.sky.constant.JwtClaimsConstant;
import com.sky.dto.UserLoginDTO;
import com.sky.entity.User;
import com.sky.properties.JwtProperties;
import com.sky.result.Result;
import com.sky.service.UserService;
import com.sky.utils.JwtUtil;
import com.sky.vo.UserLoginVO;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/user/user")
@Api(tags = "C端用户相关接口")
@Slf4j
public class UserController {
    @Autowired
    private UserService userService;
    @Autowired
    private JwtProperties jwtProperties;
    /**
     * 微信登录
     * @param userLoginDTO
     * @return
     */
    @PostMapping("/login")
    @ApiOperation("微信登录")
    public Result<UserLoginVO> login(@RequestBody UserLoginDTO userLoginDTO) {
        log.info("微信登录:{}", userLoginDTO.getCode());
        //微信登录
        User user = userService.wxLogin(userLoginDTO);

        //为微信用户生成jwt令牌
        Map<String, Object> claims = new HashMap<>();
        claims.put(JwtClaimsConstant.USER_ID, user.getId());
        String token = JwtUtil.createJWT(jwtProperties.getUserSecretKey(),jwtProperties.getUserTtl(),claims);
        UserLoginVO userLoginVO = UserLoginVO.builder()
                .id(user.getId())
                .openid(user.getOpenid())
                .token(token)
                .build();
        return Result.success(userLoginVO);
    }
}
sky-server  com/sky/service/UserService.java
package com.sky.service;

import com.sky.dto.UserLoginDTO;
import com.sky.entity.User;

public interface UserService {
    /**
     * 微信登录
     * @return
     */
    User wxLogin(UserLoginDTO userLoginDTO);
}
sky-server  com/sky/service/impl/UserServiceImpl.java
package com.sky.service.impl;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.sky.constant.MessageConstant;
import com.sky.dto.UserLoginDTO;
import com.sky.entity.User;
import com.sky.exception.LoginFailedException;
import com.sky.mapper.UserMapper;
import com.sky.properties.WeChatProperties;
import com.sky.service.UserService;
import com.sky.utils.HttpClientUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

@Service
@Slf4j
public class UserServiceImpl implements UserService {
    // 微信服务接口地址
    public static final String WX_LOGIN = "https://api.weixin.qq.com/sns/jscode2session";

    @Autowired
    private WeChatProperties weChatProperties;
    @Autowired
    private UserMapper userMapper;
    /**
     * 微信登录
     * @param userLoginDTO
     * @return
     */
    @Override
    public User wxLogin(UserLoginDTO userLoginDTO) {
        String openid = getOpenid(userLoginDTO.getCode());
        // 判断openId是否真的获取到 如果为空代表失败 业务异常
        if (openid == null){
            throw new LoginFailedException(MessageConstant.LOGIN_FAILED);
        }
        // openId是否在表里 可判断是否为新用户
        User user = userMapper.getByOpenid(openid);
        // 如果是新用户,自动完成注册
        if (user == null) {
            user = User.builder()
                    .openid(openid)
                    .createTime(LocalDateTime.now())
                    .build();
            userMapper.insert(user);
        }
        // 返回这个用户对象
        return user;
    }

    /**
     * 调用微信接口服务,获取微信用户的openid
     * @param code
     * @return
     */
    //只有当前类用到
    private String getOpenid(String code) {
        // 调用微信服务器接口 获得当前用户的openid
        // 四个请求参数
        Map<String, String> map = new HashMap<>();
        map.put("appid", weChatProperties.getAppid());
        map.put("secret", weChatProperties.getSecret());
        map.put("js_code", code);
        map.put("grant_type", "authorization_code");
        String json = HttpClientUtil.doGet(WX_LOGIN, map);

        // 获得json对象
        JSONObject jsonObject = JSON.parseObject(json);
        String openid = jsonObject.getString("openid");
        return openid;
    }
}
sky-server  com/sky/mapper/UserMapper.java
package com.sky.mapper;

import com.sky.entity.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;

@Mapper
public interface UserMapper {
    /**
     * 根据openid查询用户
     * @param openid
     * @return
     */
    @Select("select * from user where openid = #{openid}")
    User getByOpenid(String openid);

    /**
     * 新增用户
     * @param user
     */
    void insert(User user);
}
sky-server  mapper/UserMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.UserMapper">

    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
        insert into user (openid, name, phone, sex, id_number, avatar, create_time)
        values (#{openid}, #{name}, #{phone}, #{sex}, #{idNumber}, #{avatar}, #{createTime})
    </insert>
</mapper>
【检测小程序用户是否登陆性】
sky-server  com/sky/interceptor/JwtTokenUserInterceptor.java
package com.sky.interceptor;

import com.sky.constant.JwtClaimsConstant;
import com.sky.context.BaseContext;
import com.sky.properties.JwtProperties;
import com.sky.utils.JwtUtil;
import io.jsonwebtoken.Claims;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * jwt令牌校验的拦截器
 */
@Component
@Slf4j
public class JwtTokenUserInterceptor implements HandlerInterceptor {

    @Autowired
    private JwtProperties jwtProperties;

    /**
     * 校验jwt
     *
     * @param request
     * @param response
     * @param handler
     * @return
     * @throws Exception
     */
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //判断当前拦截到的是Controller的方法还是其他资源
        if (!(handler instanceof HandlerMethod)) {
            //当前拦截到的不是动态方法,直接放行
            return true;
        }

        //1、从请求头中获取令牌
        String token = request.getHeader(jwtProperties.getUserTokenName());

        //2、校验令牌
        try {
            log.info("jwt校验:{}", token);
            Claims claims = JwtUtil.parseJWT(jwtProperties.getUserSecretKey(), token);
            Long userId = Long.valueOf(claims.get(JwtClaimsConstant.USER_ID).toString());
            log.info("当前用户的id:", userId);
            BaseContext.setCurrentId(userId);
            //3、通过,放行
            return true;
        } catch (Exception ex) {
            //4、不通过,响应401状态码
            response.setStatus(401);
            return false;
        }
    }
}
sky-server  com/sky/config/WebMvcConfiguration.java 【增加jwtTokenUserInterceptor】
/**
 * 配置类,注册web层相关组件
 */
@Configuration
@Slf4j
public class WebMvcConfiguration extends WebMvcConfigurationSupport {

    @Autowired
    private JwtTokenAdminInterceptor jwtTokenAdminInterceptor;
    @Autowired
    private JwtTokenUserInterceptor jwtTokenUserInterceptor;

    /**
     * 注册自定义拦截器
     * @param registry
     */
    protected void addInterceptors(InterceptorRegistry registry) {
        log.info("开始注册自定义拦截器...");
        registry.addInterceptor(jwtTokenAdminInterceptor)
                .addPathPatterns("/admin/**")
                .excludePathPatterns("/admin/employee/login");

        registry.addInterceptor(jwtTokenUserInterceptor)
                .addPathPatterns("/user/**")
                .excludePathPatterns("/user/user/login")
                .excludePathPatterns("/user/shop/status");
    }
}

导入商品浏览功能代码

接口设计
  • 查询分类

    Path: /user/category/list
    Method:GET
    请求参数
    Type: 分类类型→1.菜品分类 2.套餐分类

  • 根据分类id查询菜品

    Path: /user/dish/list
    Method:GET
    请求参数
    categoryId 分类id

  • 根据分类id查询套餐

    Path: /user/setmeal/list?category=111
    Method:GET
    请求参数
    categoryId 分类id

  • 根据套餐id查询包含的菜品

    Path: /user/setmeal/dish/10
    Method:GET
    请求参数
    id 套餐id
    返回数据:
    copies 份数
    description 菜品描述
    image 菜品图片
    name 菜品名称

sky-server  com/sky/controller/user/DishController.java
package com.sky.controller.user;

import com.sky.constant.StatusConstant;
import com.sky.entity.Dish;
import com.sky.result.Result;
import com.sky.service.DishService;
import com.sky.vo.DishVO;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;

@RestController("userDishController")
@RequestMapping("/user/dish")
@Slf4j
@Api(tags = "C端-菜品浏览接口")
public class DishController {
    @Autowired
    private DishService dishService;

    /**
     * 根据分类id查询菜品
     *
     * @param categoryId
     * @return
     */
    @GetMapping("/list")
    @ApiOperation("根据分类id查询菜品")
    public Result<List<DishVO>> list(Long categoryId) {
        Dish dish = new Dish();
        dish.setCategoryId(categoryId);
        dish.setStatus(StatusConstant.ENABLE);//查询起售中的菜品

        List<DishVO> list = dishService.listWithFlavor(dish);

        return Result.success(list);
    }
}
sky-server  com/sky/service/DishService.java
package com.sky.service;

import com.sky.dto.DishDTO;
import com.sky.dto.DishPageQueryDTO;
import com.sky.entity.Dish;
import com.sky.result.PageResult;
import com.sky.vo.DishVO;

import java.util.List;

public interface DishService {
    /**
     * 新增菜品和对应的口味
     * @param dishDTO
     */
    public void saveWithFlavour(DishDTO dishDTO);

    /**
     * 菜品分页查询
     * @param dishPageQueryDTO
     * @return
     */
    PageResult pageQuery(DishPageQueryDTO dishPageQueryDTO);

    /**
     * 菜品的批量删除
     * @param ids
     * @return
     */
    void deleteBatch(List<Long> ids);

    /**
     * 根据id查询菜品和对应的口味数据
     * @param id
     * @return
     */
    DishVO getByIdWithFlavor(long id);

    /**
     * 修改菜品
     * @param dishDTO
     * @return
     */
    void updateWithFlavor(DishDTO dishDTO);

    /**
     * 菜品起售停售
     *
     * @param status
     * @param id
     * @return
     */
    void startOrStop(Integer status, Long id);

    /**
     * 根据分类id查询菜品
     * @param categoryId
     * @return
     */
    List<Dish> list(Long categoryId);

    /**
     * 条件查询菜品和口味
     * @param dish
     * @return
     */
    List<DishVO> listWithFlavor(Dish dish);
}
sky-server  com/sky/service/impl/DishServiceImpl.java
package com.sky.service.impl;

import com.github.pagehelper.Page;
import com.github.pagehelper.PageHelper;
import com.sky.constant.MessageConstant;
import com.sky.constant.StatusConstant;
import com.sky.dto.DishDTO;
import com.sky.dto.DishPageQueryDTO;
import com.sky.entity.Dish;
import com.sky.entity.DishFlavor;
import com.sky.entity.Setmeal;
import com.sky.exception.DeletionNotAllowedException;
import com.sky.mapper.DishFlavorMapper;
import com.sky.mapper.DishMapper;
import com.sky.mapper.SetmealDishMapper;
import com.sky.mapper.SetmealMapper;
import com.sky.result.PageResult;
import com.sky.service.DishService;
import com.sky.vo.DishVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;

@Service
@Slf4j

public class DishServiceImpl implements DishService {
    @Autowired
    private DishMapper dishMapper;
    @Autowired
    private DishFlavorMapper dishFlavorMapper;
    @Autowired
    private SetmealDishMapper setmealDishMapper;
    @Autowired
    private SetmealMapper setmealMapper;
    /**
     * 新增菜品和对应的口味
     * @param dishDTO
     */
    @Override
    @Transactional //保证事务一致性
    public void saveWithFlavour(DishDTO dishDTO) {
        Dish dish = new Dish();
        //直接new出来是空的需要先赋值 属性拷贝[属性命名要一致]
        BeanUtils.copyProperties(dishDTO,dish);

        // 向菜品表插入1条数据
        dishMapper.insert(dish);
        // 前端无法传 要获取dishId
// <insert id="insertBatch" useGeneratedKeys="true" keyProperty="id"> 获取主键值
        Long dishId = dish.getId();

        List<DishFlavor> flavors = dishDTO.getFlavors();
        if (flavors != null && flavors.size() > 0) {
            flavors.forEach(dishFlavor -> {
                dishFlavor.setDishId(dishId);
            });
            // 向口味表插入n条数据 集合对象批量传入不用集合
            dishFlavorMapper.insertBatch(flavors);
        }
    }

    /**
     * 菜品分页查询
     * @param dishPageQueryDTO
     * @return
     */
    @Override
    public PageResult pageQuery(DishPageQueryDTO dishPageQueryDTO) {
        PageHelper.startPage(dishPageQueryDTO.getPage(), dishPageQueryDTO.getPageSize());
        Page<DishVO> page = dishMapper.pageQuery(dishPageQueryDTO);
        return new PageResult(page.getTotal(), page.getResult());
    }

    /**
     * 菜品的批量删除
     * @param ids
     * @return
     */
    @Transactional
    public void deleteBatch(List<Long> ids) {
        // 判断当前菜品是否能够删除--是否存在起售中的菜品?? 取出id
        for (Long id : ids) {
            Dish dish = dishMapper.getById(id);
            if (dish.getStatus() == StatusConstant.ENABLE) {
                //当前菜品处于起售中,不能删除
                throw new DeletionNotAllowedException(MessageConstant.DISH_ON_SALE);
            }
        }

        // 判断当前菜品是否能够删除--是否被套餐关联了
        List<Long> setMealIds = setmealDishMapper.getSetmealIdsByDishIds(ids);
        if (setMealIds != null && setMealIds.size() > 0) { //存在不允许删除
            // 当前菜品被套餐关联了,不能删除
            throw new DeletionNotAllowedException(MessageConstant.DISH_BE_RELATED_BY_SETMEAL);
        }
        // 删除菜品表中的菜品数据
        for (Long id : ids) {
            dishMapper.deleteById(id);
            // 删除菜品关联的口味数据
            dishFlavorMapper.deleteByDishId(id);
        }
    }

    /**
     * 根据id查询菜品和对应的口味数据
     * @param id
     * @return
     */
    @Override
    public DishVO getByIdWithFlavor(long id) {
        // 根据id查询菜品数据
        Dish dish = dishMapper.getById(id);
        // 根据菜品id查询口味数据
        List<DishFlavor> dishFlavors = dishFlavorMapper.getByDishId(id);

        // 将查询到的数据封装到VO
        DishVO dishVO = new DishVO();
            // 属性拷贝
        BeanUtils.copyProperties(dish,dishVO);
        dishVO.setFlavors(dishFlavors);

        return dishVO;
    }



    /**
     * 修改菜品
     * @param dishDTO
     * @return
     */
    @Override
    public void updateWithFlavor(DishDTO dishDTO) {
        Dish dish = new Dish();
        BeanUtils.copyProperties(dishDTO,dish);

        // 修改菜品表基本信息 只是基础信息噢
        dishMapper.update(dish);
        // 先删掉原先的
        dishFlavorMapper.deleteByDishId(dishDTO.getId());
        // 再重新插入新的
        List<DishFlavor> flavors = dishDTO.getFlavors();
        if (flavors != null && flavors.size() > 0) {
            flavors.forEach(dishFlavor -> {
                dishFlavor.setDishId(dishDTO.getId());
            });
            // 向口味表插入n条数据 集合对象批量传入不用集合
            dishFlavorMapper.insertBatch(flavors);
        }
    }

    /**
     * 菜品起售停售
     * @param status
     * @param id
     */
    @Override
    public void startOrStop(Integer status, Long id) {
        Dish dish = Dish.builder()
                .id(id)
                .status(status)
                .build();
        dishMapper.update(dish);

        if (status == StatusConstant.DISABLE) {
            // 如果是停售操作,还需要将包含当前菜品的套餐也停售
            List<Long> dishIds = new ArrayList<>();
            dishIds.add(id);
            // select setmeal_id from setmeal_dish where dish_id in (?,?,?)
            List<Long> setmealIds = setmealDishMapper.getSetmealIdsByDishIds(dishIds);
            if (setmealIds != null && setmealIds.size() > 0) {
                for (Long setmealId : setmealIds) {
                    Setmeal setmeal = Setmeal.builder()
                            .id(setmealId)
                            .status(StatusConstant.DISABLE)
                            .build();
                    setmealMapper.update(setmeal);
                }
            }

        }
    }

    /**
     * 根据分类id查询菜品
     * @param categoryId
     * @return
     */
    public List<Dish> list(Long categoryId) {
        Dish dish = Dish.builder()
                .categoryId(categoryId)
                .status(StatusConstant.ENABLE)
                .build();
        return dishMapper.list(dish);
    }

    /**
     * 条件查询菜品和口味
     * @param dish
     * @return
     */
    public List<DishVO> listWithFlavor(Dish dish) {
        List<Dish> dishList = dishMapper.list(dish);

        List<DishVO> dishVOList = new ArrayList<>();

        for (Dish d : dishList) {
            DishVO dishVO = new DishVO();
            BeanUtils.copyProperties(d,dishVO);

            //根据菜品id查询对应的口味
            List<DishFlavor> flavors = dishFlavorMapper.getByDishId(d.getId());

            dishVO.setFlavors(flavors);
            dishVOList.add(dishVO);
        }

        return dishVOList;
    }
}

sky-server  com/sky/controller/user/SetmealController.java
package com.sky.controller.user;

import com.sky.constant.StatusConstant;
import com.sky.entity.Setmeal;
import com.sky.result.Result;
import com.sky.service.SetmealService;
import com.sky.vo.DishItemVO;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;

@RestController("userSetmealController")
@RequestMapping("/user/setmeal")
@Api(tags = "C端-套餐浏览接口")
public class SetmealController {
    @Autowired
    private SetmealService setmealService;

    /**
     * 条件查询
     *
     * @param categoryId
     * @return
     */
    @GetMapping("/list")
    @ApiOperation("根据分类id查询套餐")
    public Result<List<Setmeal>> list(Long categoryId) {
        Setmeal setmeal = new Setmeal();
        setmeal.setCategoryId(categoryId);
        setmeal.setStatus(StatusConstant.ENABLE);

        List<Setmeal> list = setmealService.list(setmeal);
        return Result.success(list);
    }

    /**
     * 根据套餐id查询包含的菜品列表
     *
     * @param id
     * @return
     */
    @GetMapping("/dish/{id}")
    @ApiOperation("根据套餐id查询包含的菜品列表")
    public Result<List<DishItemVO>> dishList(@PathVariable("id") Long id) {
        List<DishItemVO> list = setmealService.getDishItemById(id);
        return Result.success(list);
    }
}
sky-server  com/sky/service/SetmealService.java
package com.sky.service;

import com.sky.dto.SetmealDTO;
import com.sky.dto.SetmealPageQueryDTO;
import com.sky.entity.Setmeal;
import com.sky.result.PageResult;
import com.sky.vo.DishItemVO;
import com.sky.vo.SetmealVO;

import java.util.List;

public interface SetmealService {

    /**
     * 新增套餐,同时需要保存套餐和菜品的关联关系
     *
     * @param setmealDTO
     */
    void saveWithDish(SetmealDTO setmealDTO);

    /**
     * 分页查询
     *
     * @param setmealPageQueryDTO
     * @return
     */
    PageResult pageQuery(SetmealPageQueryDTO setmealPageQueryDTO);

    /**
     * 批量删除套餐
     *
     * @param ids
     */
    void deleteBatch(List<Long> ids);

    /**
     * 根据id查询套餐和关联的菜品数据
     *
     * @param id
     * @return
     */
    SetmealVO getByIdWithDish(Long id);

    /**
     * 修改套餐
     *
     * @param setmealDTO
     */
    void update(SetmealDTO setmealDTO);

    /**
     * 套餐起售、停售
     *
     * @param status
     * @param id
     */
    void startOrStop(Integer status, Long id);

    /**
     * 条件查询
     * @param setmeal
     * @return
     */
    List<Setmeal> list(Setmeal setmeal);

    /**
     * 根据id查询菜品选项
     * @param id
     * @return
     */
    List<DishItemVO> getDishItemById(Long id);
}
sky-server  com/sky/service/impl/SetmealServiceImpl.java
package com.sky.service.impl;

import com.github.pagehelper.Page;
import com.github.pagehelper.PageHelper;
import com.sky.constant.MessageConstant;
import com.sky.constant.StatusConstant;
import com.sky.dto.SetmealDTO;
import com.sky.dto.SetmealPageQueryDTO;
import com.sky.entity.Dish;
import com.sky.entity.Setmeal;
import com.sky.entity.SetmealDish;
import com.sky.exception.DeletionNotAllowedException;
import com.sky.exception.SetmealEnableFailedException;
import com.sky.mapper.DishMapper;
import com.sky.mapper.SetmealDishMapper;
import com.sky.mapper.SetmealMapper;
import com.sky.result.PageResult;
import com.sky.service.SetmealService;
import com.sky.vo.DishItemVO;
import com.sky.vo.SetmealVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

/**
 * 套餐业务实现
 */
@Service
@Slf4j
public class SetmealServiceImpl implements SetmealService {

    @Autowired
    private SetmealMapper setmealMapper;
    @Autowired
    private SetmealDishMapper setmealDishMapper;
    @Autowired
    private DishMapper dishMapper;


    /**
     * 新增套餐,同时需要保存套餐和菜品的关联关系
     *
     * @param setmealDTO
     */
    @Transactional
    public void saveWithDish(SetmealDTO setmealDTO) {
        Setmeal setmeal = new Setmeal();
        BeanUtils.copyProperties(setmealDTO, setmeal);

        //向套餐表插入数据
        setmealMapper.insert(setmeal);

        //获取生成的套餐id
        Long setmealId = setmeal.getId();

        List<SetmealDish> setmealDishes = setmealDTO.getSetmealDishes();
        setmealDishes.forEach(setmealDish -> {
            setmealDish.setSetmealId(setmealId);
        });

        //保存套餐和菜品的关联关系
        setmealDishMapper.insertBatch(setmealDishes);
    }

    /**
     * 分页查询
     *
     * @param setmealPageQueryDTO
     * @return
     */
    public PageResult pageQuery(SetmealPageQueryDTO setmealPageQueryDTO) {
        int pageNum = setmealPageQueryDTO.getPage();
        int pageSize = setmealPageQueryDTO.getPageSize();

        PageHelper.startPage(pageNum, pageSize);
        Page<SetmealVO> page = setmealMapper.pageQuery(setmealPageQueryDTO);
        return new PageResult(page.getTotal(), page.getResult());
    }

    /**
     * 批量删除套餐
     *
     * @param ids
     */
    @Transactional
    public void deleteBatch(List<Long> ids) {
        ids.forEach(id -> {
            Setmeal setmeal = setmealMapper.getById(id);
            if (StatusConstant.ENABLE == setmeal.getStatus()) {
                //起售中的套餐不能删除
                throw new DeletionNotAllowedException(MessageConstant.SETMEAL_ON_SALE);
            }
        });

        ids.forEach(setmealId -> {
            //删除套餐表中的数据
            setmealMapper.deleteById(setmealId);
            //删除套餐菜品关系表中的数据
            setmealDishMapper.deleteBySetmealId(setmealId);
        });
    }

    /**
     * 根据id查询套餐和套餐菜品关系
     *
     * @param id
     * @return
     */
    public SetmealVO getByIdWithDish(Long id) {
        SetmealVO setmealVO = setmealMapper.getByIdWithDish(id);
        return setmealVO;
    }

    /**
     * 修改套餐
     *
     * @param setmealDTO
     */
    @Transactional
    public void update(SetmealDTO setmealDTO) {
        Setmeal setmeal = new Setmeal();
        BeanUtils.copyProperties(setmealDTO, setmeal);

        //1、修改套餐表,执行update
        setmealMapper.update(setmeal);

        //套餐id
        Long setmealId = setmealDTO.getId();

        //2、删除套餐和菜品的关联关系,操作setmeal_dish表,执行delete
        setmealDishMapper.deleteBySetmealId(setmealId);

        List<SetmealDish> setmealDishes = setmealDTO.getSetmealDishes();
        setmealDishes.forEach(setmealDish -> {
            setmealDish.setSetmealId(setmealId);
        });
        //3、重新插入套餐和菜品的关联关系,操作setmeal_dish表,执行insert
        setmealDishMapper.insertBatch(setmealDishes);
    }

    /**
     * 套餐起售、停售
     *
     * @param status
     * @param id
     */
    public void startOrStop(Integer status, Long id) {
        //起售套餐时,判断套餐内是否有停售菜品,有停售菜品提示"套餐内包含未启售菜品,无法启售"
        if (status == StatusConstant.ENABLE) {
            //select a.* from dish a left join setmeal_dish b on a.id = b.dish_id where b.setmeal_id = ?
            List<Dish> dishList = dishMapper.getBySetmealId(id);
            if (dishList != null && dishList.size() > 0) {
                dishList.forEach(dish -> {
                    if (StatusConstant.DISABLE == dish.getStatus()) {
                        throw new SetmealEnableFailedException(MessageConstant.SETMEAL_ENABLE_FAILED);
                    }
                });
            }
        }

        Setmeal setmeal = Setmeal.builder()
                .id(id)
                .status(status)
                .build();
        setmealMapper.update(setmeal);
    }

    /**
     * 条件查询
     * @param setmeal
     * @return
     */
    public List<Setmeal> list(Setmeal setmeal) {
        List<Setmeal> list = setmealMapper.list(setmeal);
        return list;
    }

    /**
     * 根据id查询菜品选项
     * @param id
     * @return
     */
    public List<DishItemVO> getDishItemById(Long id) {
        return setmealMapper.getDishItemBySetmealId(id);
    }
}

sky-server  com/sky/controller/user/CategoryController.java
package com.sky.controller.user;

import com.sky.entity.Category;
import com.sky.result.Result;
import com.sky.service.CategoryService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;

@RestController("userCategoryController")
@RequestMapping("/user/category")
@Api(tags = "C端-分类接口")
public class CategoryController {

    @Autowired
    private CategoryService categoryService;

    /**
     * 查询分类
     * @param type
     * @return
     */
    @GetMapping("/list")
    @ApiOperation("查询分类")
    public Result<List<Category>> list(Integer type) {
        List<Category> list = categoryService.list(type);
        return Result.success(list);
    }
}
sky-server  com/sky/service/CategoryService.java
package com.sky.service;

import com.sky.annotation.AutoFill;
import com.sky.dto.CategoryDTO;
import com.sky.dto.CategoryPageQueryDTO;
import com.sky.entity.Category;
import com.sky.result.PageResult;
import java.util.List;

public interface CategoryService {

    /**
     * 新增分类
     * @param categoryDTO
     */
    void save(CategoryDTO categoryDTO);
    /**
     * 分页查询
     * @param categoryPageQueryDTO
     * @return
     */
    PageResult pageQuery(CategoryPageQueryDTO categoryPageQueryDTO);

    /**
     * 根据id删除分类
     * @param id
     */
    void deleteById(Long id);

    /**
     * 修改分类
     * @param categoryDTO
     */
    void update(CategoryDTO categoryDTO);

    /**
     * 启用、禁用分类
     * @param status
     * @param id
     */
    void startOrStop(Integer status, Long id);

    /**
     * 根据类型查询分类
     * @param type
     * @return
     */
    List<Category> list(Integer type);
}
sky-server  com/sky/service/impl/CategoryServiceImpl.java
package com.sky.service.impl;

import com.github.pagehelper.Page;
import com.github.pagehelper.PageHelper;
import com.sky.constant.MessageConstant;
import com.sky.constant.StatusConstant;
import com.sky.context.BaseContext;
import com.sky.dto.CategoryDTO;
import com.sky.dto.CategoryPageQueryDTO;
import com.sky.entity.Category;
import com.sky.exception.DeletionNotAllowedException;
import com.sky.mapper.CategoryMapper;
import com.sky.mapper.DishMapper;
import com.sky.mapper.SetmealMapper;
import com.sky.result.PageResult;
import com.sky.service.CategoryService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;

/**
 * 分类业务层
 */
@Service
@Slf4j
public class CategoryServiceImpl implements CategoryService {

    @Autowired
    private CategoryMapper categoryMapper;
    @Autowired
    private DishMapper dishMapper;
    @Autowired
    private SetmealMapper setmealMapper;

    /**
     * 新增分类
     * @param categoryDTO
     */
    public void save(CategoryDTO categoryDTO) {
        Category category = new Category();
        //属性拷贝
        BeanUtils.copyProperties(categoryDTO, category);

        //分类状态默认为禁用状态0
        category.setStatus(StatusConstant.DISABLE);
/**  公共属性
        //设置创建时间、修改时间、创建人、修改人
        category.setCreateTime(LocalDateTime.now());
        category.setUpdateTime(LocalDateTime.now());
        category.setCreateUser(BaseContext.getCurrentId());
        category.setUpdateUser(BaseContext.getCurrentId());
 **/

        categoryMapper.insert(category);
    }

    /**
     * 分页查询
     * @param categoryPageQueryDTO
     * @return
     */
    public PageResult pageQuery(CategoryPageQueryDTO categoryPageQueryDTO) {
        PageHelper.startPage(categoryPageQueryDTO.getPage(),categoryPageQueryDTO.getPageSize());
        //下一条sql进行分页,自动加入limit关键字分页
        Page<Category> page = categoryMapper.pageQuery(categoryPageQueryDTO);
        return new PageResult(page.getTotal(), page.getResult());
    }

    /**
     * 根据id删除分类
     * @param id
     */
    public void deleteById(Long id) {
        //查询当前分类是否关联了菜品,如果关联了就抛出业务异常
        Integer count = dishMapper.countByCategoryId(id);
        if(count > 0){
            //当前分类下有菜品,不能删除
            throw new DeletionNotAllowedException(MessageConstant.CATEGORY_BE_RELATED_BY_DISH);
        }

        //查询当前分类是否关联了套餐,如果关联了就抛出业务异常
        count = setmealMapper.countByCategoryId(id);
        if(count > 0){
            //当前分类下有菜品,不能删除
            throw new DeletionNotAllowedException(MessageConstant.CATEGORY_BE_RELATED_BY_SETMEAL);
        }

        //删除分类数据
        categoryMapper.deleteById(id);
    }

    /**
     * 修改分类
     * @param categoryDTO
     */
    public void update(CategoryDTO categoryDTO) {
        Category category = new Category();
        BeanUtils.copyProperties(categoryDTO,category);

        //设置修改时间、修改人 (公共属性)
//        category.setUpdateTime(LocalDateTime.now());
//        category.setUpdateUser(BaseContext.getCurrentId());

        categoryMapper.update(category);
    }

    /**
     * 启用、禁用分类
     * @param status
     * @param id
     */
    public void startOrStop(Integer status, Long id) {
        Category category = Category.builder()
                .id(id)
                .status(status) // 下面注释是公共属性AOP有写
//                .updateTime(LocalDateTime.now())
//                .updateUser(BaseContext.getCurrentId())
                .build();
        categoryMapper.update(category);
    }

    /**
     * 根据类型查询分类
     * @param type
     * @return
     */
    public List<Category> list(Integer type) {
        return categoryMapper.list(type);
    }
}

缓存菜品 【redis】

问题说明:

用户端小程序展示的菜品数据都是通过查询数据库获得,如果用户端访问量比较大,数据库访问压力随之增大

实现思路:

通过Redis来缓存菜品数据,减少数据库查询操作

开始→(查询菜品)→后端服务→缓存是否存在→(是)→读取缓存
(否)→查询数据库→载入缓存

缓存逻辑分析:
  • 每个分类下的菜品保存一份缓存数据

    key:dish_1
    value:string(…) [List集合]

sky-server com/sky/controller/user/DishController.java
package com.sky.controller.user;

import com.sky.constant.StatusConstant;
import com.sky.entity.Dish;
import com.sky.result.Result;
import com.sky.service.DishService;
import com.sky.vo.DishVO;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;

@RestController("userDishController")
@RequestMapping("/user/dish")
@Slf4j
@Api(tags = "C端-菜品浏览接口")
public class DishController {
    @Autowired
    private DishService dishService;
    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 根据分类id查询菜品
     *
     * @param categoryId
     * @return
     */
    @GetMapping("/list")
    @ApiOperation("根据分类id查询菜品")
    public Result<List<DishVO>> list(Long categoryId) {

        //构造redis中的key,规则:dish_分类id
        String key = "dish_" + categoryId;

        //查询redis中是否存在菜品数据
        List<DishVO> list = (List<DishVO>) redisTemplate.opsForValue().get(key);
        if(list != null && list.size() > 0){
            //如果存在,直接返回,无须查询数据库
            return Result.success(list);
        }

        Dish dish = new Dish();
        dish.setCategoryId(categoryId);
        dish.setStatus(StatusConstant.ENABLE);//查询起售中的菜品

        //如果不存在,查询数据库,将查询到的数据放入redis中
        list = dishService.listWithFlavor(dish);
        redisTemplate.opsForValue().set(key, list);

        return Result.success(list);
    }
}
清理缓存数据
防止 新增/更改/删除/起售停售 后无法及时在用户手机端接收

修改管理端接口 DishController 加入清理缓存的逻辑 (新增菜品、修改菜品、批量删除菜品、起售停售菜品)

sky-server  com/sky/controller/admin/DishController.java
/**
 * 菜品管理
 */
@RestController
@RequestMapping("/admin/dish")
@Api(tags = "菜品相关接口")
@Slf4j
public class DishController {
    @Autowired
    private DishService dishService;
    @Autowired
    private RedisTemplate redisTemplate;
    @PostMapping
    @ApiOperation("新增菜品")
    //@RequestBody 封装JSON格式的数据
    public Result save(@RequestBody DishDTO dishDTO) {
        log.info("新增菜品:{}", dishDTO);
        dishService.saveWithFlavour(dishDTO);

        //清理缓存数据(精确查询)
        String key = "dish_" + dishDTO.getCategoryId();
        cleanCache(key);
        return Result.success();
    }
 /**
     * 菜品的批量删除
     * @param ids
     * @return
     */
    @DeleteMapping
    @ApiOperation("批量删除菜品")
    //@RequestParam MVC动态解析字符串 ids提取出来
    public Result delete(@RequestParam List<Long> ids) { //ids
        log.info("批量删除菜品:{}", ids);
        dishService.deleteBatch(ids);

        // 将所有的菜品缓存数据清理掉,所有的以dish_开头的key
        cleanCache("dish_*");
        return Result.success();
    }
/**
     * 修改菜品
     * @param dishDTO
     * @return
     */
    @PutMapping
    @ApiOperation("修改菜品")
    public Result update(@RequestBody DishDTO dishDTO) {
        log.info("修改菜品:{}", dishDTO);
        dishService.updateWithFlavor(dishDTO);

        // 将所有的菜品缓存数据清理掉,所有的以dish_开头的key
        cleanCache("dish_*");;
        return Result.success();
    }
/**
     * 菜品起售停售
     *
     * @param status
     * @param id
     * @return
     */
    @PostMapping("/status/{status}")
    @ApiOperation("菜品起售停售")
    public Result<String> startOrStop(@PathVariable Integer status, Long id) {
        dishService.startOrStop(status, id);

        // 将所有的菜品缓存数据清理掉,所有的以dish_开头的key
        cleanCache("dish_*");

        return Result.success();
    }
private void cleanCache(String pattern){
        /** 因为单独清理每个菜品可能会有关联套餐 就直接清理全部
         * 1. 先获取到所有的key
         * 2. 遍历key,判断是否以pattern开头
         * 3. 删除所有的key
         */
        Set keys = redisTemplate.keys(pattern);
        redisTemplate.delete(keys);
    }
}

缓存套餐 【SpringCache】

Spring Cache 是一个框架,实现了基于注解的缓存功能,只需要简单地加一个注解,就能实现缓存功能
Spring Cache 提供了一层抽象,底层可以切换不同的缓存实现

  • EHCache
  • Caffeine
  • Redis
<dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
Spring Cache
注解 说明
@EnableCaching 开启缓存注解功能,通常加在启动类
@Cacheable 在方法执行前先查询缓存中是否有数据,如果有数据,则直接返回缓存数据;如果没有缓存数据,调用方法并将方法返回值放到缓存中
@CachePut 将方法的返回值放到缓存中
@CacheEvict 将一条或多条数据从缓存中删除
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

     @CachePut(cacheNames = "userCache",key = "#user.id")
// 将方法的返回值放到缓存中
    // 如果使用Spring Cache缓存数据,key的生成"#user.id"
    public User save(@RequestBody User user){
        userMapper.insert(user);
        return user;
    }

    @DeleteMapping
    @CacheEvict(cacheNames = "userCache",key = "#id")
    public void deleteById(Long id){
        userMapper.deleteById(id);
    }

    @DeleteMapping("/delAll")
    @CacheEvict(cacheNames = "userCache",allEntries = true)
    public void deleteAll(){
        userMapper.deleteAll();
    }

    @GetMapping
    @Cacheable(cacheNames = "userCache",key = "#id")
// 在方法执行前先查询缓存中是否有数据,如果有数据,则直接返回缓存数据;如果没有缓存数据,调用方法并将方法返回值放到缓存中
    public User getById(Long id){
        User user = userMapper.getById(id);
        return user;
    }
SpringCache入门案例
初始资源:
package com.itheima.controller;

import com.itheima.entity.User;
import com.itheima.mapper.UserMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

    @Autowired
    private UserMapper userMapper;

    @PostMapping
    public User save(@RequestBody User user){
        userMapper.insert(user);
        return user;
    }

    @DeleteMapping
    public void deleteById(Long id){
        userMapper.deleteById(id);
    }

    @DeleteMapping("/delAll")
    public void deleteAll(){
        userMapper.deleteAll();
    }

    @GetMapping
    public User getById(Long id){
        User user = userMapper.getById(id);
        return user;
    }

}
package com.itheima.mapper;

import com.itheima.entity.User;
import org.apache.ibatis.annotations.*;

@Mapper
public interface UserMapper{

    @Insert("insert into user(name,age) values (#{name},#{age})")
    @Options(useGeneratedKeys = true,keyProperty = "id")
    void insert(User user);

    @Delete("delete from user where id = #{id}")
    void deleteById(Long id);

    @Delete("delete from user")
    void deleteAll();

    @Select("select * from user where id = #{id}")
    User getById(Long id);
}
package com.itheima.entity;

import lombok.Data;
import java.io.Serializable;

@Data
public class User implements Serializable {

    private static final long serialVersionUID = 1L;

    private Long id;

    private String name;

    private int age;

}
package com.itheima.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;

@Configuration
@Slf4j
public class WebMvcConfiguration extends WebMvcConfigurationSupport {

    /**
     * 生成接口文档配置
     * @return
     */
    @Bean
    public Docket docket(){
        log.info("准备生成接口文档...");

        ApiInfo apiInfo = new ApiInfoBuilder()
                .title("接口文档")
                .version("2.0")
                .description("接口文档")
                .build();

        Docket docket = new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(apiInfo)
                .select()
                //指定生成接口需要扫描的包
                .apis(RequestHandlerSelectors.basePackage("com.itheima.controller"))
                .paths(PathSelectors.any())
                .build();

        return docket;
    }

    /**
     * 设置静态资源映射
     * @param registry
     */
    protected void addResourceHandlers(ResourceHandlerRegistry registry) {
        log.info("开始设置静态资源映射...");
        registry.addResourceHandler("/doc.html").addResourceLocations("classpath:/META-INF/resources/");
        registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
    }
}
application.yml
server:
  port: 8888
spring:
  datasource:
    druid:
      driver-class-name: com.mysql.cj.jdbc.Driver
      url: jdbc:mysql://localhost:3306/spring_cache_demo?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
      username: root
      password: root
  redis:
    host: localhost
    port: 6379
    database: 1
logging:
  level:
    com:
      itheima:
        mapper: debug
        service: info
        controller: info
springcachedemo.sql
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `name` varchar(45) DEFAULT NULL,
  `age` int DEFAULT NULL,
  PRIMARY KEY (`id`)
);
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.3</version>
        <relativePath/>
    </parent>
    <groupId>com.itheima</groupId>
    <artifactId>springcache-demo</artifactId>
    <version>1.0-SNAPSHOT</version>

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

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <scope>compile</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.20</version>
        </dependency>

        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.76</version>
        </dependency>

        <dependency>
            <groupId>commons-lang</groupId>
            <artifactId>commons-lang</artifactId>
            <version>2.6</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-cache</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.2.0</version>
        </dependency>

        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.2.1</version>
        </dependency>

        <dependency>
            <groupId>com.github.xiaoymin</groupId>
            <artifactId>knife4j-spring-boot-starter</artifactId>
            <version>3.0.2</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>2.7.3</version>
            </plugin>
        </plugins>
    </build>
</project>


开始调试咯
com/itheima/CacheDemoApplication.java
package com.itheima;

import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
//@EnableCaching 放在 Application 类上,这样整个应用就启用了缓存支持
@Slf4j
@SpringBootApplication
@EnableCaching
public class CacheDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(CacheDemoApplication.class,args);
        log.info("项目启动成功...");
    }
}
com/itheima/controller/UserController.java
package com.itheima.controller;

import com.itheima.entity.User;
import com.itheima.mapper.UserMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
    /*
    set a:b:c:d: itheima 这个就是树形结构在Redis里面 文件夹包着文件夹
    */
    @Autowired
    private UserMapper userMapper;

    @PostMapping
//  @CachePut(cacheNames = "userCache",key = "#result.id") 对象导航
//  @CachePut(cacheNames = "userCache",key = "#p0.id")
//  @CachePut(cacheNames = "userCache",key = "#root.args[0]")
    @CachePut(cacheNames = "userCache",key = "#user.id")
// 将方法的返回值放到缓存中
    // 如果使用Spring Cache缓存数据,key的生成"#user.id"
    public User save(@RequestBody User user){
        userMapper.insert(user);
        return user;
    }

    @DeleteMapping
    @CacheEvict(cacheNames = "userCache",key = "#id")
    public void deleteById(Long id){
        userMapper.deleteById(id);
    }

    @DeleteMapping("/delAll")
    @CacheEvict(cacheNames = "userCache",allEntries = true)
    public void deleteAll(){
        userMapper.deleteAll();
    }


    @GetMapping
    @Cacheable(cacheNames = "userCache",key = "#id")
// 在方法执行前先查询缓存中是否有数据,如果有数据,则直接返回缓存数据;如果没有缓存数据,调用方法并将方法返回值放到缓存中
    public User getById(Long id){
        User user = userMapper.getById(id);
        return user;
    }
}

缓存套餐_代码开发

实现思路
  • 导入 Spring CacheRedis 相关maven坐标
  • 启动类上加入 @EnableCaching 注解,开启缓存注解功能
  • 用户端接口 SetmealControllerlist 方法上加入 @Cacheable 注解
  • 管理端接口 SetmealControllersave、delete、update、startOrStop 等方法上
    加入**@CacheEvict** 注解保证数据一致性
com/sky/SkyApplication.java
package com.sky;

import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.transaction.annotation.EnableTransactionManagement;

@SpringBootApplication
@EnableTransactionManagement //开启注解方式的事务管理
@Slf4j
@EnableCaching //开启缓存注解
public class SkyApplication {
    public static void main(String[] args) {
        SpringApplication.run(SkyApplication.class, args);
        log.info("server started");
    }
}
sky-server  com/sky/controller/user/SetmealController.java
@RestController("userSetmealController")
@RequestMapping("/user/setmeal")
@Api(tags = "C端-套餐浏览接口")
public class SetmealController {
    @Autowired
    private SetmealService setmealService;

    /**
     * 条件查询
     *
     * @param categoryId
     * @return
     */
    @GetMapping("/list")
    @ApiOperation("根据分类id查询套餐")
    @Cacheable(cacheNames = "setmealCache",key = "#categoryId") //key: setmealCache::100
    public Result<List<Setmeal>> list(Long categoryId) {
        Setmeal setmeal = new Setmeal();
        setmeal.setCategoryId(categoryId);
        setmeal.setStatus(StatusConstant.ENABLE);

        List<Setmeal> list = setmealService.list(setmeal);
        return Result.success(list);
    }
}
sky-server  com/sky/controller/admin/SetmealController.java

/**
 * 套餐管理
 */
@RestController
@RequestMapping("/admin/setmeal")
@Api(tags = "套餐相关接口")
@Slf4j
public class SetmealController {

    @Autowired
    private SetmealService setmealService;

    /**
     * 新增套餐
     *
     * @param setmealDTO
     * @return
     */
    @PostMapping
    @ApiOperation("新增套餐")
    @CacheEvict(cacheNames = "setmealCache",key = "#setmealDTO.categoryId")//key: setmealCache::100
    public Result save(@RequestBody SetmealDTO setmealDTO) {
        setmealService.saveWithDish(setmealDTO);
        return Result.success();
    }
 /**
     * 批量删除套餐
     *
     * @param ids
     * @return
     */
    @DeleteMapping
    @ApiOperation("批量删除套餐")
    @CacheEvict(cacheNames = "setmealCache",allEntries = true)
    public Result delete(@RequestParam List<Long> ids) {
        setmealService.deleteBatch(ids);
        return Result.success();
    }
/**
     * 修改套餐
     *
     * @param setmealDTO
     * @return
     */
    @PutMapping
    @ApiOperation("修改套餐")
    @CacheEvict(cacheNames = "setmealCache",allEntries = true)
    public Result update(@RequestBody SetmealDTO setmealDTO) {
        setmealService.update(setmealDTO);
        return Result.success();
    }

    /**
     * 套餐起售停售
     *
     * @param status
     * @param id
     * @return
     */
    @PostMapping("/status/{status}")
    @ApiOperation("套餐起售停售")
    @CacheEvict(cacheNames = "setmealCache",allEntries = true)
    public Result startOrStop(@PathVariable Integer status, Long id) {
        setmealService.startOrStop(status, id);
        return Result.success();
    }
}

添加购物车

套餐直接点击加号
菜品+ 或者有口味数据的选择后才可以加入购物车

接口设计:
  • 请求方式:POST
  • 请求路径:/user/shoppingCart/add
  • 请求参数:菜品id(dish_id)、口味(dish_flavor) 或 套餐id(setmeal_id) (JSON请求体)
  • 返回结果:code、data、msg
数据库设计(shopping_cart表):设置冗余字段可提高数据库效率
  • 作用:暂时存放所选商品的地方
  • 选的什么商品
  • 每个商品都买了几个
  • 不同用户的购物车需要区分开
sky-pojo  com/sky/dto/ShoppingCartDTO.java
package com.sky.dto;

import lombok.Data;
import java.io.Serializable;

@Data
public class ShoppingCartDTO implements Serializable {

    private Long dishId;
    private Long setmealId;
    private String dishFlavor;
}
sky-server  com/sky/controller/user/ShoppingCartController.java
package com.sky.controller.user;

import com.sky.dto.ShoppingCartDTO;
import com.sky.result.Result;
import com.sky.service.ShoppingCartService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/user/shoppingCart")
@Slf4j
@Api(tags = "C端添加购物车接口")
public class ShoppingCartController {
    @Autowired
    private ShoppingCartService shoppingCartService;
    /**
     * 添加购物车
     * @param shoppingCartDTO
     * @return
     */
    @PostMapping("/add")
    @ApiOperation("添加购物车")
    public Result add(@RequestBody ShoppingCartDTO shoppingCartDTO){
        log.info("添加购物车,商品信息为:{}",shoppingCartDTO);
        shoppingCartService.addShoppingCart(shoppingCartDTO);
        return Result.success();
    }
}
sky-server  com/sky/service/ShoppingCartService.java
package com.sky.service;

import com.sky.dto.ShoppingCartDTO;
import org.springframework.stereotype.Service;

public interface ShoppingCartService {
    /**
     * 添加购物车
     * @param shoppingCartDTO
     */
    void addShoppingCart(ShoppingCartDTO shoppingCartDTO);
}
sky-server  com/sky/service/impl/ShoppingCartServiceImpl.java
package com.sky.service.impl;

import com.sky.context.BaseContext;
import com.sky.dto.ShoppingCartDTO;
import com.sky.entity.Dish;
import com.sky.entity.Setmeal;
import com.sky.entity.ShoppingCart;
import com.sky.mapper.DishMapper;
import com.sky.mapper.SetmealMapper;
import com.sky.mapper.ShoppingCartMapper;
import com.sky.service.ShoppingCartService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.List;
@Service
@Slf4j
public class ShoppingCartServiceImpl implements ShoppingCartService {
    /**
     * 添加购物车
     * @param shoppingCartDTO
     */
    @Autowired
    private ShoppingCartMapper shoppingCartMapper;
    @Autowired
    private DishMapper dishMapper;
    @Autowired
    private SetmealMapper setmealMapper;
    @Override
    public void addShoppingCart(ShoppingCartDTO shoppingCartDTO) {
        // 判断当前加入购物车中的商品是否已经存在了 (user_id + setmeal_id)
        ShoppingCart shoppingCart = new ShoppingCart();
        BeanUtils.copyProperties(shoppingCartDTO, shoppingCart);
        Long userId = BaseContext.getCurrentId();
        shoppingCart.setUserId(userId);

        List<ShoppingCart> list = shoppingCartMapper.list(shoppingCart);

        // 如果已经存在了,只需要将数量+1
        if (list != null && list.size() > 0) {
            ShoppingCart cart = list.get(0);
            cart.setNumber(cart.getNumber() + 1);
            // update shopping_cart set number = ? where id = ?
            shoppingCartMapper.updateNumberById(cart);
        } else {
            // 如果不存在,需要插入一条购物车数据
            // [先确定套餐or菜品]
            // 判断本次添加到购物车的是菜品还是套餐
            Long dishId = shoppingCartDTO.getDishId();
            if (dishId != null) {
                //本次添加到购物车的是菜品
                Dish dish = dishMapper.getById(dishId);
                shoppingCart.setName(dish.getName());
                shoppingCart.setImage(dish.getImage());
                shoppingCart.setAmount(dish.getPrice());
            } else {
                //本次添加到购物车的是套餐 查菜品表
                Long setmealId = shoppingCartDTO.getSetmealId();
                Setmeal setmeal = setmealMapper.getById(setmealId);
                shoppingCart.setName(setmeal.getName());
                shoppingCart.setImage(setmeal.getImage());
                shoppingCart.setAmount(setmeal.getPrice());

            }
            shoppingCart.setNumber(1);
            shoppingCart.setCreateTime(LocalDateTime.now());
            shoppingCartMapper.insert(shoppingCart);
        }
    }
}
sky-server  com/sky/mapper/ShoppingCartMapper.java
package com.sky.mapper;

import com.sky.entity.ShoppingCart;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Update;

import java.util.List;

@Mapper
public interface ShoppingCartMapper {
    List<ShoppingCart> list(ShoppingCart shoppingCart);

    /**
     * 根据id修改商品数量
     * @param shoppingCart
     */
    @Update("update shopping_cart set number = #{number} where id = #{id}")
    void updateNumberById(ShoppingCart shoppingCart);

    /**
     * 插入购物车数据
     * @param shoppingCart
     */
    @Insert("insert into shopping_cart (name, user_id, dish_id, setmeal_id, dish_flavor, number, amount, image, create_time) " +
            " values (#{name},#{userId},#{dishId},#{setmealId},#{dishFlavor},#{number},#{amount},#{image},#{createTime})")
    void insert(ShoppingCart shoppingCart);
}
sky-server  mapper/ShoppingCartMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.ShoppingCartMapper">

    <select id="list" resultType="com.sky.entity.ShoppingCart">
        select * from shopping_cart
        <where>
            <if test="userId != null">
                and user_id = #{userId}
            </if>
            <if test="dishId != null">
                and dish_id = #{dishId}
            </if>
            <if test="setmealId != null">
                and setmeal_id = #{setmealId}
            </if>
            <if test="dishFlavor != null">
                and dish_flavor = #{dishFlavor}
            </if>
        </where>
    </select>
</mapper>

删除购物车

sky-server  com/sky/controller/user/ShoppingCartController.java
/**
     * 删除购物车
     * @param shoppingCartDTO
     * @return
     */
    @PostMapping("/sub")
    @ApiOperation("删除购物车")
    public Result sub(@RequestBody ShoppingCartDTO shoppingCartDTO){
        log.info("删除购物车,商品信息为:{}",shoppingCartDTO);
        shoppingCartService.subShoppingCart(shoppingCartDTO);
        return Result.success();
    }
sky-server  com/sky/service/ShoppingCartService.java
    /**
     * 删除购物车
     * @param shoppingCartDTO
     */
    void subShoppingCart(ShoppingCartDTO shoppingCartDTO);
sky-server  com/sky/service/impl/ShoppingCartServiceImpl.java
/**
     * 删除购物车中的商品
     * @param shoppingCartDTO
     */
    @Override
    public void subShoppingCart(ShoppingCartDTO shoppingCartDTO) {
        Long userId = BaseContext.getCurrentId();
        ShoppingCart shoppingCart = new ShoppingCart();
        BeanUtils.copyProperties(shoppingCartDTO, shoppingCart);
        shoppingCart.setUserId(userId);

        List<ShoppingCart> list = shoppingCartMapper.list(shoppingCart);
        if (list != null && list.size() > 0) {
            ShoppingCart cart = list.get(0);
            if (cart.getNumber() > 1) {
                // 如果 number >1, 则需要将 number - 1
                cart.setNumber(cart.getNumber() - 1);
                shoppingCartMapper.updateNumberById(cart);
            } else {
                // 如果 number <=1,则直接删除该购物车数据
                shoppingCartMapper.deleteById(cart.getId());
            }
        }
    }
sky-server  com/sky/mapper/ShoppingCartMapper.java
/**
     * 根据id删除购物车数据
     * @param id
     */
    @Delete("delete from shopping_cart where id = #{id}")
    void deleteById(Long id);

查看购物车

名称、价格、商品、数量
Path:/user/shoppingCart/list
PUT

sky-server  com/sky/controller/user/ShoppingCartController.java
/**
     * 查看购物车
     * @return
     */
    @GetMapping("/list")
    @ApiOperation("查看购物车")
    public Result<List<ShoppingCart>> list(){
       List<ShoppingCart> list = shoppingCartService.showShoppingCart();
       return Result.success(list);
    }
sky-server  com/sky/service/ShoppingCartService.java
    /**
     * 查看购物车
     * @return
     */
    List<ShoppingCart> showShoppingCart();
sky-server  com/sky/service/impl/ShoppingCartServiceImpl.java
/**
     * 查看购物车
     * @return
     */
    @Override
    public List<ShoppingCart> showShoppingCart() {
        // 获取到当前微信用户的id
        Long userId = BaseContext.getCurrentId();
        ShoppingCart shoppingCart = ShoppingCart.builder()
                .userId(userId)
                .build();
        List<ShoppingCart> list = shoppingCartMapper.list(shoppingCart);
        return list;
    }

清空购物车

Path:user/shoppingCart/clean
Method:DELETE
返回:code date msg

sky-server  com/sky/controller/user/ShoppingCartController.java
/**
     * 清空购物车
     * @return
     */
    @DeleteMapping("/clean")
    @ApiOperation("清空购物车")
    public Result clean() {
        shoppingCartService.cleanShoppingCart();
        return Result.success();
    }
sky-server  com/sky/service/ShoppingCartService.java
/**
     * 清空购物车 删除自己的购物车
     */
    void cleanShoppingCart();
sky-server  com/sky/service/impl/ShoppingCartServiceImpl.java
/**
     * 清空购物车
     */
    @Override
    public void cleanShoppingCart() {
        //获取到当前用户的id
        Long userId = BaseContext.getCurrentId();
        shoppingCartMapper.deleteByUserId(userId);
    }
sky-server  com/sky/mapper/ShoppingCartMapper.java
/**
     * 根据用户id清空购物车
     * @param userId
     */
    @Delete("delete from shopping_cart where user_id = #{userId}")
    void deleteByUserId(Long userId);

支付接口

导入地址簿功能代码

业务功能:

  • 查询地址列表

  • 新增地址

  • 修改地址

    Path:/user/addressBook
    Method:PUT

  • 删除地址

  • 设置默认地址

  • 查询默认地址

接口设计:

  • 新增地址

    Path: /user/addressBook
    Method: POST

  • 查询当前登录用户的所有地址信息

    Path: /user/addressBook/list
    Method: GET

  • 查询默认地址

  • 根据id修改地址

  • 根据id删除地址

    Path:/user/addressBook
    Method:DELETE

  • 根据id查询地址

    Path:/user/addressBook/{id}
    Method:GET

  • 设置默认地址

    Path:/user/addressBook/default
    Method:PUT

数据库设计(address_book表)

用户下单

在电商系统中,用户是通过下单的方式通知商家,用户已经购买了商品,需要商家进行备货和发货

用户下单后会产生订单相关数据,订单数据需要体现信息:

  • 订单总金额是多少
  • 哪个用户下的单
  • 买的哪些商品
  • 每个商品数量是多少
  • 收货地址是哪
  • 用户手机号是多少

餐盒费:用数量算

用户下单接口设计

请求方式:POST
请求路径:/user/order/submit

参数:

  • 地址簿idaddressBookId
  • 配送状态(立即送出、选择送出时间)deliveryStatus
  • 打包费packAmount
  • 总金额amount
  • 备注remark
  • 餐具数量tablewareNumber
支付订单接口设计

返回数据:

  • 下单时间
  • 订单总金额
  • 订单号
  • 订单id
数据库设计订单表orders、订单明细表order_detail
  • 订单表 orders
    • 谁的订单?
    • 送哪去?
    • 打哪个电话联系?
    • 多少钱?
    • 什么时间下的单?
    • 什么时间支付的?
    • 订单的状态?
    • 订单号是多少?
  • 订单明细表 order_detail
    • 当前明细属于哪个订单?
    • 具体点的是什么商品?
    • 这个商品点了几份?

代码开发

用户下单1
根据用户下单接口的参数设计DTO:
sky-pojo  com/sky/dto/OrdersSubmitDTO.java
package com.sky.dto;

import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;

import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Data
public class OrdersSubmitDTO implements Serializable {
    //地址簿id
    private Long addressBookId;
    //付款方式
    private int payMethod;
    //备注
    private String remark;
    //预计送达时间
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime estimatedDeliveryTime;
    //配送状态  1立即送出  0选择具体时间
    private Integer deliveryStatus;
    //餐具数量
    private Integer tablewareNumber;
    //餐具数量状态  1按餐量提供  0选择具体数量
    private Integer tablewareStatus;
    //打包费
    private Integer packAmount;
    //总金额
    private BigDecimal amount;
}
sky-pojo  com/sky/vo/OrdersSubmitVO.java
package com.sky.vo;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderSubmitVO implements Serializable {
    //订单id
    private Long id;
    //订单号
    private String orderNumber;
    //订单金额
    private BigDecimal orderAmount;
    //下单时间
    private LocalDateTime orderTime;
}
sky-server  com/sky/controller/user/OrderController.java
package com.sky.controller.user;

import com.sky.dto.OrdersSubmitDTO;
import com.sky.result.Result;
import com.sky.service.OrderService;
import com.sky.vo.OrderSubmitVO;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController("userOrderController")
@RequestMapping("/user/order")
@Api(tags = "用户订单相关接口")
@Slf4j
public class OrderController {
    @Autowired
    private OrderService orderService;
    /**
     * 用户下单
     * @param ordersSubmitDTO
     * @return
     */
    @PostMapping("/submit")
    @ApiOperation("用户下单")
    public Result<OrderSubmitVO> submit(@RequestBody OrdersSubmitDTO ordersSubmitDTO) {
        log.info("用户下单,参数为:{}", ordersSubmitDTO);
        OrderSubmitVO orderSubmitVO = orderService.submitOrder(ordersSubmitDTO);
        return Result.success(orderSubmitVO);
    }
}
sky-server  com/sky/service/OrderService.java
package com.sky.vo;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderSubmitVO implements Serializable {
    //订单id
    private Long id;
    //订单号
    private String orderNumber;
    //订单金额
    private BigDecimal orderAmount;
    //下单时间
    private LocalDateTime orderTime;
}
用户下单2
sky-pojo  com/sky/entity/AddressBook.java
package com.sky.entity;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

/**
 * 地址簿
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AddressBook implements Serializable {

    private static final long serialVersionUID = 1L;

    private Long id;

    //用户id
    private Long userId;

    //收货人
    private String consignee;

    //手机号
    private String phone;

    //性别 0 女 1 男
    private String sex;

    //省级区划编号
    private String provinceCode;

    //省级名称
    private String provinceName;

    //市级区划编号
    private String cityCode;

    //市级名称
    private String cityName;

    //区级区划编号
    private String districtCode;

    //区级名称
    private String districtName;

    //详细地址
    private String detail;

    //标签
    private String label;

    //是否默认 0否 1是
    private Integer isDefault;
}
sky-server  com/sky/service/impl/OrderServiceImpl.java
package com.sky.service.impl;

import com.sky.constant.MessageConstant;
import com.sky.context.BaseContext;
import com.sky.dto.OrdersSubmitDTO;
import com.sky.entity.AddressBook;
import com.sky.entity.Orders;
import com.sky.entity.ShoppingCart;
import com.sky.exception.AddressBookBusinessException;
import com.sky.exception.ShoppingCartBusinessException;
import com.sky.mapper.AddressBookMapper;
import com.sky.mapper.OrderDetailMapper;
import com.sky.mapper.OrderMapper;
import com.sky.mapper.ShoppingCartMapper;
import com.sky.service.OrderService;
import com.sky.vo.OrderSubmitVO;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.List;

@Service
public class OrderServiceImpl implements OrderService {
    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private OrderDetailMapper orderDetailMapper;
    @Autowired
    private AddressBookMapper addressBookMapper;
    @Autowired
    private ShoppingCartMapper shoppingCartMapper;
    /**
     * 用户下单
     * @param ordersSubmitDTO
     * @return
     */
    @Override
    public OrderSubmitVO submitOrder(OrdersSubmitDTO ordersSubmitDTO) {

        // 1.处理各种业务异常(地址簿为空,购物车数据为空)
        AddressBook addressBook = addressBookMapper.getById(ordersSubmitDTO.getAddressBookId());
        if (addressBook == null) {
            // 抛出业务异常
            throw new AddressBookBusinessException(MessageConstant.ADDRESS_BOOK_IS_NULL);
        }
        // 查询当前用户购物车信息
        Long userId = BaseContext.getCurrentId();
        ShoppingCart shoppingCart = new ShoppingCart();
        shoppingCart.setUserId(userId);
        List<ShoppingCart> shoppingCartList = shoppingCartMapper.list(shoppingCart);
        if (shoppingCartList == null || shoppingCartList.size() == 0) {
            // 抛出业务异常
            throw new ShoppingCartBusinessException(MessageConstant.SHOPPING_CART_IS_NULL);
        }

        // 2.向订单表插入1条数据
        Orders orders = new Orders();
        BeanUtils.copyProperties(ordersSubmitDTO, orders);
        orders.setOrderTime(LocalDateTime.now());
        orders.setPayStatus(Orders.UN_PAID);
        orders.setStatus(Orders.PENDING_PAYMENT);
        orders.setNumber(String.valueOf(System.currentTimeMillis()));//订单号
        orders.setPhone(addressBook.getPhone());
        orders.setConsignee(addressBook.getConsignee());
        orders.setUserId(userId);
        orderMapper.insert(orders);

        // 3.向订单明细表插入n条数据

        // 4,清空当前用户的购物车数据

        // 5.封装VO返回结果
        return null;
    }
}
sky-server  com/sky/mapper/OrderMapper.java
package com.sky.mapper;

import com.sky.entity.Orders;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface OrderMapper {
    /**
     * 用户下单
     * @param orders
     */
    void insert(Orders orders);
}
代码开发3
sky-server  com/sky/service/impl/OrderServiceImpl.java
package com.sky.service.impl;

import com.sky.constant.MessageConstant;
import com.sky.context.BaseContext;
import com.sky.dto.OrdersSubmitDTO;
import com.sky.entity.AddressBook;
import com.sky.entity.OrderDetail;
import com.sky.entity.Orders;
import com.sky.entity.ShoppingCart;
import com.sky.exception.AddressBookBusinessException;
import com.sky.exception.ShoppingCartBusinessException;
import com.sky.mapper.AddressBookMapper;
import com.sky.mapper.OrderDetailMapper;
import com.sky.mapper.OrderMapper;
import com.sky.mapper.ShoppingCartMapper;
import com.sky.service.OrderService;
import com.sky.vo.OrderSubmitVO;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Service
public class OrderServiceImpl implements OrderService {
    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private OrderDetailMapper orderDetailMapper;
    @Autowired
    private AddressBookMapper addressBookMapper;
    @Autowired
    private ShoppingCartMapper shoppingCartMapper;
    /**
     * 用户下单
     * @param ordersSubmitDTO
     * @return
     */
    @Transactional
    public OrderSubmitVO submitOrder(OrdersSubmitDTO ordersSubmitDTO) {

        // 1.处理各种业务异常(地址簿为空,购物车数据为空)
        AddressBook addressBook = addressBookMapper.getById(ordersSubmitDTO.getAddressBookId());
        if (addressBook == null) {
            // 抛出业务异常
            throw new AddressBookBusinessException(MessageConstant.ADDRESS_BOOK_IS_NULL);
        }
        // 查询当前用户购物车信息
        Long userId = BaseContext.getCurrentId();
        ShoppingCart shoppingCart = new ShoppingCart();
        shoppingCart.setUserId(userId);
        List<ShoppingCart> shoppingCartList = shoppingCartMapper.list(shoppingCart);
        if (shoppingCartList == null || shoppingCartList.size() == 0) {
            // 抛出业务异常
            throw new ShoppingCartBusinessException(MessageConstant.SHOPPING_CART_IS_NULL);
        }

        // 2.向订单表插入1条数据
        Orders orders = new Orders();
        BeanUtils.copyProperties(ordersSubmitDTO, orders);
        orders.setOrderTime(LocalDateTime.now());
        orders.setPayStatus(Orders.UN_PAID);
        orders.setStatus(Orders.PENDING_PAYMENT);
        orders.setNumber(String.valueOf(System.currentTimeMillis()));//订单号
        orders.setPhone(addressBook.getPhone());
        orders.setConsignee(addressBook.getConsignee());
        orders.setUserId(userId);
        orderMapper.insert(orders);

        //批量插入订单明细数据
        List<OrderDetail> orderDetailList = new ArrayList<>();
        // 3.向订单明细表插入n条数据
        for (ShoppingCart cart : shoppingCartList) {
            OrderDetail orderDetail = new OrderDetail(); //订单明细
            BeanUtils.copyProperties(cart, orderDetail);
            orderDetail.setOrderId(orders.getId()); //设置当前订单明细关联的订单id
            orderDetailList.add(orderDetail);
        }
        orderDetailMapper.insertBatch(orderDetailList);
        // 4.清空当前用户的购物车数据
        shoppingCartMapper.deleteByUserId(userId);
        // 5.封装VO返回结果
        OrderSubmitVO ordersubmitVO = OrderSubmitVO.builder()
                .id(orders.getId())
                .orderNumber(orders.getNumber())
                .orderAmount(orders.getAmount())
                .orderTime(orders.getOrderTime())
                .build();
        return ordersubmitVO;
    }
}
sky-server  com/sky/mapper/OrderDetailMapper.java
package com.sky.mapper;

import com.sky.entity.OrderDetail;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface OrderDetailMapper {
    /**
     * 批量插入订单明细数据
     */
    void insertBatch(List<OrderDetail> orderDetailList);
}
sky-server  mapper/OrderDetailMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.OrderDetailMapper">

    <insert id="insertBatch">
        insert into order_detail (name, image, order_id, dish_id, setmeal_id, dish_flavor, number, amount)
        values
        <foreach collection="orderDetailList" item="od" separator=",">
            (#{od.name},#{od.image},#{od.orderId},#{od.dishId},#{od.setmealId},#{od.dishFlavor},#{od.number},#{od.amount})
        </foreach>
    </insert>
</mapper>

订单支付

微信支付产品 + 微信支付

参考:https://pay.weixin.qq.com/static/product/product_index.shtml

微信支付接入流程:

提交资料 → 签署协议 → 绑定场景

微信小程序支付时序图:

JSAPI下单:商户系统调用该接口在微信支付服务后台生成预支付交易单

请求URLhttps://api.mch.weixin.qq.com/v3/pay/transactions/jsapi

获取微信支付平台证书、商户私钥文件:

内网穿透工具

E:\Java实例项目1-20套\第24套【项目实战】Java外卖项目实战《苍穹外卖》SpringBoot+SpringMVC+Vue+Swagger+Lombok+Mybatis+SpringSession+Redis+Nginx+小程序\0-0 源码资料\资料\day08\安装包cpolar_amd64.msi

cpolar - secure introspectable tunnels to localhost
验证
你的隧道
[复制token] → 在cpolar文件里/cmd[C:\Program Files\cpolar] → cpolar.exe authtoken xxxx[Authtoken:这个是在网站验证里复制的] → cpolar.exe http 8080

cpolar by @bestexpresser (Ctrl+C to quit)

Tunnel Status online
Account Pluminary (Plan: Free)
Version 2.86.16/3.18
Web Interface 127.0.0.1:4042
Forwarding http://22d34b67.r9.cpolar.top -> http://localhost:8080
Forwarding https://22d34b67.r9.cpolar.top -> http://localhost:8080

Conn 0

Avg Conn Time 0.00ms

启动穿透地址:[22d34b67.r9.cpolar.top/doc.html] (https://22d34b67.r9.cpolar.top/doc.html)

此时正在下载资源
HTTP Requests


GET /v2/api-docs 200
GET /swagger-resources 200
GET /webjars/js/chunk-3b888a6 200
GET /webjars/js/chunk-589faee 200
GET /webjars/js/chunk-2d0bd79 200
GET /webjars/js/chunk-0fd6771 200
GET /webjars/js/chunk-0c58d94 200
GET /webjars/css/chunk-62d2fe 200
GET /webjars/js/app.0f2f48b5. 200

随后就可以访问到接口文档了!!
原理:使用内网穿透工具临时获得一个域名

CPolarSwitchHosts 是两种不同类型的软件,它们的功能和用途有所区别。

CPolar 是一款内网穿透软件,主要用于将本地运行的服务暴露到公网上,使得外网可以访问。它通过在本地和公网服务器之间建立一个安全的隧道,使得用户可以在任何地方通过互联网访问到本地的服务,比如网站、SSH、数据库等。

SwitchHosts 则是一款用于管理和切换本地hosts文件的软件。Hosts文件是操作系统用于将一些域名解析到特定的IP地址的一个文本文件。SwitchHosts 允许用户方便地添加、切换、备份不同的hosts规则,对于开发者来说,这在开发过程中进行域名映射和测试非常有用。

总结来说,CPolar主要用于内网穿透,而SwitchHosts用于hosts文件管理。两者解决的问题和适用场景不同,不是同一种软件。


内网、公网、外网和CPolar这几个概念在网络通信中扮演着不同的角色,以下是它们的定义和它们之间的联系:

  1. 内网(Local Network 或 Intranet): 内网是指一个私有网络,通常是在家庭、办公室或企业内部使用。内网中的设备通常通过路由器连接,并使用私有IP地址(如192.168.x.x或10.x.x.x)。内网中的设备一般不能直接从外部互联网访问,它们之间的通信受到防火墙和NAT(网络地址转换)的保护。
  2. 公网(Public Network 或 Internet): 公网是指全球范围内的开放网络,即互联网。公网上的设备使用公网IP地址,这些地址是全球唯一的,可以通过互联网被其他设备访问。网站、电子邮件服务器和其他在线服务都部署在公网上。
  3. 外网(External Network): 外网通常是指相对于内网而言的任何外部网络,特别是指互联网。当说“外网”时,通常是指从内网之外访问的资源或服务。
  4. CPolar: CPolar是一款内网穿透工具,它的主要作用是帮助内网中的设备暴露服务到公网上,使得这些服务可以被外网访问。以下是CPolar与内网、公网、外网之间的联系:
  • 内网到公网:CPolar在本地设备上运行一个客户端,该客户端与CPolar的服务器建立连接。当外部网络(公网)尝试访问CPolar服务器上配置的特定端口时,CPolar服务器会将这些请求转发到运行CPolar客户端的内网设备上。
  • 公网访问:通过CPolar,内网中的服务可以被赋予一个公网可访问的地址(通常是CPolar服务器的一个子域名或自定义域名),这样外网的任何用户都可以通过这个地址访问到内网的服务。

简而言之,CPolar是实现内网服务与公网之间通信的桥梁,它使得原本只能在局域网内部访问的服务能够被外网的用户访问。这对于远程工作、调试、以及需要在公网上提供服务的内网应用来说非常有用

导入功能代码【由于没有微信凭证 此接口未能正常开发 但代码均可学习】
sky-server  application-dev.yml
 wechat:
    appid: wxffb3637a228223b8
    secret: 84311df9199ecacdf4f12d27b6b9522d
    mchid : 1561414331
    mchSerialNo: 4B3B3DC35414AD50B1B755BAF8DE9CC7CF407606
    privateKeyFilePath: D:\pay\apiclient_key.pem
    apiV3Key: CZBK51236435wxpay435434323FFDuv3
    weChatPayCertFilePath: D:\pay\wechatpay_166D96F876F45C7D07CE98952A96EC980368ACFC.pem
    notifyUrl: https://58869fb.r2.cpolar.top/notify/paySuccess
    refundNotifyUrl: https://58869fb.r2.cpolar.top/notify/refundSuccess
sky-server  com/sky/controller/user/OrderController.java
/**
     * 订单支付
     *
     * @param ordersPaymentDTO
     * @return
     */
    @PutMapping("/payment")
    @ApiOperation("订单支付")
    public Result<OrderPaymentVO> payment(@RequestBody OrdersPaymentDTO ordersPaymentDTO) throws Exception {
        log.info("订单支付:{}", ordersPaymentDTO);
        OrderPaymentVO orderPaymentVO = orderService.payment(ordersPaymentDTO);
        log.info("生成预支付交易单:{}", orderPaymentVO);
        return Result.success(orderPaymentVO);
    }
sky-server  com/sky/service/OrderService.java
/**
     * 订单支付
     * @param ordersPaymentDTO
     * @return
     */
    OrderPaymentVO payment(OrdersPaymentDTO ordersPaymentDTO) throws Exception;

    /**
     * 支付成功,修改订单状态
     * @param outTradeNo
     */
    void paySuccess(String outTradeNo);
sky-server  com/sky/service/impl/OrderServiceImpl.java
 @Autowired
    private UserMapper userMapper;
 @Autowired
    private WeChatPayUtil weChatPayUtil;

    
    /**
     * 订单支付
     *
     * @param ordersPaymentDTO
     * @return
     */
    public OrderPaymentVO payment(OrdersPaymentDTO ordersPaymentDTO) throws Exception {
        // 查询订单
        Orders order = orderMapper.getByOrderNumber(ordersPaymentDTO.getOrderNumber());
        if (order == null) {
            throw new OrderBusinessException("订单不存在");
        }

        // 检查订单支付状态
        if (order.getPayStatus() == 1) { // 1 表示已支付
            throw new OrderBusinessException("该订单已支付");
        }
        order.setPayStatus(1);

        // 更新订单支付状态为已支付
        order.setPayStatus(Orders.PAID);
        order.setCheckoutTime(LocalDateTime.now());
        order.setPayMethod(ordersPaymentDTO.getPayMethod());
        order.setStatus(Orders.CONFIRMED);

        orderMapper.update(order);

        // 构造并返回支付结果对象
        OrderPaymentVO orderPaymentVO = new OrderPaymentVO();
        orderPaymentVO.setOrderNumber(order.getNumber()); // 订单号
        orderPaymentVO.setPaymentTime(new Date());
        orderPaymentVO.setPaymentStatus("SUCCESS");

        return orderPaymentVO;
    }
    /**
     * 支付成功,修改订单状态
     *
     * @param outTradeNo
     */
    public void paySuccess(String outTradeNo) {
        // 当前登录用户id
        Long userId = BaseContext.getCurrentId();

        // 根据订单号查询当前用户的订单
        Orders ordersDB = orderMapper.getByNumberAndUserId(outTradeNo, userId);

        // 根据订单id更新订单的状态、支付方式、支付状态、结账时间
        Orders orders = Orders.builder()
                .id(ordersDB.getId())
                .status(Orders.TO_BE_CONFIRMED)
                .payStatus(Orders.PAID)
                .checkoutTime(LocalDateTime.now())
                .build();

        orderMapper.update(orders);
    }
}
sky-server  com/sky/mapper/OrderMapper.java
package com.sky.mapper;

import com.github.pagehelper.Page;
import com.sky.dto.GoodsSalesDTO;
import com.sky.dto.OrdersPageQueryDTO;
import com.sky.entity.Orders;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;

@Mapper
public interface OrderMapper {
    /**
     * 插入订单数据
     * @param order
     */
    void insert(Orders order);

    /**
     * 根据订单号和用户id查询订单
     * @param orderNumber
     * @param userId
     */
    @Select("select * from orders where number = #{orderNumber} and user_id= #{userId}")
    Orders getByNumberAndUserId(String orderNumber, Long userId);

    /**
     * 修改订单信息
     * @param orders
     */
    void update(Orders orders);
    /**
     * 根据id查询订单
     * @param id
     */
    @Select("select * from orders where id = #{id}}")
    Orders getById(Long id);
}
resources/mapper/OrderMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.OrderMapper">

    <insert id="insert" parameterType="Orders" useGeneratedKeys="true" keyProperty="id">
        insert into orders
        (number, status, user_id, address_book_id, order_time, checkout_time, pay_method, pay_status, amount, remark,
         phone, address, consignee, estimated_delivery_time, delivery_status, pack_amount, tableware_number,
         tableware_status)
        values (#{number}, #{status}, #{userId}, #{addressBookId}, #{orderTime}, #{checkoutTime}, #{payMethod},
                #{payStatus}, #{amount}, #{remark}, #{phone}, #{address}, #{consignee},
                #{estimatedDeliveryTime}, #{deliveryStatus}, #{packAmount}, #{tablewareNumber}, #{tablewareStatus})
    </insert>

    <update id="update" parameterType="com.sky.entity.Orders">
        update orders
        <set>
            <if test="cancelReason != null and cancelReason!='' ">
                cancel_reason=#{cancelReason},
            </if>
            <if test="rejectionReason != null and rejectionReason!='' ">
                rejection_reason=#{rejectionReason},
            </if>
            <if test="cancelTime != null">
                cancel_time=#{cancelTime},
            </if>
            <if test="payStatus != null">
                pay_status=#{payStatus},
            </if>
            <if test="payMethod != null">
                pay_method=#{payMethod},
            </if>
            <if test="checkoutTime != null">
                checkout_time=#{checkoutTime},
            </if>
            <if test="status != null">
                status = #{status},
            </if>
            <if test="deliveryTime != null">
                delivery_time = #{deliveryTime}
            </if>
        </set>
        where id = #{id}
    </update>
        
        <!-- 根据订单号查询订单 -->
    <select id="getByOrderNumber" parameterType="String" resultType="Orders">
        select * from orders where number = #{orderNumber}
    </select>
</mapper>
sky-server  com/sky/mapper/UserMapper.java
  @Select("select * from user where id = #{id}}")
    User getById(Long userId);
sky-server  com/sky/controller/notify/PayNotifyController.java
package com.sky.controller.notify;

import com.alibaba.druid.support.json.JSONUtils;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
//import com.sky.annotation.IgnoreToken;
import com.sky.properties.WeChatProperties;
import com.sky.service.OrderService;
import com.wechat.pay.contrib.apache.httpclient.util.AesUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.entity.ContentType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;

/**
 * 支付回调相关接口
 */
@RestController
@RequestMapping("/notify")
@Slf4j
public class PayNotifyController {
    @Autowired
    private OrderService orderService;
    @Autowired
    private WeChatProperties weChatProperties;

    /**
     * 支付成功回调
     *
     * @param request
     */
//  @IgnoreToken
    @RequestMapping("/paySuccess")
    public void paySuccessNotify(HttpServletRequest request, HttpServletResponse response) throws Exception {
        //读取数据
        String body = readData(request);
        log.info("支付成功回调:{}", body);

        //数据解密
        String plainText = decryptData(body);
        log.info("解密后的文本:{}", plainText);

        JSONObject jsonObject = JSON.parseObject(plainText);
        String outTradeNo = jsonObject.getString("out_trade_no");//商户平台订单号
        String transactionId = jsonObject.getString("transaction_id");//微信支付交易号

        log.info("商户平台订单号:{}", outTradeNo);
        log.info("微信支付交易号:{}", transactionId);

        //业务处理,修改订单状态、来单提醒
        orderService.paySuccess(outTradeNo);

        //给微信响应
        responseToWeixin(response);
    }

    /**
     * 读取数据
     *
     * @param request
     * @return
     * @throws Exception
     */
    private String readData(HttpServletRequest request) throws Exception {
        BufferedReader reader = request.getReader();
        StringBuilder result = new StringBuilder();
        String line = null;
        while ((line = reader.readLine()) != null) {
            if (result.length() > 0) {
                result.append("\n");
            }
            result.append(line);
        }
        return result.toString();
    }

    /**
     * 数据解密
     *
     * @param body
     * @return
     * @throws Exception
     */
    private String decryptData(String body) throws Exception {
        JSONObject resultObject = JSON.parseObject(body);
        JSONObject resource = resultObject.getJSONObject("resource");
        String ciphertext = resource.getString("ciphertext");
        String nonce = resource.getString("nonce");
        String associatedData = resource.getString("associated_data");

        AesUtil aesUtil = new AesUtil(weChatProperties.getApiV3Key().getBytes(StandardCharsets.UTF_8));
        //密文解密
        String plainText = aesUtil.decryptToString(associatedData.getBytes(StandardCharsets.UTF_8),
                nonce.getBytes(StandardCharsets.UTF_8),
                ciphertext);

        return plainText;
    }

    /**
     * 给微信响应
     * @param response
     */
    private void responseToWeixin(HttpServletResponse response) throws Exception{
        response.setStatus(200);
        HashMap<Object, Object> map = new HashMap<>();
        map.put("code", "SUCCESS");
        map.put("message", "SUCCESS");
        response.setHeader("Content-type", ContentType.APPLICATION_JSON.toString());
        response.getOutputStream().write(JSONUtils.toJSONString(map).getBytes(StandardCharsets.UTF_8));
        response.flushBuffer();
    }
}

查询历史订单

业务规则

  • 分页查询历史订单
  • 可以根据订单状态查询
  • 展示订单数据时,需要展示的数据包括:下单时间、订单状态、订单金额、订单明细(商品名称、图片)

接口设计:参见接口文档

1.2 代码实现

1.2.1 user/OrderController

    /**
     * 历史订单查询
     *
     * @param page
     * @param pageSize
     * @param status   订单状态 1待付款 2待接单 3已接单 4派送中 5已完成 6已取消
     * @return
     */
    @GetMapping("/historyOrders")
    @ApiOperation("历史订单查询")
    public Result<PageResult> page(int page, int pageSize, Integer status) {
        PageResult pageResult = orderService.pageQuery4User(page, pageSize, status);
        return Result.success(pageResult);
    }

1.2.2 OrderService

    /**
     * 用户端订单分页查询
     * @param page
     * @param pageSize
     * @param status
     * @return
     */
    PageResult pageQuery4User(int page, int pageSize, Integer status);

1.2.3 OrderServiceImpl

/**
     * 用户端订单分页查询
     *
     * @param pageNum
     * @param pageSize
     * @param status
     * @return
     */
    public PageResult pageQuery4User(int pageNum, int pageSize, Integer status) {
        // 设置分页
        PageHelper.startPage(pageNum, pageSize);

        OrdersPageQueryDTO ordersPageQueryDTO = new OrdersPageQueryDTO();
        ordersPageQueryDTO.setUserId(BaseContext.getCurrentId());
        ordersPageQueryDTO.setStatus(status);

        // 分页条件查询
        Page<Orders> page = orderMapper.pageQuery(ordersPageQueryDTO);

        List<OrderVO> list = new ArrayList();

        // 查询出订单明细,并封装入OrderVO进行响应
        if (page != null && page.getTotal() > 0) {
            for (Orders orders : page) {
                Long orderId = orders.getId();// 订单id

                // 查询订单明细
                List<OrderDetail> orderDetails = orderDetailMapper.getByOrderId(orderId);

                OrderVO orderVO = new OrderVO();
                BeanUtils.copyProperties(orders, orderVO);
                orderVO.setOrderDetailList(orderDetails);

                list.add(orderVO);
            }
        }
        return new PageResult(page.getTotal(), list);
    }

1.2.4 OrderMapper

    /**
     * 分页条件查询并按下单时间排序
     * @param ordersPageQueryDTO
     */
    Page<Orders> pageQuery(OrdersPageQueryDTO ordersPageQueryDTO);

1.2.5 OrderMapper.xml

    <select id="pageQuery" resultType="Orders">
        select * from orders
        <where>
            <if test="number != null and number!=''">
                and number like concat('%',#{number},'%')
            </if>
            <if test="phone != null and phone!=''">
                and phone like concat('%',#{phone},'%')
            </if>
            <if test="userId != null">
                and user_id = #{userId}
            </if>
            <if test="status != null">
                and status = #{status}
            </if>
            <if test="beginTime != null">
                and order_time &gt;= #{beginTime}
            </if>
            <if test="endTime != null">
                and order_time &lt;= #{endTime}
            </if>
        </where>
        order by order_time desc
    </select>

1.2.6 OrderDetailMapper

    /**
     * 根据订单id查询订单明细
     * @param orderId
     * @return
     */
    @Select("select * from order_detail where order_id = #{orderId}")
    List<OrderDetail> getByOrderId(Long orderId);

查询订单详情

2.2 代码实现

2.2.1 user/OrderController
    /**
     * 查询订单详情
     *
     * @param id
     * @return
     */
    @GetMapping("/orderDetail/{id}")
    @ApiOperation("查询订单详情")
    public Result<OrderVO> details(@PathVariable("id") Long id) {
        OrderVO orderVO = orderService.details(id);
        return Result.success(orderVO);
    }

2.2.2 OrderService

    /**
     * 查询订单详情
     * @param id
     * @return
     */
    OrderVO details(Long id);

2.2.3 OrderServiceImpl

    /**
     * 查询订单详情
     *
     * @param id
     * @return
     */
    public OrderVO details(Long id) {
        // 根据id查询订单
        Orders orders = orderMapper.getById(id);

        // 查询该订单对应的菜品/套餐明细
        List<OrderDetail> orderDetailList = orderDetailMapper.getByOrderId(orders.getId());

        // 将该订单及其详情封装到OrderVO并返回
        OrderVO orderVO = new OrderVO();
        BeanUtils.copyProperties(orders, orderVO);
        orderVO.setOrderDetailList(orderDetailList);

        return orderVO;
    }

2.2.4 OrderMapper

    /**
     * 根据id查询订单
     * @param id
     */
    @Select("select * from orders where id=#{id}")
    Orders getById(Long id);

取消订单

业务规则:

  • 待支付和待接单状态下,用户可直接取消订单
  • 商家已接单状态下,用户取消订单需电话沟通商家
  • 派送中状态下,用户取消订单需电话沟通商家
  • 如果在待接单状态下取消订单,需要给用户退款
  • 取消订单后需要将订单状态修改为“已取消”

3.2.1 user/OrderController

    /**
     * 用户取消订单
     *
     * @return
     */
    @PutMapping("/cancel/{id}")
    @ApiOperation("取消订单")
    public Result cancel(@PathVariable("id") Long id) throws Exception {
        orderService.userCancelById(id);
        return Result.success();
    }

3.2.2 OrderService

    /**
     * 用户取消订单
     * @param id
     */
    void userCancelById(Long id) throws Exception;

3.2.3 OrderServiceImpl

    /**
     * 用户取消订单
     *
     * @param id
     */
    public void userCancelById(Long id) throws Exception {
        // 根据id查询订单
        Orders ordersDB = orderMapper.getById(id);

        // 校验订单是否存在
        if (ordersDB == null) {
            throw new OrderBusinessException(MessageConstant.ORDER_NOT_FOUND);
        }

        //订单状态 1待付款 2待接单 3已接单 4派送中 5已完成 6已取消
        if (ordersDB.getStatus() > 2) {
            throw new OrderBusinessException(MessageConstant.ORDER_STATUS_ERROR);
        }

        Orders orders = new Orders();
        orders.setId(ordersDB.getId());

        // 订单处于待接单状态下取消,需要进行退款
        if (ordersDB.getStatus().equals(Orders.TO_BE_CONFIRMED)) {
            //调用微信支付退款接口
            weChatPayUtil.refund(
                    ordersDB.getNumber(), //商户订单号
                    ordersDB.getNumber(), //商户退款单号
                    new BigDecimal(0.01),//退款金额,单位 元
                    new BigDecimal(0.01));//原订单金额

            //支付状态修改为 退款
            orders.setPayStatus(Orders.REFUND);
        }

        // 更新订单状态、取消原因、取消时间
        orders.setStatus(Orders.CANCELLED);
        orders.setCancelReason("用户取消");
        orders.setCancelTime(LocalDateTime.now());
        orderMapper.update(orders);
    }

再来一单

4.2.1 user/OrderController

    /**
     * 再来一单
     *
     * @param id
     * @return
     */
    @PostMapping("/repetition/{id}")
    @ApiOperation("再来一单")
    public Result repetition(@PathVariable Long id) {
        orderService.repetition(id);
        return Result.success();
    }

4.2.2 OrderService

    /**
     * 再来一单
     *
     * @param id
     */
    void repetition(Long id);

4.2.3 OrderServiceImpl

    /**
     * 再来一单
     *
     * @param id
     */
   @Override
    public void repetition(Long id) {
        //查询当前用户id
        Long userId = BaseContext.getCurrentId();
        //根据订单id查询当前订单详情
        List<OrderDetail> orderDetailList = orderDetailMapper.getByOrderId(id);

        // 将订单详情对象转换为购物车对象
        // 这一行使用 map 方法对每个 OrderDetail 对象进行转换操作,x 是当前遍历的 OrderDetail 对象
        List<ShoppingCart> shoppingCartList = orderDetailList.stream().map(x -> {
            //表示一个函数,该函数接受一个参数 x 并返回一个新对象
            ShoppingCart shoppingCart = new ShoppingCart();

            // 将原订单详情里面的菜品信息重新复制到购物车对象中
            BeanUtils.copyProperties(x, shoppingCart, "id");
            shoppingCart.setUserId(userId);
            shoppingCart.setCreateTime(LocalDateTime.now());

            return shoppingCart;
        }).collect(Collectors.toList());
// 使用 collect 方法将转换后的 ShoppingCart 对象收集到一个新的 List<ShoppingCart> 列表中
        // 将购物车对象批量添加到数据库
        shoppingCartMapper.insertBatch(shoppingCartList);
    }

4.2.4 ShoppingCartMapper

    /**
     * 批量插入购物车数据
     *
     * @param shoppingCartList
     */
    void insertBatch(List<ShoppingCart> shoppingCartList);

4.2.5 ShoppingCartMapper.xml

<insert id="insertBatch" parameterType="list">
        insert into shopping_cart
        (name, image, user_id, dish_id, setmeal_id, dish_flavor, number, amount, create_time)
        values
        <foreach collection="shoppingCartList" item="sc" separator=",">
            
        </foreach>
</insert>

订单搜索

1.2.1 admin/OrderController

在admin包下创建OrderController

/**
 * 订单管理
 */
@RestController("adminOrderController")
@RequestMapping("/admin/order")
@Slf4j
@Api(tags = "订单管理接口")
public class OrderController {

    @Autowired
    private OrderService orderService;

    /**
     * 订单搜索
     *
     * @param ordersPageQueryDTO
     * @return
     */
    @GetMapping("/conditionSearch")
    @ApiOperation("订单搜索")
    public Result<PageResult> conditionSearch(OrdersPageQueryDTO ordersPageQueryDTO) {
        PageResult pageResult = orderService.conditionSearch(ordersPageQueryDTO);
        return Result.success(pageResult);
    }
}

1.2.2 OrderService

    /**
     * 条件搜索订单
     * @param ordersPageQueryDTO
     * @return
     */
    PageResult conditionSearch(OrdersPageQueryDTO ordersPageQueryDTO);

1.2.3 OrderServiceImpl

    /**
     * 订单搜索
     *
     * @param ordersPageQueryDTO
     * @return
     */
    public PageResult conditionSearch(OrdersPageQueryDTO ordersPageQueryDTO) {
        PageHelper.startPage(ordersPageQueryDTO.getPage(), ordersPageQueryDTO.getPageSize());

        Page<Orders> page = orderMapper.pageQuery(ordersPageQueryDTO);

        // 部分订单状态,需要额外返回订单菜品信息,将Orders转化为OrderVO
        List<OrderVO> orderVOList = getOrderVOList(page);

        return new PageResult(page.getTotal(), orderVOList);
    }

    private List<OrderVO> getOrderVOList(Page<Orders> page) {
        // 需要返回订单菜品信息,自定义OrderVO响应结果
        List<OrderVO> orderVOList = new ArrayList<>();

        List<Orders> ordersList = page.getResult();
        if (!CollectionUtils.isEmpty(ordersList)) {
            for (Orders orders : ordersList) {
                // 将共同字段复制到OrderVO
                OrderVO orderVO = new OrderVO();
                BeanUtils.copyProperties(orders, orderVO);
                String orderDishes = getOrderDishesStr(orders);

                // 将订单菜品信息封装到orderVO中,并添加到orderVOList
                orderVO.setOrderDishes(orderDishes);
                orderVOList.add(orderVO);
            }
        }
        return orderVOList;
    }

    /**
     * 根据订单id获取菜品信息字符串
     *
     * @param orders
     * @return
     */
    private String getOrderDishesStr(Orders orders) {
        // 查询订单菜品详情信息(订单中的菜品和数量)
        List<OrderDetail> orderDetailList = orderDetailMapper.getByOrderId(orders.getId());

        // 将每一条订单菜品信息拼接为字符串(格式:宫保鸡丁*3;)
        List<String> orderDishList = orderDetailList.stream().map(x -> {
            String orderDish = x.getName() + "*" + x.getNumber() + ";";
            return orderDish;
        }).collect(Collectors.toList());

        // 将该订单对应的所有菜品信息拼接在一起
        return String.join("", orderDishList);
    }

各个状态的订单数量统计

2.2.1 admin/OrderController

    /**
     * 各个状态的订单数量统计
     *
     * @return
     */
    @GetMapping("/statistics")
    @ApiOperation("各个状态的订单数量统计")
    public Result<OrderStatisticsVO> statistics() {
        OrderStatisticsVO orderStatisticsVO = orderService.statistics();
        return Result.success(orderStatisticsVO);
    }

2.2.2 OrderService

    /**
     * 各个状态的订单数量统计
     * @return
     */
    OrderStatisticsVO statistics();

2.2.3 OrderServiceImpl

    /**
     * 各个状态的订单数量统计
     *
     * @return
     */
    public OrderStatisticsVO statistics() {
        // 根据状态,分别查询出待接单、待派送、派送中的订单数量
        Integer toBeConfirmed = orderMapper.countStatus(Orders.TO_BE_CONFIRMED);
        Integer confirmed = orderMapper.countStatus(Orders.CONFIRMED);
        Integer deliveryInProgress = orderMapper.countStatus(Orders.DELIVERY_IN_PROGRESS);

        // 将查询出的数据封装到orderStatisticsVO中响应
        OrderStatisticsVO orderStatisticsVO = new OrderStatisticsVO();
        orderStatisticsVO.setToBeConfirmed(toBeConfirmed);
        orderStatisticsVO.setConfirmed(confirmed);
        orderStatisticsVO.setDeliveryInProgress(deliveryInProgress);
        return orderStatisticsVO;
    }

2.2.4 OrderMapper

    /**
     * 根据状态统计订单数量
     * @param status
     */
    @Select("select count(id) from orders where status = #{status}")
    Integer countStatus(Integer status);

查询订单详情

业务规则:

  • 订单详情页面需要展示订单基本信息(状态、订单号、下单时间、收货人、电话、收货地址、金额等)
  • 订单详情页面需要展示订单明细数据(商品名称、数量、单价)

3.2.1 admin/OrderController

    /**
     * 订单详情
     *
     * @param id
     * @return
     */
    @GetMapping("/details/{id}")
    @ApiOperation("查询订单详情")
    public Result<OrderVO> details(@PathVariable("id") Long id) {
        OrderVO orderVO = orderService.details(id);
        return Result.success(orderVO);
    }

接单

业务规则:

  • 商家接单其实就是将订单的状态修改为“已接单”

4.2.1 admin/OrderController

    /**
     * 接单
     *
     * @return
     */
    @PutMapping("/confirm")
    @ApiOperation("接单")
    public Result confirm(@RequestBody OrdersConfirmDTO ordersConfirmDTO) {
        orderService.confirm(ordersConfirmDTO);
        return Result.success();
    }

4.2.2 OrderService

    /**
     * 接单
     *
     * @param ordersConfirmDTO
     */
    void confirm(OrdersConfirmDTO ordersConfirmDTO);

4.2.3 OrderServiceImpl

    /**
     * 接单
     *
     * @param ordersConfirmDTO
     */
    public void confirm(OrdersConfirmDTO ordersConfirmDTO) {
        Orders orders = Orders.builder()
                .id(ordersConfirmDTO.getId())
                .status(Orders.CONFIRMED)
                .build();

        orderMapper.update(orders);
    }

拒单

业务规则:

  • 商家拒单其实就是将订单状态修改为“已取消”
  • 只有订单处于“待接单”状态时可以执行拒单操作
  • 商家拒单时需要指定拒单原因
  • 商家拒单时,如果用户已经完成了支付,需要为用户退款

5.2.1 admin/OrderController

    /**
     * 拒单
     *
     * @return
     */
    @PutMapping("/rejection")
    @ApiOperation("拒单")
    public Result rejection(@RequestBody OrdersRejectionDTO ordersRejectionDTO) throws Exception {
        orderService.rejection(ordersRejectionDTO);
        return Result.success();
    }

5.2.2 OrderService

    /**
     * 拒单
     *
     * @param ordersRejectionDTO
     */
    void rejection(OrdersRejectionDTO ordersRejectionDTO) throws Exception;

5.2.3 OrderServiceImpl

    /**
     * 拒单
     *
     * @param ordersRejectionDTO
     */
    public void rejection(OrdersRejectionDTO ordersRejectionDTO) throws Exception {
        // 根据id查询订单
        Orders ordersDB = orderMapper.getById(ordersRejectionDTO.getId());

        // 订单只有存在且状态为2(待接单)才可以拒单
        if (ordersDB == null || !ordersDB.getStatus().equals(Orders.TO_BE_CONFIRMED)) {
            throw new OrderBusinessException(MessageConstant.ORDER_STATUS_ERROR);
        }

        //支付状态
        Integer payStatus = ordersDB.getPayStatus();
        if (payStatus == Orders.PAID) {
            //用户已支付,需要退款
            String refund = weChatPayUtil.refund(
                    ordersDB.getNumber(),
                    ordersDB.getNumber(),
                    new BigDecimal(0.01),
                    new BigDecimal(0.01));
            log.info("申请退款:{}", refund);
        }

        // 拒单需要退款,根据订单id更新订单状态、拒单原因、取消时间
        Orders orders = new Orders();
        orders.setId(ordersDB.getId());
        orders.setStatus(Orders.CANCELLED);
        orders.setRejectionReason(ordersRejectionDTO.getRejectionReason());
        orders.setCancelTime(LocalDateTime.now());

        orderMapper.update(orders);
    }

取消订单

6.2 代码实现

6.2.1 admin/OrderController

    /**
     * 取消订单
     *
     * @return
     */
    @PutMapping("/cancel")
    @ApiOperation("取消订单")
    public Result cancel(@RequestBody OrdersCancelDTO ordersCancelDTO) throws Exception {
        orderService.cancel(ordersCancelDTO);
        return Result.success();
    }

6.2.2 OrderService

    /**
     * 商家取消订单
     *
     * @param ordersCancelDTO
     */
    void cancel(OrdersCancelDTO ordersCancelDTO) throws Exception;

6.2.3 OrderServiceImpl

    /**
     * 取消订单
     *
     * @param ordersCancelDTO
     */
    public void cancel(OrdersCancelDTO ordersCancelDTO) throws Exception {
        // 根据id查询订单
        Orders ordersDB = orderMapper.getById(ordersCancelDTO.getId());

        //支付状态
        Integer payStatus = ordersDB.getPayStatus();
        if (payStatus == 1) {
            //用户已支付,需要退款
            String refund = weChatPayUtil.refund(
                    ordersDB.getNumber(),
                    ordersDB.getNumber(),
                    new BigDecimal(0.01),
                    new BigDecimal(0.01));
            log.info("申请退款:{}", refund);
        }

        // 管理端取消订单需要退款,根据订单id更新订单状态、取消原因、取消时间
        Orders orders = new Orders();
        orders.setId(ordersCancelDTO.getId());
        orders.setStatus(Orders.CANCELLED);
        orders.setCancelReason(ordersCancelDTO.getCancelReason());
        orders.setCancelTime(LocalDateTime.now());
        orderMapper.update(orders);
    }

派送订单

业务规则:

  • 派送订单其实就是将订单状态修改为“派送中”
  • 只有状态为“待派送”的订单可以执行派送订单操作

7.2.1 admin/OrderController

    /**
     * 派送订单
     *
     * @return
     */
    @PutMapping("/delivery/{id}")
    @ApiOperation("派送订单")
    public Result delivery(@PathVariable("id") Long id) {
        orderService.delivery(id);
        return Result.success();
    }

7.2.2 OrderService

    /**
     * 派送订单
     *
     * @param id
     */
    void delivery(Long id);

7.2.3 OrderServiceImpl

    /**
     * 派送订单
     *
     * @param id
     */
    public void delivery(Long id) {
        // 根据id查询订单
        Orders ordersDB = orderMapper.getById(id);

        // 校验订单是否存在,并且状态为3
        if (ordersDB == null || !ordersDB.getStatus().equals(Orders.CONFIRMED)) {
            throw new OrderBusinessException(MessageConstant.ORDER_STATUS_ERROR);
        }

        Orders orders = new Orders();
        orders.setId(ordersDB.getId());
        // 更新订单状态,状态转为派送中
        orders.setStatus(Orders.DELIVERY_IN_PROGRESS);

        orderMapper.update(orders);
    }

完成订单

业务规则:

  • 完成订单其实就是将订单状态修改为“已完成”
  • 只有状态为“派送中”的订单可以执行订单完成操作

8.2.1 admin/OrderController

    /**
     * 完成订单
     *
     * @return
     */
    @PutMapping("/complete/{id}")
    @ApiOperation("完成订单")
    public Result complete(@PathVariable("id") Long id) {
        orderService.complete(id);
        return Result.success();
    }

8.2.2 OrderService

    /**
     * 完成订单
     *
     * @param id
     */
    void complete(Long id);

8.2.3 OrderServiceImpl

    /**
     * 完成订单
     *
     * @param id
     */
    public void complete(Long id) {
        // 根据id查询订单
        Orders ordersDB = orderMapper.getById(id);

        // 校验订单是否存在,并且状态为4
        if (ordersDB == null || !ordersDB.getStatus().equals(Orders.DELIVERY_IN_PROGRESS)) {
            throw new OrderBusinessException(MessageConstant.ORDER_STATUS_ERROR);
        }

        Orders orders = new Orders();
        orders.setId(ordersDB.getId());
        // 更新订单状态,状态转为完成
        orders.setStatus(Orders.COMPLETED);
        orders.setDeliveryTime(LocalDateTime.now());

        orderMapper.update(orders);
    }

校验收货地址是否超出配送范围

1. 环境准备

注册账号:https://passport.baidu.com/v2/?reg&tt=1671699340600&overseas=&gid=CF954C2-A3D2-417F-9FE6-B0F249ED7E33&tpl=pp&u=https%3A%2F%2Flbsyun.baidu.com%2Findex.php%3Ftitle%3D%E9%A6%96%E9%A1%B5

登录百度地图开放平台:https://lbsyun.baidu.com/

进入控制台,创建应用,获取AK:

![image-20221222170049729](E:\Java实例项目1-20套\第24套【项目实战】Java外卖项目实战《苍穹外卖》SpringBoot+SpringMVC+Vue+Swagger+Lombok+Mybatis+SpringSession+Redis+Nginx+小程序\0-0 源码资料\资料\day09\项目实战参考答案\assets\image-20221222170049729.png)

![image-20221222170256927](E:\Java实例项目1-20套\第24套【项目实战】Java外卖项目实战《苍穹外卖》SpringBoot+SpringMVC+Vue+Swagger+Lombok+Mybatis+SpringSession+Redis+Nginx+小程序\0-0 源码资料\资料\day09\项目实战参考答案\assets\image-20221222170256927.png)

相关接口:

https://lbsyun.baidu.com/index.php?title=webapi/guide/webservice-geocoding

https://lbsyun.baidu.com/index.php?title=webapi/directionlite-v1

2. 代码开发

2.1 application.yml

配置外卖商家店铺地址和百度地图的AK:

![image-20221222170819582](E:\Java实例项目1-20套\第24套【项目实战】Java外卖项目实战《苍穹外卖》SpringBoot+SpringMVC+Vue+Swagger+Lombok+Mybatis+SpringSession+Redis+Nginx+小程序\0-0 源码资料\资料\day09\项目实战参考答案\assets\image-20221222170819582.png)

2.2 OrderServiceImpl

改造OrderServiceImpl,注入上面的配置项:

com/sky/properties/BaiDuProperties.java
package com.sky.properties;

import lombok.Data;
import lombok.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
// 多个prefix
@ConfigurationProperties(prefix = "sky.baidu")
@Data
public class BaiDuProperties {
    private String shopAddress;
    private String ak;
}
application.yml
  baidu:
    ak: ${sky.baidu.ak}
    shopAddress: ${sky.baidu.shopAddress}
application-dev.yml
  baidu:
    ak: xxxxxxxxx
    shopAddress: 河北省唐山市丰润区燕山路街道美景花园

在OrderServiceImpl中提供校验方法:

/**
     * 检查客户的收货地址是否超出配送范围
     * @param address
     */
    private void checkOutOfRange(String address) {
        Map<String, String> map = new HashMap<>();
        map.put("address", baiDuProperties.getShopAddress());
        map.put("output", "json");
        map.put("ak", baiDuProperties.getAk());

        //获取店铺的经纬度坐标
        String shopCoordinate = HttpClientUtil.doGet("https://api.map.baidu.com/geocoding/v3", map);

        JSONObject jsonObject = JSON.parseObject(shopCoordinate);
        if(!jsonObject.getString("status").equals("0")){
            throw new OrderBusinessException("店铺地址解析失败");
        }

        //数据解析
        JSONObject location = jsonObject.getJSONObject("result").getJSONObject("location");
        String lat = location.getString("lat");
        String lng = location.getString("lng");
        //店铺经纬度坐标
        String shopLngLat = lat + "," + lng;

        map.put("address",address);
        //获取用户收货地址的经纬度坐标
        String userCoordinate = HttpClientUtil.doGet("https://api.map.baidu.com/geocoding/v3", map);

        jsonObject = JSON.parseObject(userCoordinate);
        if(!jsonObject.getString("status").equals("0")){
            throw new OrderBusinessException("收货地址解析失败");
        }

        //数据解析
        location = jsonObject.getJSONObject("result").getJSONObject("location");
        lat = location.getString("lat");
        lng = location.getString("lng");
        //用户收货地址经纬度坐标
        String userLngLat = lat + "," + lng;

        map.put("origin",shopLngLat);
        map.put("destination",userLngLat);
        map.put("steps_info","0");

        //路线规划
        String json = HttpClientUtil.doGet("https://api.map.baidu.com/directionlite/v1/driving", map);

        jsonObject = JSON.parseObject(json);
        if(!jsonObject.getString("status").equals("0")){
            throw new OrderBusinessException("配送路线规划失败");
        }

        //数据解析
        JSONObject result = jsonObject.getJSONObject("result");
        JSONArray jsonArray = (JSONArray) result.get("routes");
        Integer distance = (Integer) ((JSONObject) jsonArray.get(0)).get("distance");

        if(distance > 5000){
            //配送距离超过5000米
            throw new OrderBusinessException("超出配送范围");
        }
    }

在OrderServiceImpl的submitOrder方法中调用上面的校验方法:

   // 检查用户的收获地址是否超出配送范围
        checkOutOfRange(addressBook.getCityName() + addressBook.getDistrictName() + addressBook.getDetail());

![image-20221222171444981](E:\Java实例项目1-20套\第24套【项目实战】Java外卖项目实战《苍穹外卖》SpringBoot+SpringMVC+Vue+Swagger+Lombok+Mybatis+SpringSession+Redis+Nginx+小程序\0-0 源码资料\资料\day09\项目实战参考答案\assets\image-20221222171444981.png)

SpringTask[定时任务]定时自动执行某段Java代码

SpringTask是Spring框架提供的任务调度工具,可以按照约定的时间自动执行某个代码逻辑

应用场景
  • 信用卡每月还款提醒
  • 银行贷款每月还款提醒
  • 火车票售票系统处理未支付订单
  • 入职纪念日为用户发送通知
cron表达式

cron表达式其实就是一个字符串,通过cron表达式可以定义任务触发时间
构成规则:分为6或7个域,由空格分隔开,每个域代表一个含义
每个域的含义分别为:秒、分钟、小时、日、月、周、年(可选)

2022年10月12日上午9点整 对应的cron表达式(日 和 周 不能同时定义)

0 0 9 12 10 ? 2022
https://cron.qqe2.com

分钟 小时
0 0 9 12 10
SpringTask使用步骤:
  • 导入maven坐标 spring-context(已存在)
  • 启动类添加注解 @EnableScheduling 开启任务调度
  • 自定义定时任务类
sky-server  com/sky/task/MyTask.java
package com.sky.task;

import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.Date;

/**
 * 自定义定时任务类
 */
@Component
@Slf4j
public class MyTask {

    /**
     * 定时任务 每隔5秒触发一次
     */
    @Scheduled(cron = "0/5 * * * * ?")
    public void executeTask(){
        log.info("定时任务开始执行:{}", new Date());
    }
}

订单状态定时处理

用户下单后可能存在的情况:
  • 下单后未支付,订单一直处于”待支付“状态
  • 用户收获后管理端未点击完成按钮,订单一直处于“派送中”状态
    • 通过定时任务每分钟检查一次是否存在支付超时订单(超过15min),如果存在则修改订单状态为”已取消”
    • 通过定时任务每天凌晨1点检查一次是否存在”派送中”的订单,如果存在则修改订单状态为”已完成”
代码开发:
sky-server  com/sky/task/OrderTask.java
package com.sky.task;

import com.sky.entity.Orders;
import com.sky.mapper.OrderMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.List;

@Component
@Slf4j
public class OrderTask {
    @Autowired
    private OrderMapper orderMapper;

    /**
     * 处理超时订单的方法
     */
    @Scheduled(cron = "0 * * * * ?")//每分钟触发一次
    public void processTimeoutOrder(){
        log.info("定时处理超时订单");
        // select * from orders where status = ? and order_time = (当前时间 - 15分钟)
        LocalDateTime time = LocalDateTime.now().plusMinutes(-15);
        List<Orders> ordersList = orderMapper.getByStatusAndOrderTimeLT(Orders.PENDING_PAYMENT, time);
        if (ordersList != null && ordersList.size() > 0) {
            for (Orders orders : ordersList) {
                orders.setStatus(Orders.CANCELLED);
                orders.setCancelReason("订单超时,自动取消");
                orders.setCancelTime(LocalDateTime.now());
                orderMapper.update(orders);
            }
        }
    }

    /**
     * 处理一直处于派送中状态的订单
     */
    @Scheduled(cron = "0 0 1 * * ?")//每天凌晨一点
    public void processDeliveryOrder() {
        log.info("定时处理处于派送中的订单");
        LocalDateTime time = LocalDateTime.now().plusMinutes(-60);
        List<Orders> ordersList = orderMapper.getByStatusAndOrderTimeLT(Orders.DELIVERY_IN_PROGRESS, time);
        if (ordersList != null && ordersList.size() > 0) {
            for (Orders orders : ordersList) {
                orders.setStatus(Orders.COMPLETED);
                orderMapper.update(orders);
            }
        }
    }
}
sky-server  com/sky/mapper/OrderMapper.java
    /**
     *
     * 根据订单状态和下单时间查询订单
     * @param status
     * @param orderTime
     * @return
     */
    @Select("select * from orders where status = #{status} and order_time < #{orderTime}")
    List<Orders> getByStatusAndOrderTimeLT(Integer status, LocalDateTime orderTime);

WebSocket

WebSocket是基于TCP的一种新的网络协议,它实现了浏览器与服务器全双工通信——浏览器和服务器只需要完成一次握手,两者之间就可以创建持续性连接,并进行双向数据传输

应用场景:
  • 视频弹幕
  • 网页聊天
  • 体育实况更新
  • 股票基金报价实时更新
入门案例

实现步骤:

  • 直接使用websocket.html页面坐位WebSocket客户端
  • 导入WebSocket的maven坐标
  • 导入WebSocket服务端组件WebSocketServer,用于和客户端通信
  • 导入配置类WebSocketConfiguration,注册WebSocket的服务端组件
  • 导入定时人物类WebSocketTask,定时向客户端推送数据
websocket.html
<!DOCTYPE HTML>
<html>
<head>
    <meta charset="UTF-8">
    <title>WebSocket Demo</title>
</head>
<body>
    <input id="text" type="text" />
    <button onclick="send()">发送消息</button>
    <button onclick="closeWebSocket()">关闭连接</button>
    <div id="message">
    </div>
</body>
<script type="text/javascript">
    var websocket = null;
    var clientId = Math.random().toString(36).substr(2);

    //判断当前浏览器是否支持WebSocket
    if('WebSocket' in window){
        //连接WebSocket节点
        websocket = new WebSocket("ws://localhost:8080/ws/"+clientId);
    }
    else{
        alert('Not support websocket')
    }

    //连接发生错误的回调方法
    websocket.onerror = function(){
        setMessageInnerHTML("error");
    };

    //连接成功建立的回调方法
    websocket.onopen = function(){
        setMessageInnerHTML("连接成功");
    }

    //接收到消息的回调方法
    websocket.onmessage = function(event){
        setMessageInnerHTML(event.data);
    }

    //连接关闭的回调方法
    websocket.onclose = function(){
        setMessageInnerHTML("close");
    }

    //监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
    window.onbeforeunload = function(){
        websocket.close();
    }

    //将消息显示在网页上
    function setMessageInnerHTML(innerHTML){
        document.getElementById('message').innerHTML += innerHTML + '<br/>';
    }

    //发送消息
    function send(){
        var message = document.getElementById('text').value;
        websocket.send(message);
    }
    
    //关闭连接
    function closeWebSocket() {
        websocket.close();
    }
</script>
</html>
com/sky/websocket/WebSocketServer.java
package com.sky.websocket;

import org.springframework.stereotype.Component;
import javax.websocket.OnClose;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;

/**
 * WebSocket服务
 */
@Component //交给spring容器管理
@ServerEndpoint("/ws/{sid}")
public class WebSocketServer {

    //存放会话对象
    private static Map<String, Session> sessionMap = new HashMap();

    /**
     * 连接建立成功调用的方法
     */
    @OnOpen
    public void onOpen(Session session, @PathParam("sid") String sid) {
        System.out.println("客户端:" + sid + "建立连接");
        sessionMap.put(sid, session);
    }

    /**
     * 收到客户端消息后调用的方法
     *
     * @param message 客户端发送过来的消息
     */
    @OnMessage
    public void onMessage(String message, @PathParam("sid") String sid) {
        System.out.println("收到来自客户端:" + sid + "的信息:" + message);
    }

    /**
     * 连接关闭调用的方法
     *
     * @param sid
     */
    @OnClose
    public void onClose(@PathParam("sid") String sid) {
        System.out.println("连接断开:" + sid);
        sessionMap.remove(sid);
    }

    /**
     * 群发
     *
     * @param message
     */
    public void sendToAllClient(String message) {
        Collection<Session> sessions = sessionMap.values();
        for (Session session : sessions) {
            try {
                //服务器向客户端发送消息
                session.getBasicRemote().sendText(message);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}
com/sky/config/WebSocketConfiguration.java
package com.sky.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

/**
 * WebSocket配置类,用于注册WebSocket的Bean
 */
@Configuration
public class WebSocketConfiguration {

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}
com/sky/task/WebSocketTask.java
package com.sky.task;

import com.sky.websocket.WebSocketServer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

@Component
public class WebSocketTask {
    @Autowired
    private WebSocketServer webSocketServer;

    /**
     * 通过WebSocket每隔5秒向客户端发送消息
     */
    @Scheduled(cron = "0/5 * * * * ?")
    public void sendMessageToClient() {
        webSocketServer.sendToAllClient("这是来自服务端的消息:" + DateTimeFormatter.ofPattern("HH:mm:ss").format(LocalDateTime.now()));
    }
}

来单提醒

用户下单并且支付成功后,需要第一时间通知外卖商家

  • 语音播报
  • 弹出提示框
设计:
  • 通过WebSocket实现管理端页面和服务端保持长连接状态
  • 当客户支付后,调用WebSocket的相关API实现服务端向客户端推送消息
  • 客户端浏览器解析服务端推送的消息,判断是来单提醒还是客户催单,进行相应的消息提示和语音播报
  • 约定服务器发送给客户端浏览器的数据格式为JSON,字段包括:type,orderId,content
    • type 为消息类型,1为来单提醒 2为客户催单
    • orderId 为订单id
    • content 为消息内容
sky-server  com/sky/service/impl/OrderServiceImpl.java
 /**
     * 订单支付
     *
     * @param ordersPaymentDTO
     * @return
     */
    public OrderPaymentVO payment(OrdersPaymentDTO ordersPaymentDTO) throws Exception {
        // 查询订单
        Orders order = orderMapper.getByOrderNumber(ordersPaymentDTO.getOrderNumber());
        if (order == null) {
            throw new OrderBusinessException("订单不存在");
        }

        // 检查订单支付状态
        if (order.getPayStatus() == 1) { // 1 表示已支付
            throw new OrderBusinessException("该订单已支付");
        }
        order.setPayStatus(1);

        // 更新订单支付状态为已支付
        order.setPayStatus(Orders.PAID);
        order.setCheckoutTime(LocalDateTime.now());
        order.setPayMethod(ordersPaymentDTO.getPayMethod());
        order.setStatus(Orders.TO_BE_CONFIRMED);

        // 支付成功后通过 WebSocket 向客户端推送消息
        Map<String, Object> map = new HashMap<>();
        map.put("type", 1); // 1 表示来单提醒
        map.put("orderId", order.getId());
        map.put("content", "订单号:" + ordersPaymentDTO.getOrderNumber());

        webSocketServer.sendToAllClient(JSON.toJSONString(map));


        orderMapper.update(order);

        // 构造并返回支付结果对象
        OrderPaymentVO orderPaymentVO = new OrderPaymentVO();
        orderPaymentVO.setOrderNumber(order.getNumber()); // 订单号
        orderPaymentVO.setPaymentTime(new Date());
        orderPaymentVO.setPaymentStatus("SUCCESS");

        return orderPaymentVO;

    }

用户催单

com/sky/controller/user/OrderController.java 
/**
     * 客户催单
     * @param id
     * @return
     */
    @GetMapping("/reminder/{id}")
    @ApiOperation("客户催单")
    public Result reminder(@PathVariable("id") Long id){
        orderService.reminder(id);
        return Result.success();
    }
com/sky/service/OrderService.java
 /**
     * 用户催单
     * @param id
     */
    void reminder(Long id);
com/sky/service/impl/OrderServiceImpl.java
/**
     * 客户催单
     * @param id
     */
    public void reminder(Long id) {
        // 根据id查询订单
        Orders ordersDB = orderMapper.getById(id);

        // 校验订单是否存在
        if (ordersDB == null) {
            throw new OrderBusinessException(MessageConstant.ORDER_STATUS_ERROR);
        }

        Map map = new HashMap();
        map.put("type",2); //1表示来单提醒 2表示客户催单
        map.put("orderId",id);
        map.put("content","订单号:" + ordersDB.getNumber());

        //通过websocket向客户端浏览器推送消息
        webSocketServer.sendToAllClient(JSON.toJSONString(map));
    }

ApacheECharts

http://echarts.apache.org/zh/index.html

  • 柱形图 bar
  • 饼形图
  • 折线图

使用Echarts,重点在于研究当前图标所需数据格式,通常是需要后端提供符合格式要求的动态数据,然后相应给前端来展示图表

营业额统计

业务规则
  • 营业额指订单状态为已完成的订单金额合计
  • 基于可视化报表的折线图展示营业额数据,x轴为日期,y轴为营业额
  • 根据时间选择区间,展示每天的营业额数据
根据接口定义设计对应的vo:
sky-pojo  com/sky/vo/TurnoverReportVO.java
package com.sky.vo;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TurnoverReportVO implements Serializable {

    //日期,以逗号分隔,例如:2022-10-01,2022-10-02,2022-10-03 [开始到结束的每一天]
    private String dateList;

    //营业额,以逗号分隔,例如:406.0,1520.0,75.0 [营业额一一对应]
    private String turnoverList;
}
sky-server  com/sky/controller/admin/ReportController.java
package com.sky.controller.admin;

import com.sky.result.Result;
import com.sky.service.ReportService;
import com.sky.vo.TurnoverReportVO;
import io.swagger.annotations.Api;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDate;

/**
 * 数据统计相关接口
 */
@RestController
@RequestMapping("/admin/report")
@Api(tags = "数据统计接口")
@Slf4j
public class ReportController {
    @Autowired
    private ReportService reportService;

    /**
     * 统计指定时间区间内的营业额数据
     * @param begin
     * @param end
     * @return
     */
    @GetMapping("/turnoverStatistics")
    public Result<TurnoverReportVO> turnoverStatistics(
            @DateTimeFormat(pattern = "yyyy-MM-dd")LocalDate begin,
            @DateTimeFormat(pattern = "yyyy-MM-dd")LocalDate end) {
        log.info("营业额统计:{},{}",begin,end);
        return Result.success(reportService.getTurnoverStatistics(begin,end));
    }
}
sky-server  com/sky/service/ReportService.java
package com.sky.service;

import com.sky.vo.TurnoverReportVO;

import java.time.LocalDate;

public interface ReportService {
    /**
     * 统计指定时间区间内的营业额数据
     * @param begin
     * @param end
     * @return
     */
    TurnoverReportVO getTurnoverStatistics(LocalDate begin, LocalDate end);
}
sky-server  com/sky/service/impl/ReportServiceImpl.java
package com.sky.service.impl;

import com.sky.entity.Orders;
import com.sky.mapper.OrderMapper;
import com.sky.service.ReportService;
import com.sky.vo.TurnoverReportVO;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Service
@Slf4j
public class ReportServiceImpl implements ReportService {
    @Autowired
    private OrderMapper orderMapper;
    /**
     * 统计指定时间区间内的营业额数据
     * @param begin
     * @param end
     * @return
     */
    @Override
    public TurnoverReportVO getTurnoverStatistics(LocalDate begin, LocalDate end) {
        // 当前集合用于存放从begin到end范围内的每天的日期
        List<LocalDate> dateList = new ArrayList<>();
        dateList.add(begin);

        while (!begin.equals(end)) {
            //日期计算,计算指定日期的后一天对应的日期
            begin = begin.plusDays(1);
            dateList.add(begin);
        }

        // 存放每天的营业额
        List<Double> turnoverList = new ArrayList<>();
        for (LocalDate date : dateList) { //LocalDate只是年月日 而下单的Order有时分秒
            // 查询Date日期对应的营业额数据,数据额是指:订单状态为“已完成”的订单金额合计
            LocalDateTime beginTime = LocalDateTime.of(date, LocalTime.MIN);
            LocalDateTime endTime = LocalDateTime.of(date, LocalTime.MAX);
        // select sum(count) from orders where order_time > ? and order_time < ? and status = 5
            Map map = new HashMap<>();
            map.put("begin", beginTime);
            map.put("end", endTime);
            map.put("status", Orders.COMPLETED);
            Double turnover = orderMapper.sumByMap(map);
            turnover = turnover == null ? 0.0 : turnover;//没有营业额则默认为0
            turnoverList.add(turnover);
        }

        return TurnoverReportVO.builder()
                .dateList(StringUtils.join(dateList, ","))
                .turnoverList(StringUtils.join(turnoverList, ","))
                .build();
    }
}
sky-server  com/sky/mapper/OrderMapper.java
/**
     * 根据动态条件统计营业额数据
     * @param map
     * @return
     */
    Double sumByMap(Map map);
sky-server  mapper/OrderMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.OrderMapper">

    <insert id="insert" parameterType="Orders" useGeneratedKeys="true" keyProperty="id">
        insert into orders
        (number, status, user_id, address_book_id, order_time, checkout_time, pay_method, pay_status, amount, remark,
         phone, address, consignee, estimated_delivery_time, delivery_status, pack_amount, tableware_number,
         tableware_status)
        values (#{number}, #{status}, #{userId}, #{addressBookId}, #{orderTime}, #{checkoutTime}, #{payMethod},
                #{payStatus}, #{amount}, #{remark}, #{phone}, #{address}, #{consignee},
                #{estimatedDeliveryTime}, #{deliveryStatus}, #{packAmount}, #{tablewareNumber}, #{tablewareStatus})
    </insert>

    <update id="update" parameterType="com.sky.entity.Orders">
        update orders
        <set>
            <if test="cancelReason != null and cancelReason!='' ">
                cancel_reason=#{cancelReason},
            </if>
            <if test="rejectionReason != null and rejectionReason!='' ">
                rejection_reason=#{rejectionReason},
            </if>
            <if test="cancelTime != null">
                cancel_time=#{cancelTime},
            </if>
            <if test="payStatus != null">
                pay_status=#{payStatus},
            </if>
            <if test="payMethod != null">
                pay_method=#{payMethod},
            </if>
            <if test="checkoutTime != null">
                checkout_time=#{checkoutTime},
            </if>
            <if test="status != null">
                status = #{status},
            </if>
            <if test="deliveryTime != null">
                delivery_time = #{deliveryTime}
            </if>
        </set>
        where id = #{id}
    </update>

    <select id="pageQuery" resultType="Orders">
        select * from orders
        <where>
            <if test="number != null and number!=''">
                and number like concat('%',#{number},'%')
            </if>
            <if test="phone != null and phone!=''">
                and phone like concat('%',#{phone},'%')
            </if>
            <if test="userId != null">
                and user_id = #{userId}
            </if>
            <if test="status != null">
                and status = #{status}
            </if>
            <if test="beginTime != null">
                and order_time &gt;= #{beginTime}
            </if>
            <if test="endTime != null">
                and order_time &lt;= #{endTime}
            </if>
        </where>
        order by order_time desc
    </select>
    <!-- 根据订单号查询订单 -->
    <select id="getByOrderNumber" parameterType="String" resultType="Orders">
        select * from orders where number = #{orderNumber}
    </select>
    <select id="sumByMap" resultType="java.lang.Double">
        select sum(amount) from orders
        <where>
            <if test="begin != null">
                and order_time &gt; #{begin}
            </if>
            <if test="end != null">
                and order_time &lt; #{end}
            </if>
            <if test="status != null">
                and status = #{status}
            </if>
        </where>
    </select>
</mapper>

用户统计

业务规则:

  • 根据时间选择区间,展示每天的用户总量和新增用户量数据
据接口定义设计对应的vo:
sky-pojo  com/sky/vo/UserReportVO.java
package com.sky.vo;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserReportVO implements Serializable {

    //日期,以逗号分隔,例如:2022-10-01,2022-10-02,2022-10-03
    private String dateList;

    //用户总量,以逗号分隔,例如:200,210,220
    private String totalUserList;

    //新增用户,以逗号分隔,例如:20,21,10
    private String newUserList;

}
sky-server  com/sky/controller/admin/ReportController.java
package com.sky.controller.admin;

import com.sky.result.Result;
import com.sky.service.ReportService;
import com.sky.vo.TurnoverReportVO;
import io.swagger.annotations.Api;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDate;

/**
     * 用户统计
     * @param begin
     * @param end
     * @return
     */
    @GetMapping("/userStatistics")
    @ApiOperation("用户统计")
    public Result<UserReportVO> userStatistics(
            @DateTimeFormat(pattern = "yyyy-MM-dd")LocalDate begin,
            @DateTimeFormat(pattern = "yyyy-MM-dd")LocalDate end){
        log.info("用户数据统计:{},{}",begin,end);
        return Result.success(reportService.getUserStatistics(begin,end));
    }
sky-server  com/sky/service/ReportService.java
package com.sky.service;

import com.sky.vo.TurnoverReportVO;

import java.time.LocalDate;

public interface ReportService {
   /**
     * 用户统计
     * @param begin
     * @param end
     * @return
     */
    UserReportVO getUserStatistics(LocalDate begin, LocalDate end);
}
sky-server  com/sky/service/impl/ReportServiceImpl.java
package com.sky.service.impl;

import com.sky.entity.Orders;
import com.sky.mapper.OrderMapper;
import com.sky.service.ReportService;
import com.sky.vo.TurnoverReportVO;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Service
@Slf4j
public class ReportServiceImpl implements ReportService {
    /**
     * 统计指定时间区间内的用户数据
     * @param begin
     * @param end
     * @return
     */
    @Override
    public UserReportVO getUserStatistics(LocalDate begin, LocalDate end) {
        // 存放从begin 到 end之间的日期
        List<LocalDate> dateList = new ArrayList<>();
        dateList.add(begin);
        while (!begin.equals(end)) {
            //日期计算,计算指定日期的后一天的日期
            begin = begin.plusDays(1);
            dateList.add(begin);
        }

        // 存放每天新增用户数量 select count(id) from user where create_time > ? and create_time < ?
        List<Integer> newUserList = new ArrayList<>();
        // 存放每天的总用户数量 select count(id) from user where create_time <= ?
        List<Integer> totalUserList = new ArrayList<>();

        for (LocalDate date : dateList) {
        // 遍历每一天的用户总量和数量
            LocalDateTime beginTime = LocalDateTime.of(date, LocalTime.MIN);
            LocalDateTime endTime = LocalDateTime.of(date, LocalTime.MAX);

            Map map = new HashMap<>();
            map.put("end", endTime);

            // 总用户数量
            Integer integer = userMapper.countByMap(map);

            map.put("begin", beginTime);
            //新增用户数量
            Integer newUser = userMapper.countByMap(map);
            totalUserList.add(integer);
            newUserList.add(newUser);
        }
        return UserReportVO.builder()
                .dateList(StringUtils.join(dateList, ","))
                .totalUserList(StringUtils.join(totalUserList, ","))
                .newUserList(StringUtils.join(newUserList, ","))
                .build();
    }
}
sky-server  com/sky/mapper/UserMapper.java
/**
     * 根据动态条件统计用户数量
     * @param map
     * @return
     */
    Integer countByMap(Map map);
sky-server  mapper/UserMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.OrderMapper">

<select id="countByMap" resultType="java.lang.Integer">
        select count(id) from orders
        <where>
            <if test="begin != null">
                and order_time &gt; #{begin}
            </if>
            <if test="end != null">
                and order_time &lt; #{end}
            </if>
        </where>
    </select>
</mapper>

订单统计

业务规则
  • 根据时间选择区间,展示每天的订单总数和有效订单数
  • 展示所选时间区间内的有效订单数、总订单数、订单完成率
  • 订单完成率 = 有效订单数 / 总订单数 * 100%
返回数据
  • dataList 日期列表以逗号分隔
  • orderCompletionRate 订单完成率
  • orderCountList 订单数列表以逗号分隔
  • totalOrderCount 订单总数
  • validOrderCount 有效订单数
  • validOrderCountList 有效订单数列表以逗号分隔
据接口定义设计对应的vo:
sky-pojo  com/sky/vo/OrderReportVO.java
package com.sky.vo;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderReportVO implements Serializable {

    //日期,以逗号分隔,例如:2022-10-01,2022-10-02,2022-10-03
    private String dateList;

    //每日订单数,以逗号分隔,例如:260,210,215
    private String orderCountList;

    //每日有效订单数,以逗号分隔,例如:20,21,10
    private String validOrderCountList;

    //订单总数
    private Integer totalOrderCount;

    //有效订单数
    private Integer validOrderCount;

    //订单完成率
    private Double orderCompletionRate;

}
sky-server  com/sky/controller/admin/ReportController.java
package com.sky.controller.admin;

import com.sky.result.Result;
import com.sky.service.ReportService;
import com.sky.vo.TurnoverReportVO;
import io.swagger.annotations.Api;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDate;

/**
     * 订单统计
     * @param begin
     * @param end
     * @return
     */
    @GetMapping("/ordersStatistics")
    @ApiOperation("订单统计")
    public Result<OrderReportVO> ordersStatistics(
            @DateTimeFormat(pattern = "yyyy-MM-dd")  LocalDate begin,
            @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end){
        log.info("订单数据统计:{},{}",begin,end);
        return Result.success(reportService.getOrderStatistics(begin,end));
    }
sky-server  com/sky/service/ReportService.java
package com.sky.service;

import com.sky.vo.TurnoverReportVO;

import java.time.LocalDate;

public interface ReportService {
 /**
     * 统计指定时间区间内的订单数据
     * @param begin
     * @param end
     * @return
     */
    OrderReportVO getOrderStatistics(LocalDate begin, LocalDate end);
}
sky-server  com/sky/service/impl/ReportServiceImpl.java
package com.sky.service.impl;

import com.sky.entity.Orders;
import com.sky.mapper.OrderMapper;
import com.sky.service.ReportService;
import com.sky.vo.TurnoverReportVO;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

 /**
     * 统计指定时间区间内的订单数据
     * @param begin
     * @param end
     * @return
     */
    public OrderReportVO getOrderStatistics(LocalDate begin, LocalDate end) {
        //存放从begin到end之间的每天对应的日期
        List<LocalDate> dateList = new ArrayList<>();

        dateList.add(begin);

        while (!begin.equals(end)) {
            begin = begin.plusDays(1);
            dateList.add(begin);
        }

        //存放每天的订单总数
        List<Integer> orderCountList = new ArrayList<>();
        //存放每天的有效订单数
        List<Integer> validOrderCountList = new ArrayList<>();

        //遍历dateList集合,查询每天的有效订单数和订单总数
        for (LocalDate date : dateList) {
            //查询每天的订单总数 select count(id) from orders where order_time > ? and order_time < ?
            LocalDateTime beginTime = LocalDateTime.of(date, LocalTime.MIN);
            LocalDateTime endTime = LocalDateTime.of(date, LocalTime.MAX);
            Integer orderCount = getOrderCount(beginTime, endTime, null);

            //查询每天的有效订单数 select count(id) from orders where order_time > ? and order_time < ? and status = 5
            Integer validOrderCount = getOrderCount(beginTime, endTime, Orders.COMPLETED);

            orderCountList.add(orderCount);
            validOrderCountList.add(validOrderCount);
        }

        //计算时间区间内的订单总数量
        Integer totalOrderCount = orderCountList.stream().reduce(Integer::sum).get();

        //计算时间区间内的有效订单数量
        Integer validOrderCount = validOrderCountList.stream().reduce(Integer::sum).get();

        Double orderCompletionRate = 0.0;
        if(totalOrderCount != 0){
            //计算订单完成率
            orderCompletionRate = validOrderCount.doubleValue() / totalOrderCount;
        }

        return  OrderReportVO.builder()
                .dateList(StringUtils.join(dateList,","))
                .orderCountList(StringUtils.join(orderCountList,","))
                .validOrderCountList(StringUtils.join(validOrderCountList,","))
                .totalOrderCount(totalOrderCount)
                .validOrderCount(validOrderCount)
                .orderCompletionRate(orderCompletionRate)
                .build();
    }
    /**
     * 根据条件统计订单数量
     * @param begin
     * @param end
     * @param status
     * @return
     */
    private Integer getOrderCount(LocalDateTime begin, LocalDateTime end, Integer status){
        Map map = new HashMap();
        map.put("begin",begin);
        map.put("end",end);
        map.put("status",status);

        return orderMapper.countByMap(map);
    }
sky-server  com/sky/mapper/OrderMapper.java
/**
     * 根据动态条件统计用户数量
     * @param map
     * @return
     */
    Integer countByMap(Map map);
sky-server  mapper/OrderMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.sky.mapper.OrderMapper">

<select id="countByMap" resultType="java.lang.Integer">
        select count(id) from orders
        <where>
            <if test="begin != null">
                and order_time &gt; #{begin}
            </if>
            <if test="end != null">
                and order_time &lt; #{end}
            </if>
            <if test="status != null">
                and status = #{status}
            </if>
        </where>
    </select>
</mapper>

销量排名Top10

产品原型 (查已完成的数据)
  • 此处的销量为商品销售的份数
sky-server  com/sky/controller/admin/ReportController.java
/**
     * 销量排名统计
     * @param begin
     * @param end
     * @return
     */
    @GetMapping("/top10")
    @ApiOperation("销量排名top10")
    public Result<SalesTop10ReportVO> top10(
            @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate begin,
            @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end){
        log.info("销量排名top10:{},{}",begin,end);
        return Result.success(reportService.getSalesTop10(begin,end));
    }
sky-server  com/sky/service/ReportService.java
/**
     * 销量排名统计
     * @param begin
     * @param end
     * @return
     */
    SalesTop10ReportVO getSalesTop10(LocalDate begin, LocalDate end);
sky-server  com/sky/service/impl/ReportServiceImpl.java
    /**
     * 统计指定时间区间内的销量排名前10
     * @param begin
     * @param end
     * @return
     */
    @Override
    public SalesTop10ReportVO getSalesTop10(LocalDate begin, LocalDate end) {
        LocalDateTime beginTime = LocalDateTime.of(begin, LocalTime.MIN);
        LocalDateTime endTime = LocalDateTime.of(end, LocalTime.MAX);

        List<GoodsSalesDTO> salesTop10 = orderMapper.getSalesTop10(beginTime, endTime);
        List<String> names = salesTop10.stream().map(GoodsSalesDTO::getName).collect(Collectors.toList());
        String nameList = StringUtils.join(names, ",");

        List<Integer> numbers = salesTop10.stream().map(GoodsSalesDTO::getNumber).collect(Collectors.toList());
        String numberList = StringUtils.join(numbers, ",");

        //封装返回结果数据
        return SalesTop10ReportVO
                .builder()
                .nameList(nameList)
                .numberList(numberList)
                .build();
    }
sky-server  com/sky/mapper/OrderMapper.java
  /**
     * 统计指定时间内的销量排名
     * @return
     */
    List<GoodsSalesDTO> getSalesTop10(LocalDateTime begin,LocalDateTime end);
sky-server  mapper/OrderMapper.xml
<select id="getSalesTop10" resultType="com.sky.dto.GoodsSalesDTO">
        select od.name, sum(od.number) number
        from order_detail od,orders o
        where od.order_id = o.id and o.status = 5
        <if test="begin != null">
            and o.order_time &gt; #{begin}
        </if>
        <if test="end != null">
            and o.order_time &lt; #{end}
        </if>
        group by od.name
        order by number desc
        limit 0,10
    </select>
重装数据库
C:\Windows\System32>cd D:\MySQL\MySQL Server 8.0\bin

C:\Windows\System32>mysqld --install MySQL80
Service successfully installed.

C:\Windows\System32>sc query | findstr MySQL

C:\Windows\System32>net start MySQL80
MySQL80 服务正在启动 .
MySQL80 服务已经启动成功。
服务里的MySQL80是Mysql服务
-----------------------------------------------------------------------------------------

C:\Windows\System32>cd D:\MariaDB 11.0\bin

C:\Windows\System32>mysqld --install MariaDB
Service successfully installed.

C:\Windows\System32>net start MariaDB
MariaDB 服务正在启动 .
MariaDB 服务无法启动。
服务里的MariaDB是MariaDB服务

工作台

工作台是系统运营的数据看板,并提供快捷操作入口,可以有效提高商家的工作效率

功能工作台展示的数据:

  • 今日数据
  • 订单管理
  • 菜品总览
  • 套餐总览
  • 订单信息
名词解释:
  • 营业额:已完成订单的总金额
  • 有效订单:已完成订单的数量
  • 订单完成率:有效订单数 / 总订单数 * 100%
  • 平均客单价:营业额 / 有效订单数
  • 新增用户:新增用户的数量
接口设计:
  • 今日数据接口

    Path: /admin/workspace/businessData
    Method: Get

  • 订单管理接口

    Path: /admin/workspace/overviewOrders
    Method: Get

  • 菜品总览接口

    Path: /admin/workspace/overviewDishes
    Method: Get

  • 套餐总览接口

    Path: /admin/workspace/overviewSetmeals
    Method: Get

  • 订单搜索(已完成)

sky-server  com/sky/controller/admin/WorkSpaceController.java
package com.sky.controller.admin;

import com.sky.result.Result;
import com.sky.service.WorkspaceService;
import com.sky.vo.BusinessDataVO;
import com.sky.vo.DishOverViewVO;
import com.sky.vo.OrderOverViewVO;
import com.sky.vo.SetmealOverViewVO;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.time.LocalTime;

/**
 * 工作台
 */
@RestController
@RequestMapping("/admin/workspace")
@Slf4j
@Api(tags = "工作台相关接口")
public class WorkSpaceController {

    @Autowired
    private WorkspaceService workspaceService;

    /**
     * 工作台今日数据查询
     * @return
     */
    @GetMapping("/businessData")
    @ApiOperation("工作台今日数据查询")
    public Result<BusinessDataVO> businessData(){
        //获得当天的开始时间
        LocalDateTime begin = LocalDateTime.now().with(LocalTime.MIN);
        //获得当天的结束时间
        LocalDateTime end = LocalDateTime.now().with(LocalTime.MAX);

        BusinessDataVO businessDataVO = workspaceService.getBusinessData(begin, end);
        return Result.success(businessDataVO);
    }

    /**
     * 查询订单管理数据
     * @return
     */
    @GetMapping("/overviewOrders")
    @ApiOperation("查询订单管理数据")
    public Result<OrderOverViewVO> orderOverView(){
        return Result.success(workspaceService.getOrderOverView());
    }

    /**
     * 查询菜品总览
     * @return
     */
    @GetMapping("/overviewDishes")
    @ApiOperation("查询菜品总览")
    public Result<DishOverViewVO> dishOverView(){
        return Result.success(workspaceService.getDishOverView());
    }

    /**
     * 查询套餐总览
     * @return
     */
    @GetMapping("/overviewSetmeals")
    @ApiOperation("查询套餐总览")
    public Result<SetmealOverViewVO> setmealOverView(){
        return Result.success(workspaceService.getSetmealOverView());
    }
}
sky-server  com/sky/service/WorkspaceService.java
package com.sky.service;

import com.sky.vo.BusinessDataVO;
import com.sky.vo.DishOverViewVO;
import com.sky.vo.OrderOverViewVO;
import com.sky.vo.SetmealOverViewVO;
import java.time.LocalDateTime;

public interface WorkspaceService {

    /**
     * 根据时间段统计营业数据
     * @param begin
     * @param end
     * @return
     */
    BusinessDataVO getBusinessData(LocalDateTime begin, LocalDateTime end);

    /**
     * 查询订单管理数据
     * @return
     */
    OrderOverViewVO getOrderOverView();

    /**
     * 查询菜品总览
     * @return
     */
    DishOverViewVO getDishOverView();

    /**
     * 查询套餐总览
     * @return
     */
    SetmealOverViewVO getSetmealOverView();

}
sky-server  com/sky/service/impl/WorkspaceServiceImpl.java
package com.sky.service.impl;

import com.sky.constant.StatusConstant;
import com.sky.entity.Orders;
import com.sky.mapper.DishMapper;
import com.sky.mapper.OrderMapper;
import com.sky.mapper.SetmealMapper;
import com.sky.mapper.UserMapper;
import com.sky.service.WorkspaceService;
import com.sky.vo.BusinessDataVO;
import com.sky.vo.DishOverViewVO;
import com.sky.vo.OrderOverViewVO;
import com.sky.vo.SetmealOverViewVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.HashMap;
import java.util.Map;

@Service
@Slf4j
public class WorkspaceServiceImpl implements WorkspaceService {

    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private UserMapper userMapper;
    @Autowired
    private DishMapper dishMapper;
    @Autowired
    private SetmealMapper setmealMapper;

    /**
     * 根据时间段统计营业数据
     * @param begin
     * @param end
     * @return
     */
    public BusinessDataVO getBusinessData(LocalDateTime begin, LocalDateTime end) {
        /**
         * 营业额:当日已完成订单的总金额
         * 有效订单:当日已完成订单的数量
         * 订单完成率:有效订单数 / 总订单数
         * 平均客单价:营业额 / 有效订单数
         * 新增用户:当日新增用户的数量
         */

        Map map = new HashMap();
        map.put("begin",begin);
        map.put("end",end);

        //查询总订单数
        Integer totalOrderCount = orderMapper.countByMap(map);

        map.put("status", Orders.COMPLETED);
        //营业额
        Double turnover = orderMapper.sumByMap(map);
        turnover = turnover == null? 0.0 : turnover;

        //有效订单数
        Integer validOrderCount = orderMapper.countByMap(map);

        Double unitPrice = 0.0;

        Double orderCompletionRate = 0.0;
        if(totalOrderCount != 0 && validOrderCount != 0){
            //订单完成率
            orderCompletionRate = validOrderCount.doubleValue() / totalOrderCount;
            //平均客单价
            unitPrice = turnover / validOrderCount;
        }

        //新增用户数
        Integer newUsers = userMapper.countByMap(map);

        return BusinessDataVO.builder()
                .turnover(turnover)
                .validOrderCount(validOrderCount)
                .orderCompletionRate(orderCompletionRate)
                .unitPrice(unitPrice)
                .newUsers(newUsers)
                .build();
    }


    /**
     * 查询订单管理数据
     *
     * @return
     */
    public OrderOverViewVO getOrderOverView() {
        Map map = new HashMap();
        map.put("begin", LocalDateTime.now().with(LocalTime.MIN));
        map.put("status", Orders.TO_BE_CONFIRMED);

        //待接单
        Integer waitingOrders = orderMapper.countByMap(map);

        //待派送
        map.put("status", Orders.CONFIRMED);
        Integer deliveredOrders = orderMapper.countByMap(map);

        //已完成
        map.put("status", Orders.COMPLETED);
        Integer completedOrders = orderMapper.countByMap(map);

        //已取消
        map.put("status", Orders.CANCELLED);
        Integer cancelledOrders = orderMapper.countByMap(map);

        //全部订单
        map.put("status", null);
        Integer allOrders = orderMapper.countByMap(map);

        return OrderOverViewVO.builder()
                .waitingOrders(waitingOrders)
                .deliveredOrders(deliveredOrders)
                .completedOrders(completedOrders)
                .cancelledOrders(cancelledOrders)
                .allOrders(allOrders)
                .build();
    }

    /**
     * 查询菜品总览
     *
     * @return
     */
    public DishOverViewVO getDishOverView() {
        Map map = new HashMap();
        map.put("status", StatusConstant.ENABLE);
        Integer sold = dishMapper.countByMap(map);

        map.put("status", StatusConstant.DISABLE);
        Integer discontinued = dishMapper.countByMap(map);

        return DishOverViewVO.builder()
                .sold(sold)
                .discontinued(discontinued)
                .build();
    }

    /**
     * 查询套餐总览
     *
     * @return
     */
    public SetmealOverViewVO getSetmealOverView() {
        Map map = new HashMap();
        map.put("status", StatusConstant.ENABLE);
        Integer sold = setmealMapper.countByMap(map);

        map.put("status", StatusConstant.DISABLE);
        Integer discontinued = setmealMapper.countByMap(map);

        return SetmealOverViewVO.builder()
                .sold(sold)
                .discontinued(discontinued)
                .build();
    }
}
sky-server  com/sky/mapper/DishMapper.java
/**
     * 根据条件统计菜品数量
     * @param map
     * @return
     */
    Integer countByMap(Map map);
sky-server  mapper/DishMapper.xml
<select id="countByMap" resultType="java.lang.Integer">
        select count(id) from dish
        <where>
            <if test="status != null">
                and status = #{status}
            </if>
            <if test="categoryId != null">
                and category_id = #{categoryId}
            </if>
        </where>
    </select>
sky-server  com/sky/mapper/SetmealMapper.java
/**
     * 根据条件统计套餐数量
     * @param map
     * @return
     */
    Integer countByMap(Map map);
sky-server  mapper/SetmealMapper.xml
<select id="countByMap" resultType="java.lang.Integer">
        select count(id) from setmeal
        <where>
            <if test="status != null">
                and status = #{status}
            </if>
            <if test="categoryId != null">
                and category_id = #{categoryId}
            </if>
        </where>
    </select>

Apache POI

在Java中操控Excel文件 [读写操作]

Apache POI 是一个处理Miscrosoft Office各种文件格式的开源项目,POI都是用于操作Excel文件

Apache POI应用场景:
  • 银行网银系统导出交易明细
  • 各种业务系统到出Excel报表
  • 批量导入业务数据
sky-server  com/sky/test/POITest.java
package com.sky.test;

import org.apache.poi.xssf.usermodel.XSSFRow;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;

public class POITest {
    /**
     * 通过POI创建Excel文件并且写入文件内容
     */
    public static void write() throws Exception {
        // 在内存中创建一个Excel文件
        XSSFWorkbook excel = new XSSFWorkbook();
        // 在Excel文件中创建一个sheet页
        XSSFSheet sheet = excel.createSheet("info");
        // 在Sheet中创建行对象, rownum编号从0开始
        XSSFRow row = sheet.createRow(1);
        // 创建单元格并写入文件内容
        row.createCell(1).setCellValue("姓名");
        row.createCell(2).setCellValue("城市");

        // 创建一个新行
        row = sheet.createRow(2);
        row.createCell(1).setCellValue("张三");
        row.createCell(2).setCellValue("北京");

        row = sheet.createRow(3);
        row.createCell(1).setCellValue("李四");
        row.createCell(2).setCellValue("南京");

        // 通过输出流将内存中的Excel文件写入到磁盘
        FileOutputStream out = new FileOutputStream(new File("C:\\Users\\Pluminary\\Desktop\\itcast.xlsx"));
        excel.write(out);

        // 关闭资源
        out.close();
        excel.close();
    }

    public static void main(String[] args) throws Exception {
        write();
    }
}

导出运营数据Excel报表

实现步骤:
  • 设计Excel模板文件
  • 查询近30天的运营数据
  • 将查询到的运营数据写入模板文件
  • 通过输出流将Excel文件下载到客户端浏览器
sky-pojo  com/sky/vo/BusinessDataVO.java
package com.sky.vo;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

/**
 * 数据概览
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BusinessDataVO implements Serializable {

    private Double turnover;//营业额

    private Integer validOrderCount;//有效订单数

    private Double orderCompletionRate;//订单完成率

    private Double unitPrice;//平均客单价

    private Integer newUsers;//新增用户数

}
sky-server  com/sky/controller/admin/ReportController.java
/**
     * 导出运营数据报表
     * @param response
     */
    @GetMapping("/export")
    @ApiOperation("导出运营数据报表")
    public void export(HttpServletResponse response) {
        reportService.exportBusinessData(response);
    }
sky-server  com/sky/service/ReportService.java
 /**
     * 导出运营数据报表
     * @param response
     */
    void exportBusinessData(HttpServletResponse response);
sky-server  com/sky/service/impl/ReportServiceImpl.java
/**
     * 导出运营数据报表
     * @param response
     */
    @Override
    public void exportBusinessData(HttpServletResponse response) {
        // 查询数据库 获取营业数据 -- 查询最近30天的营业数据
        LocalDate dateBegin = LocalDate.now().minusDays(30);
        LocalDate dateEnd = LocalDate.now().minusDays(1);
        // 查询概览数据
        BusinessDataVO businessDataVO = workspaceService.getBusinessData(LocalDateTime.of(dateBegin, LocalTime.MIN),LocalDateTime.of(dateEnd, LocalTime.MAX));

        // 查询的数据通过POI写入Excel文件中 (获得对象 获得类加载器 类加载器读取资源)
        InputStream in = this.getClass().getClassLoader().getResourceAsStream("template/运营数据报表模板.xlsx");
        try {
            // 基于模板文件创建一个新的Excel文件
            XSSFWorkbook excel = new XSSFWorkbook();

            // 填充数据 [获取标签页]

            XSSFSheet sheet = excel.getSheet("Sheet1");
            // 获取第二行[索引是从0开始]
            sheet.getRow(1).createCell(1).setCellValue("时间:" + dateBegin + "至" + dateEnd);

            // 获得第四行
            XSSFRow row = sheet.getRow(3);
            row.getCell(2).setCellValue(businessDataVO.getTurnover());//营业额
            row.getCell(4).setCellValue(businessDataVO.getOrderCompletionRate());//订单完成率
            row.getCell(6).setCellValue(businessDataVO.getNewUsers());//新增用户数

            // 获得第五行
            row = sheet.getRow(4);
            row.getCell(2).setCellValue(businessDataVO.getValidOrderCount());//有效订单数
            row.getCell(4).setCellValue(businessDataVO.getUnitPrice());//平均单品价格

            // 填充明细数据
            for (int i = 0; i < 30; i++) {
                LocalDate date = dateBegin.plusDays(i);
                // 查询某一天的营业数据
                workspaceService.getBusinessData(LocalDateTime.of(date, LocalTime.MIN), LocalDateTime.of(date, LocalTime.MAX));
                // 获得某一行
                row = sheet.getRow(7 + i);// 利用循环 超越循环
                row.getCell(1).setCellValue(date.toString());
                row.getCell(2).setCellValue(businessDataVO.getTurnover());
                row.getCell(3).setCellValue(businessDataVO.getValidOrderCount());
                row.getCell(6).setCellValue(businessDataVO.getOrderCompletionRate());
                row.getCell(4).setCellValue(businessDataVO.getUnitPrice());
                row.getCell(5).setCellValue(businessDataVO.getNewUsers());
            }

            // 通过输出流将Excel文件下载到客户端浏览器
            ServletOutputStream out = response.getOutputStream();
            excel.write(out);

            // 关闭资源
            out.close();
            excel.close();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
阅读全文

Vue

2024/7/3

Vue快速入门

简介

MVVM响应式编程模型,避免直接操作DOM,降低DOM操作的复杂性

安装Vscode 安装nodejs
检测是否安装成功 cmd → node -v → npm -v

npm设置镜像[cmd]
C:\Users\Pluminary>npm config set registry https://registry.npmmirror.com
C:\Users\Pluminary>npm config ls
C:\Users\Pluminary>npm config get registry
在Vscode里的终端输入 
PS C:\Users\Pluminary\Desktop\vue2> npm init -y
PS C:\Users\Pluminary\Desktop\vue2> npm install vue

Vue Chrome调试工具 Vue.js devtools 5.3.3 安装包及教程_vuedevtool.crx百度网盘-CSDN博客

简单案例

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
<!--<div>{{name}}</div> 这个就没有被挂载-->
    <div id="app">
        <input type="text" v-model="num"> <!--在对话框里输入的时候改变下面的次数-->
        <button v-on:click="num++">登录</button> <!--实现当点击按钮的时候num数值++-->
        <button v-on:click="loginout()">登出</button>
        <h1>{{name}}, 欢迎您,当前登录第{{num}}次,当前登录时间:{{nowDate()}}</h1>
    </div>
    <script src="./node_modules/vue/dist/vue.js"></script>

    <script>
        // 1. new vue实例 每个Vue应用都是通过Vue函数创建一个新的Vue实例开始的
        new Vue({
            el:"#app", // 挂载的模板
            data(){ // 绑定的数据 把data对象中的所有属性 加到相应视图里
                return{
                    name : "图灵学院",
                    num:1
                }
            },
            methods: {
                nowDate(){
                    return new Date().toLocaleDateString()
                },
                loginout(){
                    this.num--; //当调用实例时要加this
                }
            },
        });
        // 双向绑定:数据发生改变 视图也要随之改变;在谷歌浏览器的Vue调试中
        // 指令:简化对Dom的频繁操作
        // 方法:声明方法可以实现更复杂的操作,声明methods属性中
    </script>
</body>
</html>

指令 v-text、v-html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id="app">
        <div v-text="message"></div>{{message}}
        <!-- {{}}在网络延迟情况下会出现暂时显示的情况 -->
        <div v-html="message"></div>
    </div>
</body>
<script>
    // 1.实例Vue
    var vm = new Vue({
        el:'#app',
        data:{
            message: "<a href = '#'>Hello</a>"
        },
    })
// v-text 用于绑定数据,语法v-text="属性",会直接将值作为文本显示
// v-html 会将值进行编译再显示
</script>
</html>

指令 v-bind

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        .red{
            background-color: red;
        } 
        .yellow{
            background-color: yellow;
        }
    </style>
</head>
<body>
    <div id="app">    <!--:style="font-size: {{bigFont}}; 错误写法-->
        <div v-bind:title="title" :class="red" :style="{fontSize:bigFont}">
            鼠标悬停查看信息!
        </div>
        <div :class="{yellow:isyellow}">
            <a :href="href" :style="{fontSize:bigFont}">图灵学院</a>
        </div>
        <div>
            <img :src="src"/>
        </div>
        <div>
            <button v-bind:disabled="disabled">禁用按钮</button>
        </div>
    </div>
</body>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
<script type="text/javascript">
    var vm = new Vue({
        el: "#app",
        data:{
            red:"red",
            isyellow:true,
            bigFont:"50px",
            title: "您好,本网站可以学到更多的知识",
            href: "https://www.pronhub.com/",
            scr: '../Imooc_Cat.jpg',
            disabled: true
        }
    })

    // 绑定元素属性 -- v-bind
    // 语法:v-bind:元素属性="vue的属性"
    // 简写:v-bind:title 简写成 :title
    /*
       针对样式的特殊用法:
         动态控制class是否添加 :class="{red:isred}"
           语法 {red:isred} : {需要动态控制的class样式:vue的属性(需要是boolean类型)}

         动态设置style的样式   :style="{fontSize:bigFont}"
           语法 {fontSize: bigFont} : {css样式的驼峰命名方法:} font-size=fontSize
    */
</script>
</html>

指令 v-model

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id="app">
        <div>
            <label>年龄:</label>
            <input v-model="age"/>
        </div>
        <div>当前输入的年龄是:{{age}}</div>
        <button @click="add">加一岁</button> <!--  @click = v-on:click  -->
        <button @click="alertYear">弹出年龄</button>

    <hr>
        <label>课程:</label>
        <select v-model="course">
            <option value="javascript">javascript</option>
            <option value="java">java</option>
            <option value="c++">c++</option>
        </select>

        <div>
            <input v-model="course" type="radio" name="course" value="javascript">javascript
            <input v-model="course" type="radio" name="course" value="java">java
            <input v-model="course" type="radio" name="course" value="c++">c++
        </div>

        <div>当前课程是:{{course}}</div>
    </div>
    
</body>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
<script type="text/javascript">
var vm = new Vue({
    el:"#app",
    data:{
        age:10,
        course:'java'
    },
    methods: {
        add(){
            this.age++;
        },
        alertYear(){
            alert(this.age)
        }
    }
})

//v-model 用于实现双向绑定 一般用在表单元素
</script>
</html>

指令 v-on

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id="app">
        <button v-on:click="hello('hello')">hello</button>
        <button @click="hello('world')">world</button>
        <button @click="num++">数据:</button>{{num}}
<!-- 不让事件传播 禁止默认a标签事件 
 @click.stop 不让事件传播
 @click.self 需要自己点击[不会参与传播]
 @click.prevent 组织默认事件
 @click.once 事件只会触发一次
 -->
        <div @click="hello(1)" style="background-color: red; width: 800px; height: 500px;padding: 50px;">
            1
            <div @click.stop="hello(2)" style="background-color: blue; width: 400px; height: 200px;padding: 50px;">
                2
                <a @click.stop.prevent="hello(3)" style="background-color: yellow; width: 200px; height: 100px; display: block;padding: 50px;" href="www.baidu.com">3</a>
            </div>
        </div>

        <input v-on:keyup.enter="hello('enter')"> <!--键盘按回车触发--><br>
        <input v-on:keyup.space="hello('enter')">
    </div>
</body>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
<script type="text/javascript">
    var vm = new Vue({
        el: "#app",
        data:{
            num: 0
        },
        methods: {
            hello(str){
                alert(str)
            }
        }
    })

/*
 v-on用于绑定事件
 语法  v-on:事件名
 简写方式 v-on:click => @click
 只能调用vue中的函数和数据
*/
</script>
</html>

指令 v-for

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id="app">
        循环数组
        <ul>
            <li v-for="(item, index) in music">{{item.name}}---{{index+1}}</li>
        </ul>
        循环对象
        <ul>
            <li v-for="(item, index) in obj">{{item}}</li>
        </ul>
    </div>
</body>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
<script type="text/javascript">
var vm = new Vue({
    el: '#app',
    data:{
        // 要循环的数组
        music:[
            {name: '青花瓷'},
            {name: '阳光总在风雨后'},
            {name: '十年'}
        ],
        // 要循环的对象
        obj:{
            name: '句号',
            age: 18,
            sex: '男'
        }
    }
})
/*
v-for循环,可以循环数组和对象
v-for="(item, index) in intems
语法:v-for="(每次循环接收的变量, 当前循环的索引) in 需要循环的变量"
*/
</script>
</html>

指令 v-if 与 v-show

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id="app">
        <button @click="vif=!vif">切换显示隐藏</button>
        <div v-if="vif" style="background-color: red; width: 200px; height: 200px;">
            v-if
        </div>
        <hr>
        <button @click="vshow=!vshow">切换显示隐藏</button>
        <div v-show="vshow" style="background-color: yellow; width: 200px; height: 200px;">
            v-show
        </div>
    </div>
</body>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
<script type="text/javascript">
var vm = new Vue({
    el: "#app",
    data:{
        vif: true,
        vshow: true
    }
})
/*
    v-if 和 v-show 用于控制元素显示隐藏
    语法 v-if = "指定Boolean值" v-show一样
    
    v-if 控制元素是否生成
    v-show 控制元素是否显示隐藏
*/
</script>
</html>

指令 v-else 与 v-else-if

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id="app">
        <div v-if="number == 1">
            A
        </div>
        <div v-else-if="number == 2">
            B
        </div>
        <div v-else>
            C
        </div>
    </div>
</body>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
<script type="text/javascript">
var vm = new Vue({
    el: '#app',
    data() {
        return {
            number: 1
        }
    },
})
</script>
</html>

计算属性和侦听器

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>计算属性和侦听器</title>
</head>
<body>
    <div id="app">
        <!-- 实现一个购物车功能 
            1. 数据初始化处理
            2. 选择商品数量:当商品数量超过库存做提示
            3. 计算商品总价
        -->
        <ul>
            <li v-for="(item, index) in car">
                {{item.pname}} --- 商品价格:{{item.price}} --- 库存:{{item.stock}}
                数量:<input type="number" v-model="item.num" style="width: 30px;"/>
            </li>
            <li><strong>总价:</strong>{{calcSum}}</li>
            <!-- <li v-html="warn()"></li> -->
            <li v-html="message"></li>
        </ul>
    </div>
</body>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
<script type="text/javascript">
 new Vue({
    el:"#app",
    data:{
        car:[
            {pname:'IPhone 12',price:10000,stock:10,num:1},
            {pname:'Mate40 pro',price:7000,stock:3,num:1},
        ],
        message:"",
    },
    // methods:当方法中的数据发生改变,方法会自动调用 所以当数量增加时会自动计算总价
    methods: {
        // calcSum(){
        //     let sum=0;
        //     this.car.forEach(item => {
        //         sum+=item.price*item.num;
        //     });
        //     return sum;
        // }

        // warn(){
        //     let message="";
        //     this.car.forEach(item => {
        //         if(item.num>item.stock){
        //             message+=`${item.pname}的库存超出限制<br/>`
        //         }
        //     });
        //     return message;
        // }
    },
    // 计算属性 上面就不加大括号  <li><strong>总价:</strong>{{calcSum}}</li>
    computed:{
        calcSum(){
            let sum=0;
            this.car.forEach(item => {
                sum+=item.price*item.num;
            });
            return sum;
        }
    },
    // 侦听器 专门用于侦听某些数据的变化,当数据发生变化会自动调节方法 不能像方法那样调用
    watch:{
        // 要侦听的数据 
        car:{
            handler(newvalue,oldvalue){
                this.message="";
                this.car.forEach(item => {
                    if(item.num>item.stock){
                        this.message+=`${item.pname}的库存超出限制<br/>`
                    }
                });
            },
            deep:true
        },
        //侦听message 基础类型的侦听 将侦听数据作为函数就可以了
        message(newvalue,oldvalue){
            console.info(newvalue,oldvalue)
        }
    }
 });
 /*
    方法和计算属性的区别:
    声明方式不一样,调用不一样方法要用"()", 计算属性调用不要加"()"
 */
/*
    方法和侦听器的区别:
        方法:方法中任意数据发生改变了就会自动调用方法 
            可以调用,进行返回值

        侦听器:需要指定具体的侦听数据,只有被具体指定的侦听数据发生了改变才会触发
            不能像方法那样去调用,而是靠vue自动触发 如果初始情况就数量就大于库存是不改变的
*/
</script>
</html>

过滤器

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>过滤器</title>
</head>
<body>
    

    <div id="app">
        <ul>
            <li v-for="user in userList">
                姓名:{{user.name}}; 性别:{{user.gender==1?"男":"女"}}
                {{user.gender | filterGender}}
            </li>
        </ul>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
    <script type="text/javascript">
    let vm = new Vue({
        el: "#app",
        data:{
            userList:[
                {id:1, name:'xushu', gender:1},
                {id:2, name:'zhuge', gender:0}
            ]
        },
        //针对数据过滤的
        methods:{
            formateGender(gender){
                if(gender==1){
                    return "~男"
                }else{
                    return "~女"
                }
            }
        },
        filters:{
            filterGender(gender){
                if(gender==1){
                    return "!男"
                }else{
                    return "!女"
                }
            }
        }
    })
</script>
</body>
</html>

Vue-组件化

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>组件化</title>
</head>
<body>
    <div id="app">
        <!-- <div @click='num++'>{{num}}</div> -->
        <!-- <xushu></xushu> -->
        <xushu :num="numxx"></xushu> <!--传递属性-->  <!--输出50-->
        <app-xushu></app-xushu> <!--输出66-->
    </div>

    <div id="app2">
        <!-- <div @click='num++'>{{num}}</div> -->
        <!-- <xushu></xushu> -->
        <xushu :num="numxx"></xushu> <!--传递属性--> <!--输出100-->
    </div>

    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
    <script>
        // 1.全局组件 在所有的Vue实例中使用
        /*
            1.1 命名:不要使用驼峰命名法 可以用中划线命名
            1.2 模板:必须的
            1.3 数据:一定要函数的方式声明
        */
        Vue.component("xushu", {
            template: `<div @click='num++'>{{num}}</div>`,
            //1.在自己的字方法中声明  
            // data() {
            //     return {
            //         num:10
            //     }
            // },

            //2.在下方父方法中声明并传递过来
            props: ["num"]
        });

        // 2.局部组件 //自己定义下面引用
        const appXushu = {
            template: `<div @click='num++'>{{num}}</div>`,
            data() {
                return {
                    num: 66
                }
            }
        };

        new Vue({
            el: "#app",
            data: {
                numxx: 50
            },
            components: {
                "app-xushu": appXushu
            }
        });

        new Vue({
            el: "#app2",
            data: {
                numxx: 100
            }
        });
    </script>
</body>
</html>

生命周期和钩子函数

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id="app">
        <span id="name">{{name}}</span>
        <button @click="updateName">更新</button>
        <button @click="destroyInstance">销毁</button>
    </div>
</body>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
<script type="text/javascript">
    var vm = new Vue({
        el: '#app',
        data:{
            name: 'hello !'
        },
        methods: {
            updateName(){
                console.log('准备改名字啦!')
                this.name = 'hello 图灵!'
            },
            destroyInstance(){
                console.log('销毁实例')
                vm.$destroy()
            }
        }
    })
</script>
</html>

vue-router

npm install vue-router@3
npm install vue-router@3 --legacy-peer-deps

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<!-- 1.安装vue-router路由
     2.引入vue-router文件
     3.根据不同url连接到不同的页面,需要使用模板实现,将模板绑定对应的路由地址
-->
<body>
    <div id="app">
        <nav>
            <router-link to="/">Home</router-link>
            <router-link to="/about">About</router-link>
        </nav>
        <router-view></router-view>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/vue-router@3.5.1/dist/vue-router.js"></script>
    <script>
                // 定义组件:原本应该是在外面定义HomeComponent.vue [创建一些组件]
        const HomeComponent = {
            template: `<div><h1>Home</h1><p>Welcome to the Home page!</p></div>`
        };
              // 定义组件:原本应该是在外面定义AboutComponent.vue [创建一些组件]
        const AboutComponent = {
            template: `<div><h1>About</h1><p>Learn more about us on this page.</p></div>`
        };

        // 定义路由:原本应该是外面定义router.js文件来定义路由。
        const routes = [
            { path: '/', component: HomeComponent },
            { path: '/about', component: AboutComponent }
        ];

        // 创建路由实例
        const router = new VueRouter({
            routes
        });

        // 创建并挂载根实例
        const app = new Vue({
            router// 挂载路由
        }).$mount('#app');
    </script>
</body>
</html>

axios

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Vue with Axios Example</title>
</head>
<body>
    <div id="app">
        <h1>Data from API</h1>
        <ul>
            <li v-for="post in posts" :key="post.id">{{ post.title }}</li>
        <!-- 使用 Vue 的 v-for 指令遍历 posts 数组,并生成列表项,每个列表项显示 post.title。 -->
        </ul>
    </div>

    <!-- 引入 Vue.js -->
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
    <!-- 引入 Axios -->
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <script>
        // 创建 Vue 实例
        let vm = new Vue({
            el: "#app", // Vue 实例绑定到 HTML 中的 id="app" 的元素
            data: {
                posts: [] // 初始化一个空数组用于存储从 API 获取的数据
            },
            created() {
                // Vue 实例创建时调用的生命周期钩子
                axios.get('https://jsonplaceholder.typicode.com/posts') // 使用 Axios 发送 GET 请求
                    .then(response => { // 请求成功后的处理
                        this.posts = response.data; // 将响应数据赋值给 posts 数组
                    })
                    .catch(error => { // 请求失败后的处理
                        console.error('Error fetching data:', error); // 输出错误信息到控制台
                    });
            }
        });
    </script>
</body>
</html>

安装脚手架

CLI文档,开箱即用!
cmd全局安装:
npm install -g @vue/cli         或者
npm install -g @vue/cli-init

去想要的文件夹里面:C:\Users\Pluminary\Desktop\vue_cli>vue init webpack tuling
vue init webpack 项目名

? Project name tuling
? Project description A Vue.js project
? Author pcy
? Vue build standalone
? Install vue-router? Yes
? Use ESLint to lint your code? No //严格检验Javascript语法的
? Set up unit tests No
? Setup e2e tests with Nightwatch? No
? Should we run `npm install` for you after the project has been created? (recommended) npm

完成后需要开启:
cd tuling
npm run dev

DONE  Compiled successfully in 1780ms 18:41:21
I  Your application is running here: http://localhost:8080

运用element-ui创建一个简易的登录界面

<template>
  <div class="hello-world">
    <h1>{{ message }}</h1>
    <el-button type="primary" @click="showAlert">Click Me</el-button>
  </div>

  <div class="app">
    <h3>{{passage}}</h3>
    <h4>{{getVal()}}</h4>
  </div>

   <!-- 使用Element UI的Table组件展示empList -->
  <el-table :data="empList" style="width: 100%">
    <el-table-column prop="name" label="Name" width="180"></el-table-column>
    <el-table-column prop="salary" label="Salary" width="180"></el-table-column>
  </el-table>

   <!-- 登录表单 -->
  <el-form ref="loginForm" :model="loginForm" class="loginForm" label-width="80px">
    <h2 class="loginTitle">人事后台管理系统</h2>
    <el-form-item label="用户名" prop="username">
      <el-input v-model="loginForm.username" placeholder="请输入用户名"></el-input>
    </el-form-item>
    <el-form-item label="密码" prop="password">
      <el-input v-model="loginForm.password" type="password" placeholder="请输入密码"></el-input>
    </el-form-item>
    <el-form-item label="验证码" prop="code">
      <el-input v-model="loginForm.code" placeholder="请输入验证码"></el-input>
    </el-form-item>
    <el-form-item style="width:100%;">
      <el-button type="primary" @click="handleLogin">登录</el-button>
    </el-form-item>
  </el-form>
   

</template>

<script>
  export default {
    name: 'app',
    data(){
      return{
        passage: 'Oh?',
        message: 'Hello World!',
        empList:[
          { name: 'Peter', salary: '20000' },
          { name: 'Mike', salary: '16000' },
          { name: 'Tom', salary: '17000' }
        ],
        loginForm:{
          username:'',
          password:'',
          code:''
        }
      }
    },
    methods: {
      showAlert() {
        this.$message({
          message: 'Hello from Element UI!',
          type: 'success'
        });
      },
      getVal:function(){
        return "getVal的方法";
      },
      handleLogin() {
      this.$message({
        message: `登录成功,用户名: ${this.loginForm.username}`,
        type: 'success'
      });
     }
    }
  }

</script>

<style scoped>
    .hello-world {
      text-align: center;
      margin-top: 20px;
    }

    h1 {
      font-size: 24px;
      margin-bottom: 20px;
    }
    .app {
      margin-top: 30px;
    }
</style>
在Vue项目中,Element UI需要正确引入CSS样式和JavaScript文件才能正常工作。如果你没有一个HTML模板文件(例如index.html),而是在Vue组件中直接编写代码,你需要通过一些方式来确保Element UI的样式和脚本被正确加载。

// 1. 创建index.html文件:
这个index.html文件通常放在public文件夹下,是你Vue应用的入口文件。Vue CLI自动生成的项目通常会有这个文件。

// 2. 为什么需要这个HTML文件?
这个index.html文件是Vue CLI或手动设置的Vue项目的入口页面。当你运行npm run serve时,Vue CLI会将所有内容注入到<div id="app"></div>中,这个HTML文件是项目的基本框架。

Element UI的CSS和JS资源:需要在HTML文件的<head>中引入Element UI的样式文件(CSS)和在<body>中引入JS库,这样才能确保Element UI组件在你的Vue组件中正确渲染和运行。
    
// 3. 直接在Vue组件中引入:
如果你不想使用外部HTML文件,Vue项目中可以直接在main.js中引入Element UI的样式和组件:
/*
    // main.js
    import Vue from 'vue';
    import App from './App.vue';
    import ElementUI from 'element-ui';
    import 'element-ui/lib/theme-chalk/index.css';

    Vue.config.productionTip = false;

    Vue.use(ElementUI);

    new Vue({
      render: h => h(App),
    }).$mount('#app');
*/
阅读全文
头像
Asuna
You are the one who can always get to me even with screen between us.