md_files/自学/苍穹外卖.md

2255 lines
69 KiB
Markdown
Raw Normal View History

2025-03-18 12:46:59 +08:00
# 苍穹外卖
## 踩坑总结
## 项目简介
2025-03-18 12:46:59 +08:00
### 整体介绍
2025-03-18 12:46:59 +08:00
本项目(苍穹外卖)是专门为餐饮企业(餐厅、饭店)定制的一款软件产品,包括 **系统管理后台****小程序端应用** 两部分。其中系统管理后台主要提供给餐饮企业内部员工使用,可以对餐厅的分类、菜品、套餐、订单、员工等进行管理维护,对餐厅的各类数据进行统计,同时也可进行来单语音播报功能。小程序端主要提供给消费者使用,可以在线浏览菜品、添加购物车、下单、支付、催单等。
![image-20221106194424735](https://pic.bitday.top/i/2025/04/10/qipsdw-0.png)
**1). 管理端功能**
员工登录/退出 , 员工信息管理 , 分类管理 , 菜品管理 , 套餐管理 , 菜品口味管理 , 订单管理 ,数据统计,来单提醒。
**2). 用户端功能**
微信登录 , 收件人地址管理 , 用户历史订单查询 , 菜品规格查询 , 购物车功能 , 下单 , 支付、分类及菜品浏览。
### 技术选型
![image-20221106185646994](https://pic.bitday.top/i/2025/04/10/qk8jfn-0.png)
**1). 用户层**
本项目中在构建系统管理后台的前端页面我们会用到H5、Vue.js、ElementUI、apache echarts(展示图表)等技术。而在构建移动端应用时,我们会使用到微信小程序。
**2). 网关层**
Nginx是一个服务器主要用来作为Http服务器部署静态资源访问性能高。在Nginx中还有两个比较重要的作用 反向代理和负载均衡, 在进行项目部署时要实现Tomcat的负载均衡就可以通过Nginx来实现。
**3). 应用层**
SpringBoot 快速构建Spring项目, 采用 "约定优于配置" 的思想, 简化Spring项目的配置开发。
SpringMVCSpringMVC是spring框架的一个模块springmvc和spring无需通过中间整合层进行整合可以无缝集成。
Spring Task: 由Spring提供的定时任务框架。
httpclient: 主要实现了对http请求的发送。
Spring Cache: 由Spring提供的数据缓存框架
JWT: 用于对应用程序上的用户进行身份验证的标记。
阿里云OSS: 对象存储服务,在项目中主要存储文件,如图片等。
Swagger 可以自动的帮助开发人员生成接口文档,并对接口进行测试。
POI: 封装了对Excel表格的常用操作。
WebSocket: 一种通信网络协议,使客户端和服务器之间的数据交换更加简单,用于项目的来单、催单功能实现。
**4). 数据层**
MySQL 关系型数据库, 本项目的核心业务数据都会采用MySQL进行存储。
Redis 基于key-value格式存储的内存数据库, 访问速度快, 经常使用它做缓存。
Mybatis 本项目持久层将会使用Mybatis开发。
pagehelper: 分页插件。
spring data redis: 简化java代码操作Redis的API。
**5). 工具**
git: 版本控制工具, 在团队协作中, 使用该工具对项目中的代码进行管理。
maven: 项目构建工具。
junit单元测试工具开发人员功能实现完毕后需要通过junit对功能进行单元测试。
postman: 接口测工具模拟用户发起的各类HTTP请求获取对应的响应结果。
### 准备工作
//待完善最后写一套本地java开发、nginx部署前端服务器docker部署的方案
#### 前端环境搭建
**Windows下**
1.构建和打包前端项目
```bash
npm run build
```
2.将构建文件复制到指定目录
Nginx 默认的静态文件根目录通常是 **`/usr/share/nginx/html`**,你可以选择将打包好的静态文件拷贝到该目录
或者使用自定义目录`/var/www/my-frontend`,并修改 Nginx 配置文件来指向这个目录。
3.配置 Nginx
打开 Nginx 的配置文件,通常位于 `/etc/nginx/nginx.conf`
以下是一个使用自定义目录 `/var/www/my-frontend` 作为站点根目录的示例配置:
```nginx
server {
listen 80;
server_name your-domain.com; # 如果没有域名可以使用 _ 或 localhost
root /var/www/my-frontend;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
```
4.启动或重启 Nginx
启动双击nginx.exe
重启nginx -s reload
5.查看是否正在运行
```bash
tasklist /FI "IMAGENAME eq nginx.exe"
```
6.访问前端项目
在浏览器中输入你配置的域名或服务器 IP 地址
终止运行nginx
```bash
nginx.exe -s stop
```
#### 后端环境搭建
![image-20250410161050451](https://pic.bitday.top/i/2025/04/10/qmunln-0.png)
工程的每个模块作用说明:
| **序号** | **名称** | **说明** |
| -------- | ------------ | ------------------------------------------------------------ |
| 1 | sky-take-out | maven父工程统一管理依赖版本聚合其他子模块 |
| 2 | sky-common | 子模块,存放公共类,例如:工具类、常量类、异常类等 |
| 3 | sky-pojo | 子模块存放实体类、VO、DTO等 |
| 4 | sky-server | 子模块后端服务存放配置文件、Controller、Service、Mapper等 |
分析sky-common模块的每个包的作用
| 名称 | 说明 |
| ----------- | ------------------------------ |
| constant | 存放相关常量类 |
| context | 存放上下文类 |
| enumeration | 项目的枚举类存储 |
| exception | 存放自定义异常类 |
| json | 处理json转换的类 |
| properties | 存放SpringBoot相关的配置属性类 |
| result | 返回结果类的封装 |
| utils | 常用工具类 |
2025-03-18 12:46:59 +08:00
分析sky-pojo模块的每个包的作用
| **名称** | **说明** |
| -------- | ------------------------------------------------------------ |
| Entity | 实体,通常和数据库中的表对应 |
| DTO | 数据传输对象通常用于程序中各层之间传递数据接收从web来的数据 |
| VO | 视图对象为前端展示数据提供的对象响应给web |
| POJO | 普通Java对象只有属性和对应的getter和setter |
分析sky-server模块的每个包的作用
| 名称 | 说明 |
| -------------- | ---------------- |
| config | 存放配置类 |
| controller | 存放controller类 |
| interceptor | 存放拦截器类 |
| mapper | 存放mapper接口 |
| service | 存放service类 |
| SkyApplication | 启动类 |
#### 数据库初始化
执行sky.sql文件
2025-03-18 12:46:59 +08:00
| 序号 | 数据表名 | 中文名称 |
| ---- | ------------- | -------------- |
| 1 | employee | 员工表 |
| 2 | category | 分类表 |
| 3 | dish | 菜品表 |
| 4 | dish_flavor | 菜品口味表 |
| 5 | setmeal | 套餐表 |
| 6 | setmeal_dish | 套餐菜品关系表 |
| 7 | user | 用户表 |
| 8 | address_book | 地址表 |
| 9 | shopping_cart | 购物车表 |
| 10 | orders | 订单表 |
| 11 | order_detail | 订单明细表 |
#### Nginx
**1.静态资源托管**
直接高效地托管前端静态文件HTML/CSS/JS/图片等)。
```nginx
server {
root /var/www/html;
index index.html;
location / {
try_files $uri $uri/ /index.html; # 支持前端路由(如 React/Vue
}
}
```
- **`try_files`**:按顺序尝试多个文件或路径,直到找到第一个可用的为止。
- `$uri`:尝试直接访问请求的路径对应的文件(例如 `/css/style.css`)。
- `$uri/`:尝试将路径视为目录(例如 `/blog/` 会查找 `/blog/index.html`)。
- `/index.html`:如果前两者均未找到,最终返回前端入口文件 `index.html`
2025-03-18 12:46:59 +08:00
**2.nginx 反向代理:**
2025-03-18 12:46:59 +08:00
**反向代理的好处:**
2025-03-18 12:46:59 +08:00
- 提高访问速度
2025-03-18 12:46:59 +08:00
因为nginx本身可以进行缓存如果访问的同一接口并且做了数据缓存nginx就直接可把数据返回不需要真正地访问服务端从而提高访问速度。
2025-03-18 12:46:59 +08:00
- 保证后端服务安全
因为一般后台服务地址不会暴露所以使用浏览器不能直接访问可以把nginx作为请求访问的入口请求到达nginx后转发到具体的服务中从而保证后端服务的安全。
- 统一入口**解决跨域**问题(无需后端配置 CORS
2025-03-18 12:46:59 +08:00
**nginx 反向代理的配置方式:**
```nginx
2025-03-18 12:46:59 +08:00
server{
listen 80;
server_name localhost;
location /api/{
proxy_pass http://localhost:8080/admin/; #反向代理
}
}
```
监听80端口号 然后当我们访问 http://localhost:80/api/../..这样的接口的时候,它会通过 location /api/ {} 这样的反向代理到 http://localhost:8080/admin/上来。
2025-03-18 12:46:59 +08:00
**3.负载均衡配置**(默认是轮询)
将流量分发到多个后端服务器,提升系统吞吐量和容错能力。
```nginx
2025-03-18 12:46:59 +08:00
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;#负载均衡
}
}
```
**完整流程示例**
1. **用户访问**:浏览器打开 `http://yourdomain.com`
2. **Nginx 返回静态文件**:返回 `index.html` 和前端资源。
3. **前端发起 API 请求**:前端代码调用 `/api/data`
4. **Nginx 代理请求**:将 `/api/data` 转发到 `http://backend_server:3000/api/data`
5. **后端响应**处理请求并返回数据Nginx 将结果传回前端。
#### APIFox
使用APIFox管理、测试接口、导出接口文档...
**优势:**
1.多格式支持
APIFox 能够导入包括 YApi 格式在内的多种接口文档,同时支持导出为 OpenAPI、Postman Collection、Markdown 和 HTML 等格式,使得接口文档在不同工具间无缝迁移和使用。
2.接口调试与 Mock
内置接口调试工具可以直接发送请求、查看返回结果,同时内置 Mock 服务功能,方便前后端联调和接口数据模拟,提升开发效率。
3.易用性与团队协作
界面直观、操作便捷,支持多人协作,通过分支管理和版本控制,团队成员可以并行开发并进行变更管理,确保接口维护有序。
**迭代分支功能:**
新建迭代分支,新增的待测试的接口在这里充分测试,没问题之后合并回主分支。
<img src="https://pic.bitday.top/i/2025/04/10/tvvptv-0.png" alt="image-20250410180705866" style="zoom:67%;" />
**导出接口文档:**
推荐导出数据格式为OpenAPI Spec它是一种通用的 API 描述标准Postman和APIFox都支持。
<img src="https://pic.bitday.top/i/2025/04/10/u48k7q-0.png" alt="image-20250410182110385" style="zoom:67%;" />
#### Swagger
2025-03-18 12:46:59 +08:00
1. 使得前后端分离开发更加方便,有利于团队协作
2. 接口的文档在线自动生成,降低后端开发人员编写接口文档的负担
3. 功能测试
**使用:**
**1.导入 knife4j 的maven坐标**
2025-03-18 12:46:59 +08:00
在pom.xml中添加依赖
```java
2025-03-18 12:46:59 +08:00
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
</dependency>
```
**2.在配置类中加入 knife4j 相关配置**
2025-03-18 12:46:59 +08:00
WebMvcConfiguration.java
```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;
}
```
**3.设置静态资源映射,否则接口文档页面无法访问**
2025-03-18 12:46:59 +08:00
WebMvcConfiguration.java
```java
/**
* 设置静态资源映射
* @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/");
}
```
**4.访问测试**
接口文档访问路径为 http://ip:port/doc.html ---> http://localhost:8080/doc.html
这是根据后端 Java 代码(通常是注解)自动生成接口文档,访问是通过**后端服务的端口**,这些文档最终会以静态文件的形式存在于 jar 包内,通常存放在 `META-INF/resources/`
2025-03-18 12:46:59 +08:00
**常用注解**
通过注解可以控制生成的接口文档,使接口文档拥有更好的可读性,常用注解如下:
| **注解** | **说明** |
| ----------------- | ---------------------------------------------------------- |
| @Api | 用在类上例如Controller表示对类的说明 |
| @ApiModel | 用在类上例如entity、DTO、VO |
| @ApiModelProperty | 用在属性上,描述属性信息 |
| @ApiOperation | 用在**方法**上例如Controller的方法说明方法的用途、作用 |
EmployeeLoginDTO.java
```java
2025-03-18 12:46:59 +08:00
@Data
@ApiModel(description = "员工登录时传递的数据模型")
public class EmployeeLoginDTO implements Serializable {
@ApiModelProperty("用户名")
private String username;
@ApiModelProperty("密码")
private String password;
}
```
2025-03-19 18:31:37 +08:00
![image-20240327170247852](https://pic.bitday.top/i/2025/03/19/u7ybj9-2.png)
2025-03-18 12:46:59 +08:00
EmployeeController.java
```java
@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) {
//..............
}
}
```
## 后端部署
### 项目开发完毕
这种情况下JAVA代码无需改动直接本地打包maven->package成Jar包复制到服务器上部署
本项目为multi-module (聚合) Maven 工程父工程为sky-take-out子模块有common,pojo,server其中server依赖common和pojo
```xml
<dependency>
<groupId>com.sky</groupId>
<artifactId>sky-common</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.sky</groupId>
<artifactId>sky-pojo</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
```
**打包方式:**
1.直接对父工程执行mvn clean install
2.分别对子模块common和pojo执行install再对server执行package
因为Maven 在构建 `sky-server` 时,去你本地仓库或远程仓库寻找它依赖的两个 SNAPSHOT 包。
在父工程的pom中添加这段能将你的应用和所有依赖都打到一个可执行的 “fat jar” 里
```xml
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
```
**JAVA项目dockerfile**
```dockerfile
# 使用 JDK 17 运行时镜像
FROM openjdk:17-jdk-slim
# 设置时区为上海
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# 创建工作目录
WORKDIR /app
# 复制 Fat Jar重命名为 app.jar
COPY sky-server-1.0-SNAPSHOT.jar ./app.jar
# 暴露端口(与 application.properties 中的 server.port 保持一致)
EXPOSE 8085
# 以 exec 形式启动
ENTRYPOINT ["java", "-jar", "app.jar"]
```
`ENTRYPOINT ["java", "-jar", "app.jar"]` 能够启动是因为Spring Boot 的 Maven 插件在打包时已经将你的启动类标记进去了:
Spring Boot 的启动器会:
1. 读取 `MANIFEST.MF` 里的 `Start-Class: com.sky.SkyApplication`
2.`com.sky.SkyApplication` 作为入口调用其 `main` 方法
由于该项目还需依赖Mysql和Redis运行因此需在**docker-compose.yml**中统一创建容器环境。(:ro代表只读
```yml
version: "3.8"
services:
mysql:
image: mysql:8.0
container_name: sky-mysql
restart: always
environment:
MYSQL_ROOT_PASSWORD: 123456
MYSQL_DATABASE: sky_take_out
TZ: Asia/Shanghai
volumes:
- ./data/mysql:/var/lib/mysql
- ./init/sky.sql:/docker-entrypoint-initdb.d/sky.sql:ro
- ./mysql-conf/my.cnf:/etc/mysql/conf.d/my.cnf:ro
ports:
- "3306:3306"
networks:
- sky-net
redis:
image: redis:7.0-alpine
container_name: sky-redis
restart: always
command: redis-server --requirepass 123456
volumes:
- ./data/redis:/data
ports:
- "6379:6379"
networks:
- sky-net
app:
build:
context: .
dockerfile: Dockerfile
image: sky-server:latest
container_name: sky-server
depends_on:
- mysql
- redis
volumes:
- ./config:/app/config:ro
environment:
TZ: Asia/Shanghai
SPRING_PROFILES_ACTIVE: dev
ports:
- "8085:8085"
restart: always
networks:
- sky-net
volumes:
mysql:
redis:
networks:
sky-net:
external: true
```
**其中启动数据库要准备两份文件:**
初始化脚本sky.sql用来创建数据库和表
my.cnf让初始化脚本创建的表中的中文数据正常显示
```cnf
[client]
default-character-set = utf8mb4
[mysql]
default-character-set = utf8mb4
[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
init_connect='SET NAMES utf8mb4'
```
**另外:**
`application-dev.yml` 是给 **Spring Boot** 读取的Spring Boot 会在启动时自动加载它填充到JAVA项目中。
Docker Compose 里的 `environment:` 无法读取`application-dev.yml`,要不就写死、要不就写在.env文件中。
**最后项目结构:**
![image-20250515092129486](https://pic.bitday.top/i/2025/05/15/f8ix5c-0.png)
### 滚动开发阶段
1.仅需改动Dokcerfiledocker-compose无需更改:
```dockerfile
# —— 第一阶段Maven 构建 ——
FROM maven:3.8.7-eclipse-temurin-17-alpine AS builder
WORKDIR /workspace
# 把项目级 settings.xml 复制到容器里
COPY .mvn/settings.xml /root/.m2/settings.xml
# 1) 先把父 POM 和所有子模块的目录结构都复制过来
COPY whut-take-out-backend/pom.xml ./pom.xml
COPY whut-take-out-backend/sky-common ./sky-common
COPY whut-take-out-backend/sky-pojo ./sky-pojo
COPY whut-take-out-backend/sky-server ./sky-server
# (可选:如果父 pom 有 <modules>,也可把 settings.xml、父级的其它 POM 拷过来)
RUN mvn dependency:go-offline -B
# 2) 拷贝所有子模块源码
COPY whut-take-out-backend ./whut-take-out-backend
# 3) 只构建 sky-server 模块(并且把依赖模块一并编译)
RUN mvn -f whut-take-out-backend/pom.xml clean package \
-pl sky-server -am \
-DskipTests -B
# —— 第二阶段:运行时镜像 ——
FROM openjdk:17-jdk-slim
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
# 4) 把第一阶段产物sky-server 模块的 Jar拷过来
COPY --from=builder \
/workspace/whut-take-out-backend/sky-server/target/sky-server-*.jar \
./app.jar
EXPOSE 8085
ENTRYPOINT ["java", "-jar", "app.jar"]
```
2.maven构建依赖可能比较慢需创建.mvn/settings.xml
```xml
<settings xmlns="http://maven.apache.org/SETTINGS/1.1.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.1.0
https://maven.apache.org/xsd/settings-1.1.0.xsd">
<mirrors>
<mirror>
<id>aliyun</id>
<name>aliyun maven</name>
<url>https://maven.aliyun.com/repository/public</url>
<mirrorOf>central,apache.snapshots</mirrorOf>
</mirror>
</mirrors>
</settings>
```
使用阿里云镜像加速
3.验证http://124.71.159.195:8085/doc.html
## 前端部署
直接部署开发完毕的前端代码,准备:
0.创建docker网络`docker network create sky-net`
1.静态资源html文件夹(npm run build 打包源码获得)
2.nginx.conf
![image-20250515111352735](https://pic.bitday.top/i/2025/05/15/iexngr-0.png)
注意把nginx.conf中的server改为Docker 容器(或服务)在**同一网络中**的主机名,如
```nginx
upstream webservers {
server sky-server:8085 weight=90;
}
```
因为同一个网络下的服务名会自动注册DNS进行地址解析
3.docker-compose文件
```yml
version: "3.8"
services:
frontend:
image: nginx:alpine
container_name: sky-frontend
ports:
- "85:80"
volumes:
# 把本地 html 目录挂到容器的默认站点目录
- ./html:/usr/share/nginx/html:ro
# 把本地的 nginx.conf 覆盖容器里的配置
- ./nginx.conf:/etc/nginx/nginx.conf:ro
networks:
- sky-net
networks:
sky-net:
external: true
```
## 实战开发
### 分页查询
传统员工分页查询分析:
![image-20221215153413290](https://pic.bitday.top/i/2025/03/19/u6p5ae-2.png)
**采用分页插件PageHelper**
![image-20221215170038833](https://pic.bitday.top/i/2025/03/19/u6o1ho-2.png)
**在执行empMapper.list()方法时就是执行select * from emp 语句,怎么能够实现分页操作呢?**
分页插件帮我们完成了以下操作:
1. 先获取到要执行的SQL语句
```
select * from emp
```
2. 为了实现分页,第一步是获取符合条件的总记录数。分页插件将原始 SQL 查询中的 `SELECT *` 改成 `SELECT count(*)`
```mysql
select count(*) from emp;
```
3. 一旦知道了总记录数,分页插件会将 `SELECT *` 的查询语句进行修改,加入 `LIMIT` 关键字,限制返回的记录数。
```mysql
select * from emp limit ?, ?
```
第一个参数(`?`)是 起始位置,通常是 `(当前页 - 1) * 每页显示的记录数`,即从哪一行开始查询。
第二个参数(`?`)是 每页显示的记录数,即返回多少条数据。
4. 执行分页查询,例如,假设每页显示 10 条记录,你请求第 2 页数据,那么 SQL 语句会变成:
```mysql
select * from emp limit 10, 10;
```
**使用方法:**
当使用 **PageHelper** 分页插件时,无需在 Mapper 中手动处理分页。只需在 Mapper 中编写常规的列表查询。
-**Service 层**,调用 Mapper 方法之前,**设置分页参数**。
- 调用 Mapper 查询后,**自动进行分页**,并将结果封装到 `PageBean` 对象中返回。
1、在pom.xml引入依赖
```java
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.4.2</version>
</dependency>
```
2、EmpMapper
```java
@Mapper
public interface EmpMapper {
//获取当前页的结果列表
@Select("select * from emp")
public List<Emp> list();
}
```
3、EmpServiceImpl
当调用 `PageHelper.startPage(page, pageSize)`PageHelper 插件会拦截随后的 SQL 查询,自动修改查询,加入 `LIMIT` 子句来实现分页功能。
```java
@Override
public PageBean page(Integer page, Integer pageSize) {
// 设置分页参数
PageHelper.startPage(page, pageSize); //page是页号不是起始索引
// 执行分页查询
List<Emp> empList = empMapper.list();
// 获取分页结果
Page<Emp> p = (Page<Emp>) empList;
//封装PageBean
PageBean pageBean = new PageBean(p.getTotal(), p.getResult());
return pageBean;
}
```
4、Controller
```java
@Slf4j
@RestController
@RequestMapping("/emps")
public class EmpController {
@Autowired
private EmpService empService;
//条件分页查询
@GetMapping
public Result page(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize) {
//记录日志
log.info("分页查询,参数:{},{}", page, pageSize);
//调用业务层分页查询功能
PageBean pageBean = empService.page(page, pageSize);
//响应
return Result.success(pageBean);
}
}
```
### 条件分页查询
思路分析:
![image-20221215180528415](https://pic.bitday.top/i/2025/03/19/u6mjck-2.png)
```java
<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>
```
### 文件上传
#### 阿里云OSS存储
pom文件中添加如下依赖
```java
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.15.1</version>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>1.1.1</version>
</dependency>
<!-- no more than 2.3.3-->
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
<version>2.3.3</version>
</dependency>
```
上传文件的工具类
```java
/**
* 阿里云 OSS 工具类
*/
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实例。
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.toString());
return stringBuilder.toString();
}
}
```
2025-03-18 12:46:59 +08:00
#### 自己搭建FileBrowser存储
```java
public class FileBrowserUtil {
private String domain;
private String username;
private String password;
/**
* —— 第一步:登录拿 token ——
* 调用 /api/login 接口,返回纯 JWT 字符串,或 {"token":"..."} 结构
*/
public String login() throws IOException {
String url = domain + "/api/login";
try (CloseableHttpClient client = HttpClients.createDefault()) {
HttpPost post = new HttpPost(url);
post.setHeader("Content-Type", "application/json");
// 构造登录参数
JSONObject cred = new JSONObject();
cred.put("username", username);
cred.put("password", password);
post.setEntity(new StringEntity(cred.toString(), StandardCharsets.UTF_8));
try (CloseableHttpResponse resp = client.execute(post)) {
int status = resp.getStatusLine().getStatusCode();
String body = EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8).trim();
if (status >= 200 && status < 300) {
// 如果返回 JSON 对象,则解析出 token 字段
if (body.startsWith("{") && body.endsWith("}")) {
JSONObject obj = JSONObject.parseObject(body);
String token = obj.getString("token");
log.info("登录成功token={}", token);
return token;
}
// 否则直接当成原始 JWT 返回
log.info("登录成功token={}", body);
return body;
} else {
log.error("Login failed: HTTP {} - {}", status, body);
throw new IOException("Login failed: HTTP " + status);
}
}
}
}
/**
* —— 第二步:上传文件 ——
* POST {domain}/api/resources/{encodedPath}?override=true
* Header: X-Auth: token
*/
public String uploadFile(byte[] fileBytes, String fileName) throws IOException {
String token = login();
String remotePath = "store/" + fileName;
String encodedPath = URLEncoder
.encode(remotePath, StandardCharsets.UTF_8)
.replace("%2F", "/");
// 根据后缀猜 MIME 类型
String mimeType = URLConnection.guessContentTypeFromName(fileName);
if (mimeType == null) {
mimeType = "application/octet-stream";
}
String uploadUrl = domain + "/api/resources/" + encodedPath + "?override=true";
try (CloseableHttpClient client = HttpClients.createDefault()) {
HttpPost post = new HttpPost(uploadUrl);
post.setHeader("X-Auth", token);
post.setEntity(new ByteArrayEntity(fileBytes, ContentType.create(mimeType)));
try (CloseableHttpResponse resp = client.execute(post)) {
int status = resp.getStatusLine().getStatusCode();
String respBody = EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8);
if (status < 200 || status >= 300) {
log.error("文件上传失败: HTTP {} - {}", status, respBody);
throw new IOException("Upload failed: HTTP " + status);
}
log.info("文件上传成功remotePath={}", remotePath);
return remotePath;
}
}
}
/**
* 第三步:生成公开分享链接
* 模拟浏览器的 POST /api/share/{encodedPath} 请求body 为 "{}"
*/
public String createShareLink(String remotePath) throws IOException {
String token = login();
// URL encode 并保留斜杠
String encodedPath = URLEncoder
.encode(remotePath, StandardCharsets.UTF_8)
.replace("%2F", "/");
String shareUrl = domain + "/api/share/" + encodedPath;
log.info("准备创建分享链接POST {}", shareUrl);
try (CloseableHttpClient client = HttpClients.createDefault()) {
HttpPost post = new HttpPost(shareUrl);
post.setHeader("Cookie", "auth=" + token);
post.setHeader("X-Auth", token);
post.setHeader("Content-Type", "text/plain;charset=UTF-8");
post.setEntity(new StringEntity("{}", StandardCharsets.UTF_8));
try (CloseableHttpResponse resp = client.execute(post)) {
int status = resp.getStatusLine().getStatusCode();
String body = EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8).trim();
if (status < 200 || status >= 300) {
log.error("创建分享失败 HTTP {} - {}", status, body);
throw new IOException("Share failed: HTTP " + status);
}
// ========= 这里改为先检测是对象还是数组 =========
JSONObject json;
if (body.startsWith("[")) {
// 如果真返回数组(老版本可能是 [{...}]
json = JSONArray.parseArray(body).getJSONObject(0);
} else if (body.startsWith("{")) {
// 当前版本直接返回对象
json = JSONObject.parseObject(body);
} else {
throw new IOException("Unexpected share response: " + body);
}
String hash = json.getString("hash");
String publicUrl = domain + "/api/public/dl/" + hash + "/" + remotePath;
log.info("创建分享链接成功:{}", publicUrl);
return publicUrl;
}
}
}
/**
* —— 整合示例:上传并立即返回分享链接 ——
*/
public String uploadAndGetUrl(byte[] fileBytes, String fileName) throws IOException {
String remotePath = uploadFile(fileBytes, fileName);
return createShareLink(remotePath);
}
}
```
### 数据库密码加密
2025-03-18 12:46:59 +08:00
加密存储确保即使数据库泄露,攻击者也不能轻易获取用户原始密码。
2025-03-18 12:46:59 +08:00
spring security中提供了一个加密类BCryptPasswordEncoder。
它采用[哈希算法](https://so.csdn.net/so/search?q=哈希算法&spm=1001.2101.3001.7020) SHA-256 +随机盐+密钥对密码进行加密。加密算法是一种**可逆**的算法,而哈希算法是一种**不可逆**的算法。
因为有随机盐的存在,所以**相同的明文密码**经过加密后的密码是**不一样**的盐在加密的密码中是有记录的所以需要对比的时候springSecurity是可以从中获取到盐的
2025-03-18 12:46:59 +08:00
- 添加 spring-security-crypto 依赖,无需引入Spring Security 的认证、授权、过滤器链等其它安全组件!
2025-03-18 12:46:59 +08:00
```xml
2025-03-18 12:46:59 +08:00
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
2025-03-18 12:46:59 +08:00
```
- 添加配置
```java
2025-03-18 12:46:59 +08:00
@Configuration
public class SecurityConfig {
2025-03-18 12:46:59 +08:00
@Bean
public PasswordEncoder passwordEncoder() {
// 参数 strength 为工作因子,默认为 10这里可以根据需要进行调整
return new BCryptPasswordEncoder(10);
2025-03-18 12:46:59 +08:00
}
}
```
- 用户注册、加密 **encode**
2025-03-18 12:46:59 +08:00
```java
2025-03-18 12:46:59 +08:00
@Autowired
private PasswordEncoder passwordEncoder;
// 对密码进行加密
String encodedPassword = passwordEncoder.encode(rawPassword);
```
2025-03-18 12:46:59 +08:00
- 验证密码 **matches**
2025-03-18 12:46:59 +08:00
```java
// 使用 matches 方法来对比明文密码和存储的哈希密码
boolean judge= passwordEncoder.matches(rawPassword, user.getPassword());
2025-03-18 12:46:59 +08:00
```
### BaseContext
2025-03-18 12:46:59 +08:00
**如何获得当前登录的用户id**
2025-03-18 12:46:59 +08:00
方法ThreadLocal
ThreadLocal为**每个线程**提供**单独**一份存储空间,具有线程隔离的效果,只有在线程内才能获取到对应的值,线程外则不能访问。
**每次请求代表一个线程**请求可以先经过拦截器再经过controller=>service=>mapper,都是在一个线程里。而即使同一个用户,先用两次请求/login、 /upload它们也不处于同一线程中
2025-03-18 12:46:59 +08:00
```java
2025-03-18 12:46:59 +08:00
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();
}
}
```
实现方式:登录成功 -> 生成jwt令牌 (claims中存userId)->前端浏览器保存
后续每次请求携带jwt -> 拦截器检查jwt令牌 -> BaseContext.setCurrentId(jwt中取出的userId); ->
2025-03-18 12:46:59 +08:00
BaseContext.getCurrentId(); //service层中获取当前userId
2025-03-18 12:46:59 +08:00
### 全局异常处理
**新增员工时的问题**
录入的用户名已存,抛出的异常后没有处理。
法一每次新增员工前查询一遍数据库保证无重复username再插入。
- [x] 法二插入后系统报“Duplicate entry”再处理。 「乐观策略」减少不必要的查询,只有在冲突时才抛错。
解决方法:定义全局异常处理器
```java
@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){
//Duplicate entry 'zhangsan' for key 'employee.idx_username'
String message = ex.getMessage();
log.info(message);
if(message.contains("Duplicate entry")){
String[] split = message.split(" ");
String username = split[2];
String msg = username + MessageConstant.ALREADY_EXISTS;
return Result.error(msg);
}else{
return Result.error(MessageConstant.UNKNOWN_ERROR);
}
}
}
```
`SQLIntegrityConstraintViolationException`用来捕获各种 **完整性约束冲突**,如唯一/主键约束冲突(向一个设置了 `UNIQUE` 或者 `PRIMARY KEY` 的字段重复插入相同的值。。可以捕捉username重复异常。并以`Result.error(msg)`返回通用响应。
另外自定义一个异常BaseException与若干个业务层异常
```java
public class BaseException extends RuntimeException {
public BaseException() {
}
public BaseException(String msg) {
super(msg);
}
}
```
![image-20250418124757147](https://pic.bitday.top/i/2025/04/18/kn2942-0.png)
业务层抛异常:
```java
throw new AccountLockedException(MessageConstant.ACCOUNT_LOCKED);
```
这样抛出异常之后可以被全局异常处理器 `exceptionHandler(BaseException ex)` 捕获。
2025-03-18 12:46:59 +08:00
### SpringMVC的消息转换器(处理日期)
**Jackson** 是一个用于处理 **JSON 数据** 的流行 Java 库,主要用于:
1. **序列化**:将 Java 对象转换为 JSON 字符串(例如:`Java对象 → {"name":"Alice"}`。Controller 返回值上带有 `@ResponseBody` 或者使用 `@RestController`
2. **反序列化**:将 JSON 字符串解析为 Java 对象(例如:`{"name":"Alice"} → Java对象`)。方法参数上标注了 `@RequestBody`
**Spring Boot**默认集成了Jackson
2025-03-18 12:46:59 +08:00
**1). 方式一**
在属性上加上注解,对日期进行格式化
2025-03-19 18:31:37 +08:00
<img src="https://pic.bitday.top/i/2025/03/19/u7y008-2.png" alt="image-20221112103501581" style="zoom:67%;" />
2025-03-18 12:46:59 +08:00
但这种方式,需要在每个时间属性上都要加上该注解,使用较麻烦,不能全局处理。
**2). 方式二(推荐 )**
在**WebMvcConfiguration**中扩展SpringMVC的消息转换器统一对`LocalDateTime、LocalDate、LocalTime`进行格式处理
2025-03-18 12:46:59 +08:00
```java
/**
* 扩展Spring MVC框架的消息转化器
* @param converters
*/
protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
log.info("扩展消息转换器...");
//创建一个消息转换器对象
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
//需要为消息转换器设置一个对象转换器对象转换器可以将Java对象序列化为json数据
converter.setObjectMapper(new JacksonObjectMapper());
//将自己的消息转化器加入容器中,确保覆盖默认的 Jackson 行为
2025-03-18 12:46:59 +08:00
converters.add(0,converter);
}
```
JacksonObjectMapper()文件:
```java
//直接复用 Jackson 的核心功能,仅覆盖或扩展特定行为。
2025-03-18 12:46:59 +08:00
public class JacksonObjectMapper extends ObjectMapper {
public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd"; //LocalDate
public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss"; //LocalDateTime
2025-03-18 12:46:59 +08:00
// public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm";
public static final String DEFAULT_TIME_FORMAT = "HH:mm:ss"; //LocalTime
2025-03-18 12:46:59 +08:00
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);
}
}
```
### 数据库操作代码复用
为提高代码复用率,在 Mapper 层统一定义一个通用的 `update` 方法,利用 MyBatis 的动态 SQL根据传入的 `Employee` 对象中非空字段生成对应的 `SET` 子句。这样:
2025-03-18 12:46:59 +08:00
- **启用/禁用员工**:只需在业务层调用(如 `startOrStop`),传入带有 `id``status``Employee` 实例,底层自动只更新 `status` 字段。
- **更新员工信息**:调用(如 `updateEmployee`)时,可传入包含多个属性的 `Employee` 实例,自动更新那些非空字段。
Controller 层和 Service 层的方法命名可根据不同业务场景进行区分,底层均复用同一个 update方法
2025-03-18 12:46:59 +08:00
在 EmployeeMapper 接口中声明 update 方法:
```java
/**
* 根据主键动态修改属性
* @param employee
*/
void update(Employee employee);
```
在 EmployeeMapper.xml 中编写SQL
```java
2025-03-18 12:46:59 +08:00
<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>
```
### 操作多表时的规范操作
功能:实现批量删除套餐操作,只能删除'非起售中'的套餐,关联表有套餐表和套餐菜品表。
代码1
```java
@Transactional
public void deleteBatch(Long[] ids) {
for(Long id:ids){
Setmeal setmeal = setmealMapper.getById(id);
if(StatusConstant.ENABLE == setmeal.getStatus()){
//起售中的套餐不能删除
throw new DeletionNotAllowedException(MessageConstant.SETMEAL_ON_SALE);
}
else{
Long setmealId=id;
setmealMapper.deleteById(id);
setmeal_dishMapper.deleteByDishId(id);
}
}
}
```
代码2
```java
@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);
});
}
```
代码2更好因为
1.把「验证」逻辑和「删除」逻辑分成了两段,职责更单一,读代码的时候一目了然
2.避免不必要的删除操作,第一轮只做 `getById` 校验,碰到 `起售中` 马上抛异常,**从不执行**任何删除 SQL效率更高。
`@Transactional` 最典型的场景就是:**在同一个业务方法里要执行多条数据库操作(增删改),而且这些操作必须保证“要么都成功、要么都失败”** 时,用它来把这些 SQL 语句包裹在同一个事务里,遇到运行时异常就回滚,避免出现“删到一半、中途抛错”导致的数据不一致。也就是说,同时操作多表时,都在方法上加下这个注解!
2025-03-18 12:46:59 +08:00
### 公共字段自动填充——AOP编程
在数据库操作中通常需要为某些公共字段如创建时间、更新时间等自动赋值。采用AOP
1. 统一管理这些字段的赋值逻辑
2. 避免在业务代码中重复设置
3. 确保数据一致性
2025-03-18 12:46:59 +08:00
| **序号** | **字段名** | **含义** | **数据类型** | **操作类型** |
| -------- | ----------- | -------- | ------------ | -------------- |
| 1 | create_time | 创建时间 | datetime | insert |
| 2 | create_user | 创建人id | bigint | insert |
| 3 | update_time | 修改时间 | datetime | insert、update |
| 4 | update_user | 修改人id | bigint | insert、update |
**实现步骤:**
1). 自定义注解 AutoFill用于标识需要进行公共字段自动填充的方法
2). 自定义切面类 AutoFillAspect统一拦截加入了 AutoFill 注解的方法,通过反射为公共字段赋值
```text
客户端 → Service → Mapper接口方法@AutoFill
↓ 切面触发 Before 通知AutoFillAspect.autoFill
[1] 读取注解,确定 INSERT/UPDATE
[2] 从 BaseContext 拿到 currentId
[3] 反射调用 entity.setXxx()
↓ 切面执行完毕,回到原方法
Mapper 执行动态 SQL将已填充的字段写入数据库
```
3). 在 需要统一填充的**Mapper 的方法上**加入 AutoFill 注解
2025-03-18 12:46:59 +08:00
**技术点:**枚举、注解、AOP、反射
## HttpClient
**HttpClient作用**
- 在Java程序中发送HTTP请求
- 接收响应数据
**HttpClient的maven坐标**
```xml
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.13</version>
</dependency>
```
**HttpClient的核心API**
- HttpClientHttp客户端对象类型使用该类型对象可发起Http请求。
- HttpClients可认为是构建器可创建HttpClient对象。
- CloseableHttpClient实现类实现了HttpClient接口。
- HttpGetGet方式请求类型。
- HttpPostPost方式请求类型。
2025-03-18 12:46:59 +08:00
**HttpClient发送请求步骤**
- 创建HttpClient对象
- 创建Http请求对象
- 调用HttpClient的execute方法发送请求
**测试用例**
2025-03-18 12:46:59 +08:00
```java
@SpringBootTest
public class HttpClientTest {
/**
* 测试通过httpclient发送GET方式的请求
*/
@Test
public void testGET() throws Exception{
//创建httpclient对象
2025-03-18 12:46:59 +08:00
CloseableHttpClient httpClient = HttpClients.createDefault();
//创建请求对象
HttpGet httpGet = new HttpGet("http://localhost:8080/user/shop/status");
2025-03-18 12:46:59 +08:00
//发送请求,接受响应结果
CloseableHttpResponse response = httpClient.execute(httpGet);
2025-03-18 12:46:59 +08:00
//获取服务端返回的状态码
int statusCode = response.getStatusLine().getStatusCode();
System.out.println("服务端返回的状态码为:" + statusCode);
2025-03-18 12:46:59 +08:00
HttpEntity entity = response.getEntity();
String body = EntityUtils.toString(entity);
System.out.println("服务端返回的数据为:" + body);
2025-03-18 12:46:59 +08:00
//关闭资源
response.close();
httpClient.close();
}
@Test
public void testPOST() throws Exception{
// 创建httpclient对象
CloseableHttpClient httpClient = HttpClients.createDefault();
//创建请求对象
HttpPost httpPost = new HttpPost("http://localhost:8080/admin/employee/login");
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);
2025-03-18 12:46:59 +08:00
//发送请求
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();
2025-03-18 12:46:59 +08:00
}
}
2025-03-18 12:46:59 +08:00
```
## 微信小程序
<img src="https://pic.bitday.top/i/2025/03/19/u7y804-2.png" alt="image-20221204211800753" style="zoom:80%;" />
**步骤分析:**
1. 小程序端调用wx.login()获取code就是授权码。
2. 小程序端调用wx.request()发送请求并携带code请求开发者服务器(自己编写的后端服务)。
3. 开发者服务端通过HttpClient向微信接口服务发送请求并携带appId+appsecret+code三个参数。
4. 开发者服务端接收微信接口服务返回的数据session_key+opendId等。opendId是微信用户的唯一标识。
5. 开发者服务端,自定义登录态,生成令牌(token)和openid等数据返回给小程序端方便后绪请求身份校验。
6. 小程序端收到自定义登录态存储storage。
7. 小程序端后绪通过wx.request()发起业务请求时携带token。
8. 开发者服务端收到请求后通过携带的token解析当前登录用户的id无需获取openai因为token中存了userid可以确认用户身份
9. 开发者服务端,身份校验通过后,继续相关的业务逻辑处理,最终返回业务数据。
2025-03-18 12:46:59 +08:00
## 缓存功能
用户端小程序展示的菜品数据都是通过查询数据库获得,如果用户端访问量比较大,数据库访问压力随之增大。
2025-03-19 18:31:37 +08:00
![image-20221208180228667](https://pic.bitday.top/i/2025/03/19/u80ct0-2.png)
2025-03-18 12:46:59 +08:00
### 实现思路
通过Redis来缓存菜品数据减少数据库查询操作。
2025-03-19 18:31:37 +08:00
![image-20221208180818572](https://pic.bitday.top/i/2025/03/19/u7yytn-2.png)
2025-03-18 12:46:59 +08:00
### 经典缓存实现代码
**缓存逻辑分析:**
- 每个分类下的菜品保存一份缓存数据
- 数据库中菜品数据有**变更时清理缓存数据**
```java
2025-03-18 12:46:59 +08:00
@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);
}
```
为了保证**数据库**和**Redis**中的数据保持一致,修改**管理端接口 DishController** 的相关方法,加入清理缓存逻辑。
需要改造的方法:
- 新增菜品
- 修改菜品
- 批量删除菜品
- 起售、停售菜品
清理缓冲方法:
```java
2025-03-18 12:46:59 +08:00
private void cleanCache(String pattern){
Set keys = redisTemplate.keys(pattern);
redisTemplate.delete(keys);
}
```
### Spring Cache框架实现缓存
Spring Cache 是一个框架,实现了基于注解的缓存功能,只需要简单地加一个注解,就能实现缓存功能。
```java
2025-03-18 12:46:59 +08:00
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId> <version>2.7.3</version>
</dependency>
```
在SpringCache中提供了很多缓存操作的注解常见的是以下的几个
| **注解** | **说明** |
| -------------- | ------------------------------------------------------------ |
| @EnableCaching | 开启缓存注解功能,通常加在**启动类**上 |
| @Cacheable | 在**方法**执行前先查询缓存中是否有数据,如果有数据,则直接返回缓存数据(**取**);如果没有缓存数据,调用方法并将方法返回值**放**到缓存中 |
| @CachePut | 将方法的返回值**放**到缓存中 |
| @CacheEvict | 将一条或多条数据从缓存中**删除** |
**1@CachePut 说明:**
2025-03-18 12:46:59 +08:00
作用: 将方法返回值,放入缓存
value: 缓存的名称, 每个缓存名称下面可以有很多key
key: 缓存的key ----------> 支持Spring的表达式语言SPEL语法
`value``cacheNames` 属性在用法上是等效的。它们都用来指定缓存区的名称
在Redis中并没有直接的“缓存名”概念而是通过键key来访问数据。Spring Cache通过`cacheNames`属性来模拟不同的“缓存区”,实际上这是通过将这些名称作为键的一部分来实现的。例如,如果你有一个缓存名为 `userCache`,那么所有相关的缓存条目的键可能以 `"userCache::"` 开头。
**在save方法上加注解@CachePut**
2025-03-18 12:46:59 +08:00
```java
@PostMapping
@CachePut(value = "userCache", key = "#user.id")//key的生成userCache::1
public User save(@RequestBody User user){
userMapper.insert(user);
return user;
}
2025-03-18 12:46:59 +08:00
```
**说明:**key的写法如下
#user.id : #user指的是**方法形参**的名称, id指的是user的id属性 , 也就是使用user的id属性作为key ;
2025-03-18 12:46:59 +08:00
#result.id : #result代表方法返回值该表达式 代表以返回对象的id属性作为key
**2@Cacheable 说明:**
2025-03-18 12:46:59 +08:00
作用: 在方法执行前spring先查看缓存中是否有**指定的key的**数据如果有数据则直接返回缓存数据不执行后续sql操作若没有数据调用方法并将方法返回值放到缓存中。
2025-03-18 12:46:59 +08:00
**在getById上加注解@Cacheable**
2025-03-18 12:46:59 +08:00
```java
2025-03-18 12:46:59 +08:00
@GetMapping
@Cacheable(cacheNames = "userCache",key="#id")
public User getById(Long id){
User user = userMapper.getById(id);
return user;
}
2025-03-18 12:46:59 +08:00
```
**3@CacheEvict 说明:**
作用: 清理指定缓存
**在 delete 方法上加注解@CacheEvict**
2025-03-18 12:46:59 +08:00
```java
@DeleteMapping
2025-03-18 12:46:59 +08:00
@CacheEvict(cacheNames = "userCache",key = "#id")//删除某个key对应的缓存数据
public void deleteById(Long id){
userMapper.deleteById(id);
}
@DeleteMapping("/delAll")
@CacheEvict(cacheNames = "userCache",allEntries = true)//删除userCache下所有的缓存数据
public void deleteAll(){
userMapper.deleteAll();
}
```
**总结:**新增数据的时候->添加缓存@CachePut ;
查询的时候->判断有无缓存@Cacheable;
删除的时候->删除缓存@CacheEvict
Spring Cache是经典缓存的上位替代
注意,如果缓存的是套餐分类,即一个套餐分类中含有多个套餐,那么在新增套餐的时候,需要清除相应的套餐分类缓存,因为当你新增一个属于分类 5 的套餐时,原来缓存里那份「分类 5 的列表」已经不再完整──它少了新加的那个套餐。
2025-03-18 12:46:59 +08:00
但是如果缓存的就是套餐本身,新增套餐的时候就可以直接缓存套餐。不要混淆两者!
2025-03-18 12:46:59 +08:00
## 下单支付
2025-03-18 12:46:59 +08:00
**下单**
2025-03-18 12:46:59 +08:00
<img src="https://pic.bitday.top/i/2025/04/24/sm31f9-0.png" alt="image-20221214200913654" style="zoom:50%;" /><img src="https://pic.bitday.top/i/2025/04/24/snaksp-0.png" alt="image-20221214200959943" style="zoom:50%;" />
| 表名 | 含义 | 说明 |
| ------------ | ---------- | ------------------------------------------------------------ |
| orders | 订单表 | 主要存储订单的基本信息(如: 订单号、状态、金额、支付方式、下单用户、收件地址等) |
| order_detail | 订单明细表 | 主要存储订单详情信息(如: 该订单关联的套餐及菜品的信息) |
### 微信支付
官方文档https://pay.weixin.qq.com/static/product/product_index.shtml
<img src="https://pic.bitday.top/i/2025/03/19/u7z7u7-2.png" alt="image-20221214223910840" style="zoom:80%;" />
**商户系统调用微信后台:**
**JSAPI下单**商户系统调用该接口在微信支付服务后台生成预支付交易单(对应时序图的第5步)
2025-03-18 12:46:59 +08:00
2025-03-19 18:31:37 +08:00
![image-20221214224409174](https://pic.bitday.top/i/2025/03/19/u7zxts-2.png)
2025-03-18 12:46:59 +08:00
**微信小程序调起支付:**
通过JSAPI下单接口获取到发起支付的必要参数prepay_id然后使用微信支付提供的小程序方法调起小程序支付(对应时序图的第10步)
2025-03-18 12:46:59 +08:00
2025-03-19 18:31:37 +08:00
![image-20221214224551220](https://pic.bitday.top/i/2025/03/19/u7zlem-2.png)
2025-03-18 12:46:59 +08:00
### 内网穿透
微信后台会调用到商户系统给推送支付的结果在这里我们就会遇到一个问题就是微信后台怎么就能调用到我们这个商户系统呢因为这个调用过程其实本质上也是一个HTTP请求。
目前商户系统它的ip地址就是当前自己电脑的ip地址只是一个局域网内的ip地址微信后台无法调用到。
**解决:**内网穿透。通过**cpolar软件**可以获得一个临时域名而这个临时域名是一个公网ip这样微信后台就可以请求到商户系统了。
1)下载地址https://dashboard.cpolar.com/get-started
**2). cpolar指定authtoken**
复制authtoken
2025-03-19 18:31:37 +08:00
![image-20240806133753849](https://pic.bitday.top/i/2025/03/19/u7yuuq-2.png)
2025-03-18 12:46:59 +08:00
执行命令:
注意cd到cpolar.exe所在的目录打开cmd
输入代码:
```java
2025-03-18 12:46:59 +08:00
cpolar.exe authtoken ZmIwMmQzZDYtZDE2ZS00ZGVjLWE2MTUtOGQ0YTdhOWI2M2Q1
```
3获取临时域名
```java
2025-03-18 12:46:59 +08:00
cpolar.exe http 8080
```
2025-03-19 18:31:37 +08:00
![image-20240806135141280](https://pic.bitday.top/i/2025/03/19/u7xscv-2.png)
2025-03-18 12:46:59 +08:00
这里的 https://52ac2ecb.r18.cpolar.top 就是与http://localhost:8080对应的临时域名。
**原理:**
1. 客户端向 cpolar 的中转节点发起 **出站**outbound连接完成身份认证authtoken并在连接上报出要映射的本地端口比如 HTTP 的 8080
2. 中转节点分配一个公网端点如abcd1234.cpolar.com
3. **外部用户** 访问 `http://abcd1234.cpolar.com`,落到 cpolar 的中转节点,中转节点 **通过先前建立好的持久隧道**,把流量转发到你本地运行的客户端。
### 百度地址解析
优化用户下单功能加入校验逻辑如果用户的收货地址距离商家门店超出配送范围配送范围为5公里内则下单失败。
思路:
1. 基于百度地图开放平台实现https://lbsyun.baidu.com/
2. 注册账号--->创建应用获取AK(服务端应用)--->调用接口
3. 相关接口
https://lbsyun.baidu.com/index.php?title=webapi/guide/webservice-geocoding
https://lbsyun.baidu.com/index.php?title=webapi/directionlite-v1
4. 商家门店地址可以配置在配置文件中,例如:
~~~yaml
sky:
shop:
address: 湖北省武汉市洪山区武汉理工大学
baidu:
ak: ${sky.baidu.ak}
~~~
2025-03-18 12:46:59 +08:00
## Spring Task
**Spring Task** 是Spring框架提供的任务调度工具可以按照约定的时间自动执行某个代码逻辑。
**定位:**定时任务框架
**作用:**定时自动执行某段Java代码
### cron表达式
2025-03-18 12:46:59 +08:00
**cron表达式**其实就是一个字符串通过cron表达式可以**定义任务触发的时间**
**构成规则:**分为6或7个域由空格分隔开每个域代表一个含义
每个域的含义分别为:秒、分钟、小时、日、月、周、年(可选)
<img src="https://pic.bitday.top/i/2025/03/19/u7yh97-2.png" alt="image-20240807141614724" style="zoom: 67%;" />
2025-03-18 12:46:59 +08:00
**通配符:**
2025-03-18 12:46:59 +08:00
\* 表示所有值;
2025-03-18 12:46:59 +08:00
? 表示未说明的值,即不关心它为何值;
2025-03-18 12:46:59 +08:00
\- 表示一个指定的范围;
2025-03-18 12:46:59 +08:00
, 表示附加一个可能值;
2025-03-18 12:46:59 +08:00
/ 符号前表示开始时间,符号后表示每次递增的值;
2025-03-18 12:46:59 +08:00
**cron表达式案例**
2025-03-18 12:46:59 +08:00
*/5 * * * * ? 每隔5秒执行一次
2025-03-18 12:46:59 +08:00
0 0 5-15 * * ? 每天5-15点整点触发
2025-03-18 12:46:59 +08:00
0 0/3 * * * ? 每三分钟触发一次
2025-03-18 12:46:59 +08:00
0 0-5 14 * * ? 在每天下午2点到下午2:05期间的每1分钟触发
2025-03-18 12:46:59 +08:00
0 10/5 14 * * ? 在每天下午2点10分到下午2:55期间的每5分钟触发
2025-03-18 12:46:59 +08:00
0 0/30 9-17 * * ? 朝九晚五工作时间内每半小时
2025-03-18 12:46:59 +08:00
0 0 10,14,16 * * ? 每天上午10点下午2点4点
2025-03-18 12:46:59 +08:00
**cron表达式在线生成器**https://cron.qqe2.com/
现在可以直接GPT生成
2025-03-18 12:46:59 +08:00
### 入门案例
#### Spring Task使用步骤
2025-03-18 12:46:59 +08:00
1). 导入maven坐标 spring-contextSpring Boot Starter已包含
<img src="https://pic.bitday.top/i/2025/03/19/u805dw-2.png" alt="image-20221218193251182" style="zoom:50%;" />
2). 启动类添加注解 @EnableScheduling 开启任务调度
3). 自定义定时任务类,然后只要在方法上标注 @Scheduled(cron = xxx)
2025-03-18 12:46:59 +08:00
```java
2025-03-18 12:46:59 +08:00
@Slf4j
@Component
public class MyTask {
//定时任务 每隔5秒触发一次
@Scheduled(cron = "0/5 * * * * ?")
public void executed(){
log.info("定時任務開始執行:{}",new Date());
2025-03-18 12:46:59 +08:00
}
}
```
### 订单状态定时处理
用户下单后可能存在的情况:
- 下单后未支付,订单一直处于**“待支付”**状态
- 用户收货后管理端未点击完成按钮,订单一直处于**“派送中”**状态
对于上面两种情况需要通过**定时任务**来修改订单状态,具体逻辑为:
- 通过定时任务每分钟检查一次是否存在支付超时订单下单后超过15分钟仍未支付则判定为支付超时订单如果存在则修改订单状态为“已取消”
- 通过定时任务每天凌晨1点打烊后检查一次是否存在“派送中”的订单如果存在则修改订单状态为“已完成”
2025-03-18 12:46:59 +08:00
## Websocket
WebSocket 是基于 TCP 的一种新的**网络协议**。它实现了浏览器与服务器全双工通信——浏览器和服务器只需要完成一次握手,两者之间就可以创建**持久性**的连接, 并进行**双向**数据传输。
**HTTP协议和WebSocket协议对比**
- HTTP是**短连接**
- WebSocket是**长连接**
- HTTP通信是**单向**的,基于请求响应模式
- WebSocket支持**双向**通信
- HTTP和WebSocket底层都是TCP连接
**工作流程:**
1.握手Handshake
- 客户端发起一个特殊的 HTTP 请求(带有 `Upgrade: websocket``Connection: Upgrade` 头)
- 服务端如果支持 WebSocket则返回 HTTP 101 Switching Protocols双方在同一个 TCP 连接上切换到 WebSocket 协议
2.数据帧交换
- 握手成功后客户端和服务端可以互相推送push“数据帧”Frame不再有 HTTP 的请求/响应模型
3.关闭连接
- 任一端发送关闭控制帧Close Frame对方确认后关闭 TCP 连接
<img src="https://pic.bitday.top/i/2025/05/08/m3qkmg-0.png" alt="image-20221222184352573" style="zoom:67%;" />
**WebSocket应用场景**
视频弹幕、实时聊天、体育实况更新、股票基金实时更新报价
2025-03-18 12:46:59 +08:00
### 入门案例
**实现步骤:**
1). 直接使用websocket.html页面作为WebSocket客户端
```text
http://localhost:8080/ws/12345
```
最主要的是建立websocket连接
2025-03-18 12:46:59 +08:00
2). 导入WebSocket的maven坐标
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
```
2025-03-18 12:46:59 +08:00
3). 导入WebSocket服务端组件WebSocketServer用于和客户端通信比较固定建立连接、接收消息、关闭连接、发送消息
```java
/**
* WebSocket服务
*/
@Component
@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();
}
}
}
}
```
2025-03-18 12:46:59 +08:00
4). 导入配置类WebSocketConfiguration注册WebSocket的服务端组件
```java
/**
* WebSocket配置类用于注册WebSocket的Bean
*/
@Configuration
public class WebSocketConfiguration {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
```
作用:找到`@ServerEndpoint` 的类并注册到容器中。
2025-03-18 12:46:59 +08:00
5). 导入定时任务类WebSocketTask定时向客户端推送数据
```java
@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()));
}
}
```
这里可以改为来单提醒、催单提醒。
2025-03-18 12:46:59 +08:00
### 来单提醒
**设计思路:**
- 通过WebSocket实现管理端页面和服务端保持长连接状态
- 当客户支付后调用WebSocket的相关API实现服务端向客户端推送消息
- 客户端浏览器解析服务端推送的消息,判断是来单提醒还是客户催单,进行相应的消息提示和语音播报
- 约定服务端发送给客户端浏览器的数据格式为JSON字段包括typeorderIdcontent
- type 为消息类型1为来单提醒 2为客户催单
- orderId 为订单id
- content 为消息内容
## 数据展示与处理
### 数据展示
Apache ECharts 是一款基于 Javascript 的数据可视化图表库,提供直观,生动,可交互,可个性化定制的数据可视化图表。
官网地址https://echarts.apache.org/zh/index.html
例:营业额统计
<img src="https://pic.bitday.top/i/2025/05/08/qxl46n-0.png" alt="image-20230101160812029" style="zoom:67%;" />
具体返回数据一般由前端来决定,前端展示图表,折线图对应数据是什么格式,是有固定的要求的。所以说,后端需要去适应前端,它需要什么格式的数据,后端就返回什么格式的数据。
### 导出数据到Excel
#### Apache POI
我们可以使用 POI 在 Java 程序中对Miscrosoft Office各种文件进行读写操作。一般情况下POI 都是用于操作 Excel 文件。
**Apache POI的maven坐标**
```xml
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>3.16</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>3.16</version>
</dependency>
```
**实现步骤:**
1). 设计Excel模板文件
2). 查询近30天的运营数据
3). 将查询到的运营数据写入模板文件
```java
row = sheet.getRow(7 + i); //获取行
row.getCell(1).setCellValue(date.toString()); //获取该行的某列,并设值。
```
4). 通过输出流将Excel文件下载到客户端浏览器
<img src="https://pic.bitday.top/i/2025/05/08/r1mrz8-0.png" alt="image-20230131152610559" style="zoom:67%;" />
2025-03-18 12:46:59 +08:00