0%

苍穹外卖

密码加密

平常密码都是明文保存,安全性太低,我们采用md5对其进行加密之后保存

1
password = DigestUtils.md5DigestAsHex("要保存的密码".getBytes());

同时进行比对时也是采用同样的方法进行加密比对

Swagger

介绍

Swagger 是一个规范和完整的框架,用于生成、描述、调用和可视化 RESTful 风格的 Web 服务(https://swagger.io/)。 它的主要作用是:

  1. 使得前后端分离开发更加方便,有利于团队协作

  2. 接口的文档在线自动生成,降低后端开发人员编写接口文档的负担

  3. 功能测试

    Spring已经将Swagger纳入自身的标准,建立了Spring-swagger项目,现在叫Springfox。通过在项目中引入Springfox ,即可非常简单快捷的使用Swagger。

knife4j是为Java MVC框架集成Swagger生成Api文档的增强解决方案,前身是swagger-bootstrap-ui,取名kni4j是希望它能像一把匕首一样小巧,轻量,并且功能强悍!

目前,一般都使用knife4j框架。

使用步骤

  1. 导入 knife4j 的maven坐标

    在pom.xml中添加依赖

    1
    2
    3
    4
    5
    <dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-openapi2-spring-boot-starter</artifactId>
    <version>4.1.0</version>
    </dependency>
  2. 在配置文件中加入 knife4j 相关配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    knife4j:
    enable: true
    openapi:
    title: 用户管理接口文档
    description: "用户管理接口文档"
    email: email
    concat: jiahao
    url: jiahao.love
    version: v1.0.0
    group:
    default:
    group-name: default
    api-rule: package
    api-rule-resources:
    - com.example.controller
  3. 访问测试

    接口文档访问路径为 http://ip:port/doc.html —> http://localhost:8080/doc.html

常用注解

注解 说明
@Api 用在类上,例如Controller,表示对类的说明
@ApiModel 用在类上,例如entity、DTO、VO
@ApiModelProperty 用在属性上,描述属性信息
@ApiOperation 用在方法上,例如Controller的方法,说明方法的用途、作用
@RequestPart 用在传入参数上,可以上传文件,例如:@RequestPart MultipartFile file

ThreadLocal

我们该怎么通过某种方式来动态的获取当前登录员工的员工id?

思路:每次访问请求都会携带一个token,而我们的员工id又存储在了token中,所以我们可以通过解析token来获得员工id,但是我们要怎么传递给Service层呢?

答:通过ThreadLocal进行传递。


介绍:

ThreadLocal 并不是一个Thread,而是Thread的局部变量。

ThreadLocal为每个线程提供单独一份存储空间,具有线程隔离的效果,只有在线程内才能获取到对应的值,线程外则不能访

问。

常用方法:

  • public void set(T value) 设置当前线程的线程局部变量的值
  • public T get() 返回当前线程所对应的线程局部变量的值
  • public void remove() 移除当前线程的线程局部变量

有了Thread Local我们就能解决上面的问题,当用户请求新增员工时,首先会被拦截器拦截下来,解析token,这个时候可以将

id存入线程,然后解析token通过执行对应的业务方法,Service层就可以获得该id

image-20231017172334818

为了解决这个问题,我们定义一个封装了ThreadLocal操作的工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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();
}
}

在解析令牌时,解析出用户id,存入线程中

1
BaseContext.setCurrentId(empId);

需要用到用户id时,取出

1
2
3
4
5
6
7
// 设置创建用户和修改用户
// 通过ThreadLocal获得当前操作用户id
employee.setCreateUser(BaseContext.getCurrentId());
employee.setUpdateUser(BaseContext.getCurrentId());

// 移除线程id
BaseContext.removeCurrentId();

统一格式化日期类型

由于我们的日期,转化为json字符串传递给前端时,格式会发生改变,所以我们需要统一格式

其实通过每个日期加上注解格式化也可以,但是对于项目而言,这种方法效率太低了

1
2
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime

所以我们可以通过SpringMVC的消息转换器来做到统一格式化日期

首先我们设置对象映射器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* 对象映射器:基于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);
}
}

其次我们在WebMvcConfiguration中扩展SpringMVC的消息转换器,统一对日期类型进行格式处理

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 扩展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);
}

这样就完成了日期的统一格式化处理

公共字段自动填充

对于一个项目我们每次新增数据和修改数据的时候都会添加或者修改创建时间创建人id最后一次修改时间修改人id,,而这些字段就属于公共字段,如果我们每次都为其添加,效率太低了,并且代码冗余,下面我们就根据Spring的AOP切面编程结合注解,来为新增和修改功能进行增强。

序号 字段名 含义 数据类型
1 create_time 创建时间 datetime
2 create_user 创建人id bigint
3 update_time 修改时间 datetime
4 update_user 修改人id bigint

而针对于这些字段,我们的赋值方式为:

1). 在新增数据时, 将createTime、updateTime 设置为当前时间, createUser、updateUser设置为当前登录用户ID。

2). 在更新数据时, 将updateTime 设置为当前时间, updateUser设置为当前登录用户ID。

实现步骤:

1). 自定义注解 AutoFill,用于标识需要进行公共字段自动填充的方法(注解中需要传入value,值为枚举类)

2). 自定义切面类 AutoFillAspect,统一拦截加入了 AutoFill 注解的方法,通过反射为公共字段赋值

3). 在 Mapper 的方法上加入 AutoFill 注解

代码开发:

首先定义枚举类,来标记mapper方法的类型(Insert or Update)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 数据库操作类型
*/
public enum OperationType {

/**
* 更新操作
*/
UPDATE,

/**
* 插入操作
*/
INSERT

}

其次自定义注解AutoFill

1
2
3
4
5
6
7
8
9
/**
* 自定义注解,用于切面编程,自动填充公共字段
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFill {
// 数据库操作类型
OperationType value();
}

最后自定义切面类AutoFillAspect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
/**
* 自定义切面,实现公共字段自动填充处理逻辑
*/
@Aspect // 当前类为切面类
@Slf4j
@Component // 被Spring管理
public class AutoFillAspect {
// 切入点
@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
public void autoFillPointCut(){}

/**
* 前置通知,在通知中给公共字段进行赋值
*/
@Before("autoFillPointCut()")
public void autoFill(JoinPoint joinPoint){
log.debug("开始填充公共字段");

// 获取到当前被拦截的方法的上面的数据库操作类型
MethodSignature signature = (MethodSignature) joinPoint.getSignature(); // 获取方法签名对象
AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class);// 获取注解对象
OperationType operationType = autoFill.value(); // 获取数据库操作类型

// 获取到当前被拦截方法的参数
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.equals(OperationType.INSERT)){
// 为4个公共字段赋值
try {
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);
setUpdateTime.invoke(entity, now);
setCreateUser.invoke(entity, currentId);
setUpdateUser.invoke(entity, currentId);

} catch (Exception e) {
throw new RuntimeException(e);
}
} else if (operationType.equals(OperationType.UPDATE)) {
// 为2个公共字段赋值
try {
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);

} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
}

最后把注解标记在响应的方法上面,启动测试,即可

1
2
3
4
5
6
/**
* 新增员工
* @param employee
*/
@AutoFill(OperationType.INSERT)
void add(Employee employee);

SpringDataRedis

介绍

Spring Data Redis 是 Spring 的一部分,提供了在 Spring 应用中通过简单的配置就可以访问 Redis 服务,对 Redis 底层开发包进行了高度封装。在 Spring 项目中,可以使用Spring Data Redis来简化 Redis 操作。

网址:https://spring.io/projects/spring-data-redis

Spring Boot提供了对应的Starter,maven坐标:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Spring Data Redis中提供了一个高度封装的类:RedisTemplate,对相关api进行了归类封装,将同一类型操作封装为operation接口,具体分类如下:

  • ValueOperations:string数据操作
  • SetOperations:set类型数据操作
  • ZSetOperations:zset类型数据操作
  • HashOperations:hash类型的数据操作
  • ListOperations:list类型的数据操作

环境搭建

1). 导入Spring Data Redis的maven坐标

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

2). 配置Redis数据源

1
2
3
4
5
6
spring:
redis:
host: localhost
port: 6379
password: 6379
database: 0

3). 编写配置类,创建RedisTemplate对象(非必需)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@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;
}
}

当前配置类不是必须的,因为 Spring Boot 框架会自动装配 RedisTemplate 对象,但是默认的key序列化器为

JdkSerializationRedisSerializer,导致我们存到Redis中后的数据和原始数据有差别,故设置为

StringRedisSerializer序列化器。

操作常见类型数据

1). 操作字符串类型数据

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 操作字符串类型的数据
*/
@Test
public void testString(){
// set get setex setnx
redisTemplate.opsForValue().set("name","小明");
String city = (String) redisTemplate.opsForValue().get("name");
System.out.println(city);
redisTemplate.opsForValue().set("code","1234",3, TimeUnit.MINUTES);
redisTemplate.opsForValue().setIfAbsent("lock","1");
redisTemplate.opsForValue().setIfAbsent("lock","2");
}

2). 操作哈希类型数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 操作哈希类型的数据
*/
@Test
public void testHash(){
//hset hget hdel hkeys hvals
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");
}

3). 操作列表类型数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 操作列表类型的数据
*/
@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);
}

4). 操作集合类型数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 操作集合类型的数据
*/
@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");
}

5). 操作有序集合类型数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 操作有序集合类型的数据
*/
@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");
}

6). 通用命令操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 通用命令操作
*/
@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");
}

HttpClient

介绍

HttpClient 是Apache Jakarta Common 下的子项目,可以用来提供高效的、最新的、功能丰富的支持 HTTP 协议的客户端编程工具包,并且它支持 HTTP 协议最新的版本和建议。

image-20221203185003231

HttpClient作用:

  • 发送HTTP请求
  • 接收响应数据

image-20221203185149985 为什么要在Java程序中发送Http请求?有哪些应用场景呢?

HttpClient应用场景:

当我们在使用扫描支付、查看地图、获取验证码、查看天气等功能时

其实,应用程序本身并未实现这些功能,都是在应用程序里访问提供这些功能的服务,访问这些服务需要发送HTTP请求,并且接收响应数据,可通过HttpClient来实现。

image-20221203185427462

HttpClient的maven坐标:

1
2
3
4
5
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.13</version>
</dependency>

HttpClient的核心API:

  • HttpClient:Http客户端对象类型,使用该类型对象可发起Http请求。
  • HttpClients:可认为是构建器,可创建HttpClient对象。
  • CloseableHttpClient:实现类,实现了HttpClient接口。
  • HttpGet:Get方式请求类型。
  • HttpPost:Post方式请求类型。

HttpClient发送请求步骤:

  • 创建HttpClient对象
  • 创建Http请求对象
  • 调用HttpClient的execute方法发送请求

入门案例

对HttpClient编程工具包有了一定了解后,那么,我们使用HttpClient在Java程序当中来构造Http的请求,并且把请求发送出去,接下来,就通过入门案例分别发送GET请求POST请求,具体来学习一下它的使用方法。

GET方式请求

正常来说,首先,应该导入HttpClient相关的坐标,但在项目中,就算不导入,也可以使用相关的API。

因为在项目中已经引入了aliyun-sdk-oss坐标:

1
2
3
4
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
</dependency>

上述依赖的底层已经包含了HttpClient相关依赖。

image-20221203194852825

故选择导入或者不导入均可。

进入到sky-server模块,编写测试代码,发送GET请求。

实现步骤:

  1. 创建HttpClient对象
  2. 创建请求对象
  3. 发送请求,接受响应结果
  4. 解析结果
  5. 关闭资源
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package com.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;

@SpringBootTest
public class HttpClientTest {

/**
* 测试通过httpclient发送GET方式的请求
*/
@Test
public void testGET() throws Exception{
//创建httpclient对象
CloseableHttpClient httpClient = HttpClients.createDefault();

//创建请求对象
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();
}
}

在访问http://localhost:8080/user/shop/status请求时,需要提前启动项目。

测试结果:

image-20221203195917572

POST方式请求

在HttpClientTest中添加POST方式请求方法,相比GET请求来说,POST请求若携带参数需要封装请求体对象,并将该对象设置在请求对象中。

实现步骤:

  1. 创建HttpClient对象
  2. 创建请求对象
  3. 发送请求,接收响应结果
  4. 解析响应结果
  5. 关闭资源
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* 测试通过httpclient发送POST方式的请求
*/
@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);

//发送请求
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();
}

测试结果:

image-20221203201023925

微信小程序

登录流程

微信小程序登录流程.drawio

  1. 小程序端:调用wx.login()获取code,也就是授权码
  2. 小程序端:通过wx.request()发送请求并携带code,发送到自己编写的服务器
  3. 开发者服务端:通过自己配置的appid+appsecret+grant_type+传入的code,请求微信接口服务
  4. 开发者服务端:接收微信接口服务返回的数据,openid是用户的唯一标识
  5. 开发者服务端:用户如果没有注册,添加到数据库注册,生成token返回给小程序端
  6. 小程序端:收到token保存到本地
  7. 小程序端:后续通过wx.request()向后端发起业务请求,携带token
  8. 开发者服务端:解析token,验证通过返回对应的数据
  9. 小程序端:访问成功

支付流程

微信支付流程图.drawio

  1. 用户端:进入小程序并进行下单,首先提交订单
  2. 小程序端:包装订单信息请求到后台系统(自己开发的系统)
  3. 后台系统端:将订单信息进行处理写入数据库,并包装结果返回给小程序端
  4. 小程序端:请求微信支付
  5. 后台系统端:调用微信官方提供的微信下单接口
  6. 微信接口端:返回预支付交易标识到后台系统
  7. 后台系统端:将微信接口端返回的结果进行处理,组合数据返回给小程序端
  8. 小程序端:接收后台系统返回的支付参数
  9. 用户端:确认支付
  10. 小程序端:直接调用微信支付
  11. 微信接口端:返回支付结果
  12. 小程序端:显示微信接口端返回的结果
  13. 微信接口端:推送支付结果到后端系统
  14. 后台系统端:接收支付结果,更新订单状态

百度地图

由于是外卖项目,我们需要地图服务,这里选择了百度地图,来处理收货地址是否超出配送范围的问题

登录百度地图开放平台:https://lbsyun.baidu.com/

进入控制台,创建应用,获取AK:

image-20221222170256927

相关接口:

获取经纬度坐标的接口,需要获得商家和客户的经纬度

https://lbsyun.baidu.com/index.php?title=webapi/guide/webservice-geocoding

通过商家和客户的经纬度来获取实际距离单位为米

https://lbsyun.baidu.com/index.php?title=webapi/directionlite-v1


下面进入代码的开发:

  1. 首先我们需要在配置文件中配置外卖店家的店铺地址和百度地图的AK
1
2
3
4
sky:
baidu:
address: 北京市海淀区上地十街10号
ak: 你的AK
  1. 创建实体类,读取配置文件的属性
1
2
3
4
5
6
7
8
9
@Component
@Data
@ConfigurationProperties(prefix = "sky.baidu")
public class BaiduProperties {
// 商家地址
private String address;
// 百度地图服务ak
private String ak;
}
  1. 创建百度地图工具类,提供方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
/**
* 百度工具类
*/
@Component
@Slf4j
public class BaiduUtils {
@Autowired
private BaiduProperties baiduProperties;

/**
* 检查客户的收获地址是否超出配送范围
* @param address
*/
public void checkOutOfRange(String address){
Map<String, String> map = new HashMap<>();
map.put("address", baiduProperties.getAddress());
map.put("output", "json");
map.put("ak", baiduProperties.getAk());

// 获取店铺的经纬度坐标
String shopCoordinate = HttpClientUtil.doGet("https://api.map.baidu.com/geocoding/v3", map);
// 解析json
JSONObject jsonObject = JSON.parseObject(shopCoordinate);
if (!jsonObject.getString("status").equals("0")) {
throw new OrderBusinessException(MessageConstant.SHOP_NOT_FOUND);
}

// 数据解析
JSONObject location = jsonObject.getJSONObject("result").getJSONObject("location");
String lng = location.getString("lng"); // 经度
String lat = location.getString("lat"); // 纬度

// 店铺的经纬度坐标
String shopLngLat = lat + "," + lng;
// 传入收获地址
map.put("address", address);

// 获取用户的经纬度坐标
String userCoordinate = HttpClientUtil.doGet("https://api.map.baidu.com/geocoding/v3", map);
JSONObject userObject = JSON.parseObject(userCoordinate);
if (!userObject.getString("status").equals("0")) {
throw new OrderBusinessException(MessageConstant.USER_NOT_FOUND);
}

JSONObject userLocation = userObject.getJSONObject("result").getJSONObject("location");
// 用户的经纬度坐标
String userLngLat = userLocation.getString("lat") + "," + userLocation.getString("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 jsonResult = JSON.parseObject(json);

if (!jsonResult.getString("status").equals("0")) {
throw new OrderBusinessException(MessageConstant.ROUTE_PLANNING_FAILED);
}

JSONObject result = jsonResult.getJSONObject("result");
JSONArray jsonArray = (JSONArray) result.get("routes");
// 得到距离
Integer distance = Integer.valueOf(((JSONObject) jsonArray.get(0)).getString("distance"));

// 距离大于10000米就不予配送
if (distance > 10000) {
throw new OrderBusinessException(MessageConstant.OUT_OF_DELIVERY);
}
}
}

注意:这里判断距离传入的商家和客户的经纬度,是按照”纬度,经度”来传入参数的

  1. 有了工具类之后,我们就可以在订单提交时,进行判断
1
2
3
4
5
6
@Autowired
private BaiduUtils baiduUtils;

// 由于我们这里是北京市,所以就干脆获取省份的名称了,正常情况下应该获得城市的名字,毕竟外卖也没有送出市的啊
baiduUtils.checkOutOfRange(addressBook.getProvinceName()
+ addressBook.getDistrictName() + addressBook.getDetail());

Spring Cache

介绍

Spring Cache 是一个框架,实现了基于注解的缓存功能,只需要简单地加一个注解,就能实现缓存功能。

Spring Cache 提供了一层抽象,底层可以切换不同的缓存实现,例如:

  • EHCache
  • Caffeine
  • Redis(常用)

应用场景

用户端小程序展示的数据都是通过查询数据库获得,如果用户端访问量比较大,数据库访问压力随之增大。

image-20221208180228667

结果:系统响应慢、用户体验差

通过Redis来缓存数据,减少数据库查询操作。

image-20221208180818572

起步依赖

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId> <version>2.7.3</version>
</dependency>

常用注解

在SpringCache中提供了很多缓存操作的注解,常见的是以下的几个:

注解 说明
@EnableCaching 开启缓存注解功能,通常加在启动类
@Cacheable 在方法执行前先查询缓存中是否有数据,如果有数据,则直接返回缓存数据;如果没有缓存数据,调用方法并将方法返回值放到缓存中
@CachePut 将方法的返回值放到缓存中
@CacheEvict 将一条或多条数据从缓存中删除

在spring boot项目中,使用缓存技术只需在项目中导入相关缓存技术的依赖包,并在启动类上使用@EnableCaching开启缓存支持即可。

例如,使用Redis作为缓存技术,只需要导入Spring data Redis的maven坐标即可。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@PostMapping
// value 就是键的名称 key就是对应的标志 #user.id表示获取user的id作为标志
// 结合下来到Redis就是 userCache::user的id值 = 该方法返回的结果
@CachePut(value = "userCache", key = "#user.id")
public User save(@RequestBody User user){
userMapper.insert(user);
return user;
}

@DeleteMapping("/delAll")
@CacheEvict(cacheNames = "userCache",allEntries = true)//删除userCache前缀下所有的缓存数据
public void deleteAll(){
userMapper.deleteAll();
}

思路

  • 我们可以在用户端请求数据的时候,将对应分类菜品的所有数据保存到Redis里面进行缓存,再下一次访问时,就直接访问Redis里面的数据,保证用户的体验

  • 在商家对菜品或者套餐进行新增的时候,我们需要清除Redis里面菜品或套餐所属分类对应的缓存,对整个分类进行更新

  • 由于我们修改和删除,涉及到的分类比较多,情况比较复杂,所以我们直接清除菜品或套餐的所有缓存,对整个菜品或者套餐进行更新数据

Spring Task

Spring Task 是Spring框架提供的任务调度工具,可以按照约定的时间自动执行某个代码逻辑。

定位:定时任务框架

作用:定时自动执行某段Java代码

背景:

假如用户没有及时支付,我们需要将这些订单改为取消,取消原因是用户未及时支付

还有商家每天凌晨一点休息后,把那些没有及时处理的在派送的订单改为已完成

强调:只要是需要定时处理的场景都可以使用Spring Task

cron表达式

cron表达式其实就是一个字符串,通过cron表达式可以定义任务触发的时间

构成规则:分为6或7个域,由空格分隔开,每个域代表一个含义

每个域的含义分别为:秒、分钟、小时、日、月、周、年(可选)

举例:

2022年10月12日上午9点整 对应的cron表达式为:0 0 9 12 10 ? 2022

image-20221218184412491

说明:一般的值不同时设置,其中一个设置,另一个用?表示。

比如:描述2月份的最后一天,最后一天具体是几号呢?可能是28号,也有可能是29号,所以就不能写具体数字。

为了描述这些信息,提供一些特殊的字符。这些具体的细节,我们就不用自己去手写,因为这个cron表达式,它其实有在线生成器。

cron表达式在线生成器:https://cron.qqe2.com/

可以直接在这个网站上面,只要根据自己的要求去生成corn表达式即可。所以一般就不用自己去编写这个表达式。

通配符:

* 表示所有值;

? 表示未说明的值,即不关心它为何值;

- 表示一个指定的范围;

, 表示附加一个可能值;

/ 符号前表示开始时间,符号后表示每次递增的值;

代码开发

  1. 我们首先需要导入SpringTask的起步依赖(已存在:因为包含在spring-context)

  2. SpringBoot启动类添加注解@EnableScheduling 开启任务调度

1
@EnableScheduling // 开启定时任务调度
  1. 自定义定时任务类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

/**
* 订单定时任务
*/
@Component
@Slf4j
public class OrderTask {
@Autowired
private OrderMapper orderMapper;


/**
* 处理支付超时订单
*/
@Scheduled(cron = "0 * * * * ?")
public void processTimeoutOrder(){
log.info("处理支付超时订单:{}", new Date());
LocalDateTime time = LocalDateTime.now().plusMinutes(-15);

// select * from orders where status = 1 and order_time < 当前时间-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.setCancelTime(LocalDateTime.now());
orders.setCancelReason(MessageConstant.ORDER_OUT_TIME);
orderMapper.update(orders);
}
}

}

/**
* 处理“派送中”状态的订单
*/
@Scheduled(cron = "0 0 1 * * ?")
public void processDeliveryOrder(){
log.info("处理派送中订单:{}", new Date());

LocalDateTime time = LocalDateTime.now();
// 每天凌晨一点还在配送的订单直接完成
// select * from orders where status = 4 and order_time < 当前时间
List<Orders> ordersList = orderMapper.getByStatusAndOrdertimeLT(Orders.DELIVERY_IN_PROGRESS, time);
for (Orders orders : ordersList) {
orders.setStatus(Orders.COMPLETED);
orders.setDeliveryTime(time);
orderMapper.update(orders);
}
}
}

WebSocket

介绍

WebSocket 是基于 TCP 的一种新的网络协议。它实现了浏览器与服务器全双工通信——浏览器和服务器只需要完成一次握手,两者之间就可以创建持久性的连接, 并进行双向数据传输。

HTTP协议和WebSocket协议对比:

  • HTTP是短连接
  • WebSocket是长连接
  • HTTP通信是单向的,基于请求响应模式
  • WebSocket支持双向通信
  • HTTP和WebSocket底层都是TCP连接
image-20221222184340172

思考:既然WebSocket支持双向通信,功能看似比HTTP强大,那么我们是不是可以基于WebSocket开发所有的业务功能?

WebSocket缺点:

服务器长期维护长连接需要一定的成本
各个浏览器支持程度不一
WebSocket 是长连接,受网络限制比较大,需要处理好重连

结论:WebSocket并不能完全取代HTTP,它只适合在特定的场景下使用

代码开发

这里我们的使用场景就是,当用户下单支付完成之后,需要提醒外卖商家,有新的订单

当订单超过预计时间时,用户可以进行催单,会提醒商家

  1. 导入Maven坐标
1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
  1. 定义WebSocketSerVer,用于和客户端通信
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/**
* 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();
}
}
}
}
  1. 定义配置类,注册WebSocket的服务端组件
1
2
3
4
5
6
7
8
9
10
11
12
/**
* WebSocket配置类,用于注册WebSocket的Bean
*/
@Configuration
public class WebSocketConfiguration {

@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}

}
  1. 在订单处理实现类中注入WebSocketServer
1
2
@Autowired
private WebSocketServer webSocketServer;
  1. 完成支付时,向商家发送消息
1
2
3
4
5
6
7
// 实现来单提醒,向客户端
HashMap map = new HashMap();
map.put("type", 1); // 消息类型,1表示来单提醒
map.put("orderId", orders.getId());
map.put("content", "订单号:" + outTradeNo); // outTradeNo为微信提供的商家订单号

webSocketServer.sendToAllClient(JSON.toJSONString(map));
  1. 用户催单时,向商家发送消息Controller
1
2
3
4
5
6
@GetMapping("/reminder/{id}")
@ApiOperation("催单")
public Result reminder(@PathVariable Long id){
orderService.reminder(id);
return Result.success();
}
  1. Service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 用户催单
* @param id
*/
@Override
public void reminder(Long id) {
// 只有超时的订单才可以进行催单,否则提示别着急
// 根据id查询当前订单
Orders orders = orderMapper.getByOrderId(id);

if (LocalDateTime.now().isBefore(orders.getEstimatedDeliveryTime())) {
throw new OrderBusinessException(MessageConstant.ORDER_NOT_TIME_OUT);
}
Duration between = Duration.between(orders.getEstimatedDeliveryTime(), LocalDateTime.now());
Map map = new HashMap();
map.put("type", 2); // 客户催单
map.put("orderId", id);
map.put("content", "订单号:" + id + "催单,该订单已超时" +
between.toHours() + "时" + between.toMinutes() % 60 + "分" + ",请及时处理");
webSocketServer.sendToAllClient(JSON.toJSONString(map));
}

Apache POI

Apache POI 是一个处理Miscrosoft Office各种文件格式的开源项目。简单来说就是,我们可以使用 POI 在 Java 程序中对Miscrosoft Office各种文件进行读写操作。
一般情况下,POI 都是用于操作 Excel 文件。

image-20230131110631081

这里我们用来导出营业数据表

首先通过一个入门案例熟悉Apache POI

入门案例

  1. 导入依赖
1
2
3
4
5
6
7
8
9
10
<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)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Test
public void test(){
// 在内存中创建一个Excel文件对象
XSSFWorkbook excel = new XSSFWorkbook();

// 创建Sheet页
XSSFSheet sheet = excel.createSheet("sheet1");

// 在Sheet页中创建行, 0表示第一行
XSSFRow row1 = sheet.createRow(0);
// 创建单元格,并在单元格中设置值,单元格编号也是从0开始
row1.createCell(0).setCellValue("姓名");
row1.createCell(1).setCellValue("城市");

XSSFRow row2 = sheet.createRow(1);
row2.createCell(0).setCellValue("张三");
row2.createCell(1).setCellValue("北京");

XSSFRow row3 = sheet.createRow(2);
row3.createCell(0).setCellValue("李四");
row3.createCell(1).setCellValue("杭州");
// 通过文件输出流将内存中的Excel文件写入到磁盘上
try (
FileOutputStream out = new FileOutputStream(new File("D:\\13684\\Desktop\\test.xlsx"))
) {
excel.write(out);
excel.close();
}catch (IOException e) {
e.printStackTrace();
}
}
  1. 读取Excel文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Test
public void test2(){
try (
FileInputStream inputStream = new FileInputStream(new File("D:\\13684\\Desktop\\test.xlsx"))
) {
// 通过输入流,取指定的Excel文件
XSSFWorkbook excel = new XSSFWorkbook(inputStream);
// 获取第一个Sheet页
XSSFSheet sheet = excel.getSheetAt(0);

// 获取Sheet页张的最后一行的行号(获取的也是索引值)
int lastRowNum = sheet.getLastRowNum();

for (int i = 0; i <= lastRowNum; i++) {
// 获取Sheet页中的行
XSSFRow row = sheet.getRow(i);
// 获取行的第二个单元格的文本内容
String value = row.getCell(0).getStringCellValue();
// 获取第三个单元格的文本内容
String value1 = row.getCell(1).getStringCellValue();
System.out.println(value + " " + value1);

}
excel.close();

}catch (IOException ex) {
ex.printStackTrace();
}
}

代码开发

我们需要导出运营数据30天内的表,首先给程序提供模板,其次填入数据即可

image-20231025155646803

接口和Controller不在赘述,直接看ServiceImpl(利用HttpServletResponse得到输出流,向浏览器输出文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/**
* 导出数据
*/
@Override
public void export(HttpServletResponse response) {
// 一个月
LocalDate begin = LocalDate.now().minusDays(30);
LocalDate end = LocalDate.now().minusDays(1);
// 概览数据
BusinessDataVO businessDataVO = workspaceService.businessData(
LocalDateTime.of(begin, LocalTime.MIN),
LocalDateTime.of(end, LocalTime.MAX));
// 读取模板
try (
InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream("template/运营数据报表模板.xlsx")
) {
// 基于提供模板,创建一个excel表格对象
XSSFWorkbook excel = new XSSFWorkbook(inputStream);
// 获得excel文件中的sheet页
XSSFSheet sheet = excel.getSheetAt(0);
sheet.getRow(1).getCell(1).setCellValue(begin + " - " + end);

// 设置概览数据
XSSFRow row4 = sheet.getRow(3);
row4.getCell(2).setCellValue(businessDataVO.getTurnover());
row4.getCell(4).setCellValue(businessDataVO.getOrderCompletionRate());
row4.getCell(6).setCellValue(businessDataVO.getNewUsers());

XSSFRow row5 = sheet.getRow(4);
row5.getCell(2).setCellValue(businessDataVO.getValidOrderCount());
row5.getCell(4).setCellValue(businessDataVO.getUnitPrice());

// 明细数据
for (int i = 0; i < 30; i++) {
// 每循环一次增加一天
LocalDate date = begin.plusDays(i);
BusinessDataVO businessDataVODetail = workspaceService.businessData(
LocalDateTime.of(date, LocalTime.MIN),
LocalDateTime.of(date, LocalTime.MAX));
XSSFRow row = sheet.getRow(7 + i);
row.getCell(1).setCellValue(date.toString());
row.getCell(2).setCellValue(businessDataVODetail.getTurnover());
row.getCell(3).setCellValue(businessDataVODetail.getValidOrderCount());
row.getCell(4).setCellValue(businessDataVODetail.getOrderCompletionRate());
row.getCell(5).setCellValue(businessDataVODetail.getUnitPrice());
row.getCell(6).setCellValue(businessDataVODetail.getNewUsers());
}
// 通过输出流将文件下载到客户端浏览器
ServletOutputStream outputStream = response.getOutputStream();
excel.write(outputStream);
excel.close();
}catch (IOException ex){
ex.printStackTrace();
}

}

注意事项

  1. 如果想获取插入之后的主键值,需要在xml文件里,配置这些

useGeneratedKeys=”true” 用数据库生成的主键值

keyProperty=”id” 将值存入java对象的id属性

1
2
3
4
<insert id="add" useGeneratedKeys="true" keyProperty="id">
insert into dish (name, category_id, price, image, description, create_time, update_time, create_user,update_user)
VALUE (#{name}, #{categoryId}, #{price}, #{image}, #{description}, #{createTime},#{updateTime}, #{createUser}, #{updateUser})
</insert>
  1. 如果一个业务要操作多张表,要在service层加事务注解,同成功,同失败
1
@Transactional
  1. Apache ECharts 属于前端操控,后端人员秩序返回数据即可

这里运用的StringUtils.join()的函数属于org.apache.commons.lang