Star描述项目

1.S->situation(背景)

2.T->task(任务)

3.A->action(动作)

4.R->result(结果)


1.S

在学习 Java 后端开发过程中,为了提升对 SpringBoot 架构和企业级开发流程 的理解,我参与开发了一个在线点餐系统 。

这个系统包含了两个端: 用户端:使用的是小程序开发的; 管理端使用的是Vue开发的;

这个项目包含的技术栈有:Spring boot,Spring Mvc,Mysql,Mybatis,Redis,JWT,Websocket;

这个项目采用的是前后端分离的架构,后端提供Restful API

管理端主要是商家来使用,提供餐品的管理功能,包含了 员工管理模块来进行员工的登录及其他相关操作,菜品管理,套餐管理模块来进行菜品,模块的增删改等操作 , 菜品分类管理模块来进行菜品分类的增删改查操作,订单管理模块来搜索和查看订单,变更订单状态,销量统计模块来统计营业额,用户,订单和销量排名, 工作台模块 来提供今日运营数据以及订单,菜品,套餐总览;

用户端,给用户点餐等使用, 包含了用户登录模块调用微信小程序登录接口实现登录功能; 菜品,套餐模块来查询菜品,套餐的信息;购物车模块,来在购物车里添加和删除菜品; 订单模块提供下单,微信支付,查询订单,取消订单,再来一单,催单等功能;

2.T

我在项目中负责后端开发,根据接口文档信息来进行后端各大核心模块的开发;
以及JWT登录认证和拦截器 , Redis缓存优化等 ** ,以及时间转换器**对时间格式问题进行改良等;

3.A

1 用户登录认证

我实现了 基于 JWT 的无状态登录认证机制


追问:

Q1:(这里可能就引出了 cookie , session ,token 的区别,以及为什么使用 JWT 而不使用cookie session 或者普通的token呢;)
Q2: (可能要你详细讲出JWT的三大部分及解密机制)

R1: cookie是客户端发起请求,服务器生成的,并且发送给客户端保存的; 因为保存在客户端中,所以是不安全的,用户可以对其修改来查看到其他人的信息;
session 是存储在服务器中的,并通过session ID来标识每个客户端的会话(这个session ID通常通过Cookie存储在客户端),服务器会根据Session ID来找到对应的session来查找对应会话数据, 相较于单用cookie 更安全,但是每来一个会话,就要在服务器内开辟一块内存空间,会造成大量内存消耗,对于大量用户的使用并不推荐;
**token ** 是一种基于令牌的身份验证机制,通常用于无状态的身份验证系统; Token由服务器生成并发送给客户端,客户端发送请求时需要通过Authorization请求头携带该Token; 服务器验证Token有效性和权限来完成身份校验; 所以Token 适用于Restful API这种无状态的认证系统;JWT属于token的一种,是一个Base64 编码和数字签名的 JSON 对象。

特性 Cookie Session Token (如 JWT)
存储位置 客户端(浏览器) 服务端(内存/数据库/Redis) 客户端(LocalStorage/Header)
状态管理 有状态(依赖服务器来维护状态) 有状态(占用服务器内存) 无状态(服务器不存数据)
安全性 易受 CSRF 攻击 相对较高(数据在服务端) 较高(防篡改,需防 XSS)
跨域支持 需要特定配置才能跨域 通常不支持 支持
扩展性 低(多台服务器需做 Session 同步) 极高(适合集群和分布式)

R2: JWT包含Header,Payload,Signature三部分;Header存储JWT和算法声明,Payload存储业务数据,Signature对前两部分加上特定的密钥经过一定算法生成; 如果对前两部分修改,Signature就会对应不上产生错误;

苍穹外卖不使用普通Token,的原因是普通Token只是一个没有意义的字符串,需要去服务器查找存储的数据; 而JWT将数据存储在payload中,只需要校验就能拿到对应数据,速度更快,存储消耗更少;

JWT只要服务器配置了对应的密钥,就能校验,对跨域的支持更好,普通Token还要搭建一个统一的 Redis 集群来同步 Token 信息;


实现过程:

1.定义了一个jwtproperties类来保存一些相关属性,key,ttl,tokenname;其中定义了管理端和用户端两组属性

2.在配置文件中定义相关属性,在jwtproperties类中用@configuration properties(prefix = “sky.jwt根据自己定义的来写”)来匹配

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

}

3.创建jwtutill工具类,定义生成和解密的方法进行封装

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
public class JwtUtil {
/**
* 生成jwt
* 使用Hs256算法, 私匙使用固定秘钥
*
* @param secretKey jwt秘钥
* @param ttlMillis jwt过期时间(毫秒)
* @param claims 设置的信息
* @return
*/
public static String createJWT(String secretKey, long ttlMillis, Map<String, Object> claims) {
// 指定签名的时候使用的签名算法,也就是header那部分
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

// 生成JWT的时间
long expMillis = System.currentTimeMillis() + ttlMillis;
Date exp = new Date(expMillis);

// 设置jwt的body
JwtBuilder builder = Jwts.builder()
// 如果有私有声明,一定要先设置这个自己创建的私有的声明,这个是给builder的claim赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明的
.setClaims(claims)
// 设置签名使用的签名算法和签名使用的秘钥
.signWith(signatureAlgorithm, secretKey.getBytes(StandardCharsets.UTF_8))
// 设置过期时间
.setExpiration(exp);

return builder.compact();
}

/**
* Token解密
*
* @param secretKey jwt秘钥 此秘钥一定要保留好在服务端, 不能暴露出去, 否则sign就可以被伪造, 如果对接多个客户端建议改造成多个
* @param token 加密后的token
* @return
*/
public static Claims parseJWT(String secretKey, String token) {
// 得到DefaultJwtParser
Claims claims = Jwts.parser()
// 设置签名的秘钥
.setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8))
// 设置需要解析的jwt
.parseClaimsJws(token).getBody();
return claims;
}

}

4.在controller包下定义拦截器包书写拦截器类,对相关请求进行拦截校验jwt令牌

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
@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);
//设置当前id
BaseContext.setCurrentId(empId);
//3、通过,放行
return true;
} catch (Exception ex) {
//4、不通过,响应401状态码
response.setStatus(401);
return false;
}
}
}

配置相关拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@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");
}

5.使用时例如员工登陆功能,员工登陆,登陆成功后,生成jwt令牌,封装进vo对象的token属性中,返回给前端;后续的请求头都携带上这个token

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
@RestController
@RequestMapping("/admin/employee")
@Slf4j
public class EmployeeController {
@Autowired
private EmployeeService employeeService;
@Autowired
private JwtProperties jwtProperties;

/**
* 登录
*
* @param employeeLoginDTO
* @return
*/
@PostMapping("/login")
public Result<EmployeeLoginVO> login(@RequestBody EmployeeLoginDTO employeeLoginDTO) throws LoginException {
log.info("员工登录: {}", employeeLoginDTO);
Employee employee=employeeService.login(employeeLoginDTO);
//生成jwt令牌校验
HashMap<String, Object> claims = new HashMap<>();
claims.put(JwtClaimsConstant.EMP_ID,employee.getId()); //存储员工的id
//使用jwtutill
String token = JwtUtil.createJWT(
jwtProperties.getAdminSecretKey(),
jwtProperties.getAdminTtl(),
claims);

//生成VO视图对象
EmployeeLoginVO employeeLoginVO=EmployeeLoginVO.builder()
.id(employee.getId())
.name(employee.getName())
.userName(employee.getUsername())
.token(token)
.build();

return Result.success(employeeLoginVO);
}

2.时间转换器

1.写一个一个基于 Jackson 库的自定义对象映射器,添加时间相关的序列化和反序列化格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*对象映射器: 基于Jackson的对象映射器, 用于将Java对象转换为JSON字符串, 或将JSON字符串转换为Java对象.*/
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_TIME_FORMAT = "HH:mm:ss";

public JacksonObjectMapper() {
super();
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);
}
}

2.配置类中注册

1
2
3
4
5
6
//添加到配置中
protected void extendMessageConverters(List<HttpMessageConverter<?>> converters){
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
converter.setObjectMapper(new JacksonObjectMapper());
converters.add(0,converter); //使得自定义的消息转换器优先级最高
}

3.Redis缓存优化

小程序菜品是通过数据库获得的,如果短时间大量人访问,会造成数据库短时间压力增大;
所以使用Redis对菜品做缓存;优先查询数据库,查询不到再查询数据库,并重建缓存;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@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中是否存在菜品数据
String key = "dish_" + categoryId;
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);
}

}

4.HttpClient

HttpClient 是一个用于发送 HTTP 请求、接收 HTTP 响应的编程工具库;

小程序用户登录时需要进行后端校验, 后端需要通过HttpClient 调用微信官方的接口(auth.code2Session);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
小程序登录


获取 code


发送给后端


后端使用 HttpClient 调用微信接口


获取 openid


生成 JWT token
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
@Service
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;
@Override
public User WxLogin(UserLoginDTO userLoginDTO) {
Map<String,String> map =new HashMap<>();
map.put("appid",weChatProperties.getAppid());
map.put("secret",weChatProperties.getSecret());
map.put("js_code",userLoginDTO.getCode());
map.put("grant_type","authorization_code");
String json = HttpClientUtil.doGet(WX_LOGIN, map);

JSONObject jsonObject = JSON.parseObject(json);
String openid = jsonObject.getString("openid");

//判断用户是否存在
if(openid ==null){
throw new LoginFailedException(MessageConstant.LOGIN_FAILED);
}

User user=userMapper.getByOpenid(openid);

if (user == null){
user = User.builder()
.openid(openid)
.createTime(LocalDateTime.now())
.build();
userMapper.insert(user);
}

return user;
}
}

5.购物车模块

购物车模块主要有新增,查询,和清空购物车的操作
新增时,会先判断菜品是否存在,存在则菜品的数量加一,不存在则插入菜品数据

苍穹外卖 中,购物车的设计是:每一种菜品(或套餐)对应一个购物车对象,但数量通过 number 字段累加

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
@Service
@Slf4j
public class ShoppingCartServiceImpl implements ShoppingCartService {
@Autowired
private ShoppingCartMapper shoppingCartMapper;
@Autowired
private DishMapper dishMapper;
@Autowired
private SetmealMapper setmealMapper;
/**
* 添加购物车
* @param shoppingCartDTO
*/
@Override
public void addShoppingCart(ShoppingCartDTO shoppingCartDTO) {

//判断当前购物车中是否存在此物品

ShoppingCart shoppingCart=new ShoppingCart();
BeanUtils.copyProperties(shoppingCartDTO,shoppingCart);
Long currentId = BaseContext.getCurrentId();
shoppingCart.setUserId(currentId);

List<ShoppingCart> list = shoppingCartMapper.list(shoppingCart);
//存在就数量加一

if(list != null && list.size()>0){
ShoppingCart cart=list.get(0);//获取第一条也是唯一一条数据
cart.setNumber(cart.getNumber()+1);
shoppingCartMapper.updateNumberById(cart);
}else{
//不存在就添加进购物车

//判断是菜品还是套餐
Long dishId= shoppingCart.getDishId();
if(dishId != null){
//这次添加的是菜品
Dish dish=dishMapper.getById(dishId);
shoppingCart.setName(dish.getName());
shoppingCart.setImage(dish.getImage());
shoppingCart.setAmount(dish.getPrice());
shoppingCart.setNumber(1);
shoppingCart.setCreateTime(LocalDateTime.now());
}else {
//这次添加的是套餐
Long setmealId = shoppingCart.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);
}
}

6.WebSocket

为了实现来单提醒功能,需要使用WebSocket

WebSocket是一种基于Tcp协议的一种新的网络协议;实现了浏览器和服务器的全双工通信—-浏览器服务器只需要一次握手,两者就能实现一个长连接,并进行双向数据传输;


Q1: 讲一下Tcp/ip协议;
Q2: 讲一下三次握手,四次挥手;
Q3: 为什么不是两次握手,为什么不是四次握手? 四次挥手中间两次挥手能不能合并为一次?

R1: Tcp/ip协议规定了 计算机在网络中如何进行数据传输和通信;

R2: 三次握手是为了确认双方通信能力、同步序列号、建立可靠连接, 在Tcp协议中,信息是通过报文传输的;
image-20260310094651398![image-20260310094652351](C:\Users\喻响\AppData\Roaming\Typora\typora-user-images\image-20260310094652351.png

三次握手:

image-20260310094723748

第一次握手,客户端向服务器发送一个SYN=1表示想要建立连接,和对应的seq; 这一步是为了(确保客户端有发送的能力),第二次握手,服务器接收到向客户端发送一个SYN=1,和ACK=1,表示同意也想建立连接,并发送对应的seq和ack(确保服务端有接收,发送的能力)第三次握手,客户端向服务器发送ACK=1,并发送对应的ack(确保客户端有接收的能力

四次挥手:

image-20260310095525468

第一次挥手,客户端向服务器端发送请求,表示想要断开连接; 第二次挥手,服务器端向客户端,发送确认信号; (这个时候连接是没有断开的) 第三次挥手服务器向客户端发送请求,表示想要断开连接; 第四次挥手客户端向服务器端发送,确认信号 (至此连接断开)

R3:为什么不是两次握手,四次握手: 因为三次握手是最少的能建立连接的握手次数,第三次连接,是为了让服务器知道客户端有接收消息的能力;

四次挥手中间两次挥手能不能合并为一次: 不能,服务器端发送确认号,这时候连接并不能断开,因为可能服务器还有信息没有发送完,服务器需要一段时间发送完信息之后再发起断开连接请求断开连接;


1.配置类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* WebSocket配置类,用于注册WebSocket的Bean
*/
/*向 Spring 容器中注册 ServerEndpointExporter,
用于扫描并注册所有 @ServerEndpoint 标注的 WebSocket 服务端。
@Configuration8*/
public class WebSocketConfiguration {

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

}

2.创建 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
57
58
59
/**
* 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();
}
}
}

}

3.业务中使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Autowired
private WebSocketServer webSocketServer;
/**
* 客户催单
* @param id
*/
@Override
public void reminder(Long id) {
//根据id查寻订单
Orders orders=new Orders();
if(orders == null){
throw new OrderBusinessException(MessageConstant.ORDER_STATUS_ERROR);
}
//基于websocket实现催单
Map map=new HashMap();
map.put("type",2);
map.put("orderId",id);
map.put("content","您的订单号为"+orders.getNumber());
webSocketServer.sendToAllClient(JSON.toJSONString(map));
}