前言:这是楼主跟做的项目,如有不足欢迎指出
项目链接
项目背景
该项目是面向餐厅管理员和用户研发的一款餐厅外卖小程序,用户可以在微信小程序下单以及支付订单、催单等功能,商家在管理端可以进行接单派送、完成订单、一键查看历史订单数据、一点导出历史订单数据等。
项目技术栈分析
后端技术栈
- 基础框架
Spring Boot:快速搭建 Java 后端应用,简化配置。
Spring MVC:处理 Web 请求,实现 MVC 模式。
MyBatis-Plus:ORM 框架,简化数据库操作,提升开发效率。 - 数据库与缓存
MySQL:关系型数据库,存储结构化业务数据。
Redis:内存缓存数据库,缓存高频访问数据,提升响应速度。 - 文件存储
MinIO:分布式对象存储系统,支持文件上传、下载和管理。 - 工具链
Maven:项目依赖管理工具,统一管理 jar 包版本。
第三方服务集成
- 地图服务
高德地图 API:实现商家定位、用户地址解析、配送路径规划。 - 支付系统
微信支付 API:集成扫码支付、订单查询等功能,支持在线交易。 - 实时通信
WebSocket:实现订单状态实时推送(如商家接单、配送中、已送达)。
技术亮点
微服务架构:系统拆分为订单、用户、支付等微服务,提升可扩展性和维护性。
高并发处理:Redis 缓存
前后端分离:独立开发、测试和部署,提升协作效率。
统一权限控制:基于 JWT(JSON Web Token)实现单点登录(SSO)和角色权限管理。
实时数据同步:WebSocket + MQ 组合确保前端与后端状态实时一致。
有待改进的点
高并发处理:通过 Redis 缓存、Sentinel 限流、RabbitMQ 异步解耦应对流量高峰。
项目知识点拆分
代码规范
这个项目因为体量较大,如果乱命名会降低代码的可读性,这个项目将项目中多次出现的字段封装成了常量类中的常量。
将多次出现的方法例如(getCurentId)封装到了一个类中,每次调用方法都会自动得到当前的线程并执行get()方法
将各个类封装,并根据输入数据和输出数据区分为xxxDTO和xxxVO方便对接接口文档更加高效的对代码进行开发
将多次出现的错误封装为不同的类,其中类名是各种不同种类的错误名称,方便后续抛出错误时进行区分
公共字段自动填充
在Impl层中有很多类似于设置createTime或者currentId的情况,为了提高代码的耦合性,可以通过AOP在数据库操作前自动为实体对象的公共字段赋值实现公共字段的自动填充功能。
实现过程
-
设计一个注解类,为AutoFill(公共字段填充) 指定级别和操作类型。
-
通过对一个切面类加上@Aspect注解,让Spring将该类识别为切面。并设计@Pointcut来指定接入点(图内是只在mapper包中生效,作用对象为刚刚声明的AutoFill注解)
-
设计代码逻辑。
前置通知(@Before):它会在满足autoFillPointCut()切入点条件的方法执行前触发,根据操作类型(INSERT/UPDATE)自动填充实体对象的公共字段。
joinPoint.getSignature()获取被拦截方法的签名信息。
getAnnotation(AutoFill.class):获取方法上的@AutoFill注解
operationType:获取注解中指定的操作类型(INSERT/UPDATE)
joinPoint.getArgs():获取方法的参数数组
args[0]:假设被拦截方法的第一个参数是需要填充的实体对象
通过类的getDeclaredMethod()方法获取指定名称和参数类型的方法,然后通过invoke()调用
然后在Mapper包中的任意一个接口中使用即可(注意要有createtime的属性否则报错)
Redis
Redis:基于内存的数据存储系统,它以键值对(key-value)的形式存储数据,被广泛应用于各种现代应用程序中,用于缓存、消息队列、分布式锁等多种场景。
key包括:String,hash,list,set,sorted set
在缓存之前首先要配置一个redis配置类,对redis操作进行一个基本的配置
Redis缓存菜品、套餐
如果用户很多且请求频繁时,不断地对数据库进行查询,可能会导致服务器压力过大,此时将一些高频的资源保存在redis中,可以有效减轻服务器压力并增加查询效率,这个操作称之为缓存,这里缓存还包括了手动缓存和声明式缓存。
当数据库更新时,应该进行判断,明确的update可以顺便将redis中的缓存数据进行更改,如果是增加删除等操作应该先清除redis中的缓存,防止数据不统一的情况发生。
手动缓存
手动缓存更适合复杂缓存逻辑,因为他可以完全的自定义缓存的失效控制。
RedisTemplate:Spring 提供的 Redis 操作模板
opsForValue():操作简单 K-V 类型数据
手动缓存:需要显式编写缓存查询和存入逻辑
Spring Cache声明式缓存
声明式缓存代码量更少,但是灵活性比较低,依赖于注解,适合简单的CRUD
@Cacheable:Spring Cache 注解,用于标记方法的返回值应该被缓存
value:缓存名称(dishCache),用于逻辑分组
key:缓存键(#categoryId),使用 SpEL 表达式,实际为dishCache::categoryId
最后要注意redis也有不足之处,比如:
缓存穿透:查询不存在的数据,导致每次请求都穿透到数据库
解决方法:在请求来时,判断请求,并将为空的数据也放入redis(初级解法)
缓存雪崩:大量缓存同时过期,导致瞬间所有请求都打到数据库
解决方法:在设置缓存管理器时,将过期时间改为30-40分钟随机过期,防止同一时间大量过期导致服务器崩溃
Jwt令牌
Jwt全程JSON Web Token,是一种用于在网络应用间安全传输声明的开放标准(RFC 7519)。它通常由三部分组成:头部(Header)、载荷(Payload)和签名(Signature)
头部(Header):通常由两部分组成,令牌的类型(即 JWT)和使用的签名算法,如 HMAC SHA256 或 RSA。它会被 Base64Url 编码形成 JWT 的第一部分。
载荷(Payload):包含声明(Claims),声明是关于实体(通常是用户)和其他数据的声明。声明有三种类型:注册声明、公开声明和私有声明。它会被 Base64Url 编码形成 JWT 的第二部分。
签名(Signature):为了创建签名部分,需要使用编码后的头部、编码后的载荷、一个秘钥(secret)和头部中指定的签名算法。签名用于验证消息在传输过程中没有被更改,并且在使用私钥签名的情况下,还可以验证 JWT 的发送者的身份。
有了jwt,用户登录会变得更加的安全,确保后端数据的安全。
创建Jwt如下:
解析token
在配置类中自定义拦截器,对所有请求携带的token进行jwt验证
@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");
}
以管理端为例
@Component
@Slf4j
public class JwtTokenAdminInterceptor implements HandlerInterceptor {
@Autowired
private JwtProperties jwtProperties;
/**
* 校验jwt
*
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//判断当前拦截到的是Controller的方法还是其他资源
if (!(handler instanceof HandlerMethod)) {
//当前拦截到的不是动态方法,直接放行
return true;
}
//1、从请求头中获取令牌
String token = request.getHeader(jwtProperties.getAdminTokenName());
//2、校验令牌
try {
log.info("jwt校验:{}", token);
Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());
log.info("当前员工id:", empId);
BaseContext.setCurrentId(empId);
//3、通过,放行
return true;
} catch (Exception ex) {
//4、不通过,响应401状态码
response.setStatus(401);
return false;
}
}
}
接入微信小程序
小程序登录
微信小程序官方提供图如上
小程序其实和网页差不多,是基于HttpClient完成的一个网页,可以和正常网页一样对服务端发起请求,与普通网页不同的是,在进行登录操作时,访问的不是后端数据库进行登录校验,而是微信官方提供的接口服务。
当前端发起请求时,会携带一份code数据,这个code只能用一次,且有时效性,后端携带着小程序的appid和秘钥(提前在yml文件配置好)以及code一起发送给微信接口服务,如果合法,那么微信接口服务会返回openid和session_key。openid是唯一的,可以在数据库存放openid作为用户的标识。
后端根据openid生成token确定用户唯一标识并返回给前端,前端发起携带着这个token就可以方便后端判断不同的用户了
WebSocket
WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议,它使得客户端和服务器之间可以实时地交换数据,并且占用更少的资源。适合聊天室或者管理端\客户端通知。
与Http不同的是,WebSocket是一次握手后进行长连接,而Http则是每次请求都是要创建新连接的短连接。WebSocket更具有实时性,但是需要注意安全和稳定性问题。
@ServerEndpoint(“/ws/{sid}”):这是一个websocket的注解,它表明这个类是一个 WebSocket 服务端,客户端可以通过ws://host:port/ws/{sid}这样的 URL 来连接,这里的{sid}属于路径参数,一般用来唯一标识客户端。
@Component
@ServerEndpoint("/ws/{sid}") //这是一个websocket的注解,它表明这个类是一个 WebSocket 服务端,客户端可以通过ws://host:port/ws/{sid}这样的 URL 来连接,这里的{sid}属于路径参数,一般用来唯一标识客户端。
public class WebSocketServer {
//存放会话对象
private static Map<String, Session> sessionMap = new HashMap();
/**
* 连接建立成功调用的方法
*/
//方法会把客户端的sid和对应的Session对象存入sessionMap,这样服务端就能跟踪所有连接的客户端了。
@OnOpen //当客户端与服务端成功建立 WebSocket 连接时,会触发这个注解修饰的方法。
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 //当客户端与服务端的 WebSocket 连接关闭时,会触发这个注解修饰的方法。
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();
}
}
}
}
Apache POI
Apache POI主要用于表格操作,可以一键将数据导出为.xlsx格式或者.docx
这里以表格.xlsx为例
在进行导出操作时,一般都会有模板,我只需要在模板表格的所需位置填入数据并输出即可
public void exportBusinessData(HttpServletResponse response) {
//1. 查询数据库----近30天的数据
LocalDate dateBegin = LocalDate.now().minusDays(30);
LocalDate dateEnd = LocalDate.now().minusDays(1);
BusinessDataVO businessDataVO = workspaceService.getBusinessData(LocalDateTime.of(dateBegin, LocalTime.MIN), LocalDateTime.of(dateEnd, LocalTime.MAX));
//2. 通过POI将数据写入Excel中
//获得输入流,模版在“template/运营数据报表模板.xlsx”
InputStream in = this.getClass().getClassLoader().getResourceAsStream("template/运营数据报表模板.xlsx");
//基于模版文件创建新的xlsx文件
try {
XSSFWorkbook excel = new XSSFWorkbook(in);
//填充数据
XSSFSheet sheet = excel.getSheet("Sheet1"); //表示对某某sheet进行操作,getSheetAt()是根据下标来的(从零开始)
//在第2行第2列填入时间(下标从0开始,但是实际显示时是从1开始,所以是2)
sheet.getRow(1).getCell(1).setCellValue("时间:" + dateBegin + " 至 " + dateEnd);
//与上面填充数据同理
XSSFRow row = sheet.getRow(3);
row.getCell(2).setCellValue(businessDataVO.getTurnover());
row.getCell(4).setCellValue(businessDataVO.getOrderCompletionRate());
row.getCell(6).setCellValue(businessDataVO.getNewUsers());
row = sheet.getRow(4);
row.getCell(2).setCellValue(businessDataVO.getValidOrderCount());
row.getCell(4).setCellValue(businessDataVO.getUnitPrice());
//这里要输入三十天的明细,进行一个for循环,在更新行数的时候更新数据的下标
for (int i = 0; i < 30; i++) {
LocalDate date = dateBegin.plusDays(i);
BusinessDataVO businessData = workspaceService.getBusinessData(LocalDateTime.of(date, LocalTime.MIN), LocalDateTime.of(date, LocalTime.MAX));
row = sheet.getRow(7 + i);
row.getCell(1).setCellValue(date.toString());
row.getCell(2).setCellValue(businessData.getTurnover());
row.getCell(3).setCellValue(businessData.getValidOrderCount());
row.getCell(4).setCellValue(businessData.getOrderCompletionRate());
row.getCell(5).setCellValue(businessData.getUnitPrice());
row.getCell(6).setCellValue(businessData.getNewUsers());
}
//输出到浏览器
ServletOutputStream out = response.getOutputStream(); //拿到输出流,通过浏览器输出做好的表格
excel.write(out); //将表格输出
//关闭资源
out.close();
excel.close();
} catch (IOException e) {
e.printStackTrace();
}
}
Swagger(docket)
Swagger 是一个用于构建、文档化和使用 RESTful API 的开源工具集,旨在简化 API 开发和管理流程。它提供了一系列工具和规范,帮助开发者设计、构建、测试和文档化 API,使团队成员(包括前端开发者、后端开发者和测试人员)能够更高效地协作。(ai的xixi)
Swagger的常用注解
- @Api 用在类上,例如Controller,表示对类的说明
- @ApiModel 用在类上,例如entity、DTO、VO
- @ApiModelProperty 用在属性上,描述属性信息
- @ApiOperation 用在方法上,例如Controller方法,说明方法的用途、作用
Docket 的作用
扫描 API 端点:自动发现并解析项目中的 Controller 和 RESTful 接口。
定义文档元信息:设置 API 标题、描述、版本等基本信息。
过滤 API 路径:通过选择器(Selectors)指定要包含或排除的 API 路径。
配置数据模型:定义参数、响应类型的格式和示例。
支持认证机制:配置 API 的认证方式(如 JWT、OAuth2)。
/**
* 通过knife4j生成接口文档
* @return
*/
@Bean
public Docket docket() {
ApiInfo apiInfo = new ApiInfoBuilder()
.title("苍穹外卖项目接口文档")
.version("2.0")
.description("苍穹外卖项目接口文档")
.build();
Docket docket = new Docket(DocumentationType.SWAGGER_2)
.apiInfo(apiInfo)
.select()
.apis(RequestHandlerSelectors.basePackage("com.sky.controller"))
.paths(PathSelectors.any())
.build();
return docket;
}
/**
* 设置静态资源映射
* @param registry
*/
protected void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/doc.html").addResourceLocations("classpath:/META-INF/resources/");
registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
}
配置好以后访问localhost:8080/doc.html即可看到基于controller层生成的接口文档
Spring Task
Spring Task 是 Spring 框架提供的轻量级定时任务解决方案,允许开发者在应用中方便地创建、配置和管理定时执行的任务。它支持简单的固定频率任务、Cron 表达式任务,并且可以与 Spring 的依赖注入(DI)无缝集成,是企业级应用中实现定时任务的首选方案。
Spring Task的核心注解是@Scheduled
@Scheduled支持三种定时策略
- fixedRate:固定频率执行,无论任务执行耗时多久,下次任务都会在上次开启时间之后的固定间隔启动。
- fixedDelay:固定延迟执行,上次任务执行完毕后,等待指定的延迟时间在执行下一次任务。
- cron:使用Cron表达式定义执行时间,灵活性最高。
在调用Spring Task前,需要在启动类前加上@EnableScheduling注解
然后在需要定时执行的方法前加上@Scheduled就可以定义定时任务了
专门创建一个任务类,来定义需要定时执行的方法。
@Component
@Slf4j
public class OrderTask {
@Autowired
private OrderMapper orderMapper;
//超过15分钟未支付的订单自动取消
@Scheduled(cron = "0 * * * * ?")//每分钟触发一下
public void processTimeoutOrder() {
log.info("定时处理超时订单:{}", LocalDateTime.now());
LocalDateTime time = LocalDateTime.now().plusMinutes(-15);
List<Orders> list = orderMapper.getByStatusAndOrderTimeLT(Orders.PENDING_PAYMENT, time);
if(list != null && list.size() > 0){
for (Orders orders : list) {
orders.setStatus(Orders.CANCELLED);
orders.setCancelReason("支付超时");
orders.setCancelTime(LocalDateTime.now());
orderMapper.update(orders);
}
}
}
//凌晨一点仍派送的订单自动完成
@Scheduled(cron = "0 0 1 * * ?")//每天凌晨一点触发
public void processDeliveryOrder(){
log.info("定时处理派送中订单:{}", LocalDateTime.now());
LocalDateTime time = LocalDateTime.now().plusMinutes(-60);
List<Orders> list = orderMapper.getByStatusAndOrderTimeLT(Orders.DELIVERY_IN_PROGRESS, time);
if(list != null && list.size() > 0){
for (Orders orders : list) {
orders.setStatus(Orders.COMPLETED);
orders.setCancelReason("订单自动完成");
orders.setCancelTime(LocalDateTime.now());
orderMapper.update(orders);
}
}
}
}
OSS对象存储
OSS(Object Storage Service)即对象存储服务,是一种面向互联网的存储架构,用于存储和管理非结构化数据(如图片、视频、文档等)。与传统的块存储或文件存储不同,OSS 以 对象(Object) 为基本单位进行存储,每个对象包含数据本身、元数据(如文件名、类型、时间戳)和唯一标识符(Key)。
特性 | OSS 对象存储 | 传统文件存储 |
---|---|---|
数据结构 | 扁平化对象结构 | 树形目录结构 |
扩展性 | 无限扩展 | 受硬件限制 |
成本 | 按使用量付费 | 前期硬件投入高 |
访问方式 | RESTful API | 挂载文件系统 |
可用性 | 99.99%+ | 依赖硬件冗余 |
适用场景 | 静态资源、大数据、归档 | 共享文件、数据库存储 |
在商品页中不仅会展示商品的价格和名称,还包括了商品的图片。这个项目用到了基于阿里云服务器的oss对象存储服务,可以在上传图片后自动在后端保存图片的链接(url),下次直接调用即可,而不用本地储存。
首先要在配置项配置oss的相关信息
application.yml
alioss:
endpoint: ${sky.alioss.endpoint}
access-key-id: ${sky.alioss.access-key-id}
access-key-secret: ${sky.alioss.access-key-secret}
bucket-name: ${sky.alioss.bucket-name}
appllication-dev.yml(这里的秘钥啥的都是瞎打的,实际上需要去官网获取…)
alioss:
endpoint: oss-cn-beijing.aliyuncs.com
access-key-id: qwedasd5sdBqwestasdadsdasdg
access-key-secret: yaIqwesdsdgfeasdadadasdaAuIoBHretertT
bucket-name: 2sdasdwdasd
相关代码
@Autowired
private AliOssUtil aliOssUtil;
/**
* 文件上传
* @param file
* @return
*/
@PostMapping("/upload")
@ApiOperation("文件上传")
public Result<String> upload(MultipartFile file){
log.info("文件上传:{}",file);
String originalFilename = file.getOriginalFilename(); //获得文件名
String extension = originalFilename.substring(originalFilename.lastIndexOf(".")); //获得文件类型(拓展名)
String objectName = UUID.randomUUID().toString() + extension; //为了防止文件重名,将文件名转为uuid格式,并加上后缀
try {
String filePath = aliOssUtil.upload(file.getBytes(), objectName); //通过定义好的aliOssUtil上传数据
return Result.success(filePath);
} catch (IOException e) {
log.error("文件上传失败:{}",e);
}
return Result.error(MessageConstant.UPLOAD_FAILED);
}
aliOssUtil相关代码如下
OssConfiguration.java
//通过配置类,确保代码中只有一个AliOssUtil对象,并为其赋值。
@Configuration
@Slf4j
public class OssConfiguration {
@Bean
@ConditionalOnMissingBean //如果容器中没有AliOssUtil对象,则创建一个,保证对象的唯一性。
public AliOssUtil aliOssUtil(AliOssProperties aliOssProperties) {
log.info("开始创建阿里云文件上传工具对象:{}",aliOssProperties);
return new AliOssUtil(aliOssProperties.getEndpoint(),
aliOssProperties.getAccessKeyId(),
aliOssProperties.getAccessKeySecret(),
aliOssProperties.getBucketName());
}
}
--------------------
AliOssUtil.java
@Data
@AllArgsConstructor
@Slf4j
public class AliOssUtil {
private String endpoint;
private String accessKeyId;
private String accessKeySecret;
private String bucketName;
/**
* 文件上传
*
* @param bytes
* @param objectName
* @return
*/
public String upload(byte[] bytes, String objectName) {
// 创建OSSClient实例。
//endpoint是在yml定义的oss域名
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();
}
}
全局异常处理器
@RestControllerAdvice 可以拦截所有控制器(Controller)中抛出的异常,并将其转换为统一的响应格式,避免了在每个控制器中重复编写异常处理代码。
@ExceptionHandler 将方法标记为异常处理方法,当控制器抛出指定类型的异常时,会自动调用该方法。
/**
* 全局异常处理器,处理项目中抛出的业务异常
*/
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/**
* 捕获业务异常
* @param ex
* @return
*/
@ExceptionHandler
public Result exceptionHandler(BaseException ex){
log.error("异常信息:{}", ex.getMessage());
return Result.error(ex.getMessage());
}
@ExceptionHandler
public Result exceptionHandler(SQLIntegrityConstraintViolationException ex){
String message = ex.getMessage();
if(message.contains("Duplicate entry")){
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);
}
}
}