文章目录
项目介绍
黑马的公开课程:瑞吉外卖 通过一个小项目入门java
开发,这是我目前看过最好的java开发入门课程
,不是开发!不会细抠,能看懂大致代码和前后端数据交互流程即可
-
开发流程
- 需求调研
- UI设计
- 架构设计、技术选型
- 代码实现
- 编写用例
- 环境搭建,项目上线
-
后台功能
- 员工管理:新增员工,编辑禁用
- 分类管理:新增分类,修改删除分类
- 菜品管理:新增菜品,修改删除查询
- 订单明细:查询订单,查看配送
- …
-
前端H5页面功能
- 菜品分类展示
- 选择规格添加购物车
- 订单支付
- 个人信息页面
- …
-
技术选型
-
技术选型
-
用户层:H5、vue.js、elementUI、微信小程序
-
网关层:Nginx
-
应用层:SpingBoot、SpingMVC、SpingSession、Sping、Swagger、Lombok(Lombok是一个Java库,能自动插入编辑器并构建工具,简化Java开发)
-
数据层:MySQL、Mybatis(MyBatis 免除了几乎所有的 JDBC 代码以及设置参数和获取结果集的工作)、Mybatis Plus、Redis
-
- 角色与权限
- 后台管理员:拥有后台系统的所有操作权限
- 后台普通员工:可以登录后台对菜品、套餐、订单进行管理
- C端用户:可以登录移动端,执行购物个人管理操作
环境准备
新建数据库,字符集使用utf8mb4
,和utf8
的区别是其使用四个字节,能存储更多字符,比如生僻字、表情符号等
表结构
address_book:地址表
category:菜品和套餐分类
dish:菜品表
dish_flavor:菜品口味关系表
employee:员工表
order_detail:订单明细表
orders:订单表
setmeal:套餐类
setmeal_dish:套餐菜品关系表
shopping_cart:购物车表
user:用户表
pom.xml: 项目依赖包配置文件
resources/application.yml: 应用的一些配置
server port 应用端口
spring application name 应用名称
datasource 数据库连接相关配置
mybatis-plus 映射关系 将表和类对应
启动类 :启动类就相当于程序的入口点
@Slf4j
是 lombok
提供的注解,用于简化日志输出代码
private final Logger logger = LoggerFactory.getLogger(当前类名.class); ---> @Slf4j
几个常用的lombok
注解
@Data: 注解在类上, 提供类所有属性的getting和setting方法,和equals、canEqual、hanshCode、toString方法
@Setter: 注解再属性上, 为属性提供setting方法
@Getter: 注解再类上, 为属性提供getting方法
@SneakyThrows: 无需再签名处显示抛出异常
@Log4j: 注解在类上, 为类提供一个属性名为log的log4j对象
@Slf4j: 同上
@NoArgsConstructor: 注解在类上, 为类提供一个无参构造方法
@AllArgsConstructor: 注解在类上, 为类提供一个全参的构造方法
下面是几个SpringBoot
提供的注解
@SpringBootApplication注解一般放在项目的一个启动类上,用来把启动类注入到容器中,用来定义容器扫描的范围,用来加载classpath环境中一些bean
使用@ServletComponentScan注解后,Servlet、Filter、Listener可以直接通过@WebServlet、@WebFilter、@WebListener注解自动注册,无需其他代码
使用注解 @EnableTransactionManagement 开启事务支持
注解相当于代码的简化,使用一个注解符@表示一段代码的缩写,很方便!
resources/backend 和 resources/front: 存储静态页面资源,html、css、js
WebMvcConfig
:配置类,通过注解@Configuration
标识,设置静态资源映射目录,这是因为项目静态资源目录有默认值,我们需要修改为自己的目录
registry.addResourceHandler("/backend/").addResourceLocations("classpath:/backend/");
//classpath 指 resources 目录
registry.addResourceHandler("/front/**").addResourceLocations("classpath:/front/");
目录结构梳理,我们可以通过具体代码再去体会层次关系
reggie:
common:用来封装一些常用的公共方法
config:配置类
controller:请求转发,接收页面过来的参数,传给service处理,接到返回值,再次传给页面
dto:系统、服务之间交互传输用,是做表示层展示给用户的
entity:持久化,与数据库对应,一般是用于orm对象关系映射,类似于mvc的model层,这里一个表对应一个类
filter:过滤器实际上就是对web资源进行拦截,做一些处理后再交给下一个过滤器或servlet处理
mapper:数据存储对象 相当于dao层,直接执行sql语句,接口提供给service层
service:业务逻辑层接口,处理完将结果传递给mapper
impl:service实现类
utils:工具类
ReggieApplication:项目启动类
resources:静态资源目录
backend:后台所需静态资源目录
front:用户C端所需静态资源目录
application.yml:应用配置文件
test:
UploadFileTest:上传测试类
pom.xml:项目依赖配置文件
后台登录功能
controller
访问后台登陆界面:/backend/page/login/login.html
输入账号密码,请求的是emploee/login
路径,全局搜索,找到位于controller
下的EmployeeController
类
@RequestMapping("/employee")
这是用来配置路由的,也就是访问某一路径,交由哪个类,哪个方法来处理
@GetMapping 处理get请求
@PostMapping 处理post请求
@PutMapping 处理put请求
@DeleteMapping 处理delete请求
例如此例,访问emploee/login
,就是访问该类配置了@PostMapping("/login")
注解的方法,其他同理
@RequestMapping("/employee")
@PostMapping("/login")
public R<Employee> login(...){...}
entity
在entity
目录下,为每个表都映射了一个类,类的内容也是定了表的字段
举例,如employee
表,定义的变量都是字段值
实体类的封装可以让我们不需要写
SQL
语句,直接对字段进行操作,字段都封装成属性了
// entity/Employee
@Data
public class Employee implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String username;
private String name;
private String password;
private String phone;
private String sex;
private String idNumber;//身份证号码
private Integer status;
@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;
}
这个定义有什么用呢,implements Serializable
是实现一个序列化的接口,但是Serializable
是一个语义级别的空接口,所以只是标识一个类的对象可以被序列化,实现了该接口的类可以被ObjectOutputStream
转换为字节流,同时也可以通过ObjectInputStream
再将其解析为对象
@Data
实现了get()
、set()
、toString()
等方法,注解的作用应该可以理解了,提高代码的简洁性
@TableField(fill = FieldFill.INSERT_UPDATE)
,该注解用于标识非主键的字段,将数据库列与 JavaBean 中的属性进行映射
mapper
mapper
目录下定义了每个表的对应的接口,虽然没有什么代码,但是它继承了BaseMapper
,里面基本的增删改查方法都有了,<Employee>
是标识泛型的
// mapper/EmployeeMapper
@Mapper
public interface EmployeeMapper extends BaseMapper<Employee>{
}
关于extends
、interface
、implement
的区别
extends: 继承,继承一个父类的所有功能
interface: 是创建接口的语法,在其中定义某些函数,但没有具体实现
implement: 是使用接口的语法,继承了接口定义的函数并实现
service
在service
目录定义一个接口,继承IService
,也是数据库的操作
// service/EmployeeService
public interface EmployeeService extends IService<Employee> {
}
IService
和BaseMapper
的增删改查有什么区别?
IService接口只是对BaseMapper的进一步封装,例如批量操作
在impl
目录编写对应的实现类
// service/impl/EmployeeServiceImpl
@Service
public class EmployeeServiceImpl extends ServiceImpl<EmployeeMapper,Employee> implements EmployeeService{
}
这里面提到的一些接口的定义、实现、注解都是按照我们使用的框架提供的规范编写的,帮助我们简化代码,规范流程,具体自己去看一下spingboot
、mybatis
什么的
common/R
通用返回结果,服务端响应的数据都会封装成此对象返回给前端页面
// common/R
@Data
public class R<T> {
private Integer code; //编码: 1成功,0和其它数字为失败
private String msg; //错误信息
private T data; //数据
private Map map = new HashMap(); //动态数据
public static <T> R<T> success(T object) {...}
public static <T> R<T> error(String msg) {...}
public R<T> add(String key, Object value) {...}
}
我们在前端的js
代码中翻看,其中api
接口基本都是返回异步请求的数据
$axios
是基于promise
对ajax
的一种封装,简而言之,ajax
可以实现局部刷新功能
它会去请求对应的url
,使用设定的请求方式,put
是上传,data
是传递的参数,简而言之,是前端去请求后端的方法
// backend/js/
const editOrderDetail = (params) => {
return $axios({
url: '/order',
method: 'put',
data: { ...params }
})
我们看登录html
页面中的js
代码,使用await
获取后端异步返回的数据,它就可以调用像是code
、msg
等属性和方法
// backend/page/login/login.html
if (valid) {
this.loading = true
let res = await loginApi(this.loginForm)
if (String(res.code) === '1') {//1表示登录成功
localStorage.setItem('userInfo',JSON.stringify(res.data))
window.location.href= '/backend/index.html'
} else {
this.$message.error(res.msg)
this.loading = false
而R.java
中,这两个方法就是标识登录状态的,在js
中进行res.code) === '1'
的判断
// common/R
public static <T> R<T> success(T object) {
R<T> r = new R<T>();
r.data = object;
r.code = 1;
return r;
}
public static <T> R<T> error(String msg) {
R r = new R();
r.msg = msg;
r.code = 0;
return r;
}
登录功能实现
因为后台提供的是管理功能,使用者是内部人员,普通员工和管理员,所以除了简单的账号密码校验,还有一个账号权限的区分,在这里暂时没有体现,还有就是账号的状态,员工账号是否允许正常使用,也是要考虑的问题
// controller/EmployeeController
// 引入了两个封装好的实体类,数据封装类&员工类
import com.itheima.reggie.common.R;
import com.itheima.reggie.entity.Employee;
@PostMapping("/login")
// HttpServletRequest request对象用来获取session
// @RequestBody将前端发送过来的数据封装成employee对象,数据的键值要和类定义的变量名相同才能实现封装
public R<Employee> login(HttpServletRequest request,@RequestBody Employee employee){
//1、将页面提交的密码password进行md5加密处理,前端发送的数据被封装成对象,所以可以直接使用get方法获取
String password = employee.getPassword();
password = DigestUtils.md5DigestAsHex(password.getBytes());
//2、根据页面提交的用户名username查询数据库,因为用户名设置了唯一unique的属性,可以使用getOne方法
LambdaQueryWrapper<Employee> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Employee::getUsername,employee.getUsername());
Employee emp = employeeService.getOne(queryWrapper);
//3、如果没有查询到则返回登录失败结果
if(emp == null){
return R.error("登录失败");
}
//4、密码比对,如果不一致则返回登录失败结果
if(!emp.getPassword().equals(password)){
return R.error("登录失败");
}
//5、查看员工状态,如果为已禁用状态,则返回员工已禁用结果,0禁用/1正常,根据status字段判断
if(emp.getStatus() == 0){
return R.error("账号已禁用");
}
//6、登录成功,将员工id存入Session并返回登录成功结果,emp.getId(),emp就是数据库查询结果返回的对象
//session可以说是一个全局数组,它的变量在每个地方都可以访问
request.getSession().setAttribute("employee",emp.getId());
return R.success(emp);
}
R对象的封装如下,登录失败可以传参返回提示和状态
public static <T> R<T> success(T object) {
R<T> r = new R<T>();
r.data = object;
r.code = 1;
return r;
}
public static <T> R<T> error(String msg) {
R r = new R();
r.msg = msg;
r.code = 0;
return r;
}
总结:
前端:提供静态资源页面,登录接口通过js进行了简单的位数和是否为空的校验,然后将提交的数据发送给后端
后端:数据进行封装处理,前端传输的数据经过查询判断用户密码是否正确,并将结果存储到session中方便前后端调用
跳转实现:前端通过判断R.success函数返回的r.code = 1来判断登录成功,使用location进行跳转,还将后端返回的数据进行本地缓存
登录后,可以看到本地的缓存了这些数据,spring
框架将后端返回的数据封装成了json
也就是键值对,我们打开employee
表就可以一一对应这些字段
后台退出功能
登录后,进入到index.html
页面,在右上角有当前用户的名称
这是通过{{ userInfo.name }}
实现的,而这个userInfo
就是上一步登录后保存到客户端的变量,点击退出请求logout
方法
// backend/index.html
<div class="avatar-wrapper">{{ userInfo.name }}</div>
// 点击退出图标就会调用logout函数
<img src="images/icons/btn_close@2x.png" class="outLogin" alt="退出" @click="logout" />
// 获取 localStorage 的 userinfo 值
const userInfo = window.localStorage.getItem('userInfo')
// logout函数首先通过logoutapi请求employee/logout controller接口,判断是否为登录状态,是的话将userinfo信息清除,然后重定向到登录页面
logout() {
// logoutApi()会请求logout方法,返回code参数
logoutApi().then((res)=>{
if(res.code === 1){
localStorage.removeItem('userInfo')
window.location.href = '/backend/page/login/login.html'
}})}
后端除了将res.code
的值返回给前端(前端没有保存),还会将
// controller/EmployeeController
@PostMapping("/logout")
public R<String> logout(HttpServletRequest request){
// 清理Session中保存的当前登录员工的id,之前登录成功后存到session里了
request.getSession().removeAttribute("employee");
// 会返回r.code=1表示退出成功
return R.success("退出成功");
关于权限校验
这时我们直接访问index.html
页面,也可以直接访问,原因是没有权限校验,在渗透中就可以测试未授权访问
我们可以通过实现过滤器或拦截器来控制用户登录
// filter/LoginCheckFilter
@WebFilter(filterName = "loginCheckFilter",urlPatterns = "/*")
public class LoginCheckFilter implements Filter{
// 定义不需要处理的请求路径
String[] urls = new String[]{
"/employee/login",
"/employee/logout",
// 静态资源的请求都不拦截,即使访问到index页面,也无法看到数据,也不能进行其他操作
"/backend/**",
"/front/**",
"/common/**",
"/user/sendMsg",
"/user/login"
}
// 判断本次请求是否需要处理
// 判断是否登录,未登录就跳转登录页面
...
// 判断登录状态,如果已登录,则直接放行,通过判断查询结果是否为空,防止前端通过code值判断容易被篡改,employee的值就是用户id
if(request.getSession().getAttribute("employee") != null){
log.info("用户已登录,用户id为:{}",request.getSession().getAttribute("employee"));
Long empId = (Long) request.getSession().getAttribute("employee");
BaseContext.setCurrentId(empId);
filterChain.doFilter(request,response);
return;
}
...
}
// 在启动类加上注解,有了这个注解才会扫描过滤器将其创建成功
@ServletComponentScan
而前端也有对应的响应拦截器,为了前端进行判断以便做出不同响应,比如这里根据后端登录数据判断是否需要跳转登录
// backend/js/request.js
if (res.data.code === 0 && res.data.msg === 'NOTLOGIN') {// 返回登录页面
console.log('---/backend/page/login/login.html---',code)
localStorage.removeItem('userInfo')
window.top.location.href = '/backend/page/login/login.html'
} else {
return res.data
页面切换
登录后台,点击左侧菜单栏,网址栏的url
并没有发生变化,都是backend/index.html
,它请求了不同的list.html
page/food/list.html
、page/category/list.html
,这就是ajax
的应用,部分刷新
// backend/index.html 在上方有遍历的实现 不在这里讨论
menuList: [
{
id: '2',
name: '员工管理',
url: 'page/member/list.html',
icon: 'icon-member'
},
{
id: '3',
name: '分类管理',
url: 'page/category/list.html',
icon: 'icon-category'
}
...
menuHandle(item, goBackFlag) {
// iframe 用于在网页内显示网页
this.iframeUrl = item.url
}
// iframe的设置,默认打开 iframeUrl: 'page/member/list.html'
<iframe
id="cIframe"
class="c_iframe"
name="cIframe"
:src="iframeUrl"
width="100%"
height="auto"
frameborder="0"
v-show="!loading"
></iframe>
在page
目录,是每个具体的list
页面
员工管理
首先是添加员工的功能,请求add.html
页面,很容易就明白是一个数据库的操作
在member.lst
中,点击添加员工按钮会调用addMemberHandle
函数
@click="addMemberHandle('add')"
//调用函数,并请求add.html页面
addMemberHandle (st) {
if (st === 'add'){
// menuHandle主要的功能就是iframe.url参数设置,进行窗口切换
window.parent.menuHandle({
id: '2',
url: '/backend/page/member/add.html',
name: '添加员工'
},true)
} else {
//修改员工同样是调用这个函数
window.parent.menuHandle({
id: '2',
url: '/backend/page/member/add.html?id='+st,
name: '修改员工'
},true)
}
}
添加员工后,我们请求了employee
接口,并传递了json
格式的数据
// 点击保存,调用submitForm
@click="submitForm('ruleForm', false)"
// submitForm调用了addEmployee函数
addEmployee(params).then(res => {...}
// member.js提交参数给employeeController处理
function addEmployee (params) {
return $axios({
url: '/employee',
method: 'post',
data: { ...params }
})
}
传进来的数据进行加密,然后传给Service
层处理,再传递给mapper
层,save()
就是给我们提供的插入数据的方法
// EmployeeController
@PostMapping
public R<String> save(HttpServletRequest request,@RequestBody Employee employee){
log.info("新增员工,员工信息:{}",employee.toString());
// 设置初始密码123456,需要进行md5加密处理
employee.setPassword(DigestUtils.md5DigestAsHex("123456".getBytes()));
employeeService.save(employee);
return R.success("新增员工成功");
}
// save
default boolean save(T entity) {
return SqlHelper.retBool(this.getBaseMapper().insert(entity));
}
// insert()插入实体
int insert(T entity);
全局异常处理
GlobalExceptionHandler
通过拦截所有带有以下两个注解的类,来捕捉其中的异常,try catch
的集中管理
@ControllerAdvice(annotations = {RestController.class, Controller.class})
员工信息分页查询
流程如下:
访问员工管理页面,会请求list.html
,然后通过函数请求employee/page
接口
async init () {
const params = {
page: this.page,
pageSize: this.pageSize,
name: this.input ? this.input : undefined
}
// getMemberList请求了EmployeeController接口的page方法
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)
})
}
1.页面发送ajax
请求,将分页查询参数提交到服务端,如page=2
、pageSize=2
2.服务端Controller
接收页面提交的数据并调用Service
查询数据
很多东西框架都给封装好了,所以看起来会云里雾里不知其所以然,比如这里的page
方法也都是继承的,它直接提供分页查询的功能,很多代码真的都是高度重复的,我们主要关注参数的处理流程就好了,就是这三个参数
@GetMapping("/page")
public R<Page> page(int page,int pageSize,String name){
//接收了三个参数,name是查询接口的参数
log.info("page = {},pageSize = {},name = {}" ,page,pageSize,name);
//构造分页构造器
Page pageInfo = new Page(page,pageSize);
//构造条件构造器
LambdaQueryWrapper<Employee> queryWrapper = new LambdaQueryWrapper();
//添加过滤条件,name参数不为空,传递为like语句的参数
queryWrapper.like(StringUtils.isNotEmpty(name),Employee::getName,name);
//添加排序条件
queryWrapper.orderByDesc(Employee::getUpdateTime);
//执行查询
employeeService.page(pageInfo,queryWrapper);
return R.success(pageInfo);
}
3.返回封装略过,分页查询功能处理一个查询语句,其他都是对数据进行一些显示方面处理,不只是这里,这个项目很多的功能都是这样的,只是一个数据库操作,但是它也没有在代码中进行sql
语句的拼接,就比较安全
启用禁用员工账号
禁用:直接将用户的状态修改为 0,交给employeeController
处理,status
是判断用户是否正常使用的参数,之前在实现登录功能的时候我们进行了判断
启用:将参数值修改为 1,它们都是使用PUT
方式提交数据,那对应的方法就是employeeController
中带有@PutMapping
注解的update
方法
需要注意的是,只有管理员有操作权限,在此处做了判断,如果不是管理员就不显示此功能,具体的信息获取还是之前的localStorage
<el-button
type="text"
size="small"
class="blueBug"
@click="addMemberHandle(scope.row.id)"
:class="{notAdmin:user !== 'admin'}"
>
编辑
</el-button>
<el-button
type="text"
size="small"
class="delBut non"
@click="statusHandle(scope.row)"
v-if="user === 'admin'"
>
{{ scope.row.status == '1' ? '禁用' : '启用' }}
</el-button>
// 获取username值并赋值给 user
created() {
this.init()
if(localStorage.getItem('userInfo') != null){
//获取当前登录员工的账号,并赋值给模型数据user
this.user = JSON.parse(localStorage.getItem('userInfo')).username
}
}
在请求时通过Cookie: JSESSIONID=xxxxxxxxxxxxxxxx
来标识身份
编辑员工信息
首先是请求add.html
页面,查询对应id
的详细信息
@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("没有查询到对应员工信息");
}
接下来和启用禁用账号是一个方法,是一个更新操作,很简单,只不过启用禁用功能只更新一个status
值,这些都是mybatisPlus
提供的封装好的方法
@PutMapping
public R<String> update(HttpServletRequest request,@RequestBody Employee employee){
log.info(employee.toString());
long id = Thread.currentThread().getId();
log.info("线程id为:{}",id);
//employee是登录时保存的用户id
employeeService.updateById(employee);
return R.success("员工信息修改成功");
}
在这个过程中,员工id
会因为js
精度的问题有所损失,所以要进行对象数据转换,在JacksonObjectMapper
中提供对象转换器
/**
* 对象映射器:基于jackson将Java对象转为json,或者将json转为Java对象
* 将JSON解析为Java对象的过程称为 [从JSON反序列化Java对象]
* 从Java对象生成JSON的过程称为 [序列化Java对象到JSON]
*/
public class JacksonObjectMapper extends ObjectMapper {...}
在WebMvcConfig
中还有消息转换器的具体利用代码将对象进行转换,它是扩展了spring
提供的默认转换器的功能
越权漏洞
员工管理
我们发现它是通过获取本地缓存来判断用户身份的,我们可以尝试修改进行越权测试
使用普通用户test
登录
修改localStroage
中的username
参数为admin
刷新,可以发现能够看到启用禁用功能,那么能否使用这个功能呢?
我们尝试禁用user
账户,确实是成功了
它只是通过cookie
来判断用户是否登录,请求该接口时并没有对身份进行后端的验证,我们可以很容易伪造, 并不安全
编辑信息
在编辑信息时,我们传递了员工id
用来查询对应数据
如果修改为管理员id
,我们就可以查询管理员信息
修改时可以尝试修改密码加密后的md5
值,举例子,从123456换成456789
这样我们就可以使用自定义的密码登录admin
账号了
公共字段自动填充
在很多表中,都有创建时间、更新时间、创建人、修改人这几个字段,每次都要设置很不方便,所以使用mybatisplus
提供的方法进行简化
实现步骤:
1.在实体类属性上加入@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;
2.按照框架要求编写元数据对象处理器,在此类中统一为公共字段赋值,此类需要实现MetaObjectHandler
接口
/**
* 自定义元数据对象处理器
*/
@Component
@Slf4j
public class MyMetaObjecthandler implements MetaObjectHandler {...}
客户端发送的每次http请求,在服务器都会分配一个新的线程来处理
BaseContext
工具类为了解决在上述MetaObjectHandler
方法中无法获取用户id
的问题,通过线程变量来获取具体值
新增分类
也是对数据库表的操作,对应category
实体类,新增菜品分类和新增套餐分类两个功能对应一个方法,只不过通过type
参数来区分
直接将传递的参数进行保存
@PostMapping
public R<String> save(@RequestBody Category category){
log.info("category:{}",category);
categoryService.save(category);
return R.success("新增分类成功");
}
分类信息查询
流程应该很熟了,请求的接口都在category.js
中,这里是请求查询数据表,和之前的查询列表功能差不多,不多说
@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);
}
删除分类
通过id
删除数据,使用自定义的方法
categoryService.remove(id);
在service
中声明
public interface CategoryService extends IService<Category> {
public void remove(Long id);
}
在servoceImpl
中实现
public class CategoryServiceImpl extends ServiceImpl<CategoryMapper,Category> implements CategoryService{
// 使用@Autowired将实体注入就可以在此查询使用
@Autowired
private DishService dishService;
@Autowired
private SetmealService setmealService;
@Override
public void remove(Long id) {
// 根据套餐id查询是否有关联菜品,如果有菜品属于这个分类,就不删除,抛出异常,如果没有菜品属于此分类,就正常删除
...
}
}
因为是使用的提供的源码,这里还检查了是否关联了套餐分类,但是没有关联套餐也不能删除,原因是源码查询语句少了一个参数
// CategoryServiceImpl删除方法 检查是否关联套餐,将语句添加如下参数
int count2 = setmealService.count();
int count2 = setmealService.count(setmealLambdaQueryWrapper);
修改分类
经过学习,很容易就知道是一个数据表更新操作
这是mybatisplus
提供的方法,我们直接使用,就是更新功能
@PutMapping
public R<String> update(@RequestBody Category category){
// 输出日志
log.info("修改分类信息:{}",category);
// 更新数据
categoryService.updateById(category);
// 返回执行结果是否成功
return R.success("修改分类信息成功");
}
文件上传功能
上传使用form
表单,要求使用post
方式提交,采用enctype="multipart/form-data"
格式上传,使用file
控件上传type="file"
<form method="post" action="/common/upload" enctype="multipart/form-data">
<input name="myFile" type="file" />
<input type="submit" value="提交" />
</form>
在这里使用elementUI
前端框架提供的upload
组件,本质是相同的
// 上传按钮
<el-upload
class="avatar-uploader"
// 请求后端方法
action="/common/upload"
:show-file-list="false"
:on-success="handleAvatarSuccess"
:on-change="onChange"
ref="upload"
>
// 调用的js上传函数,对后缀名和文件大小做简单的前端校验
onChange (file) {
if(file){
const suffix = file.name.split('.')[1]
const size = file.size / 1024 / 1024 < 2
if(['png','jpeg','jpg'].indexOf(suffix) < 0){
this.$message.error('上传图片只支持 png、jpeg、jpg 格式!')
this.$refs.upload.clearFiles()
return false
}
if(!size){
this.$message.error('上传文件大小不能超过 2MB!')
return false
}
return file
}
}
上传后,后端使用apache
组件commons-fileupload
、commons-io
接收
@PostMapping("/upload")
public R<String> upload(MultipartFile file){
// file是一个临时文件,需要转存到指定位置,否则本次请求完成后临时文件会删除
log.info(file.toString());
// 原始文件名,通过最后一个 . 来分割判断后缀名,即文件类型
String originalFilename = file.getOriginalFilename();//abc.jpg
String 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);
}
// application.yml中配置路径
reggie:
path: D:\img\
文件下载
上传后会调用handleAvatarSuccess
回调函数,请求图片进行预览
// 检查是否登录,如果登录,上传后将在上传窗口预览图片
handleAvatarSuccess (response, file, fileList) {
// 拼接down接口预览
if(response.code === 0 && response.msg === '未登录'){
window.top.location.href = '/backend/page/login/login.html'
}else{
this.imageUrl = `/common/download?name=${response.data}`
this.ruleForm.image = response.data
}
}
上传后请求下载图片
后端通过输入输出流读取传递文件
@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();
}
}
新增菜品
首先查询了已有的菜品分类,方便进行下拉框的选择,将它归类
调用submitForm
函数
<el-button
type="primary"
@click="submitForm('ruleForm')"
>
保存
而submitForm
函数也调用了几个函数,对应请求后端接口
submitForm(formName, st) {
// 判断图片是否上传,存在返回路径,若不存在,编辑后重新提交
if(!this.imageUrl){...}
// 请求 /dish 接口,使用 post 方法
addDish(params).then(res => {...}
// 请求 /dish 接口,使用 put 方法
editDish(params).then(res => {...}
}
将json
数据上传进行数据表的更新
// adddish后端实现
dishService.saveWithFlavor(dishDto);
菜品信息查询
和之前的详细信息查询一个思路,它请求接口传递参数查询数据库之后会请求所有需要的菜品图片
差不多的查询操作,就略过了,后面的数据库查询也不说了,大差不差
@GetMapping("/page")
public R<Page> page(int page,int pageSize,String name){...}
修改菜品
除了也有的查询菜品分类,在新增菜品时,它首先根据id
查询了对应菜品的数据然后返回
返回后修改重新提交
// editdish后端实现
dishService.updateWithFlavor(dishDto);
新增套餐
查询已有套餐分类,只有商务套餐和儿童套餐
查询已有分类,这两个都是为了后续填选用的
请求了菜品列表,默认请求了第一个
点击不同菜品会请求不同分类
新增
套餐信息查询
同样的操作,不复述了
删除套餐
同理,根据id
删除
短信发送
阿里云短信服务使用云服务提供的api
接口进行短信发送,申请比较困难,看看视频和文档
顺便一提,阿里云有一个accesskey
,它拥有账号完全的权限,如下,是键值对类型的,如果泄露也是很危险的 👉记一次阿里云主机泄露Access Key到Getshell
它可以创建用户,分组,并授予不同的权限进行操作
获取验证码流程如下
使用随机函数生成四位数字验证码,保存到服务端session
,然后通过短信api
发送给用户,用户输入返回通过session
验证
// 点击获取验证码,调用getcode函数
<span @click='getCode'>获取验证码</span>
// getcode函数将手机号传递,并调用sendMsgApi
sendMsgApi({phone:this.form.phone})
// 请求后端接口,并传递手机号
function sendMsgApi(data) {
return $axios({
'url': '/user/sendMsg',
'method': 'post',
data
})
}
// 调用后端 UserController
@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);
// 将生成的验证码保存到 Session,用于后续验证
session.setAttribute(phone,code);
return R.success("手机验证码短信发送成功");
}
验证
@PostMapping("/login")
public R<User> login(@RequestBody Map map, HttpSession session){
//获取手机号,这种取值方式不太了解
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)){...}
...
// 登录成功保存用户id,之前是设置employee属性,这里改成用户对象
session.setAttribute("user",user.getId());
...}
登录成功之后手机号已经显示,其他是默认的
用户地址
在address_book
表中管理
新增:将数据插入数据表
修改:将数据更新到数据表
设为默认:根据is_default
参数来判断是否是默认地址
查询:根据用户id
查询所有地址,is_default
为默认地址
菜品展示
之前已经写过了category/list
,和第一个菜品分类列表
购物车
添加购物车
@PostMapping("/add")
public R<ShoppingCart> add(@RequestBody ShoppingCart 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);
}
对应的是shopping_cart
表
如果是套餐数量减一,请求的是sub
方法,源码中没有开发这部分,其他清空、查看就略过不说了
用户下单
首先查询地址列表并返回,然后查询购物车列表返回
支付,请求order/sumit
,后面是对接支付接口,这里就自己去开发吧
很多细节的东西都在这里没有细讲,我也学到🤮了,自己体会