项目概述
| 岗位/角色 | 职责/分工 |
|---|---|
| 项目经理 | 对整个项目负责,任务分配、把控进度 |
| 产品经理 | 进行需求调研,输出需求调研文档、产品原型等 |
| UI设计师 | 根据产品原型输出界面效果图 |
| 架构师 | 项目整体架构设计、技术选型等 |
| 开发工程师 | 功能代码实现 |
| 测试工程师 | 编写测试用例,输出测试报告 |
| 运维工程师 | 软件环境搭建、项目上线 |
项目原型图

后台实现功能
| 模块 | 描述 |
|---|---|
| 登录退出 | 内部员工必须登录后,才可以访问系统管理后台 |
| 员工管理 | 管理员可以在系统后台对员工信息进行管理,包含查询、新增、编辑、禁用等功能 |
| 分类管理 | 主要对当前餐厅经营的 菜品分类 或 套餐分类 进行管理维护, 包含查询、新增、修改、删除等功能 |
| 菜品管理 | 主要维护各个分类下的菜品信息,包含查询、新增、修改、删除、启售、停售等功能 |
| 套餐管理 | 主要维护当前餐厅中的套餐信息,包含查询、新增、修改、删除、启售、停售等功能 |
| 订单明细 | 主要维护用户在移动端下的订单信息,包含查询、取消、派送、完成,以及订单报表下载等功能 |
客户端实现功能
| 模块 | 描述 |
|---|---|
| 登录/退出 | 在移动端, 用户也需要登录后使用APP进行点餐 |
| 点餐-菜单 | 在点餐界面需要展示出菜品分类/套餐分类, 并根据当前选择的分类加载其中的菜品信息, 供用户查询选择 |
| 点餐-购物车 | 用户选中的菜品就会加入用户的购物车, 主要包含 查询购物车、加入购物车、删除购物车、清空购物车等功能 |
| 订单支付 | 用户选完菜品/套餐后, 可以对购物车菜品进行结算支付, 这时就需要进行订单的支付 |
| 个人信息 | 在个人中心页面中会展示当前用户的基本信息, 用户可以管理收货地址, 也可以查询历史订单数据 |
角色
| 角色 | 权限操作 |
|---|---|
| 后台系统管理员 | 登录后台管理系统,拥有后台系统中的所有操作权限 |
| 后台系统普通员工 | 登录后台管理系统,对菜品、套餐、订单等进行管理 (不包含员工管理) |
| C端用户 | 登录移动端应用,可以浏览菜品、添加购物车、设置地址、在线下单等 |
数据表
| 序号 | 表名 | 说明 |
|---|---|---|
| pc端 | employee | 员工表 |
| pc端 | category | 菜品和套餐分类表 |
| pc端 | dish | 菜品表 |
| pc端 | setmeal | 套餐表 |
| pc端 | setmeal_dish | 套餐菜品关系表 |
| pc端 | dish_flavor | 菜品口味关系表 |
| C端 | user | 用户表(C端) |
| C端 | address_book | 地址簿表 |
| C端 | shopping_cart | 购物车表 |
| C端 | orders | 订单表 |
| C端 | order_detail | 订单明细表 |
项目依赖、配置文件
@Slf4j :是lombok中提供的注解, 用来通过slf4j记录日志
<properties><java.version>1.8</java.version></properties><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.4.5</version><relativePath/> <!-- lookup parent from repository --></parent><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId><scope>compile</scope></dependency><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>3.4.2</version></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>1.18.20</version></dependency><dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId><version>1.2.76</version></dependency><dependency><groupId>commons-lang</groupId><artifactId>commons-lang</artifactId><version>2.6</version></dependency><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId><scope>runtime</scope></dependency><dependency><groupId>com.alibaba</groupId><artifactId>druid-spring-boot-starter</artifactId><version>1.1.23</version></dependency></dependencies><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId><version>2.4.5</version></plugin></plugins></build>
后续使用redis和主从数据库会在更改
server:port: 8080spring:application:#应用名称 , 可选name: reggie_take_outdatasource:druid:driver-class-name: com.mysql.cj.jdbc.Driverurl: jdbc:mysql://localhost:3306/reggie?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=trueusername: rootpassword: 1234mybatis-plus:configuration:#在映射实体或者属性时,将数据库中表名和字段名中的下划线去掉,按照驼峰命名法映射 address_book ---> AddressBookmap-underscore-to-camel-case: true#日志输出log-impl: org.apache.ibatis.logging.stdout.StdOutImplglobal-config:db-config:id-type: ASSIGN_ID
启动类编写
import lombok.extern.slf4j.Slf4j;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;@Slf4j@SpringBootApplicationpublic class ReggieApplication {public static void main(String[] args) {SpringApplication.run(ReggieApplication.class,args);log.info("项目启动成功...");
开发
暂且先只关注后端接口,前端实现暂待2022.6.1
静态资源映射
我们的项目中静态资源存放在 backend, front 目录中, 那么这个时候要想访问到静态资源, 就需要设置静态资源映射。
使用Spring Boot的默认配置方式,提供的静态资源映射如下:
当访问项目中的任意资源(即“/**”)时,Spring Boot 会默认从以下路径中查找资源文件(优先级依次降低):
- classpath:/META-INF/resources/
- classpath:/resources/
- classpath:/static/
- classpath:/public/
这些路径又被称为静态资源文件夹,它们的优先级顺序为:classpath:/META-INF/resources/ > classpath:/resources/ > classpath:/static/ > classpath:/public/当我们请求某个静态资源(即以“.html”结尾的请求)时,Spring Boot 会先查找优先级高的文件夹,再查找优先级低的文件夹,直到找到指定的静态资源为止。
自定义配置映射
还有,你可以随机在上面一个路径下面放上index.html,当我们访问应用根目录http://lcoalhost:8080 时,会直接映射到index.html页面。
对应的配置文件配置如下:
我们可以通过修改spring.mvc.static-path-pattern来修改默认的映射,例如我改成/dudu/**,那运行的时候访问 http://lcoalhost:8080/dudu/index.html 才对应到index.html页面。
一旦自己定义了静态文件夹的路径,原来的自动配置就都会失效了!
自定义资源映射(配置类)
自定义静态资源映射目录的话,只需重写addResourceHandlers方法即可。 见本项目
如果你想指定外部的目录也很简单,直接addResourceLocations指定即可,代码如下:
spring:mvc:static-path-pattern: /backend/**web:resources:static-locations: classpath:/backend/,classpath:/front/
registry.addResourceHandler("/my/**").addResourceLocations("classpath:/my/");
本项目的映射
- addResourceLocations指的是文件放置的目录
- addResoureHandler指的是对外暴露的访问路径
@Slf4j@Configurationpublic class WebMvcConfig extends WebMvcConfigurationSupport {/*** 设置静态资源映射* @param registry*/@Overrideprotected void addResourceHandlers(ResourceHandlerRegistry registry) {log.info("开始进行静态资源映射...");registry.addResourceHandler("/backend/**").addResourceLocations("classpath:/backend/");registry.addResourceHandler("/front/**").addResourceLocations("classpath:/front/");}}
登录功能
前端
点击 "登录" 按钮, 会触发Vue中定义的 handleLogin 方法登陆中的转圈小效果**json 数据返回存储localStorage.setItem('userInfo',JSON.stringify(res.data))异步请求时间长些,方便debug;以及修改js文件后清理浏览器缓存
后端
结构
//mapper接口继承public interface EmployeeMapper extends BaseMapper<Employee>//service接口继承public interface EmployeeService extends IService<Employee>//service实现类继承public class EmployeeServiceImpl extends ServiceImpl<EmployeeMapper,Employee> implements EmployeeService//结果类R(result)@Datapublic class R<T> {private Integer code; //编码:1成功,0和其它数字为失败private String msg; //错误信息private T data; //数据private Map map = new HashMap(); //动态数据//业务执行结果为成功, 构建R对象时, 调用 success 方法; 如果需要,返回数据传递 object 参数, 如果无需返回, 可以直接传递null。public static <T> R<T> success(T object) {R<T> r = new R<T>();r.data = object;r.code = 1;return r;}//业务执行结果为失败, 构建R对象时, 调用error 方法,传递错误提示信息即可。public static <T> R<T> error(String msg) {R r = new R();r.msg = msg;r.code = 0;return r;}public R<T> add(String key, Object value) {this.map.put(key, value);return this;}}
登录处理逻辑
@PostMapping rest风格
@RequestBody 请求参数是json格式
- 将页面提交的密码password进行md5加密处理, 得到加密后的字符串 ``` spring核心包实现 org.springframework.util.DigestUtils; password = DigestUtils.md5DigestAsHex(password.getBytes());
还有种推荐方法 import org.apache.commons.codec.digest.DigestUtils; public static String encryptToMD5(String str) { return DigestUtils.md5Hex(str); }
- 根据页面提交的用户名username查询数据库中员工数据信息
mp条件构造器(复习)
LambdaQueryWrapper
- 如果没有查询到, 则返回登录失败结果R.error- 密码比对,如果不一致, 则返回登录失败结果R.error- 查看员工状态,如果为已禁用状态,则返回员工已禁用结果R.error- 登录成功,将员工id存入Session, 并返回登录成功结果(处理时传入了HttpServletRequest request)R.success```javarequest.getSession().setAttribute("employee",emp.getId());
异步请求时间长些,方便debug;以及修改js文件后清理浏览器缓存
点击登陆后会弹回来
超时了,所以异步请求时间长些,方便debug
退出功能
前端
- 调用方法logout
- 发起post请求, 调用服务端接口 /employee/logout 执行退出操作
- 删除客户端 localStorage 中存储的用户登录信息, 跳转至登录页面
后端
- 清理Session中的用户id
request.getSession().removeAttribute("employee")
- 返回结果R.success
登录完善
登录才可以访问主页面
前端
断点debug
后端
过滤器逻辑
- 获取本次请求的URI
- 判断本次请求, 是否需要登录, 才可以访问
- 如果不需要,则直接放行
- 判断登录状态,如果已登录,则直接放行
- 如果未登录, 则返回未登录结果
AntPathMatcher拓展:Spring中提供的路径匹配器 ;? 匹配一个字符* 匹配0个或多个字符** 匹配0个或多个目录/字符
@ServletComponentScan 的作用:
在SpringBoot项目中, 在引导类/配置类上加了该注解后, 会自动扫描项目中(当前包及其子包下)的@WebServlet , @WebFilter , @WebListener 注解, 自动注册Servlet的相关组件 ;
定义一个过滤器 LoginCheckFilter 并实现 Filter 接口, 在doFilter方法中完成校验的逻辑@WebFilter(filterName = "loginCheckFilter",urlPatterns = "/*")@Slf4jpublic class LoginCheckFilter implements Filter{//路径匹配器,支持通配符public static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();@Overridepublic void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {HttpServletRequest request = (HttpServletRequest) servletRequest;HttpServletResponse response = (HttpServletResponse) servletResponse;//1、获取本次请求的URIString requestURI = request.getRequestURI();// /backend/index.htmllog.info("拦截到请求:{}",requestURI);//定义不需要处理的请求路径String[] urls = new String[]{"/employee/login","/employee/logout","/backend/**","/front/**"};//2、判断本次请求是否需要处理boolean check = check(urls, requestURI);//3、如果不需要处理,则直接放行if(check){log.info("本次请求{}不需要处理",requestURI);filterChain.doFilter(request,response);return;}//4、判断登录状态,如果已登录,则直接放行if(request.getSession().getAttribute("employee") != null){log.info("用户已登录,用户id为:{}",request.getSession().getAttribute("employee"));filterChain.doFilter(request,response);return;}log.info("用户未登录");//5、如果未登录则返回未登录结果,通过输出流方式向客户端页面响应数据response.getWriter().write(JSON.toJSONString(R.error("NOTLOGIN")));return;}/*** 路径匹配,检查本次请求是否需要放行* @param urls* @param requestURI* @return*/public boolean check(String[] urls,String requestURI){for (String url : urls) {boolean match = PATH_MATCHER.match(url, requestURI);if(match){return true;}}return false;}}
实现filter接口注意:在Java8之前,接口中的实现方法必须是abstract的,实现该接口的类必须重写该方法,接口只负责声明该方法。
Java8给接口增加了default关键词,用default修饰的方法可以有实现内容,实现该接口的类可以不重写用default修饰的方法,类似于继承。但这样也会带来新的问题。
Java中只能继承一个类,但是可以实现多个接口,当多个接口中有同一个方法时,以前是没问题的,因为实现类必须重写方法。但现在,当多个接口中有同一个用default修饰的方法时,就无法判断到底实现的是哪个接口的方法。这种情况下,就必须重写方法。
还有一种情况,一个类继承的父类和实现的接口中都有同一个方法,而这个类又没有重写时,实现的是父类的方法,而不是接口中的方法。
过滤器拦截器拓展
filter过滤器
概念:对目标资源的请求和响应进行过滤截取。在请求到达servlet之前,进行逻辑判断,判断是否放行到servlet;也可以在一个响应response到达客户端之前进行过滤,判断是否允许返回客户端。
场景:
(用户授权的过滤器:判断用户是否有权限请求界面)
(日志信息的过滤器:过滤用户在网站的所有请求,记录轨迹 )
(负责解码的过滤器:规定请求的解码方式)interceptor拦截器
java中的拦截器是动态拦截action调用的对象。依赖于web框架,在springmvc中依赖于SpringMVC框架,在实现上基于Java的反射机制,属于AOP的一种应用,作用类似于过滤器,但是拦截器只能对Controller请求进行拦截,对其他的直接访问静态资源的请求无法拦截处理。
过滤器和拦截器的区别?
①:拦截器是基于java的反射机制,而过滤器基于函数回调。
②:过滤器依赖于servlet容器,拦截器不依赖于servlet容器。
③:拦截器只能对action请求起作用,而过滤器几乎对所有的请求都起作用。
④:拦截器可以访问action上下文,值栈里的对象,而过滤器不能。
⑤:在action的生命周期中,拦截器可以多次被调用,而过滤器只能在容器初始化时被调用一次。
⑥:拦截器可以获取IOC容器中的各个bean,而过滤器就不行,(在拦截器里注入一个service,可以调用业务逻辑)。
⑦:过滤器是在请求进入容器后,但进入servlert前进行预处理的。响应请求也是,在servlet处理结束后,返回给客户端前触发。而拦截器提供了三个方法支持(1)preHandle:预处理回调方法,实现处理器的预处理(如登录检查),第三个参数为响应的处理器(如我们上一章的Controller实现); 返回值:true表示继续流程(如调用下一个拦截器或处理器);false表示流程中断(如登录检查失败),不会继续调用其他的拦截器或处理器,此时我们需要通过response来产生响应;postHandle:后处理回调方法,实现处理器的后处理(但在渲染视图之前),此时我们可以通过modelAndView(模型和视图对象)对模型数据进行处理或对视图进行处理,modelAndView也可能为null。
afterCompletion:整个请求处理完毕回调方法,即在视图渲染完毕时回调,如性能监控中我们可以在此记录结束时间并输出消耗时间,还可以进行一些资源清理,类似于try-catch-finally中的finally,但仅调用处理器执行链中preHandle返回true的拦截器的afterCompletion。
新增员工
对应数据模型smployee表;status默认1,username唯一约束
前端
后端
逻辑处理
- 新增员工时, 按钮页面原型中的需求描述, 需要给员工设置初始默认密码 123456, 并对密码进行MD5加密。
- 在组装员工信息时, 还需要封装创建时间、修改时间,创建人、修改人信息(从session中获取当前登录用户)。后面会用threadlocal封装
/*** 新增员工* @param employee* @return*/@PostMappingpublic R<String> save(HttpServletRequest request,@RequestBody Employee employee){log.info("新增员工,员工信息:{}",employee.toString());//设置初始密码123456,需要进行md5加密处理employee.setPassword(DigestUtils.md5DigestAsHex("123456".getBytes()));//打断点,看时间格式employee.setCreateTime(LocalDateTime.now());employee.setUpdateTime(LocalDateTime.now());//获得当前登录用户的idLong empId = (Long) request.getSession().getAttribute("employee");employee.setCreateUser(empId);employee.setUpdateUser(empId);employeeService.save(employee);return R.success("新增员工成功");}
全局异常处理(记得看下视频)
挖坑:后续优化,异步请求查询,提示名字已重复5.31
因为此时添加用户时, 如果输入了一个已存在的用户名时,前端界面出现错误提示信息,但是信息不具体。若使用trycatch,代码冗余不通用
try{employeeService.save(employee);}catch{e.printStackTrace;return R.error("新增失败")}
在项目中自定义一个全局异常处理器,在异常处理器上加上注解 @ControllerAdvice,可以通过属性annotations指定拦截哪一类的Controller方法。 并在异常处理器的方法上加上注解 @ExceptionHandler 来指定拦截的是那一类型的异常。@ResponseBody: 将方法的返回值 R 对象转换为json格式的数据, 响应给页面;@RestControllerAdvice =@ControllerAdvice+@ResponseBody
处理逻辑
- 指定捕获的异常类型为 SQLIntegrityConstraintViolationException
- 解析异常的提示信息, 获取出是那个值违背了唯一约束
- 组装错误信息并返回
/*** 全局异常处理*/@ControllerAdvice(annotations = {RestController.class, Controller.class})@ResponseBody@Slf4jpublic class GlobalExceptionHandler {/*** 异常处理方法* @return*/@ExceptionHandler(SQLIntegrityConstraintViolationException.class)public R<String> exceptionHandler(SQLIntegrityConstraintViolationException ex){log.error(ex.getMessage());if(ex.getMessage().contains("Duplicate entry")){String[] split = ex.getMessage().split(" ");String msg = split[2] + "已存在";return R.error(msg);}return R.error("未知错误");}}
员工分页查询
前端
- 请求参数
- 搜索条件: 员工姓名(模糊查询)
- 分页条件: 每页展示条数 , 页码
- 响应数据
- 总记录数
- 结果列表
async init () {const params = {page: this.page,pageSize: this.pageSize,name: this.input ? this.input : undefined}await getMemberList(params).then(res => {if (String(res.code) === '1') {this.tableData = res.data.records || []this.counts = res.data.total}}).catch(err => {this.$message.error('请求出错了:' + err)})}
最终发送给服务端的请求为 : GET请求 , 请求链接 /employee/page?page=1&pageSize=10&name=xxx
后端
配置分页插件
@Configurationpublic class MybatisPlusConfig {@Beanpublic MybatisPlusInterceptor mybatisPlusInterceptor(){MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor();mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor());return mybatisPlusInterceptor;}}
mybatis封装的page对象,返回给前端这个
public class Page<T> implements IPage<T> {private static final long serialVersionUID = 8545996863226528798L;protected List<T> records;protected long total;protected long size;protected long current;protected List<OrderItem> orders;protected boolean optimizeCountSql;protected boolean isSearchCount;protected boolean hitCount;protected String countId;protected Long maxLimit;
实现逻辑
- A. 构造分页条件
- B. 构建搜索条件 - name进行模糊匹配
- C. 构建排序条件 - 更新时间倒序排序
- D. 执行查询
- E. 组装结果并返回
/*** 员工信息分页查询* @param page 当前查询页码* @param pageSize 每页展示记录数* @param name 员工姓名 - 可选参数* @return*/@GetMapping("/page")public R<Page> page(int page,int pageSize,String name){//构造分页构造器Page pageInfo = new Page(page,pageSize);//构造条件构造器LambdaQueryWrapper<Employee> queryWrapper = new LambdaQueryWrapper();//添加过滤条件第一个参数:布尔值,导包commons.lang.StringUtils queryWrapper.like(StringUtils.isNotEmpty(name),Employee::getName,name);//添加排序条件queryWrapper.orderByDesc(Employee::getUpdateTime);//执行查询employeeService.page(pageInfo,queryWrapper);return R.success(pageInfo);}
启用/禁用员工账号
前端
挖坑优化:不是判断用户名为admin,万一不止一个管理员呢
- 在列表页面(list.html)加载时, 触发钩子函数created, 在钩子函数中, 会从localStorage中获取到用户登录信息(随意下面好多操作能得到id), 然后获取到用户名
- 在页面中, 通过Vue指令v-if进行判断,如果登录用户为admin将展示 启用/禁用 按钮, 否则不展示
- 当管理员admin点击 “启用” 或 “禁用” 按钮时, 调用方法statusHandle
- statusHandle方法中进行二次确认, 然后发起ajax请求, 传递id、status参数
请求方式 PUT请求路径/employee 请求参数{"id":xxx,"status":xxx}{...params}: 三点是ES6中出现的扩展运算符。作用是遍历当前使用的对象能够访问到的所有属性,并将属性放入当前对象中。
后端
前端怎么得到的id?2022.6.1
实现逻辑
- 页面发送ajax请求,将参数(id、status)提交到服务端,相当于更新数据
- 服务端Controller接收页面提交的数据并调用Service更新数据
- Service调用Mapper操作数据库
@PutMappingpublic R<String> update(HttpServletRequest request,@RequestBody Employee employee){log.info(employee.toString());Long empId = (Long)request.getSession().getAttribute("employee");employee.setUpdateTime(LocalDateTime.now());employee.setUpdateUser(empId);//回顾mybatisemployeeService.updateById(employee);return R.success("员工信息修改成功");}
问题处理
在分页查询时,服务端会将返回的R对象进行json序列化,转换为json格式的数据,而员工的ID是一个Long类型的数据,而且是一个长度为 19 位的长整型数据,s在对长度较长的长整型数据进行处理时, 会损失精度, 从而导致提交的id和数据库中的id不一致
解决方法:让分页查询返回的json格式数据库中, long类型的属性, 不直接转换为数字类型, 转换为字符串类型
在SpringMVC中, 将Controller方法返回值转换为json对象, 是通过jackson来实现的, 涉及到SpringMVC中的一个消息转换器MappingJackson2HttpMessageConverter, 所以我们要解决这个问题, 就需要对该消息转换器的功能进行拓展
步骤
- 提供对象转换器JacksonObjectMapper,基于Jackson进行Java对象到json数据的转换(资料中已经提供,直接复制到项目中使用)
- 在WebMvcConfig配置类中扩展Spring mvc的消息转换器,在此消息转换器中使用提供的对象转换器进行Java对象到json数据的转换
在WebMvcConfig中重写方法extendMessageConverters@Overrideprotected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {log.info("扩展消息转换器...");//创建消息转换器对象MappingJackson2HttpMessageConverter messageConverter = new MappingJackson2HttpMessageConverter();//设置对象转换器,底层使用Jackson将Java对象转为jsonmessageConverter.setObjectMapper(new JacksonObjectMapper());//将上面的消息转换器对象追加到mvc框架的转换器集合中converters.add(0,messageConverter);}
编辑员工信息
程序流程
1). 点击编辑按钮时,页面跳转到add.html,并在url中携带参数[员工id]
2). 在add.html页面获取url中的参数[员工id]
3). 发送ajax请求,请求服务端,同时提交员工id参数
4). 服务端接收请求,根据员工id查询员工信息,将员工信息以json形式响应给页面
5). 页面接收服务端响应的json数据,通过VUE的数据绑定进行员工信息回显
6). 点击保存按钮,发送ajax请求,将页面中的员工信息以json方式提交给服务端
7). 服务端接收员工信息,并进行处理,完成后给页面响应
8). 页面接收到服务端响应信息后进行相应处理
前端
add.html页面为公共页面,新增员工和编辑员工都是在此页面操作
后端
/*** 根据id查询员工信息* @param id* @return*/@GetMapping("/{id}")public R<Employee> getById(@PathVariable Long id){log.info("根据id查询员工信息...");Employee employee = employeeService.getById(id);if(employee != null){return R.success(employee);}return R.error("没有查询到对应员工信息");}
/*** 根据id修改员工信息* @param employee* @return*/@PutMappingpublic R<String> update(HttpServletRequest request,@RequestBody Employee employee){log.info(employee.toString());Long empId = (Long)request.getSession().getAttribute("employee");employee.setUpdateTime(LocalDateTime.now());employee.setUpdateUser(empId);employeeService.updateById(employee);return R.success("员工信息修改成功");}
公共字段自动填充
前因:设置创建时间、创建人、修改时间、修改人等字段,这些字段属于公共字段,在我们的项目中处理这些字段都是在每一个业务方法中进行赋值操作,编码相对冗余、繁琐。
解决:使用Mybatis Plus提供的公共字段自动填充功能
实现步骤
在实体类的属性上加入@TableField注解,指定自动填充的策略。
@TableField(fill = FieldFill.INSERT) //插入时填充字段private LocalDateTime createTime;@TableField(fill = FieldFill.INSERT_UPDATE) //插入和更新时填充字段private LocalDateTime updateTime;@TableField(fill = FieldFill.INSERT) //插入时填充字段private Long createUser;@TableField(fill = FieldFill.INSERT_UPDATE) //插入和更新时填充字段private Long updateUser;
按照框架要求编写元数据对象处理器,在此类中统一为公共字段赋值,此类需要实现MetaObjectHandler接口。
@Component@Slf4jpublic class MyMetaObjecthandler implements MetaObjectHandler {/*** 插入操作,自动填充* @param metaObject*/@Overridepublic void insertFill(MetaObject metaObject) {log.info("公共字段自动填充[insert]...");log.info(metaObject.toString());metaObject.setValue("createTime", LocalDateTime.now());metaObject.setValue("updateTime",LocalDateTime.now());metaObject.setValue("createUser",new Long(1));metaObject.setValue("updateUser",new Long(1));}/*** 更新操作,自动填充* @param metaObject*/@Overridepublic void updateFill(MetaObject metaObject) {log.info("公共字段自动填充[update]...");log.info(metaObject.toString());metaObject.setValue("updateTime",LocalDateTime.now());metaObject.setValue("updateUser",new Long(1));}}
完善
在自动填充createUser和updateUser时设置的用户id是固定值,现在我们需要完善,改造成动态获取当前登录用户的id;但是在MyMetaObjectHandler类中是不能直接获得HttpSession对象的,所以我们需要通过其他方式来获取登录用户id
修改员工信息执行流程
客户端发送的每次http请求,对应的在服务端都会分配一个新的线程来处理,在处理过程中涉及到下面类中的方法都属于相同的一个线程:
1). LoginCheckFilter的doFilter方法
2). EmployeeController的update方法
3). MyMetaObjectHandler的updateFill方法
我们可以在上述类的方法中加入如下代码(获取当前线程ID,并输出):
执行编辑员工功能进行验证,通过观察控制台输出可以发现,一次请求对应的线程id是相同的:
long id = Thread.currentThread().getId();log.info("线程id为:{}",id);
ThreadLocal
ThreadLocal并不是一个Thread,而是Thread的局部变量。当使用ThreadLocal维护变量时,ThreadLocal为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。
ThreadLocal为每个线程提供单独一份存储空间,具有线程隔离的效果,只有在线程内才能获取到对应的值,线程外则不能访问当前线程对应的值。
ThreadLocal常用方法:
A. public void set(T value) : 设置当前线程的线程局部变量的值
B. public T get() : 返回当前线程所对应的线程局部变量的值
C. public void remove() : 删除当前线程所对应的线程局部变量的值
我们可以在LoginCheckFilter的doFilter方法中获取当前登录用户id,并调用ThreadLocal的set方法来设置当前线程的线程局部变量的值(用户id),然后在MyMetaObjectHandler的updateFill方法中调用ThreadLocal的get方法来获得当前线程所对应的线程局部变量的值(用户id)。 如果在后续的操作中, 我们需要在Controller / Service中要使用当前登录用户的ID, 可以直接从ThreadLocal直接获取。
完善逻辑
- 1). 编写BaseContext工具类,基于ThreadLocal封装的工具类
- 在LoginCheckFilter的doFilter方法中调用BaseContext来设置当前登录用户的id
- 在MyMetaObjectHandler的方法中调用BaseContext获取登录用户的id
代码实现
1). BaseContext工具类
所属包: com.itheima.reggie.common
/*** 基于ThreadLocal封装工具类,用户保存和获取当前登录用户id*/public class BaseContext {private static ThreadLocal<Long> threadLocal = new ThreadLocal<>();/*** 设置值* @param id*/public static void setCurrentId(Long id){threadLocal.set(id);}/*** 获取值* @return*/public static Long getCurrentId(){return threadLocal.get();}}
2).LoginCheckFilter中存放当前登录用户到ThreadLocal
在doFilter方法中, 判定用户是否登录, 如果用户登录, 在放行之前, 获取HttpSession中的登录用户信息, 调用BaseContext的setCurrentId方法将当前登录用户ID存入ThreadLocal。
Long empId = (Long) request.getSession().getAttribute("employee");BaseContext.setCurrentId(empId);
3). MyMetaObjectHandler中从ThreadLocal中获取
将之前在代码中固定的当前登录用户1, 修改为动态调用BaseContext中的getCurrentId方法获取当前登录用户ID
新增分类
数据模型
category表
前端
后端
Mapper接口CategoryMapper业务层接口CategoryService业务层实现类CategoryServiceImpl控制层CategoryController/*** 分类管理*/@RestController@RequestMapping("/category")@Slf4jpublic class CategoryController {@Autowiredprivate CategoryService categoryService;/*** 新增分类* @param category* @return*/@PostMappingpublic R<String> save(@RequestBody Category category){log.info("category:{}",category);categoryService.save(category);return R.success("新增分类成功");}}
测试
1). 输入的分类名称不存在
2). 输入已存在的分类名称
3). 新增菜品分类
4). 新增套餐分类
分类信息分页查询
前端
类似员工分页查询(没有根据名字模糊查询)
后端
/*** 分页查询* @param page* @param pageSize* @return*/@GetMapping("/page")public R<Page> page(int page,int pageSize){//分页构造器Page<Category> pageInfo = new Page<>(page,pageSize);//条件构造器LambdaQueryWrapper<Category> queryWrapper = new LambdaQueryWrapper<>();//添加排序条件,根据sort进行排序queryWrapper.orderByAsc(Category::getSort);//分页查询categoryService.page(pageInfo,queryWrapper);return R.success(pageInfo);}
数据库里的1、2对应套餐或者菜品分类
删除分类
程序执行流程
- 点击删除,页面发送ajax请求,将参数(id)提交到服务端
- 服务端Controller接收页面提交的数据并调用Service删除数据
- Service调用Mapper操作数据库
前端
后端
/*** 根据id删除分类* @param id* @return*/@DeleteMappingpublic R<String> delete(Long id){log.info("删除分类,id为:{}",id);categoryService.removeById(id);return R.success("分类信息删除成功");}
完善
关联表dish(菜品)setmeal(套餐)
- 根据当前分类的ID,查询该分类下是否存在菜品,如果存在,则提示错误信息
- 根据当前分类的ID,查询该分类下是否存在套餐,如果存在,则提示错误信息
- 执行正常的删除分类操作
自定义异常
所在包: com.itheima.reggie.common/*** 自定义业务异常类*/public class CustomException extends RuntimeException {public CustomException(String message){super(message);}}
CategoryService中扩展remove方法
public interface CategoryService extends IService<Category> {//根据ID删除分类public void remove(Long id);}
CategoryServiceImpl中实现remove方法
@Autowiredprivate DishService dishService;@Autowiredprivate SetmealService setmealService;/*** 根据id删除分类,删除之前需要进行判断* @param id*/@Overridepublic void remove(Long id) {//添加查询条件,根据分类id进行查询菜品数据LambdaQueryWrapper<Dish> dishLambdaQueryWrapper = new LambdaQueryWrapper<>();dishLambdaQueryWrapper.eq(Dish::getCategoryId,id);int count1 = dishService.count(dishLambdaQueryWrapper);//如果已经关联,抛出一个业务异常if(count1 > 0){throw new CustomException("当前分类下关联了菜品,不能删除");//已经关联菜品,抛出一个业务异常}//查询当前分类是否关联了套餐,如果已经关联,抛出一个业务异常LambdaQueryWrapper<Setmeal> setmealLambdaQueryWrapper = new LambdaQueryWrapper<>();setmealLambdaQueryWrapper.eq(Setmeal::getCategoryId,id);int count2 = setmealService.count(setmealLambdaQueryWrapper);if(count2 > 0){throw new CustomException("当前分类下关联了套餐,不能删除");//已经关联套餐,抛出一个业务异常}//正常删除分类super.removeById(id);}
在GlobalExceptionHandler中处理自定义异常
/*** 异常处理方法* @return*/@ExceptionHandler(CustomException.class)public R<String> exceptionHandler(CustomException ex){log.error(ex.getMessage());return R.error(ex.getMessage());}
改造CategoryController的delete方法
/*** 根据id删除分类* @param id* @return*/@DeleteMappingpublic R<String> delete(Long id){log.info("删除分类,id为:{}",id);//categoryService.removeById(id);categoryService.remove(id);return R.success("分类信息删除成功");}
修改分类
前端
后端
/*** 根据id修改分类信息* @param category* @return*/@PutMappingpublic R<String> update(@RequestBody Category category){log.info("修改分类信息:{}",category);categoryService.updateById(category);return R.success("修改分类信息成功");}
文件上传下载
前端
对页面的form表单有如下要求:
| 表单属性 | 取值 | 说明 |
|---|---|---|
| method | post | 必须选择post方式提交 |
| enctype | multipart/form-data | 采用multipart格式上传文件 |
| type | file | 使用input的file控件上传 |
后端
服务端要接收客户端页面上传的文件,通常都会使用Apache的两个组件:
- commons-fileupload
- commons-io
而Spring框架在spring-web包中对文件上传进行了封装,我们只需要在Controller的方法中声明一个MultipartFile类型的参数即可接收上传的文件
/*** 文件上传* @param file* @return*/@PostMapping("/upload")public R<String> upload(MultipartFile file){System.out.println(file);return R.success(fileName);}
文件下载
本质上就是服务端将文件以流的形式写回浏览器的过程
- 以附件形式下载,弹出保存对话框,将文件保存到指定磁盘目录
- 直接在浏览器中打开(项目所用)
上传实现
定义路径
reggie:path: D:\img\
/*** 文件上传和下载*/@RestController@RequestMapping("/common")@Slf4jpublic class CommonController {@Value("${reggie.path}")private String basePath;/*** 文件上传* @param file* @return*/@PostMapping("/upload")public R<String> upload(MultipartFile file){//file是一个临时文件,需要转存到指定位置,否则本次请求完成后临时文件会删除log.info(file.toString());//原始文件名String originalFilename = file.getOriginalFilename();//abc.jpgString suffix = originalFilename.substring(originalFilename.lastIndexOf("."));//使用UUID重新生成文件名,防止文件名称重复造成文件覆盖String fileName = UUID.randomUUID().toString() + suffix;//dfsdfdfd.jpg//创建一个目录对象File dir = new File(basePath);//判断当前目录是否存在if(!dir.exists()){//目录不存在,需要创建dir.mkdirs();}try {//将临时文件转存到指定位置file.transferTo(new File(basePath + fileName));} catch (IOException e) {e.printStackTrace();}return R.success(fileName);}}
下载实现
1). 定义输入流,通过输入流读取文件内容
2). 通过response对象,获取到输出流
3). 通过response对象设置响应数据格式(image/jpeg)
4). 通过输入流读取文件数据,然后通过上述的输出流写回浏览器
5). 关闭资源
/*** 文件下载* @param name* @param response*/@GetMapping("/download")public void download(String name, HttpServletResponse response){try {//输入流,通过输入流读取文件内容FileInputStream fileInputStream = new FileInputStream(new File(basePath + name));//输出流,通过输出流将文件写回浏览器ServletOutputStream outputStream = response.getOutputStream();response.setContentType("image/jpeg");int len = 0;byte[] bytes = new byte[1024];while ((len = fileInputStream.read(bytes)) != -1){outputStream.write(bytes,0,len);outputStream.flush();}//关闭资源outputStream.close();fileInputStream.close();} catch (Exception e) {e.printStackTrace();}}
菜品新增
数据模型
| 表结构 | 说明 |
|---|---|
| dish | 菜品表 |
| dish_flavor | 菜品口味表 |
菜品表:dish
菜品口味表:dish_flavor

创建
- 实体类 DishFlavor
- 实体类 Dish
- Mapper接口DishFlavorMapper
- 业务层接口 DishFlavorService
- 业务层实现类 DishFlavorServiceImpl
- 控制层 DishController
菜品及菜品口味的相关操作统一使用这一个controlle
前端
菜品分类数据列表查询,请求信息
| 请求 | 说明 |
|---|---|
| 请求方式 | GET |
| 请求路径 | /category/list |
| 请求参数 | ?type=1 |
保存菜品信息, 具体请求信息
| 请求 | 说明 |
|---|---|
| 请求方式 | POST |
| 请求路径 | /dish |
| 请求参数 | json格式 |
后端
1.菜品分类查询
根据分类进行查询,并对查询的结果按照sort排序字段进行升序排序,如果sort相同,再按照修改时间倒序排序
/*** 根据条件查询分类数据* @param category* @return*/@GetMapping("/list")public R<List<Category>> list(Category category){//条件构造器LambdaQueryWrapper<Category> queryWrapper = new LambdaQueryWrapper<>();//添加条件queryWrapper.eq(category.getType() != null,Category::getType,category.getType());//添加排序条件queryWrapper.orderByAsc(Category::getSort).orderByDesc(Category::getUpdateTime);List<Category> list = categoryService.list(queryWrapper);return R.success(list);}
2.保存菜品
如果使用菜品类Dish来封装,只能封装菜品的基本属性,flavors属性是无法封装的。
需要自定义一个实体类,然后继承自 Dish,并对Dish的属性进行拓展,增加 flavors 集合属性(内部封装DishFlavor)
@Datapublic class DishDto extends Dish {private List<DishFlavor> flavors = new ArrayList<>();private String categoryName;private Integer copies;}
DishController定义方法新增菜品
在该Controller的方法中,不仅需要保存菜品的基本信息,还需要保存菜品的口味信息,需要操作两张表,所以我们需要在DishService接口中定义接口方法,在这个方法中需要保存上述的两部分数据。
/*** 新增菜品* @param dishDto* @return*/@PostMappingpublic R<String> save(@RequestBody DishDto dishDto){log.info(dishDto.toString());dishService.saveWithFlavor(dishDto);return R.success("新增菜品成功");}
3). DishService中增加方法saveWithFlavor
//新增菜品,同时插入菜品对应的口味数据,需要操作两张表:dish、dish_flavorpublic void saveWithFlavor(DishDto dishDto);
4). DishServiceImpl中实现方法saveWithFlavor
页面传递的菜品口味信息,仅仅包含name 和 value属性,缺少一个非常重要的属性dishId, 所以在保存完菜品的基本信息后,我们需要获取到菜品ID,然后为菜品口味对象属性dishId赋值。
具体逻辑如下:
①. 保存菜品基本信息 ;
②. 获取保存的菜品ID ;
③. 获取菜品口味列表,遍历列表,为菜品口味对象属性dishId赋值;
④. 批量保存菜品口味列表;
代码实现如下:
@Autowiredprivate DishFlavorService dishFlavorService;/*** 新增菜品,同时保存对应的口味数据* @param dishDto*/@Transactionalpublic void saveWithFlavor(DishDto dishDto) {//保存菜品的基本信息到菜品表dishthis.save(dishDto);Long dishId = dishDto.getId();//菜品id//菜品口味List<DishFlavor> flavors = dishDto.getFlavors();flavors = flavors.stream().map((item) -> {item.setDishId(dishId);return item;}).collect(Collectors.toList());//保存菜品口味数据到菜品口味表dish_flavordishFlavorService.saveBatch(flavors);}
说明:
由于在 saveWithFlavor 方法中,进行了两次数据库的保存操作,操作了两张表,那么为了保证数据的一致性,我们需要在方法上加上注解 @Transactional来控制事务。
5). 在引导类上加注解 @EnableTransactionManagement
Service层方法上加的注解@Transactional要想生效,需要在引导类上加上注解 @EnableTransactionManagement, 开启对事务的支持。
@Slf4j@SpringBootApplication@ServletComponentScan@EnableTransactionManagement //开启对事物管理的支持public class ReggieApplication {public static void main(String[] args) {SpringApplication.run(ReggieApplication.class,args);log.info("项目启动成功...");}}
常见实体类型拓展
| 实体模型 | 描述 |
|---|---|
| DTO | Data Transfer Object(数据传输对象),一般用于展示层与服务层之间的数据传输。 |
| Entity | 最常用实体类,基本和数据表一一对应,一个实体类对应一张表。 |
| VO | Value Object(值对象), 主要用于封装前端页面展示的数据对象,用一个VO对象来封装整个页面展示所需要的对象数据 |
| PO | Persistant Object(持久层对象), 是ORM(Objevt Relational Mapping)框架中Entity,PO属性和数据库中表的字段形成一一对应关系 |
菜品分页查询
前端
请求信息如下:
| 请求 | 说明 |
|---|---|
| 请求方式 | GET |
| 请求路径 | /dish/page |
| 请求参数 | ?page=1&pageSize=10&name=xxx |
后端
分析:
在分页查询时还需要给页面返回分类的名称,而分类的名称前端在接收的时候是通过 categoryName 属性获取的,那么对应的服务端也应该封装到 categoryName 属性中。
在我们的实体类 Dish 中,仅仅包含 categoryId, 不包含 categoryName,那么我们应该如何封装查询的数据呢? 其实,这里我们可以返回DishDto对象,在该对象中我们可以拓展一个属性 categoryName,来封装菜品分类名称。
@Datapublic class DishDto extends Dish {private List<DishFlavor> flavors = new ArrayList<>();private String categoryName; //菜品分类名称private Integer copies;}
实现逻辑
1). 构造分页条件对象
2). 构建查询及排序条件
3). 执行分页条件查询
4). 遍历分页查询列表数据,根据分类ID查询分类信息,从而获取该菜品的分类名称
5). 封装数据并返回
/*** 菜品信息分页查询* @param page* @param pageSize* @param name* @return*/@GetMapping("/page")public R<Page> page(int page,int pageSize,String name){//构造分页构造器对象Page<Dish> pageInfo = new Page<>(page,pageSize);Page<DishDto> dishDtoPage = new Page<>();//条件构造器LambdaQueryWrapper<Dish> queryWrapper = new LambdaQueryWrapper<>();//添加过滤条件queryWrapper.like(name != null,Dish::getName,name);//添加排序条件queryWrapper.orderByDesc(Dish::getUpdateTime);//执行分页查询dishService.page(pageInfo,queryWrapper);//对象拷贝BeanUtils.copyProperties(pageInfo,dishDtoPage,"records");List<Dish> records = pageInfo.getRecords();List<DishDto> list = records.stream().map((item) -> {DishDto dishDto = new DishDto();BeanUtils.copyProperties(item,dishDto);Long categoryId = item.getCategoryId();//分类id//根据id查询分类对象Category category = categoryService.getById(categoryId);if(category != null){String categoryName = category.getName();dishDto.setCategoryName(categoryName);}return dishDto;}).collect(Collectors.toList());dishDtoPage.setRecords(list);return R.success(dishDtoPage);}
数据库查询菜品信息时,获取到的分页查询结果 Page 的泛型为 Dish,而我们最终需要给前端页面返回的类型为 DishDto,所以这个时候就要进行转换,基本属性我们可以直接通过属性拷贝的形式对Page中的属性进行复制,而对于结果列表 records属性,我们是需要进行特殊处理的(需要封装菜品分类名称);
菜品修改
前端
根据ID查询菜品及菜品口味信息具体请求信息如下:
| 请求 | 说明 |
|---|---|
| 请求方式 | GET |
| 请求路径 | /dish/{id} |
修改菜品及菜品口味信息具体请求信息如下:
| 请求 | 说明 |
|---|---|
| 请求方式 | PUT |
| 请求路径 | /dish |
| 请求参数 | json格式数据 |
后端
根据ID查询菜品信息
在DishService接口中扩展getByIdWithFlavor方法
//根据id查询菜品信息和对应的口味信息public DishDto getByIdWithFlavor(Long id);
在DishService实现类中实现此方法
具体逻辑为:
A. 根据ID查询菜品的基本信息
B. 根据菜品的ID查询菜品口味列表数据
C. 组装数据并返回
/*** 根据id查询菜品信息和对应的口味信息* @param id* @return*/public DishDto getByIdWithFlavor(Long id) {//查询菜品基本信息,从dish表查询Dish dish = this.getById(id);DishDto dishDto = new DishDto();BeanUtils.copyProperties(dish,dishDto);//查询当前菜品对应的口味信息,从dish_flavor表查询LambdaQueryWrapper<DishFlavor> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(DishFlavor::getDishId,dish.getId());List<DishFlavor> flavors = dishFlavorService.list(queryWrapper);dishDto.setFlavors(flavors);return dishDto;}
在DishController中创建get方法
/*** 根据id查询菜品信息和对应的口味信息* @param id* @return*/@GetMapping("/{id}")public R<DishDto> get(@PathVariable Long id){DishDto dishDto = dishService.getByIdWithFlavor(id);return R.success(dishDto);}
@PathVariable : 该注解可以用来提取url路径中传递的请求参数。
修改菜品信息
在DishService接口中扩展方法updateWithFlavor
//更新菜品信息,同时更新对应的口味信息public void updateWithFlavor(DishDto dishDto);
在DishServiceImpl中实现方法updateWithFlavor
在该方法中,我们既需要更新dish菜品基本信息表,还需要更新dish_flavor菜品口味表。而页面再操作时,关于菜品的口味,有修改,有新增,也有可能删除,我们应该如何更新菜品口味信息呢,其实,无论菜品口味信息如何变化,我们只需要保持一个原则: 先删除,后添加。
@Override@Transactionalpublic void updateWithFlavor(DishDto dishDto) {//更新dish表基本信息this.updateById(dishDto);//清理当前菜品对应口味数据---dish_flavor表的delete操作LambdaQueryWrapper<DishFlavor> queryWrapper = new LambdaQueryWrapper();queryWrapper.eq(DishFlavor::getDishId,dishDto.getId());dishFlavorService.remove(queryWrapper);//添加当前提交过来的口味数据---dish_flavor表的insert操作List<DishFlavor> flavors = dishDto.getFlavors();flavors = flavors.stream().map((item) -> {item.setDishId(dishDto.getId());return item;}).collect(Collectors.toList());dishFlavorService.saveBatch(flavors);}
在DishController中创建update方法
/*** 修改菜品* @param dishDto* @return*/@PutMappingpublic R<String> update(@RequestBody DishDto dishDto){log.info(dishDto.toString());dishService.updateWithFlavor(dishDto);return R.success("修改菜品成功");}
新增套餐
数据模型
| 表 | 说明 | 备注 |
|---|---|---|
| setmeal | 套餐表 | 存储套餐的基本信息 |
| setmeal_dish | 套餐菜品关系表 | 存储套餐关联的菜品的信息(一个套餐可以关联多个菜品) |
两张表具体的表结构如下:
1). 套餐表setmeal
在该表中,套餐名称name字段是不允许重复的,在建表时,已经创建了唯一索引。
2). 套餐菜品关系表setmeal_dish
在该表中,菜品的名称name,菜品的原价price 实际上都是冗余字段,因为我们在这张表中存储了菜品的ID(dish_id),根据该ID我们就可以查询出name,price的数据信息,而这里我们又存储了name,price,这样的话,我们在后续的查询展示操作中,就不需要再去查询数据库获取菜品名称和原价了,这样可以简化我们的操作。
前端
根据分类ID查询菜品列表
| 请求 | 说明 |
|---|---|
| 请求方式 | GET |
| 请求路径 | /dish/list |
| 请求参数 | ?categoryId=1397844263642378242 |
保存套餐信息
| 请求 | 说明 |
|---|---|
| 请求方式 | POST |
| 请求路径 | /setmeal |
| 请求参数 | json格式数据 |
后端
控制层 SetmealController
套餐管理的相关业务,我们都统一在 SetmealController 中进行统一处理操作。
根据分类查询菜品
我们只需要根据页面传递的菜品分类的ID(categoryId)来查询菜品列表即可,我们可以直接定义一个DishController的方法,声明一个Long类型的categoryId,这样做是没问题的。但是考虑到该方法的拓展性,我们在这里定义方法时,通过Dish这个实体来接收参数。
在DishController中定义方法list,接收Dish类型的参数:
在查询时,需要根据菜品分类categoryId进行查询,并且还要限定菜品的状态为起售状态(status为1),然后对查询的结果进行排序。
/*** 根据条件查询对应的菜品数据* @param dish* @return*/@GetMapping("/list")public R<List<Dish>> list(Dish dish){//构造查询条件LambdaQueryWrapper<Dish> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(dish.getCategoryId() != null ,Dish::getCategoryId,dish.getCategoryId());//添加条件,查询状态为1(起售状态)的菜品queryWrapper.eq(Dish::getStatus,1);//添加排序条件queryWrapper.orderByAsc(Dish::getSort).orderByDesc(Dish::getUpdateTime);List<Dish> list = dishService.list(queryWrapper);return R.success(list);}
保存套餐
SetmealController中定义方法save,新增套餐
在该Controller的方法中,我们不仅需要保存套餐的基本信息,还需要保存套餐关联的菜品数据,所以我们需要再该方法中调用业务层方法,完成两块数据的保存。
页面传递的数据是json格式,需要在方法形参前面加上@RequestBody注解, 完成参数封装。
@PostMappingpublic R<String> save(@RequestBody SetmealDto setmealDto){log.info("套餐信息:{}",setmealDto);setmealService.saveWithDish(setmealDto);return R.success("新增套餐成功");}
SetmealService中定义方法saveWithDish
/*** 新增套餐,同时需要保存套餐和菜品的关联关系* @param setmealDto*/public void saveWithDish(SetmealDto setmealDto);
SetmealServiceImpl实现方法saveWithDish
具体逻辑:
A. 保存套餐基本信息
B. 获取套餐关联的菜品集合,并为集合中的每一个元素赋值套餐ID(setmealId)
C. 批量保存套餐关联的菜品集合
代码实现:
/*** 新增套餐,同时需要保存套餐和菜品的关联关系* @param setmealDto*/@Transactionalpublic void saveWithDish(SetmealDto setmealDto) {//保存套餐的基本信息,操作setmeal,执行insert操作this.save(setmealDto);List<SetmealDish> setmealDishes = setmealDto.getSetmealDishes();setmealDishes.stream().map((item) -> {item.setSetmealId(setmealDto.getId());return item;}).collect(Collectors.toList());//保存套餐和菜品的关联信息,操作setmeal_dish,执行insert操作setmealDishService.saveBatch(setmealDishes);}
套餐分页查询
前端
分页查询功能,具体的请求信息
| 请求 | 说明 |
|---|---|
| 请求方式 | GET |
| 请求路径 | /setmeal/page |
| 请求参数 | ?page=1&pageSize=10&name=xxx |
后端
在SetmealController中创建套餐分页查询方法。
该方法的逻辑如下:
1). 构建分页条件对象
2). 构建查询条件对象,如果传递了套餐名称,根据套餐名称模糊查询, 并对结果按修改时间降序排序
3). 执行分页查询
4). 组装数据并返回
代码实现 :
套餐分类名称没有展示出来
/*** 套餐分页查询* @param page* @param pageSize* @param name* @return*/@GetMapping("/page")public R<Page> page(int page,int pageSize,String name){//分页构造器对象Page<Setmeal> pageInfo = new Page<>(page,pageSize);LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper<>();//添加查询条件,根据name进行like模糊查询queryWrapper.like(name != null,Setmeal::getName,name);//添加排序条件,根据更新时间降序排列queryWrapper.orderByDesc(Setmeal::getUpdateTime);setmealService.page(pageInfo,queryWrapper);return R.success(pageInfo);}
在查询套餐信息时, 只包含套餐的基本信息, 并不包含套餐的分类名称, 所以在这里查询到套餐的基本信息后, 还需要根据分类ID(categoryId), 查询套餐分类名称(categoryName),并最终将套餐的基本信息及分类名称信息封装到SetmealDto(在第一小节已经导入)中。
@Datapublic class SetmealDto extends Setmeal {private List<SetmealDish> setmealDishes; //套餐关联菜品列表private String categoryName;//套餐分类名称}
完善后代码:
/*** 套餐分页查询* @param page* @param pageSize* @param name* @return*/@GetMapping("/page")public R<Page> page(int page,int pageSize,String name){//分页构造器对象Page<Setmeal> pageInfo = new Page<>(page,pageSize);Page<SetmealDto> dtoPage = new Page<>();LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper<>();//添加查询条件,根据name进行like模糊查询queryWrapper.like(name != null,Setmeal::getName,name);//添加排序条件,根据更新时间降序排列queryWrapper.orderByDesc(Setmeal::getUpdateTime);setmealService.page(pageInfo,queryWrapper);//对象拷贝BeanUtils.copyProperties(pageInfo,dtoPage,"records");List<Setmeal> records = pageInfo.getRecords();List<SetmealDto> list = records.stream().map((item) -> {SetmealDto setmealDto = new SetmealDto();//对象拷贝BeanUtils.copyProperties(item,setmealDto);//分类idLong categoryId = item.getCategoryId();//根据分类id查询分类对象Category category = categoryService.getById(categoryId);if(category != null){//分类名称String categoryName = category.getName();setmealDto.setCategoryName(categoryName);}return setmealDto;}).collect(Collectors.toList());dtoPage.setRecords(list);return R.success(dtoPage);}
删除套餐
前端
删除单个套餐和批量删除套餐的请求信息可以发现,两种请求的地址和请求方式都是相同的,不同的则是传递的id个数,所以在服务端可以提供一个方法来统一处理。
具体的请求信息如下:
| 请求 | 说明 |
|---|---|
| 请求方式 | DELETE |
| 请求路径 | /setmeal |
| 请求参数 | ?ids=1423640210125656065,1423338765002256385 |
后端
删除套餐时, 我们不仅要删除套餐, 还要删除套餐与菜品的关联关系
1.在SetmealController中创建delete方法
我们可以先测试在delete方法中接收页面提交的参数,具体逻辑后续再完善:
/*** 删除套餐* @param ids* @return*/@DeleteMappingpublic R<String> delete(@RequestParam List<Long> ids){log.info("ids:{}",ids);return R.success("套餐数据删除成功");}
编写完代码,我们重启服务之后,访问套餐列表页面,勾选复选框,然后点击”批量删除”,我们可以看到服务端可以接收到集合参数ids,并且在控制台也可以输出对应的数据 。
2.SetmealService接口定义方法removeWithDish
/*** 删除套餐,同时需要删除套餐和菜品的关联数据* @param ids*/public void removeWithDish(List<Long> ids);
3.SetmealServiceImpl中实现方法removeWithDish
该业务层方法具体的逻辑为:
A. 查询该批次套餐中是否存在售卖中的套餐, 如果存在, 不允许删除
B. 删除套餐数据
C. 删除套餐关联的菜品数据
代码实现为:
/*** 删除套餐,同时需要删除套餐和菜品的关联数据* @param ids*/@Transactionalpublic void removeWithDish(List<Long> ids) {//select count(*) from setmeal where id in (1,2,3) and status = 1//查询套餐状态,确定是否可用删除LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper();queryWrapper.in(Setmeal::getId,ids);queryWrapper.eq(Setmeal::getStatus,1);int count = this.count(queryWrapper);if(count > 0){//如果不能删除,抛出一个业务异常throw new CustomException("套餐正在售卖中,不能删除");}//如果可以删除,先删除套餐表中的数据---setmealthis.removeByIds(ids);//delete from setmeal_dish where setmeal_id in (1,2,3)LambdaQueryWrapper<SetmealDish> lambdaQueryWrapper = new LambdaQueryWrapper<>();lambdaQueryWrapper.in(SetmealDish::getSetmealId,ids);//删除关系表中的数据----setmeal_dishsetmealDishService.remove(lambdaQueryWrapper);}
由于当前的业务方法中存在多次数据库操作,为了保证事务的完整性,需要在方法上加注解 @Transactional 来控制事务。
4.完善SetmealController代码
/*** 删除套餐* @param ids* @return*/@DeleteMappingpublic R<String> delete(@RequestParam List<Long> ids){log.info("ids:{}",ids);setmealService.removeWithDish(ids);return R.success("套餐数据删除成功");}
短信发送
SDK 就是 Software Development Kit 的缩写,翻译过来——软件开发工具包,辅助开发某一类软件的相关文档、范例和工具的集合都可以叫做SDK。在我们与第三方接口相互时, 一般都会提供对应的SDK,来简化我们的开发。
注册、申请流程….
实现
1). pom.xml
<dependency><groupId>com.aliyun</groupId><artifactId>aliyun-java-sdk-core</artifactId><version>4.5.16</version></dependency><dependency><groupId>com.aliyun</groupId><artifactId>aliyun-java-sdk-dysmsapi</artifactId><version>2.1.0</version></dependency>
2). 将官方提供的main方法封装为一个工具类
import com.aliyuncs.DefaultAcsClient;import com.aliyuncs.IAcsClient;import com.aliyuncs.dysmsapi.model.v20170525.SendSmsRequest;import com.aliyuncs.dysmsapi.model.v20170525.SendSmsResponse;import com.aliyuncs.exceptions.ClientException;import com.aliyuncs.profile.DefaultProfile;/*** 短信发送工具类*/public class SMSUtils {/*** 发送短信* @param signName 签名* @param templateCode 模板* @param phoneNumbers 手机号* @param param 参数*/public static void sendMessage(String signName, String templateCode,String phoneNumbers,String param){DefaultProfile profile = DefaultProfile.getProfile("cn-hangzhou", "xxxxxxxxxxxxxxxx", "xxxxxxxxxxxxxx");IAcsClient client = new DefaultAcsClient(profile);SendSmsRequest request = new SendSmsRequest();request.setSysRegionId("cn-hangzhou");request.setPhoneNumbers(phoneNumbers);request.setSignName(signName);request.setTemplateCode(templateCode);request.setTemplateParam("{\"code\":\""+param+"\"}");try {SendSmsResponse response = client.getAcsResponse(request);System.out.println("短信发送成功");}catch (ClientException e) {e.printStackTrace();}}}
备注 : 由于我们个人目前无法申请阿里云短信服务,所以这里我们只需要把流程跑通,具体的短信发送可以实现。
手机验证码登录
登录流程:
输入手机号 > 获取验证码 > 输入验证码 > 点击登录 > 登录成功
注意:通过手机验证码登录,手机号是区分不同用户的标识。
前端
请求信息:
1). 获取短信验证码
| 请求 | 说明 |
|---|---|
| 请求方式 | POST |
| 请求路径 | /user/sendMsg |
| 请求参数 | {“phone”:”13100001111”} |
2). 登录
| 请求 | 说明 |
|---|---|
| 请求方式 | POST |
| 请求路径 | /user/login |
| 请求参数 | {“phone”:”13100001111”, “code”:”1111”} |
后端
SMSUtils : 是我们上面改造的阿里云短信发送的工具类 ;
ValidateCodeUtils : 是验证码生成的工具类 ;
1 修改LoginCheckFilter
在 LoginCheckFilter 中进行判定,如果移动端用户已登录,我们获取到用户登录信息,存入ThreadLocal中(在后续的业务处理中,如果需要获取当前登录用户ID,直接从ThreadLocal中获取),然后放行。
增加如下逻辑:
//4-2、判断登录状态,如果已登录,则直接放行if(request.getSession().getAttribute("user") != null){log.info("用户已登录,用户id为:{}",request.getSession().getAttribute("user"));Long userId = (Long) request.getSession().getAttribute("user");BaseContext.setCurrentId(userId);filterChain.doFilter(request,response);return;}
发送短信验证码
在UserController中创建方法,处理登录页面的请求,为指定手机号发送短信验证码,同时需要将手机号对应的验证码保存到Session,方便后续登录时进行比对。
/*** 发送手机短信验证码* @param user* @return*/@PostMapping("/sendMsg")public R<String> sendMsg(@RequestBody User user, HttpSession session){//获取手机号String phone = user.getPhone();if(StringUtils.isNotEmpty(phone)){//生成随机的4位验证码String code = ValidateCodeUtils.generateValidateCode(4).toString();log.info("code={}",code);//调用阿里云提供的短信服务API完成发送短信//SMSUtils.sendMessage("瑞吉外卖","",phone,code);//需要将生成的验证码保存到Sessionsession.setAttribute(phone,code);return R.success("手机验证码短信发送成功");}return R.error("短信发送失败");}
备注:
这里发送短信我们只需要调用封装的工具类中的方法即可,我们这个功能流程跑通,在测试中我们不用真正的发送短信,只需要将验证码信息,通过日志输出,登录时,我们直接从控制台就可以看到生成的验证码(实际上也就是发送到我们手机上的验证码)
验证码登录
在UserController中增加登录的方法 login,该方法的具体逻辑为:
1). 获取前端传递的手机号和验证码
2). 从Session中获取到手机号对应的正确的验证码
3). 进行验证码的比对 , 如果比对失败, 直接返回错误信息
4). 如果比对成功, 需要根据手机号查询当前用户, 如果用户不存在, 则自动注册一个新用户
5). 将登录用户的ID存储Session中
具体代码实现:
/*** 移动端用户登录* @param map* @param session* @return*/@PostMapping("/login")public R<User> login(@RequestBody Map map, HttpSession session){log.info(map.toString());//获取手机号String phone = map.get("phone").toString();//获取验证码String code = map.get("code").toString();//从Session中获取保存的验证码Object codeInSession = session.getAttribute(phone);//进行验证码的比对(页面提交的验证码和Session中保存的验证码比对)if(codeInSession != null && codeInSession.equals(code)){//如果能够比对成功,说明登录成功LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(User::getPhone,phone);User user = userService.getOne(queryWrapper);if(user == null){//判断当前手机号对应的用户是否为新用户,如果是新用户就自动完成注册user = new User();user.setPhone(phone);user.setStatus(1);userService.save(user);}session.setAttribute("user",user.getId());return R.success(user);}return R.error("登录失败");}
用户地址簿
数据模型
用户的地址信息会存储在address_book表,即地址簿表中。具体表结构如下:
这里面有一个字段is_default,实际上我们在设置默认地址时,只需要更新这个字段就可以了。
注:功能代码已经与前面大致相同
后端
控制层 AddressBookController
controller主要开发的功能:
A. 新增地址逻辑说明:
- 需要记录当前是哪个用户的地址(关联当前登录用户)
B. 设置默认地址
- 每个用户可以有很多地址,但是默认地址只能有一个 ;
- 先将该用户所有地址的is_default更新为0 , 然后将当前的设置的默认地址的is_default设置为1
C. 根据ID查询地址
D. 查询默认地址
- 根据当前登录用户ID 以及 is_default进行查询,查询当前登录用户is_default为1的地址信息
E. 查询指定用户的全部地址
- 根据当前登录用户ID,查询所有的地址列表
代码实现如下:
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;import com.itheima.reggie.common.BaseContext;import com.itheima.reggie.common.R;import com.itheima.reggie.entity.AddressBook;import com.itheima.reggie.service.AddressBookService;import lombok.extern.slf4j.Slf4j;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.web.bind.annotation.*;import java.util.List;/*** 地址簿管理*/@Slf4j@RestController@RequestMapping("/addressBook")public class AddressBookController {@Autowiredprivate AddressBookService addressBookService;/*** 新增*/@PostMappingpublic R<AddressBook> save(@RequestBody AddressBook addressBook) {addressBook.setUserId(BaseContext.getCurrentId());log.info("addressBook:{}", addressBook);addressBookService.save(addressBook);return R.success(addressBook);}/*** 设置默认地址*/@PutMapping("default")public R<AddressBook> setDefault(@RequestBody AddressBook addressBook) {log.info("addressBook:{}", addressBook);LambdaUpdateWrapper<AddressBook> wrapper = new LambdaUpdateWrapper<>();wrapper.eq(AddressBook::getUserId, BaseContext.getCurrentId());wrapper.set(AddressBook::getIsDefault, 0);//SQL:update address_book set is_default = 0 where user_id = ?addressBookService.update(wrapper);addressBook.setIsDefault(1);//SQL:update address_book set is_default = 1 where id = ?addressBookService.updateById(addressBook);return R.success(addressBook);}/*** 根据id查询地址*/@GetMapping("/{id}")public R get(@PathVariable Long id) {AddressBook addressBook = addressBookService.getById(id);if (addressBook != null) {return R.success(addressBook);} else {return R.error("没有找到该对象");}}/*** 查询默认地址*/@GetMapping("default")public R<AddressBook> getDefault() {LambdaQueryWrapper<AddressBook> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(AddressBook::getUserId, BaseContext.getCurrentId());queryWrapper.eq(AddressBook::getIsDefault, 1);//SQL:select * from address_book where user_id = ? and is_default = 1AddressBook addressBook = addressBookService.getOne(queryWrapper);if (null == addressBook) {return R.error("没有找到该对象");} else {return R.success(addressBook);}}/*** 查询指定用户的全部地址*/@GetMapping("/list")public R<List<AddressBook>> list(AddressBook addressBook) {addressBook.setUserId(BaseContext.getCurrentId());log.info("addressBook:{}", addressBook);//条件构造器LambdaQueryWrapper<AddressBook> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(null != addressBook.getUserId(), AddressBook::getUserId, addressBook.getUserId());queryWrapper.orderByDesc(AddressBook::getUpdateTime);//SQL:select * from address_book where user_id = ? order by update_time descreturn R.success(addressBookService.list(queryWrapper));}}
菜品展示
前端
A. 根据分类ID查询菜品列表(包含菜品口味列表), 具体请求信息如下:
| 请求 | 说明 |
|---|---|
| 请求方式 | GET |
| 请求路径 | /dish/list |
| 请求参数 | ?categoryId=1397844263642378242&status=1 |
该功能在服务端已经实现,我们需要修改此方法,在原有方法的基础上增加查询菜品的口味信息。
B. 根据分类ID查询套餐列表, 具体请求信息如下:
| 请求 | 说明 |
|---|---|
| 请求方式 | GET |
| 请求路径 | /setmeal/list |
| 请求参数 | ?categoryId=1397844263642378242&status=1 |
该功能在服务端并未实现。
后端
查询菜品方法修改
代码逻辑:
A. 根据分类ID查询,查询目前正在启售的菜品列表 (已实现)
B. 遍历菜品列表,并查询菜品的分类信息及菜品的口味列表
C. 组装数据DishDto,并返回
代码实现:
@GetMapping("/list")public R<List<DishDto>> list(Dish dish){//构造查询条件LambdaQueryWrapper<Dish> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(dish.getCategoryId() != null ,Dish::getCategoryId,dish.getCategoryId());//添加条件,查询状态为1(起售状态)的菜品queryWrapper.eq(Dish::getStatus,1);//添加排序条件queryWrapper.orderByAsc(Dish::getSort).orderByDesc(Dish::getUpdateTime);List<Dish> list = dishService.list(queryWrapper);List<DishDto> dishDtoList = list.stream().map((item) -> {DishDto dishDto = new DishDto();BeanUtils.copyProperties(item,dishDto);Long categoryId = item.getCategoryId();//分类id//根据id查询分类对象Category category = categoryService.getById(categoryId);if(category != null){String categoryName = category.getName();dishDto.setCategoryName(categoryName);}//当前菜品的idLong dishId = item.getId();LambdaQueryWrapper<DishFlavor> lambdaQueryWrapper = new LambdaQueryWrapper<>();lambdaQueryWrapper.eq(DishFlavor::getDishId,dishId);//SQL:select * from dish_flavor where dish_id = ?List<DishFlavor> dishFlavorList = dishFlavorService.list(lambdaQueryWrapper);dishDto.setFlavors(dishFlavorList);return dishDto;}).collect(Collectors.toList());return R.success(dishDtoList);}
根据分类ID查询套餐
在SetmealController中创建list方法,根据条件查询套餐数据。
/*** 根据条件查询套餐数据* @param setmeal* @return*/@GetMapping("/list")public R<List<Setmeal>> list(Setmeal setmeal){LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(setmeal.getCategoryId() != null,Setmeal::getCategoryId,setmeal.getCategoryId());queryWrapper.eq(setmeal.getStatus() != null,Setmeal::getStatus,setmeal.getStatus());queryWrapper.orderByDesc(Setmeal::getUpdateTime);List<Setmeal> list = setmealService.list(queryWrapper);return R.success(list);}
购物车
数据模型
用户的购物车数据,也是需要保存在数据库中的,购物车对应的数据表为shopping_cart表,具体表结构如下:
说明:
- 购物车数据是关联用户的,在表结构中,我们需要记录,每一个用户的购物车数据是哪些
- 菜品列表展示出来的既有套餐,又有菜品,如果APP端选择的是套餐,就保存套餐ID(setmeal_id),如果APP端选择的是菜品,就保存菜品ID(dish_id)
- 对同一个菜品/套餐,如果选择多份不需要添加多条记录,增加数量number即可
最终shopping_cart表中存储的数据示例:
前端
1). 加入购物车
| 请求 | 说明 |
|---|---|
| 请求方式 | POST |
| 请求路径 | /shoppingCart/add |
| 请求参数 | json格式 |
菜品数据:{"amount":118,"dishFlavor":"不要蒜,微辣","dishId":"1397851099502260226","name":"全家福","image":"a53a4e6a-3b83-4044-87f9-9d49b30a8fdc.jpg"}套餐数据:{"amount":38,"setmealId":"1423329486060957698","name":"营养超值工作餐","image":"9cd7a80a-da54-4f46-bf33-af3576514cec.jpg"}
2). 查询购物车列表
| 请求 | 说明 |
|---|---|
| 请求方式 | GET |
| 请求路径 | /shoppingCart/list |
3). 清空购物车功能
| 请求 | 说明 |
|---|---|
| 请求方式 | DELETE |
| 请求路径 | /shoppingCart/clean |
后端
添加购物车
在ShoppingCartController中创建add方法,来完成添加购物车的逻辑实现,具体的逻辑如下:
A. 获取当前登录用户,为购物车对象赋值
B. 根据当前登录用户ID 及 本次添加的菜品ID/套餐ID,查询购物车数据是否存在
C. 如果已经存在,就在原来数量基础上加1
D. 如果不存在,则添加到购物车,数量默认就是1
代码实现如下:
/*** 添加购物车* @param shoppingCart* @return*/@PostMapping("/add")public R<ShoppingCart> add(@RequestBody ShoppingCart shoppingCart){log.info("购物车数据:{}",shoppingCart);//设置用户id,指定当前是哪个用户的购物车数据Long currentId = BaseContext.getCurrentId();shoppingCart.setUserId(currentId);Long dishId = shoppingCart.getDishId();LambdaQueryWrapper<ShoppingCart> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(ShoppingCart::getUserId,currentId);if(dishId != null){//添加到购物车的是菜品queryWrapper.eq(ShoppingCart::getDishId,dishId);}else{//添加到购物车的是套餐queryWrapper.eq(ShoppingCart::getSetmealId,shoppingCart.getSetmealId());}//查询当前菜品或者套餐是否在购物车中//SQL:select * from shopping_cart where user_id = ? and dish_id/setmeal_id = ?ShoppingCart cartServiceOne = shoppingCartService.getOne(queryWrapper);if(cartServiceOne != null){//如果已经存在,就在原来数量基础上加一Integer number = cartServiceOne.getNumber();cartServiceOne.setNumber(number + 1);shoppingCartService.updateById(cartServiceOne);}else{//如果不存在,则添加到购物车,数量默认就是一shoppingCart.setNumber(1);shoppingCart.setCreateTime(LocalDateTime.now());shoppingCartService.save(shoppingCart);cartServiceOne = shoppingCart;}return R.success(cartServiceOne);}
查询购物车
在ShoppingCartController中创建list方法,根据当前登录用户ID查询购物车列表,并对查询的结果进行创建时间的倒序排序。
代码实现如下:
/*** 查看购物车* @return*/@GetMapping("/list")public R<List<ShoppingCart>> list(){log.info("查看购物车...");LambdaQueryWrapper<ShoppingCart> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(ShoppingCart::getUserId,BaseContext.getCurrentId());queryWrapper.orderByAsc(ShoppingCart::getCreateTime);List<ShoppingCart> list = shoppingCartService.list(queryWrapper);return R.success(list);}
清空购物车
在ShoppingCartController中创建clean方法,在方法中获取当前登录用户,根据登录用户ID,删除购物车数据。
代码实现如下:
/*** 清空购物车* @return*/@DeleteMapping("/clean")public R<String> clean(){//SQL:delete from shopping_cart where user_id = ?LambdaQueryWrapper<ShoppingCart> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(ShoppingCart::getUserId,BaseContext.getCurrentId());shoppingCartService.remove(queryWrapper);return R.success("清空购物车成功");}
下单
数据模型
用户下单业务对应的数据表为orders表和order_detail表(一对多关系,一个订单关联多个订单明细):
| 表名 | 含义 | 说明 |
|---|---|---|
| orders | 订单表 | 主要存储订单的基本信息(如: 订单号、状态、金额、支付方式、下单用户、收件地址等) |
| order_detail | 订单明细表 | 主要存储订单详情信息(如: 该订单关联的套餐及菜品的信息) |
具体的表结构如下:
A. orders 订单表
B. order_detail
数据示例:
用户提交订单时,需要往订单表orders中插入一条记录,并且需要往order_detail中插入一条或多条记录。
前端
后端
控制层 OrderController、OrderDetailController
在OrderController中创建submit方法,处理用户下单的逻辑 :
/*** 用户下单* @param orders* @return*/@PostMapping("/submit")public R<String> submit(@RequestBody Orders orders){log.info("订单数据:{}",orders);orderService.submit(orders);return R.success("下单成功");}
由于下单的逻辑相对复杂,我们可以在OrderService中定义submit方法,来处理下单的具体逻辑:
/*** 用户下单* @param orders*/public void submit(Orders orders);
然后在OrderServiceImpl中完成下单功能的具体实现,下单功能的具体逻辑如下:
A. 获得当前用户id, 查询当前用户的购物车数据
B. 根据当前登录用户id, 查询用户数据
C. 根据地址ID, 查询地址数据
D. 组装订单明细数据, 批量保存订单明细
E. 组装订单数据, 批量保存订单数据
F. 删除当前用户的购物车列表数据
具体代码实现如下:
@Autowiredprivate ShoppingCartService shoppingCartService;@Autowiredprivate UserService userService;@Autowiredprivate AddressBookService addressBookService;@Autowiredprivate OrderDetailService orderDetailService;/*** 用户下单* @param orders*/@Transactionalpublic void submit(Orders orders) {//获得当前用户idLong userId = BaseContext.getCurrentId();//查询当前用户的购物车数据LambdaQueryWrapper<ShoppingCart> wrapper = new LambdaQueryWrapper<>();wrapper.eq(ShoppingCart::getUserId,userId);List<ShoppingCart> shoppingCarts = shoppingCartService.list(wrapper);if(shoppingCarts == null || shoppingCarts.size() == 0){throw new CustomException("购物车为空,不能下单");}//查询用户数据User user = userService.getById(userId);//查询地址数据Long addressBookId = orders.getAddressBookId();AddressBook addressBook = addressBookService.getById(addressBookId);if(addressBook == null){throw new CustomException("用户地址信息有误,不能下单");}long orderId = IdWorker.getId();//订单号AtomicInteger amount = new AtomicInteger(0);//组装订单明细信息List<OrderDetail> orderDetails = shoppingCarts.stream().map((item) -> {OrderDetail orderDetail = new OrderDetail();orderDetail.setOrderId(orderId);orderDetail.setNumber(item.getNumber());orderDetail.setDishFlavor(item.getDishFlavor());orderDetail.setDishId(item.getDishId());orderDetail.setSetmealId(item.getSetmealId());orderDetail.setName(item.getName());orderDetail.setImage(item.getImage());orderDetail.setAmount(item.getAmount());amount.addAndGet(item.getAmount().multiply(new BigDecimal(item.getNumber())).intValue());return orderDetail;}).collect(Collectors.toList());//组装订单数据orders.setId(orderId);orders.setOrderTime(LocalDateTime.now());orders.setCheckoutTime(LocalDateTime.now());orders.setStatus(2);orders.setAmount(new BigDecimal(amount.get()));//总金额orders.setUserId(userId);orders.setNumber(String.valueOf(orderId));orders.setUserName(user.getName());orders.setConsignee(addressBook.getConsignee());orders.setPhone(addressBook.getPhone());orders.setAddress((addressBook.getProvinceName() == null ? "" : addressBook.getProvinceName())+ (addressBook.getCityName() == null ? "" : addressBook.getCityName())+ (addressBook.getDistrictName() == null ? "" : addressBook.getDistrictName())+ (addressBook.getDetail() == null ? "" : addressBook.getDetail()));//向订单表插入数据,一条数据this.save(orders);//向订单明细表插入数据,多条数据orderDetailService.saveBatch(orderDetails);//清空购物车数据shoppingCartService.remove(wrapper);}
备注:
上述逻辑处理中,计算购物车商品的总金额时,为保证我们每一次执行的累加计算是一个原子操作,我们这里用到了JDK中提供的一个原子类 AtomicInteger
OrderDetailController?
超时了,所以异步请求时间长些,方便debug




