一、项目基础
技术选型:
- 用户层:H5、Vue.js、ElementUI、微信小程序
- 网关层:Nginx
- 应用层:SpringBoot、SpringMVC、SpringSession、Spring、Swagger、Lombok
- 数据层:Mysql、Mybatis、MybatisPlus、Redis
- 工具:git、maven、junit
角色:
- 后台系统管理员:登录后台管理系统,拥有后台系统中的所有操作权限
- 后台系统普通员工:登录后台管理系统,对菜品、套餐、订单等进行管理
- C端用户:登录移动端应用,可以浏览菜品、添加购物车、设置地址、在线下单等
二、开发环境搭建
2.1 数据开发环境搭建
2.1.1 新建数据库
2.1.2 运行sql文件
2.2 Maven项目搭建
2.2.1 创建项目
2.2.2 导入pom
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.4.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.itheima</groupId>
<artifactId>reggie_take_out</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<java.version>1.8</java.version>
</properties>
<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>
<!-- json和对象相互转换 -->
<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>
</project>
2.2.3 创建yml配置文件
application.yml
server:
port: 8080
spring:
application:
name: reggie_take_out
datasource:
druid:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/reggie?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: root
mybatis-plus:
configuration:
#在映射实体或者属性时,将数据库中表名和字段名中的下划线去掉,按照驼峰命名法映射
map-underscore-to-camel-case: true
#打印日志
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
#主键生成策略
global-config:
db-config:
id-type: ASSIGN_ID
2.2.4 编写启动类
@Slf4j
@SpringBootApplication
public class ReggieApplication {
public static void main(String[] args) {
SpringApplication.run(ReggieApplication.class, args);
log.info("项目启动成功...");
}
}
2.2.5 前端资源导入&映射
-
将前端资源导入到resources中
-
编写映射类
@Slf4j
@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {
/**
* 设置静态资源映射
*
* @param registry
*/
@Override
protected void addResourceHandlers(ResourceHandlerRegistry registry) {
log.info("开始静态资源映射...");
registry.addResourceHandler("/backend/**").addResourceLocations("classpath:/backend/");
registry.addResourceHandler("/front/**").addResourceLocations("classpath:/front/");
}
}
- 补齐最终目录结构:
三、业务开发
3.1 后台员工管理功能开发
3.1.1 登录功能开发
3.1.1.1 需求分析
1)产品原型
2)页面展示
3)查看请求信息
浏览器NetWork中General和Request Payload
Controller–>Service–>Mapper–>DB
4)数据模型(employee表)
5)前端分析
login.html/login.js文件
<el-button :loading="loading" class="login-btn" size="medium" type="primary" style="width:100%;" @click.native.prevent="handleLogin">
<span v-if="!loading">登录</span>
<span v-else>登录中...</span>
</el-button>
async handleLogin() { // 登录按钮绑定事件
// 校验用户名密码
this.$refs.loginForm.validate(async (valid) => {
if (valid) { // 校验通过
this.loading = true // 登录按钮变为加载中
let res = await loginApi(this.loginForm) // 发送请求
if (String(res.code) === '1') { // 定义1代表登录成功
localStorage.setItem('userInfo',JSON.stringify(res.data)) // 转为json数据并保存到浏览器中
window.location.href= '/backend/index.html' // 跳转到首页
} else { // 登录失败
this.$message.error(res.msg)
this.loading = false
}
}
})
}
服务端需要响应code/data/msg等数据
function loginApi(data) {
return $axios({
'url': '/employee/login', // 请求路径
'method': 'post', // 请求方式
data // 请求数据
})
}
3.1.1.2 代码开发
1)创建实体类Employee,和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;
private LocalDateTime createTime;
private LocalDateTime updateTime;
@TableField(fill = FieldFill.INSERT)
private Long createUser;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateUser;
}
2)创建Controller、Service、Mapper
@Mapper
public interface EmployeeMapper extends BaseMapper<Employee> {
}
public interface EmployeeService extends IService<Employee> {
}
@Service
public class EmployeeServiceImpl extends ServiceImpl<EmployeeMapper, Employee> implements EmployeeService {
}
@Slf4j
@RestController
@RequestMapping("/employee")
public class EmployeeController {
@Autowired
private EmployeeService employeeService;
}
3)导入结果封装类
/**
* 通用返回结果类,服务端响应的数据最终都会封装成此对象
* @param <T>
*/
@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) {
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;
}
public R<T> add(String key, Object value) {
this.map.put(key, value);
return this;
}
}
4)编写登录方法
流程图:
/**
* 员工登录
*
* @param request
* @param employee
* @return
*/
@PostMapping("/login")
public R<Employee> login(HttpServletRequest request, @RequestBody Employee employee) {
// 1.将页面提交的密码password进行md5加密处理
String password = employee.getPassword();
password = DigestUtils.md5DigestAsHex(password.getBytes());
// 2.根据页面提交的用户名username查询数据库
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.查看员工状态,如果为已禁用状态,则返回员工已禁用结果
if (emp.getStatus() == 0) { // 0表示禁用
return R.error("账号已禁用");
}
// 6.登录成功,将员工id存入Session并返回登录成功结果
request.getSession().setAttribute("employee", emp.getId());
return R.success(emp);
}
3.1.2 退出功能开发
3.1.2.1 需求分析
1)页面展示
2)查看请求信息
3)前端分析
index.html/login.js文件
<div class="right-menu">
<div class="avatar-wrapper">{{ userInfo.name }}</div>
<img src="images/icons/btn_close@2x.png" class="outLogin" alt="退出" @click="logout" />
</div>
logout() {
logoutApi().then((res)=>{
if(res.code === 1){
localStorage.removeItem('userInfo') // 移除浏览器存储的数据
window.location.href = '/backend/page/login/login.html' // 跳转到登录页面
}
})
}
function logoutApi(){
return $axios({
'url': '/employee/logout',
'method': 'post',
})
}
3.1.2.2 代码开发
/**
* 员工退出
* @param request
* @return
*/
@PostMapping("/logout")
public R<String> logout(HttpServletRequest request){
// 清理Session中保存的当前登录员工的id
request.getSession().removeAttribute("employee");
return R.success("退出成功");
}
3.1.3 登录功能完善
3.1.3.1 需求分析
1)问题分析
前面已经完成了后台系统的员工登录功能开发,但是还存在一个问题:用户如果不登录,直接访问系统首页面,照样可以正常访问。
这种设计并不合理,我们希望看到的效果应该是,只有登录成功后才可以访问系统中的页面,如果没有登录则跳转到登录页面。
那么,具体应该怎么实现呢?答案就是使用过滤器或者拦截器,在过滤器或者拦截器中判断用户是否已经完成登录,如果没有登录则跳转到登录页面。
2)前端分析
request.js
// 响应拦截器
service.interceptors.response.use(res => {
if (res.data.code === 0 && res.data.msg === 'NOTLOGIN') {// 返回登录页面
console.log('---/backend/page/login/login.html---')
localStorage.removeItem('userInfo')
window.top.location.href = '/backend/page/login/login.html'
} else {
return res.data
}
},
3.1.3.2 代码开发
流程图:
/**
* 检查用户是否已经完成登录
*/
@WebFilter(filterName = "loginCheckFilter", urlPatterns = "/*")
@Slf4j
public class LoginCheckFilter implements Filter {
// 路径匹配器,支持通配符
public static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
// 1.获取本次请求的URI
String requestURI = request.getRequestURI(); // backend/index.html
log.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.判断登录状态,如果已登录(session中是否有数据),则直接放行
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;
}
}
3.1.4 新增员工功能开发
3.1.4.1 需求分析
1)页面展示
2)数据模型(employee表)
3)向表单输入数据查看新增请求信息
4)前端分析
list.html,index.html,add.html,member.js
<el-button type="primary" @click="addMemberHandle('add')">
+ 添加员工
</el-button>
addMemberHandle (st) {
if (st === 'add'){
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)
}
}
menuHandle(item, goBackFlag) {
this.loading = true
this.menuActived = item.id
this.iframeUrl = item.url // 页面切换
this.headTitle = item.name
this.goBackFlag = goBackFlag
this.closeLoading()
}
<div class="subBox address">
<el-form-item>
<el-button @click="goBack()">
取消
</el-button>
<el-button type="primary" @click="submitForm('ruleForm', false)">
保存
</el-button>
<el-button v-if="actionType == 'add'" type="primary" class="continue" @click="submitForm('ruleForm', true)">
保存并继续添加
</el-button>
</el-form-item>
submitForm (formName, st) {
this.$refs[formName].validate((valid) => {
if (valid) {
if (this.actionType === 'add') {
const params = {
...this.ruleForm,
sex: this.ruleForm.sex === '女' ? '0' : '1'
}
addEmployee(params).then(res => {
if (res.code === 1) {
this.$message.success('员工添加成功!')
if (!st) {
this.goBack()
} else {
this.ruleForm = {
username: '',
'name': '',
'phone': '',
// 'password': '',
// 'rePassword': '',/
'sex': '男',
'idNumber': ''
}
}
} else {
this.$message.error(res.msg || '操作失败')
}
}).catch(err => {
this.$message.error('请求出错了:' + err)
})
}
}
function addEmployee (params) {
return $axios({
url: '/employee',
method: 'post',
data: { ...params }
})
}
3.1.4.2 代码开发
/**
* 新增员工
*
* @param employee
* @return
*/
@PostMapping
public 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());
// 获得当前登录用户的id
Long empId = (Long) request.getSession().getAttribute("employee");
// 设置创建、修改人
employee.setCreateUser(empId);
employee.setUpdateUser(empId);
employeeService.save(employee);
return R.success("新增员工成功");
}
异常处理:
异常信息:(账号冲突)
2023-01-13 16:02:16.776 ERROR 10964 — [nio-8080-exec-6] c.i.r.common.GlobalExceptionHandler : Duplicate entry ‘zhangsan’ for key ‘employee.idx_username’
/**
* 全局异常处理
*/
@ControllerAdvice(annotations = {RestController.class, Controller.class})
@ResponseBody
@Slf4j
public class GlobalExceptionHandler {
/**
* 异常处理方法
*
* @param ex
* @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("未知错误");
}
}
3.1.5 员工信息分页查询功能开发
3.1.5.1 需求分析
1)系统中的员工很多的时候,如果在一个页面中全部展示出来会显得比较乱,不便于查看,所以一般的系统中都会以分页的方式来展示列表数据。
2)页面展示
3)查看请求信息
4)前端分析
list.html、member.js、request.js
// 初始化
created() {
this.init()
this.user = JSON.parse(localStorage.getItem('userInfo')).username
}
// 查询
<el-input
v-model="input"
placeholder="请输入员工姓名"
style="width: 250px"
clearable
@keyup.enter.native="handleQuery"
>
// 分页
<el-pagination
class="pageList"
:page-sizes="[10, 20, 30, 40]"
:page-size="pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="counts"
:current-page.sync="page"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
></el-pagination>
handleQuery() {
this.page = 1;
this.init();
}
handleCurrentChange (val) {
this.page = val
this.init()
}
handleSizeChange (val) {
this.pageSize = val
this.init()
}
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)
})
}
数据:code、data.records、data.total
function getMemberList (params) {
return $axios({
url: '/employee/page',
method: 'get',
params
})
}
// request拦截器:拦截get请求做参数处理
service.interceptors.request.use(config => {
// get请求映射params参数
if (config.method === 'get' && config.params) {
let url = config.url + '?';
for (const propName of Object.keys(config.params)) {
const value = config.params[propName];
var part = encodeURIComponent(propName) + "=";
if (value !== null && typeof(value) !== "undefined") {
if (typeof value === 'object') {
for (const key of Object.keys(value)) {
let params = propName + '[' + key + ']';
var subPart = encodeURIComponent(params) + "=";
url += subPart + encodeURIComponent(value[key]) + "&";
}
} else {
url += part + encodeURIComponent(value) + "&";
}
}
}
url = url.slice(0, -1);
config.params = {};
config.url = url;
}
return config
}, error => {
console.log(error)
Promise.reject(error)
})
5)程序执行流程
- 页面发送ajax请求,将分页查询参数(page. pageSize、name)提交到服务端
- 服务端Controller接收页面提交的数据并调用Service查询数据
- Service调用Mapper操作数据库,查询分页数据
- Controller将查询到的分页数据响应给页面
- 页面接收到分页数据并通过ElementuI的Table组件展示到页面上
3.1.5.2 代码开发
配置MP的分页插件:
/**
* 配置MP的分页插件
*/
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor(){
MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor();
mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return mybatisPlusInterceptor;
}
}
主体代码:
/**
* 员工信息分页查询
*
* @param page
* @param pageSize
* @param name 查询名字
* @return
*/
@GetMapping("/page")
public R<Page> page(int page, int pageSize, String name) {
log.info("page = {},pageSize = {},name = {}", page, pageSize, name);
// 构造分页构造器
Page<Employee> pageInfo = new Page<>(page,pageSize);
// 构造条件构造器
LambdaQueryWrapper<Employee> queryWrapper = new LambdaQueryWrapper<>();
// 添加过滤条件
// org.apache.commons.lang.StringUtils.isNotEmpty(name):name为空则不添加
queryWrapper.like(StringUtils.isNotEmpty(name),Employee::getName,name);
// 添加排序条件
queryWrapper.orderByDesc(Employee::getUpdateTime);
// 执行查询-->查询后结果直接封装在pageInfo中
employeeService.page(pageInfo,queryWrapper);
return R.success(pageInfo);
}
3.1.6 启用/禁用员工账号功能开发
3.1.6.1 需求分析
1)在员工管理列表页面,可以对某个员工账号进行启用或者禁用操作。账号禁用的员工不能登录系统,启用后的员工可以正常登录。需要注意,只有管理员(admin用户)可以对其他普通用户进行启用、禁用操作,所有普通用户登录系统后启用、禁用按钮不显示。
2)管理员admin登录系统可以对所有员工账号进行启用、禁用操作。如果某个员工账号状态为正常,则按钮显示为“禁用”,如果员工账号状态为已禁用,则按钮显示为“启用”
3)点击禁用查看请求信息
4)前端分析
list.html
created() {
this.init()
this.user = JSON.parse(localStorage.getItem('userInfo')).username // 动态获取username
}
<el-button
type="text"
size="small"
class="delBut non"
@click="statusHandle(scope.row)"
v-if="user === 'admin'" // 判断是否为管理员->展示(禁用/启用)按钮
>
{{ scope.row.status == '1' ? '禁用' : '启用' }}
</el-button>
// 状态修改
statusHandle (row) {
this.id = row.id
this.status = row.status
this.$confirm('确认调整该账号的状态?', '提示', {
'confirmButtonText': '确定',
'cancelButtonText': '取消',
'type': 'warning'
}).then(() => {
enableOrDisableEmployee({ 'id': this.id, 'status': !this.status ? 1 : 0 }).then(res => {
console.log('enableOrDisableEmployee',res)
if (String(res.code) === '1') {
this.$message.success('账号状态更改成功!')
this.handleQuery()
}
}).catch(err => {
this.$message.error('请求出错了:' + err)
})
})
}
// 修改---启用禁用接口
function enableOrDisableEmployee (params) {
return $axios({
url: '/employee',
method: 'put',
data: { ...params }
})
}
3.1.6.2 代码开发
启用、禁用员工账号,本质上就是一个更新操作,也就是对status状态字段进行操作,在Controller中创建update方法,此方法是一个通用的修改员工信息的方法–>编辑员工信息
/**
* 根据id修改员工信息
* @param employee
* @return
*/
@PutMapping
public R<String> update(HttpServletRequest request,@RequestBody Employee employee){
log.info(employee.toString());
// 设置修改人、修改时间
Long empId = (Long) request.getSession().getAttribute("employee");
employee.setUpdateUser(empId);
employee.setUpdateTime(LocalDateTime.now());
// 调用方法修改
employeeService.updateById(employee);
return R.success("员工信息修改成功");
}
解决long型数据精度缺失问题
问题出现原因:js对long型数据进行处理时丢失精度,导致提交的id和数据库中的id不一致。
解决方法:在服务器端给页面响应json数据时进行处理,将long型数据统一转为String字符串
具体实现步骤:
1)提供对象转换器JacksonobjectMapper,基于Jackson进行Java对象到json数据的转换
2)在webMvcConfig配置类中扩展Spring mvc的消息转换器,在此消息转换器中使用提供的对象转换器进行Java对象到json数据的转换
/**
* 对象映射器:基于jackson将Java对象转为json,或者将json转为Java对象
* 将JSON解析为Java对象的过程称为 [从JSON反序列化Java对象]
* 从Java对象生成JSON的过程称为 [序列化Java对象到JSON]
*/
public class JacksonObjectMapper extends ObjectMapper {
public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
public static final String DEFAULT_TIME_FORMAT = "HH:mm:ss";
public JacksonObjectMapper() {
super();
// 收到未知属性时不报异常
this.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);
// 反序列化时,属性不存在的兼容处理
this.getDeserializationConfig().withoutFeatures(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
SimpleModule simpleModule = new SimpleModule()
.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)))
.addSerializer(BigInteger.class, ToStringSerializer.instance)
.addSerializer(Long.class, ToStringSerializer.instance)
.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
.addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
.addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)));
//注册功能模块 例如,可以添加自定义序列化器和反序列化器
this.registerModule(simpleModule);
}
}
/**
* 扩展mvc框架的消息转换器
*
* @param converters
*/
@Override
protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
log.info("扩展消息转换器...");
// 创建消息转换器对象
MappingJackson2HttpMessageConverter messageConverter = new MappingJackson2HttpMessageConverter();
// 设置对象转换器,底层使用Jackson将Java对象转为json
messageConverter.setObjectMapper(new JacksonObjectMapper());
// 将上面的消息转换器对象追加到mvc框架的转换器集合中 index=0:放置到最前面
converters.add(0, messageConverter);
}
注解方式:在id加注解:@JsonSerialize(using = ToStringSerializer.class)
3.1.7 编辑员工信息功能开发
3.1.7.1 需求分析
1)在员工管理列表页面点击编辑按钮,跳转到编辑页面,在编辑页面回显员工信息并进行修改,最后点击保存按钮完成编辑工作
2)查看请求信息
回显(查询):
上面请求的参数获取参数转发:
修改:
3)前端分析
list.html,add.html
<el-button
type="text"
size="small"
class="blueBug"
@click="addMemberHandle(scope.row.id)"
:class="{notAdmin:user !== 'admin'}"
>
编辑
</el-button>
// 添加
addMemberHandle (st) {
if (st === 'add'){
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)
}
}
menuHandle(item, goBackFlag) {
this.loading = true
this.menuActived = item.id
this.iframeUrl = item.url // 跳转编辑页面
this.headTitle = item.name
this.goBackFlag = goBackFlag
this.closeLoading()
}
created() {
this.id = requestUrlParam('id')
this.actionType = this.id ? 'edit' : 'add' // 编辑或新增
if (this.id) {
this.init()
}
}
//获取url地址上面的参数
function requestUrlParam(argname){
var url = location.href // 获取完整路径
var arrStr = url.substring(url.indexOf("?")+1).split("&") // 截取参数
for(var i =0;i<arrStr.length;i++)
{
var loc = arrStr[i].indexOf(argname+"=")
if(loc!=-1){
return arrStr[i].replace(argname+"=","").replace("?","")
}
}
return ""
}
async init () {
queryEmployeeById(this.id).then(res => {
console.log(res)
if (String(res.code) === '1') {
console.log(res.data)
this.ruleForm = res.data
this.ruleForm.sex = res.data.sex === '0' ? '女' : '男'
// this.ruleForm.password = ''
} else {
this.$message.error(res.msg || '操作失败')
}
})
}
// 修改页面反查详情接口
function queryEmployeeById(id) {
return $axios({
url: `/employee/${id}`,
method: 'get'
})
}
<el-button
type="primary"
@click="submitForm('ruleForm', false)"
>
保存
</el-button>
const params = {
...this.ruleForm,
sex: this.ruleForm.sex === '女' ? '0' : '1'
}
editEmployee(params).then(res => {
if (res.code === 1) {
this.$message.success('员工信息修改成功!')
this.goBack()
} else {
this.$message.error(res.msg || '操作失败')
}
}).catch(err => {
this.$message.error('请求出错了:' + err)
})
// 修改---添加员工
function editEmployee (params) {
return $axios({
url: '/employee',
method: 'put',
data: { ...params }
})
}
// 返回列表页面
goBack(){
window.parent.menuHandle({
id: '2',
url: '/backend/page/member/list.html',
name: '员工管理'
},false)
}
4)层序执行流程
- 点击编辑按钮时,页面跳转到add.html,并在url中携带参数[员工id]
- 在add.html页面获取url中的参数[员工id]
- 发送ajax请求,请求服务端,同时提交员工id参数
- 服务端接收请求,根据员工id查询员工信息,将员工信息以json形式响应给页面
- 页面接收服务端响应的json数据,通过VUE的数据绑定进行员工信息回显
- 点击保存按钮,发送ajax请求,将页面中的员工信息以json方式提交给服务端
- 服务端接收员工信息,并进行处理,完成后给页面响应
- 页面接收到服务端响应信息后进行相应处理
注意:add.html页面为公共页面,新增员工和编辑员工都是在此页面操作
3.1.7.2 代码开发
/**
* 根据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("没有查询到对应员工信息");
}
3.2 后台分类管理功能开发
3.2.1 公共字段自动填充
3.2.1.1 问题分析
前面已经完成了后台系统的员工管理功能开发,在新增员工时需要设置创建时间、创建人、修改时间、修改人等字段,在编辑员工时需要设置修改时间和修改人等字段。这些字段属于公共字段,也就是很多表中都有这些字段。能不能对于这些公共字段在某个地方统一处理,来简化开发呢?
答案就是使用Mybatis Plus提供的公共字段自动填充功能
3.2.1.2 代码开发
Mybatis Plus公共字段自动填充,也就是在插入或者更新的时候为指定字段赋予指定的值,使用它的好处就是可以统一对这些字段进行处理,避免了重复代码。
实现步骤:
1)把员工管理中插入和更新操作的设置创建时间、创建人、修改时间、修改人的代码注释掉或删除掉
2)在实体类的属性上加入@TableField注解,指定自动填充的策略
/**
* 员工实体类
*/
@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;
}
3)按照框架要求编写元数据对象处理器,在此类中统一为公共字段赋值,此类需要实现MetaObjectHandler接口
/**
* 自定义元数据对象处理器
*/
@Component
@Slf4j
public class MyMetaObjectHandler implements MetaObjectHandler {
/**
* 插入操作,自动填充
* @param metaObject
*/
@Override
public 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
*/
@Override
public 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。
用户登录成功后我们将用户id存入了HttpSession中,现在我从HttpSession中获取不就行了?
注意,在MyMetaObjectHandler类中是不能获得HttpSession对象的,所以我们需要通过其他方式来获取登录用户id。
可以使用ThreadLocal来解决此问题,它是JDK中提供的一个类
在学习ThreadLocal之前,我们需要先确认一个事情,就是客户端发送的每次http请求,对应的在服务端都会分配一个新的线程来处理,在处理过程中涉及到下面类中的方法都属于相同的一个线程:
- LogincheckFilter的doFilter方法
- Employeecontroller的update方法
- MyMetaObjectHandler的updateFill方法
可以在上面的三个方法中分别加入下面代码(获取当前线程id ) 进行测试:
long id = Thread.currentThread().getId() ;
log.info("线程id: {}" ,id) ;
什么是ThreadLocal?
ThreadLocal并不是一个Thread,而是Thread的局部变量。当使用ThreadLocal维护变量时,ThreadLocal为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。ThreadLocal为每个线程提供单独一份存储空间,具有线程隔离的效果,只有在线程内才能获取到对应的值,线程外则不能访问。
ThreadLocal常用方法:
- public void set(T value)设置当前线程的线程局部变量的值
- public T get()返回当前线程所对应的线程局部变量的值
我们可以在LoginCheckFilter的doFilter方法中获取当前登录用户id,并调用ThreadLocal的set方法来设置当前线程的线程局部变量的值(用户id),然后在MyMetaObjectHandler的updateFill方法中调用ThreadLocal的get方法来获得当前线程所对应的线程局部变量的值(用户id)。
功能完善:
1、编写BaseContext工具类,基于ThreadLocal封装的工具类
/**
* 基于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的doFilter方法中调用BaseContext来设置当前登录用户的id
// 4.判断登录状态,如果已登录(session中是否有数据),则直接放行
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;
}
3、在MyMeta0bjectHandler的方法中调用BaseContext获取登录用户的id
@Component
@Slf4j
public class MyMetaObjectHandler implements MetaObjectHandler {
/**
* 插入操作,自动填充
* @param metaObject
*/
@Override
public void insertFill(MetaObject metaObject) {
log.info("公共字段自动填充[insert]...");
log.info(metaObject.toString());
metaObject.setValue("createTime", LocalDateTime.now());
metaObject.setValue("updateTime", LocalDateTime.now());
metaObject.setValue("createUser", BaseContext.getCurrentId());
metaObject.setValue("updateUser", BaseContext.getCurrentId());
}
/**
* 更新操作,自动填充
* @param metaObject
*/
@Override
public void updateFill(MetaObject metaObject) {
log.info("公共字段自动填充[update]...");
log.info(metaObject.toString());
metaObject.setValue("updateTime", LocalDateTime.now());
metaObject.setValue("updateUser", BaseContext.getCurrentId());
}
}
3.2.2 新增分类
3.2.2.1 需求分析
1)后台系统中可以管理分类信息,分类包括两种类型,分别是菜品分类和套餐分类。当我们在后台系统中添加菜品时需要选择一个菜品分类,当我们在后台系统中添加一个套餐时需要选择一个套餐分类,在移动端也会按照菜品分类和套餐分类来展示对应的菜品和套餐。
2)页面展示
3)数据模型(category表)
4)查看请求
5)前端分析
category/list.html
<el-button type="primary" class="continue" @click="addClass('class')">
+ 新增菜品分类
</el-button>
<el-button type="primary" @click="addClass('meal')">
+ 新增套餐分类
</el-button>
addClass(st) {
if (st == 'class') {
this.classData.title = '新增菜品分类'
this.type = '1'
} else {
this.classData.title = '新增套餐分类'
this.type = '2'
}
this.action = 'add'
this.classData.name = ''
this.classData.sort = ''
this.classData.dialogVisible = true
}
<el-button type="primary" size="medium" @click="submitForm()">确 定</el-button>
submitForm(st) {
const classData = this.classData
const valid = (classData.name === 0 ||classData.name) && (classData.sort === 0 || classData.sort)
if (this.action === 'add') {
if (valid) {
const reg = /^\d+$/
if (reg.test(classData.sort)) {
addCategory({'name': classData.name,'type':this.type, sort: classData.sort}).then(res => {
console.log(res)
if (res.code === 1) {
this.$message.success('分类添加成功!')
if (!st) {
this.classData.dialogVisible = false
} else {
this.classData.name = ''
this.classData.sort = ''
}
this.handleQuery()
} else {
this.$message.error(res.msg || '操作失败')
}
}).catch(err => {
this.$message.error('请求出错了:' + err)
})
} else {
this.$message.error('排序只能输入数字类型')
}
} else {
this.$message.error('请输入分类名称或排序')
}
}
}
// 新增接口
const addCategory = (params) => {
return $axios({
url: '/category',
method: 'post',
data: { ...params }
})
}
3.2.2.2 代码开发
1)创建实体类Category,和category表进行映射
/**
* 分类
*/
@Data
public class Category implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private Integer type; // 类型 1 菜品分类 2 套餐分类
private String name; // 分类名称
private Integer sort; // 顺序
@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)创建Controller、Service、Mapper
@Mapper
public interface CategoryMapper extends BaseMapper<Category> {
}
public interface CategoryService extends IService<Category> {
}
@Service
public class CategoryServiceImpl extends ServiceImpl<CategoryMapper, Category> implements CategoryService {
}
/**
* 分类管理
*/
@RestController
@RequestMapping("/category")
@Slf4j
public class CategoryController {
@Autowired
private CategoryService categoryService;
}
3)编写新增分类方法
/**
* 新增分类
*
* @param category
* @return
*/
@PostMapping
public R<String> save(@RequestBody Category category) {
log.info("category:{}", category);
categoryService.save(category);
return R.success("新增分类成功");
}
3.2.3 分类信息分页查询
3.2.3.1 需求分析
1)分页需求基本类似,可参考前面开发过的需求
2)查看请求
3.2.3.2 代码开发
/**
* 分页查询
* @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);
}
3.2.4 删除分类(难)
3.2.4.1 需求分析
1)在分类管理列表页面,可以对某个分类进行删除操作。需要注意的是当分类关联了菜品或者套餐时,此分类不允许删除。
2)页面展示
3)查看请求
4)前端分析
category/list.html
<el-button type="text" size="small" class="delBut non" @click="deleteHandle(scope.row.id)">
删除
</el-button>
deleteHandle(id) {
this.$confirm('此操作将永久删除该文件, 是否继续?', '提示', {
'confirmButtonText': '确定',
'cancelButtonText': '取消',
'type': 'warning'
}).then(() => {
deleCategory(id).then(res => {
if (res.code === 1) {
this.$message.success('删除成功!')
this.handleQuery()
} else {
this.$message.error(res.msg || '操作失败')
}
}).catch(err => {
this.$message.error('请求出错了:' + err)
})
})
}
const deleCategory = (ids) => {
return $axios({
url: '/category',
method: 'delete',
params: { ids }
})
}
5)数据模型(dish、setmeal)
3.2.4.2 代码开发
1)实体类Dish和Setmeal
/**
菜品
*/
@Data
public class Dish implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String name; // 菜品名称
private Long categoryId; // 菜品分类id
private BigDecimal price; // 菜品价格
private String code; // 商品码
private String image; // 图片
private String description; // 描述信息
private Integer status; // 0 停售 1 起售
private Integer sort; // 顺序
@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;
private Integer isDeleted; // 是否删除
}
/**
* 套餐
*/
@Data
public class Setmeal implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private Long categoryId; // 分类id
private String name; // 套餐名称
private BigDecimal price; // 套餐价格
private Integer status; // 状态 0:停用 1:启用
private String code; // 编码
private String description; // 描述信息
private String image; // 图片
@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;
private Integer isDeleted; // 是否删除
}
2)Mapper接口DishMapper和SetmealMapper、Service接口DishService和SetmealService、Service实现类DishServicelmpl和SetmealServicelmpl
@Mapper
public interface DishMapper extends BaseMapper<Dish> {
}
@Mapper
public interface SetmealMapper extends BaseMapper<Setmeal> {
}
public interface DishService extends IService<Dish> {
}
public interface SetmealService extends IService<Setmeal> {
}
@Service
public class DishServiceImpl extends ServiceImpl<DishMapper, Dish> implements DishService {
}
@Service
public class SetmealServiceImpl extends ServiceImpl<SetmealMapper, Setmeal> implements SetmealService {
}
3)CategoryController
/**
* 根据id删除分类
*
* @param ids
* @return
*/
@DeleteMapping
public R<String> delete(Long ids) {
log.info("删除分类,id为{}", ids);
categoryService.remove(ids);
return R.success("分类信息删除成功");
}
4)自定义异常CustomException
/**
* 自定义业务异常类
*/
public class CustomException extends RuntimeException{
public CustomException(String message){
super(message);
}
}
5)GlobalExceptionHandler
/**
* 异常处理方法
*
* @param ex
* @return
*/
@ExceptionHandler(CustomException.class)
public R<String> exceptionHandler(CustomException ex) {
log.error(ex.getMessage());
return R.error(ex.getMessage());
}
6)CategoryServiceImpl
@Service
public class CategoryServiceImpl extends ServiceImpl<CategoryMapper, Category> implements CategoryService {
@Autowired
private DishService dishService;
@Autowired
private SetmealService setmealService;
/**
* 根据id删除分类,删除之前需要进行判断
*
* @param id
*/
@Override
public void remove(Long id) {
// 查询当前分类是否关联了菜品,如果已经关联,抛出一个业务异常
LambdaQueryWrapper<Dish> dishLambdaQueryWrapper = new LambdaQueryWrapper<>();
// 添加查询条件,根据分类id进行查询
dishLambdaQueryWrapper.eq(Dish::getCategoryId, id);
int count1 = dishService.count(dishLambdaQueryWrapper);
if (count1 > 0) {
// 已经关联菜品,抛出一个业务异常
throw new CustomException("当前分类下关联了菜品,不能删除");
}
// 查询当前分类是否关联了套餐,如果已经关联,抛出一个业务异常
LambdaQueryWrapper<Setmeal> setmealLambdaQueryWrapper = new LambdaQueryWrapper<>();
// 添加查询条件,根据分类id进行查询
setmealLambdaQueryWrapper.eq(Setmeal::getCategoryId, id);
int count2 = setmealService.count(setmealLambdaQueryWrapper);
if (count2 > 0) {
// 已经关联套餐,抛出一个业务异常
throw new CustomException("当前分类下关联了套餐,不能删除");
}
// 正常删除分类
super.removeById(id);
}
}
3.2.5 修改分类
3.2.5.1 需求分析
1)在分类管理列表页面点击修改按钮,弹出修改窗口,在修改窗口回显分类信息并进行修改,最后点击确定按钮完成修改操作。
2)页面展示
3)查看请求
4)前端分析
<el-button type="text" size="small" class="blueBug" @click="editHandle(scope.row)">
修改
</el-button>
editHandle(dat) { // 数据回显
this.classData.title = '修改分类'
this.action = 'edit'
this.classData.name = dat.name
this.classData.sort = dat.sort
this.classData.id = dat.id
this.classData.dialogVisible = true
}
else if (valid) {
const reg = /^\d+$/
if (reg.test(this.classData.sort)) {
editCategory({'id':this.classData.id,'name': this.classData.name, sort: this.classData.sort}).then(res => {
if (res.code === 1) {
this.$message.success('分类修改成功!')
this.classData.dialogVisible = false
this.handleQuery()
} else {
this.$message.error(res.msg || '操作失败')
}
}).catch(err => {
this.$message.error('请求出错了:' + err)
})
} else {
this.$message.error('排序只能输入数字类型')
}
} else {
this.$message.error('请输入分类名称或排序')
}
// 修改接口
const editCategory = (params) => {
return $axios({
url: '/category',
method: 'put',
data: { ...params }
})
}
3.2.5.2 代码开发
/**
* 根据id修改分类信息
*
* @param category
* @return
*/
@PutMapping
public R<String> update(@RequestBody Category category) {
log.info("修改分类信息:{}", category);
categoryService.updateById(category);
return R.success("修改分类信息成功");
}
3.3 后台菜品管理功能开发
3.3.1 文件上传下载
3.3.1.1 文件上传介绍
文件上传,也称为upload,是指将本地图片、视频、音频等文件上传到服务器上,可以供其他用户浏览或下载的过程。文件上传在项目中应用非常广泛,我们经常发微博、发微信朋友圈都用到了文件上传功能。
文件上传时,对页面的form表单有如下要求:
- method="post"采用post方式提交数据
- enctype="multipart/form-data"采用multipart格式上传文件
- type="file"使用input的file控件上传
举例:
<form method="post" action=" /common/upload" enctype="multipart/form-data">
<input name="myFile" type="file"/>
<input type="submit" value="提交" /></form>
目前一些前端组件库也提供了相应的上传组件,但是底层原理还是基于form表单的文件上传。例如ElementUI中提供的upload上传组件。
服务端要接收客户端页面上传的文件,通常都会使用Apache的两个组件:
- commons-fileupload
- commons-io
Spring框架在spring-web包中对文件上传进行了封装,大大简化了服务端代码,我们只需要在Controller的方法中声明一个MultipartFile类型的参数即可接收上传的文件。
@PostMapper("/upload")
public R<String> upload(MultipartFile file){
}
3.3.1.2 文件下载介绍
文件下载,也称为download,是指将文件从服务器传输到本地计算机的过程。
通过浏览器进行文件下载,通常有两种表现形式:
- 以附件形式下载,弹出保存对话框,将文件保存到指定磁盘目录
- 直接在浏览器中打开
通过浏览器进行文件下载,本质上就是服务端将文件以流的形式写回浏览器的过程。
3.3.1.3 文件上传代码开发
1)将资料中upload.html导入到项目中
注意:需要clean一下再重新运行程序
2)查看请求
3)前端分析
<el-upload class="avatar-uploader"
action="/common/upload" // 路径
:show-file-list="false"
:on-success="handleAvatarSuccess"
:before-upload="beforeUpload"
ref="upload">
<img v-if="imageUrl" :src="imageUrl" class="avatar"></img>
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
</el-upload>
4)代码实现
/**
* 文件上传和下载
*/
@RestController
@RequestMapping("/common")
@Slf4j
public class CommonController {
@Value("${reggie.path}")
private String basePath; // 参数在配置文件中
@PostMapping("/upload")
public R<String> upload(MultipartFile file) { // 形参要和前端中名称一致
// file是一个临时文件,需要转存到指定位置,否则本次请求完成后临时文件会删除
log.info(file.toString());
// 原始文件名 获取源文件的昵称
String originalFilename = file.getOriginalFilename();
// 获取后缀
String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
// 使用UUID重新生成文件名,防止文件名重复造成文件覆盖
String fileName = UUID.randomUUID().toString() + suffix;
// 创建一个目录对象
File dir = new File(basePath);
// 判断当前目录是否存在
if (!dir.exists()){
// 目录不存在需要创建
dir.mkdir();
}
try {
// 将临时文件转存到指定位置 用来把MultipartFile转换换成File
file.transferTo(new File(basePath + fileName));
} catch (IOException e) {
e.printStackTrace();
}
return R.success(fileName); // 返回文件名存储在表
}
}
application.yml
reggie:
path: D:\img\
3.3.1.4 文件下载代码开发
1)文件下载,页面端可以使用标签展示下载的图片
<img v-if="imageUrl" :src="imageUrl" class="avatar"></img>
handleAvatarSuccess (response,file,fileList){
this.imageUrl = `/common/download?name=${response.data} // 上传文件后获得的返回名称
}
2)具体代码
/**
* 文件下载
*
* @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();
}
//IOUtils.copy(fileInputStream,outputStream);
// 关闭资源
outputStream.close();
fileInputStream.close();
} catch (Exception e) {
e.printStackTrace();
}
}
3.3.2 新增菜品
3.3.2.1 需求分析
1)后台系统中可以管理菜品信息,通过新增功能来添加一个新的菜品,在添加菜品时需要选择当前菜品所属的菜品分类,并且需要上传菜品图片,在移动端会按照菜品分类来展示对应的菜品信息。
2)页面展示
3)数据模型(dish、dish_flavor)
4)查看请求
刚进入页面发送:
上传文件发送:
下载回显发送:
保存时发送:
5)前端分析
food/add.html
created() {
this.getDishList()
// 口味临时数据
this.getFlavorListHand()
this.id = requestUrlParam('id')
this.actionType = this.id ? 'edit' : 'add'
if (this.id) {
this.init()
}
}
getDishList () {
getCategoryList({ 'type': 1 }).then(res => {
if (res.code === 1) {
this.dishList = res.data
} else {
this.$message.error(res.msg || '操作失败')
}
})
}
const getCategoryList = (params) => {
return $axios({
url: '/category/list',
method: 'get',
params // type=1:菜品分类 2:套餐分类
})
}
getFlavorListHand () {
// flavor flavorData
this.dishFlavorsData = [
{'name':'甜味','value':['无糖','少糖','半糖','多糖','全糖']},
{'name':'温度','value':['热饮','常温','去冰','少冰','多冰']},
{'name':'忌口','value':['不要葱','不要蒜','不要香菜','不要辣']},
{'name':'辣度','value':['不辣','微辣','中辣','重辣']}
]
}
async init () {
queryDishById(this.id).then(res => {
console.log(res)
if (String(res.code) === '1') {
this.ruleForm = { ...res.data }
this.ruleForm.price = String(res.data.price/100)
this.ruleForm.status = res.data.status == '1'
this.dishFlavors = res.data.flavors && res.data.flavors.map(obj => ({ ...obj, value: JSON.parse(obj.value),showOption: false }))
console.log('this.dishFlavors',this.dishFlavors)
// this.ruleForm.id = res.data.data.categoryId
// this.imageUrl = res.data.data.image
this.imageUrl = `/common/download?name=${res.data.image}`
} else {
this.$message.error(res.msg || '操作失败')
}
})
}
// 查询详情
const queryDishById = (id) => {
return $axios({
url: `/dish/${id}`,
method: 'get'
})
}
submitForm(formName, st) {
this.$refs[formName].validate((valid) => {
if (valid) {
let params = {...this.ruleForm}
// params.flavors = this.dishFlavors
params.status = this.ruleForm ? 1 : 0
params.price *= 100 // 单位:元->分
params.categoryId = this.ruleForm.categoryId
params.flavors = this.dishFlavors.map(obj => ({ ...obj, value: JSON.stringify(obj.value) }))
delete params.dishFlavors
if(!this.imageUrl){
this.$message.error('请上传菜品图片')
return
}
if (this.actionType == 'add') {
delete params.id
addDish(params).then(res => {
if (res.code === 1) {
this.$message.success('菜品添加成功!')
if (!st) {
this.goBack()
} else {
this.dishFlavors = []
// this.dishFlavorsData = []
this.imageUrl = ''
this.ruleForm = {
'name': '',
'id': '',
'price': '',
'code': '',
'image': '',
'description': '',
'dishFlavors': [],
'status': true,
categoryId: ''
}
}
} else {
this.$message.error(res.msg || '操作失败')
}
}).catch(err => {
this.$message.error('请求出错了:' + err)
})
}
const addDish = (params) => {
return $axios({
url: '/dish',
method: 'post',
data: { ...params }
})
}
6)流程
- 页面(backend/page/food/add.html)发送ajax请求,请求服务端获取菜品分类数据并展示到下拉框中
- 页面发送请求进行图片上传,请求服务端将图片保存到服务器
- 页面发送请求进行图片下载,将上传的图片进行回显
- 点击保存按钮,发送ajax请求,将菜品相关数据以json形式提交到服务端
3.3.2.2 代码开发
1)实体类DishFlavor
/**
菜品口味
*/
@Data
public class DishFlavor implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private Long dishId; // 菜品id
private String name; // 口味名称
private String value; // 口味数据list
@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;
private Integer isDeleted; // 是否删除
}
2)Mapper接口DishFlavorMapper,service接口、实现类,控制层DishController
@Mapper
public interface DishFlavorMapper extends BaseMapper<DishFlavor> {
}
public interface DishFlavorService extends IService<DishFlavor> {
}
@Service
public class DishFlavorServiceImpl extends ServiceImpl<DishFlavorMapper, DishFlavor> implements DishFlavorService {
}
/**
* 菜品管理
*/
@RestController
@RequestMapping("/dish")
public class DishController {
@Autowired
private DishService dishService;
@Autowired
private DishFlavorService dishFlavorService;
}
3)主要代码
categoryController
/**
* 根据条件查询分类数据
*
* @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);
}
封装数据进行传输
DishDto:DTO全称Data Transfer Object,即数据传输对象,一般用于展示层与服务层之间的数据传输
@Data
public class DishDto extends Dish {
private List<DishFlavor> flavors = new ArrayList<>();
private String categoryName;
private Integer copies;
}
DishController
/**
* 新增菜品
* @param dishDto
* @return
*/
@PostMapping
public R<String> save(@RequestBody DishDto dishDto){
log.info(dishDto.toString());
dishService.saveWithFlavor(dishDto);
return R.success("新增菜品成功");
}
DishService
public interface DishService extends IService<Dish> {
// 新增菜品,同时插入菜品对应的口味数据,需要操作两张表:dish、dish_flavor
public void saveWithFlavor(DishDto dishDto);
}
DishServiceImpl
@Service
public class DishServiceImpl extends ServiceImpl<DishMapper, Dish> implements DishService {
@Autowired
private DishFlavorService dishFlavorService;
/**
* 新增菜品,同时保存对应的口味数据
* @param dishDto
*/
@Override
@Transactional // 多表操作,开启事务
public void saveWithFlavor(DishDto dishDto) {
// 保存菜品的基本信息到菜品表dish
this.save(dishDto);
// 菜品id
Long dishId = dishDto.getId();
// 菜品口味 设置菜品id
List<DishFlavor> flavors = dishDto.getFlavors();
flavors = flavors.stream().map((item)->{
item.setDishId(dishId);
return item;
}).collect(Collectors.toList());
// 保存菜品口味数据到菜品口味表dish_flavor
dishFlavorService.saveBatch(flavors);
}
}
ReggieApplication:开启事务
@Slf4j
@SpringBootApplication
@ServletComponentScan
@EnableTransactionManagement
public class ReggieApplication {
public static void main(String[] args) {
SpringApplication.run(ReggieApplication.class, args);
log.info("项目启动成功...");
}
}
3.3.3 菜品信息分页查询(难)
3.3.3.1 需求分析
1)系统中的菜品数据很多的时候,如果在一个页面中全部展示出来会显得比较乱,不便于查看,所以一般的系统中都会以分页的方式来展示列表数据。
2)基础部分可参考前面开发过的需求,在此需求有图片和菜品分类名称需要多做处理
3)查看请求
刚进入页面:
搜索查询:
4)前端分析
<el-table-column prop="categoryName" label="菜品分类"></el-table-column>
<el-table-column prop="image" label="图片" align="center">
<template slot-scope="{ row }">
<el-image style="width: auto; height: 40px; border:none;cursor: pointer;"
:src="getImage(row.image)"
:preview-src-list="[ `/common/download?name=${row.image}` ]" > // 路径
<div slot="error" class="image-slot">
<img src="./../../images/noImg.png" style="width: auto; height: 40px; border:none;" >
</div>
</el-image>
</template>
</el-table-column>
created() {
this.init()
}
async init () {
const params = {
page: this.page,
pageSize: this.pageSize,
name: this.input ? this.input : undefined
}
await getDishPage(params).then(res => {
if (String(res.code) === '1') {
this.tableData = res.data.records || []
this.counts = res.data.total
}
}).catch(err => {
this.$message.error('请求出错了:' + err)
})
}
const getDishPage = (params) => {
return $axios({
url: '/dish/page',
method: 'get',
params
})
}
5)流程
- 页面(backend/page/food/list.html)发送ajax请求,将分页查询参数(page、pageSize、name)提交到服务端,获取分页数据
- 页面发送请求,请求服务端进行图片下载,用于页面图片展示(已经实现)
开发菜品信息分页查询功能,其实就是在服务端编写代码去处理前端页面发送的这2次请求即可
3.3.3.2 代码开发
@Autowired
private CategoryService categoryService;
/**
* 菜品信息分页查询
*
* @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);
// 对象拷贝 忽略records(List<Dish>)属性
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);
}
3.3.4 修改菜品
3.3.4.1 需求分析
1)在菜品管理列表页面点击修改按钮,跳转到修改菜品页面,在修改页面回显菜品相关信息并进行修改,最后点击确定按钮完成修改操作
2)查看请求
点击修改按钮,根据id查询回显数据
修改保存
3)流程
- 页面发送ajax请求,请求服务端获取分类数据,用于菜品分类下拉框中数据展示(新增需求时已经实现)
- 页面发送ajax请求,请求服务端,根据id查询当前菜品信息,用于菜品信息回显
- 页面发送请求,请求服务端进行图片下载,用于页图片回显(已经实现)
- 点击保存按钮,页面发送ajax请求,将修改后的菜品相关数据以json形式提交到服务端
开发修改菜品功能,其实就是在服务端编写代码去处理前端页面发送的这4次请求即可
3.3.4.2 代码开发
DishService
// 根据id查询菜品信息和对应口味信息
public DishDto getByIdWithFlavor(Long id);
// 更新菜品信息同时更新对应的口味信息
public void updateWithFlavor(DishDto dishDto);
DishServiceImpl
/**
* 根据id查询菜品信息和对应口味信息
* @param id
* @return
*/
@Override
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;
}
/**
* 更新菜品信息同时更新对应的口味信息
* @param dishDto
*/
@Override
@Transactional
public 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操作
// 菜品口味 设置菜品id
List<DishFlavor> flavors = dishDto.getFlavors();
flavors = flavors.stream().map((item) -> {
item.setDishId(dishDto.getId());
return item;
}).collect(Collectors.toList());
dishFlavorService.saveBatch(flavors);
}
DishController
/**
* 根据id查询菜品信息和对应的口味信息
* @param id
* @return
*/
@GetMapping("/{id}")
public R<DishDto> get(@PathVariable Long id){
DishDto dishDto = dishService.getByIdWithFlavor(id);
return R.success(dishDto);
}
/**
* 修改菜品
*
* @param dishDto
* @return
*/
@PutMapping
public R<String> update(@RequestBody DishDto dishDto) {
log.info(dishDto.toString());
dishService.updateWithFlavor(dishDto);
return R.success("修改菜品成功");
}
3.4 后台套餐管理功能开发
3.4.1 新增套餐
3.4.1.1 需求分析
1)套餐就是菜品的集合。后台系统中可以管理套餐信息,通过新增套餐功能来添加一个新的套餐,在添加套餐时需要选择当前套餐所属的套餐分类和包含的菜品,并且需要上传套餐对应的图片,在移动端会按照套餐分类来展示对应的套餐。
2)页面展示
3)数据模型(setmeal、setmeal_dish)
4)查看请求
查询菜品展示:
套餐分类下拉:
保存:
5)开发流程
- 页面(backend/page/combo/add.html)发送ajax请求,请求服务端获取套餐分类数据并展示到下拉框中(已经实现)
- 页面发送ajax请求,请求服务端获取菜品分类数据并展示到添加菜品窗口中(已经实现)
- 页面发送ajax请求,请求服务端,根据菜品分类查询对应的菜品数据并展示到添加菜品窗口中
- 页面发送请求进行图片上传,请求服务端将图片保存到服务器(已经实现)
- 页面发送请求进行图片下载,将上传的图片进行回显(已经实现)
- 点击保存按钮,发送ajax请求,将套餐相关数据以json形式提交到服务端
开发新增套餐功能,其实就是在服务端编写代码去处理前端页面发送的这6次请求即可。
3.4.1.2 代码开发
SetmealDish、SetmealDto
/**
* 套餐菜品关系
*/
@Data
public class SetmealDish implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private Long setmealId; // 套餐id
private Long dishId; // 菜品id
private String name; // 菜品名称 (冗余字段)
private BigDecimal price; // 菜品原价
private Integer copies; // 份数
private Integer sort; // 排序
@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;
private Integer isDeleted; // 是否删除
}
@Data
public class SetmealDto extends Setmeal {
private List<SetmealDish> setmealDishes;
private String categoryName;
}
Mapper接口SetmealDishMapper、业务层接口SetmealDishService、业务层实现类SetmealDishServiceImpl控制层SetmealController
@Mapper
public interface SetmealDishMapper extends BaseMapper<SetmealDish> {
}
public interface SetmealDishService extends IService<SetmealDish>{
}
@Service
public class SetmealDishServiceImpl extends ServiceImpl<SetmealDishMapper, SetmealDish> implements SetmealDishService {
}
/**
* 套餐管理
*/
@RestController
@RequestMapping("/setmeal")
@Slf4j
public class SetmealController {
@Autowired
private SetmealService setmealService;
@Autowired
private SetmealDishService setmealDishService;
}
DishController
/**
* 根据条件查询对应的菜品数据
*
* @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);
}
SetmealService
public interface SetmealService extends IService<Setmeal> {
/**
* 新增套餐,同时需要保存套餐和餐品的关联关系
* @param setmealDto
*/
public void saveWithDish(SetmealDto setmealDto);
}
SetmealServiceImpl
@Service
public class SetmealServiceImpl extends ServiceImpl<SetmealMapper, Setmeal> implements SetmealService {
@Autowired
private SetmealDishService setmealDishService;
/**
* 新增套餐,同时需要保存套餐和餐品的关联关系
*
* @param setmealDto
*/
@Override
@Transactional
public void saveWithDish(SetmealDto setmealDto) {
// 保存套餐的基本信息,操作setmeal,执行insert操作 insert完后setmealId生成
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);
}
}
SetmealController
/**
* 新增套餐
* @param setmealDto
* @return
*/
@PostMapping
public R<String> save(@RequestBody SetmealDto setmealDto){
log.info("套餐信息:{}",setmealDto);
setmealService.saveWithDish(setmealDto);
return R.success("新增套餐成功");
}
3.4.2 套餐信息分页查询
3.4.2.1 需求分析
1)系统中的套餐数据很多的时候,如果在一个页面中全部展示出来会显得比较乱,不便于查看,所以一般的系统中都会以分页的方式来展示列表数据。
2)查看请求
3)流程
- 页面(backend/page/combo/list.html)发送ajax请求,将分页查询参数(page、pageSize、name)提交到服务端,获取分页数据
- 页面发送请求,请求服务端进行图片下载,用于页面图片展示
开发套餐信息分页查询功能,其实就是在服务端编写代码去处理前端页面发送的这2次请求即可。
3.4.2.2 代码开发
/**
* 套餐信息分页查询
* @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);
// 对象拷贝 -> 拷贝分页数据,records结构不一样需要封装
BeanUtils.copyProperties(pageInfo, dtoPage, "records");
List<Setmeal> records = pageInfo.getRecords();
List<SetmealDto> list = records.stream().map((item) -> {
SetmealDto setmealDto = new SetmealDto();
// 对象拷贝 -> 拷贝records
BeanUtils.copyProperties(item, setmealDto);
// 分类id
Long 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);
}
3.4.3 删除套餐
3.4.3.1 需求分析
1)在套餐管理列表页面点击删除按钮,可以删除对应的套餐信息。也可以通过复选框选择多个套餐,点击批量删除按钮一次删除多个套餐。注意,对于状态为售卖中的套餐不能删除,需要先停售,然后才能删除。
2)查看请求
删除单条:
批量删除:
请求地址和请求方式相同,id个数不同,可以提供一个方法统一处理
3.4.3.2 代码开发
SetmealService
/**
* 删除套餐,同时需要删除套餐和菜品的关联数据
* @param ids
*/
public void removeWithDish(List<Long> ids);
SetmealServiceImpl
/**
* 删除套餐,同时需要删除套餐和菜品的关联数据
*
* @param ids
*/
@Override
@Transactional
public void removeWithDish(List<Long> ids) {
// 查询套餐状态,确定是否可以删除
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("套餐正在售卖中,不能删除");
}
// 如果可以删除,先删除套餐表中的数据---setmeal
this.removeByIds(ids);
// 删除关系表中的数据---setmeal_dish
LambdaQueryWrapper<SetmealDish> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.in(SetmealDish::getSetmealId,ids);
setmealDishService.remove(lambdaQueryWrapper);
}
SetmealController
/**
* 删除套餐
*
* @param ids
* @return
*/
@DeleteMapping
public R<String> delete(@RequestParam List<Long> ids) {
log.info("ids:{}", ids);
setmealService.removeWithDish(ids);
return R.success("套餐数据删除成功");
}
3.5 移动端手机验证码登录功能开发
3.5.1 短信发送
3.5.1.1 短信服务介绍
目前市面上有很多第三方提供的短信服务,这些第三方短信服务会和各个运营商(移动、联通、电信)对接,我们只需要注册成为会员并且按照提供的开发文档进行调用就可以发送短信。需要说明的是,这些短信服务一般都是收费服务。
常用短信服务:阿里云;华为云;腾讯云;京东;梦网;乐信
阿里云短信服务(Short Message Service)是广大企业客户快速触达手机用户所优选使用的通信能力。调用API或用群发助手,即可发送验证码、通知类和营销类短信;国内验证短信秒级触达,到达率最高可达99%;国际/港澳台短信覆盖200多个国家和地区,安全稳定,广受出海企业选用。
应用场景:验证码;短信通知;推广短信
3.5.1.2 阿里云短信服务
- 注册登录
- 找到短信服务
- 设置短信签名(发送者)
- 模板管理(发送内容、场景、变量信息)
- 设置AccessKey
3.5.1.3 代码开发
使用阿里云短信服务发送短信,可以参照官方提供的文档即可。
具体开发过程:
- 导入maven坐标
- 调用API
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.4.6</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-dysmsapi</artifactId>
<version>2.1.0</version>
</dependency>
SMSUtils
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", "", "");
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();
}
}
}
该项目中可不申请验证码,前端自动生成
getCode(){
this.form.code = ''
const regex = /^(13[0-9]{9})|(15[0-9]{9})|(17[0-9]{9})|(18[0-9]{9})|(19[0-9]{9})$/;
if (regex.test(this.form.phone)) {
this.msgFlag = false
this.form.code = (Math.random()*1000000).toFixed(0) // 在前端自动填充
}else{
this.msgFlag = true
}
}
async btnLogin(){
if(this.form.phone && this.form.code){
this.loading = true
const res = await loginApi({phone:this.form.phone})
this.loading = false
if(res.code === 1){
sessionStorage.setItem("userPhone",this.form.phone)
window.requestAnimationFrame(()=>{
window.location.href= '/front/index.html'
})
}else{
this.$notify({ type:'warning', message:res.msg});
}
}else{
this.$notify({ type:'warning', message:'请输入手机号码'});
}
}
修改优化:
getCode(){
this.form.code = ''
const regex = /^(13[0-9]{9})|(15[0-9]{9})|(17[0-9]{9})|(18[0-9]{9})|(19[0-9]{9})$/;
if (regex.test(this.form.phone)) {
this.msgFlag = false
// this.form.code = (Math.random()*1000000).toFixed(0)
sendMsgApi({phone:this.form.phone})
}else{
this.msgFlag = true
}
}
// 在api/login.js中添加
function sendMsgApi(data){
return $axios({
'url': '/user/sendMsg',
'method': 'post',
data
})
}
// 登录时把验证码也发送过去
async btnLogin(){
if(this.form.phone && this.form.code){
this.loading = true
// const res = await loginApi({phone:this.form.phone})
// const res = await loginApi({phone:this.form.phone},{code:this.form.code})
const res = await loginApi(this.form)
this.loading = false
if(res.code === 1){
sessionStorage.setItem("userPhone",this.form.phone)
window.requestAnimationFrame(()=>{
window.location.href= '/front/index.html'
})
}else{
this.$notify({ type:'warning', message:res.msg});
}
}else{
this.$notify({ type:'warning', message:'请输入手机号码'});
}
}
3.5.2 手机验证码登录
3.5.2.1 需求分析
1)为了方便用户登录,移动端通常都会提供通过手机验证码登录的功能。
手机验证码登录的优点:
- 方便快捷,无需注册,直接登录
- 使用短信验证码作为登录凭证,无需记忆密码
- 安全
登录流程:输入手机号>获取验证码>输入验证码>点击登录>登录成功
注意:通过手机验证码登录,手机号是区分不同用户的标识。
2)数据模型(user)
3)查看请求
发送验证码:
登录:
4)前端分析
// 发送验证码
function sendMsgApi(data){
return $axios({
'url': '/user/sendMsg',
'method': 'post',
data
})
}
// 登录
function loginApi(data) {
return $axios({
'url': '/user/login',
'method': 'post',
data
})
}
5)流程
- 在登录页面(front/page/login.html)输入手机号,点击【获取验证码】按钮,页面发送ajax请求,在服务端调用短信服务API给指定手机号发送验证码短信
- 在登录页面输入验证码,点击【登录】按钮,发送ajax请求,在服务端处理登录请求
开发手机验证码登录功能,其实就是在服务端编写代码去处理前端页面发送的这2次请求即可。
3.5.2.2 代码开发
实体类User、Mapper接口UserMapper、业务层接口UserService、业务层实现类UserServicelmpl控制层UserController
/**
* 用户信息
*/
@Data
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String name; // 姓名
private String phone; // 手机号
private String sex; // 性别 0 女 1 男
private String idNumber; // 身份证号
private String avatar; // 头像
private Integer status; // 状态 0:禁用,1:正常
}
@Mapper
public interface UserMapper extends BaseMapper<User> {
}
public interface UserService extends IService<User> {
}
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
}
/**
* 用户管理
*/
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@Autowired
private UserService userService;
}
工具类SMSUtils、 ValidateCodeUtils
/**
* 随机生成验证码工具类
*/
public class ValidateCodeUtils {
/**
* 随机生成验证码
* @param length 长度为4位或者6位
* @return
*/
public static Integer generateValidateCode(int length){
Integer code =null;
if(length == 4){
code = new Random().nextInt(9999);//生成随机数,最大为9999
if(code < 1000){
code = code + 1000;//保证随机数为4位数字
}
}else if(length == 6){
code = new Random().nextInt(999999);//生成随机数,最大为999999
if(code < 100000){
code = code + 100000;//保证随机数为6位数字
}
}else{
throw new RuntimeException("只能生成4位或6位数字验证码");
}
return code;
}
/**
* 随机生成指定长度字符串验证码
* @param length 长度
* @return
*/
public static String generateValidateCode4String(int length){
Random rdm = new Random();
String hash1 = Integer.toHexString(rdm.nextInt());
String capstr = hash1.substring(0, length);
return capstr;
}
}
配置放行:LoginCheckFilter
// 定义不需要处理的请求路径
String[] urls = new String[]{
"/employee/login",
"/employee/logout",
"/backend/**",
"/front/**",
"/common/**",
"/user/sendMsg",
"/user/login"
};
// 4-2.判断登录状态(用户),如果已登录(session中是否有数据),则直接放行
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
/**
* 发送手机短信验证码
*
* @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);
// 需要将生成的验证码保存到Session
session.setAttribute(phone,code);
return R.success("手机验证码短信发送成功");
}
return R.error("短信发送失败");
}
/**
* 移动端用户登录
*
* @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("登录失败");
}
3.6 移动端剩余功能开发
3.6.1 导入用户地址簿相关功能代码
3.6.1.1 需求分析
1)地址簿,指的是移动端消费者用户的地址信息,用户登录成功后可以维护自己的地址信息。同一个用户可以有多个地址信息,但是只能有一个默认地址。
2)数据模型(address_book)
3.6.1.2 导入功能代码
实体类AddressBook、Mapper接口AddressBookMapper
/**
* 地址簿
*/
@Data
public class AddressBook implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private Long userId; // 用户id
private String consignee; // 收货人
private String phone; // 手机号
private String sex; // 性别 0 女 1 男
private String provinceCode; // 省级区划编号
private String provinceName; // 省级名称
private String cityCode; // 市级区划编号
private String cityName; // 市级名称
private String districtCode; // 区级区划编号
private String districtName; // 区级名称
private String detail; // 详细地址
private String label; // 标签
private Integer isDefault; // 是否默认 0否 1是
@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; // 修改人
private Integer isDeleted; // 是否删除
}
@Mapper
public interface AddressBookMapper extends BaseMapper<AddressBook> {
}
业务层接口AddressBookService、业务层实现类AddressBookServicelmpl
public interface AddressBookService extends IService<AddressBook> {
}
@Service
public class AddressBookServiceImpl extends ServiceImpl<AddressBookMapper, AddressBook> implements AddressBookService {
}
控制层AddressBookController
/**
* 地址簿管理
*/
@Slf4j
@RestController
@RequestMapping("/addressBook")
public class AddressBookController {
@Autowired
private AddressBookService addressBookService;
/**
* 新增
*/
@PostMapping
public 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 = 1
AddressBook 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 desc
return R.success(addressBookService.list(queryWrapper));
}
}
3.6.2 菜品展示功能开发
3.6.2.1 需求分析
1)用户登录成功后跳转到系统首页,在首页需要根据分类来展示菜品和套餐。如果菜品设置了口味信息,需要展示(选择规格)按钮,否则显示(+)按钮。
2)查看请求
首页面加载时同时发送两个请求
套餐请求:
3)前端分析
index.html
mounted(){
this.initData()
}
initData(){
Promise.all([categoryListApi(),cartListApi({})]).then(res=>{
//获取分类数据
if(res[0].code === 1){
this.categoryList = res[0].data
if(Array.isArray(res[0].data) && res[0].data.length > 0){
this.categoryId = res[0].data[0].id
if(res[0].data[0].type === 1){
this.getDishList()
}else{
this.getSetmealData()
}
}
}else{
this.$notify({ type:'warning', message:res[0].msg});
}
//获取菜品数据
if(res[1].code === 1){
this.cartData = res[1].data
}else{
this.$notify({ type:'warning', message:res[1].msg});
}
})
}
//获取所有的菜品分类
function categoryListApi() {
return $axios({
'url': '/category/list',
'method': 'get',
})
}
//获取购物车内商品的集合
function cartListApi(data) {
return $axios({
'url': '/shoppingCart/list',
'method': 'get',
params:{...data}
})
}
4)流程
- 页面(front/index.html)发送ajax请求,获取分类数据(菜品分类和套餐分类)
- 页面发送ajax请求,获取第一个分类下的菜品或者套餐
开发菜品展示功能,其实就是在服务端编写代码去处理前端页面发送的这2次请求即可。
3.6.2.2 代码实现
方法改造:DishController
/**
* 根据条件查询对应的菜品数据
*
* @param dish
* @return
*/
@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);
// 分类id
Long categoryId = item.getCategoryId();
// 根据id查询分类对象
Category category = categoryService.getById(categoryId);
if (category != null) {
String categoryName = category.getName();
dishDto.setCategoryName(categoryName);
}
// 当前菜品id
Long dishId = item.getId();
LambdaQueryWrapper<DishFlavor> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(DishFlavor::getDishId,dishId);
List<DishFlavor> dishFlavorList = dishFlavorService.list(lambdaQueryWrapper);
dishDto.setFlavors(dishFlavorList);
return dishDto;
}).collect(Collectors.toList());;
return R.success(dishDtoList);
}
SetmealController
/**
* 根据条件查询套餐
*
* @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);
}
3.6.3 购物车功能开发
3.6.3.1 需求分析
1)移动端用户可以将菜品或者套餐添加到购物车。对于菜品来说,如果设置了口味信息,则需要选择规格后才能加入购物车;对于套餐来说,可以直接点击(+)将当前套餐加入购物车。在购物车中可以修改菜品和套餐的数量,也可以清空购物车。
2)数据模型(shopping_cart)
3)查看请求
加购(菜品):
加购(套餐):
查看购物车:
清空购物车:
4)流程
- 点击(加入购物车)或者(+)按钮,页面发送ajax请求,请求服务端,将菜品或者套餐添加到购物车
- 点击购物车图标,页面发送ajax请求,请求服务端查询购物车中的菜品和套餐
- 点击清空购物车按钮,页面发送ajax请求,请求服务端来执行清空购物车操作
开发购物车功能,其实就是在服务端编写代码去处理前端页面发送的这3次请求即可。
3.6.3.2 代码开发
实体类ShoppingCart、Mapper接口ShoppingCartMapper
/**
* 购物车
*/
@Data
public class ShoppingCart implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String name; // 名称
private Long userId; // 用户id
private Long dishId; // 菜品id
private Long setmealId; // 套餐id
private String dishFlavor; // 口味
private Integer number; // 数量
private BigDecimal amount; // 金额
private String image; // 图片
private LocalDateTime createTime;
}
@Mapper
public interface ShoppingCartMapper extends BaseMapper<ShoppingCart> {
}
业务层接口ShoppingCartService、业务层实现类ShoppingCartServicelmpl
public interface ShoppingCartService extends IService<ShoppingCart> {
}
@Service
public class ShoppingCartServicelmpl extends ServiceImpl<ShoppingCartMapper, ShoppingCart> implements ShoppingCartService {
}
控制层ShoppingCartController
/**
* 购物车
*/
@RestController
@RequestMapping("/shoppingCart")
@Slf4j
public class ShoppingCartController {
@Autowired
private ShoppingCartService shoppingCartService;
/**
* 添加购物车
*
* @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());
}
// select * from shopping_cart where user_id = ? and dish_id/setmeal_id = ?
ShoppingCart cartServiceOne = shoppingCartService.getOne(queryWrapper);
if (cartServiceOne != null) {
// 如果已经存在,就在原来的数量基础上+1
Integer number = cartServiceOne.getNumber();
cartServiceOne.setNumber(number + 1);
shoppingCartService.updateById(cartServiceOne);
} else {
// 如果不存在,则添加到购物车,默认数量为1
shoppingCart.setNumber(1);
shoppingCart.setCreateTime(LocalDateTime.now());
shoppingCartService.save(shoppingCart);
cartServiceOne = shoppingCart;
}
return R.success(cartServiceOne);
}
/**
* 查看购物车
* @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);
}
/**
* 清空购物车
* @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("清空购物车成功");
}
}
3.6.4 下单功能开发
3.6.4.1 需求分析
1)移动端用户将菜品或者套餐加入购物车后,可以点击购物车中的(去结算)按钮,页面跳转到订单确认页面,点击(去支付)按钮则完成下单操作。
2)数据模型(orders、order_detail)
3)查看请求
支付请求:
4)流程
- 在购物车中点击(去结算)按钮,页面跳转到订单确认页面(已经实现)
- 在订单确认页面,发送ajax请求,请求服务端获取当前登录用户的默认地址(已经实现)
- 在订单确认页面,发送ajax请求,请求服务端获取当前登录用户的购物车数据(已经实现)
- 在订单确认页面点击(去支付)按钮,发送ajax请求,请求服务端完成下单操作
开发用户下单功能,其实就是在服务端编写代码去处理前端页面发送的请求即可。
3.6.4.2 代码开发
实体类Orders、OrderDetail
/**
* 订单
*/
@Data
public class Orders implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String number; // 订单号
private Integer status; // 订单状态 1待付款,2待派送,3已派送,4已完成,5已取消
private Long userId; // 下单用户id
private Long addressBookId; // 地址id
private LocalDateTime orderTime; // 下单时间
private LocalDateTime checkoutTime; // 结账时间
private Integer payMethod; // 支付方式 1微信,2支付宝
private BigDecimal amount; // 实收金额
private String remark; // 备注
private String userName; // 用户名
private String phone; // 手机号
private String address; // 地址
private String consignee; // 收货人
}
/**
* 订单明细
*/
@Data
public class OrderDetail implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String name; // 名称
private Long orderId; // 订单id
private Long dishId; // 菜品id
private Long setmealId; // 套餐id
private String dishFlavor; // 口味
private Integer number; // 数量
private BigDecimal amount; // 金额
private String image; // 图片
}
Mapper接口OrderMapper、OrderDetailMapper
@Mapper
public interface OrderMapper extends BaseMapper<Orders> {
}
@Mapper
public interface OrderDetailMapper extends BaseMapper<OrderDetail> {
}
业务层接口OrderService、OrderDetailService;业务层实现类OrderServiceImpl、OrderDetailServiceImpl
public interface OrderService extends IService<Orders> {
/**
* 用户下单
* @param orders
*/
public void submit(Orders orders);
}
@Service
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Orders> implements OrderService {
@Autowired
private ShoppingCartService shoppingCartService;
@Autowired
private UserService userService;
@Autowired
private AddressBookService addressBookService;
@Autowired
private OrderDetailService orderDetailService;
/**
* 用户下单
*
* @param orders
*/
@Transactional
public void submit(Orders orders) {
// 获得当前用户id
Long 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(); // 获取唯一ID,生成订单号
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()); // 菜品id
orderDetail.setSetmealId(item.getSetmealId()); // 套餐id
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);
}
}
public interface OrderDetailService extends IService<OrderDetail> {
}
@Service
public class OrderDetailServiceImpl extends ServiceImpl<OrderDetailMapper, OrderDetail> implements OrderDetailService {
}
控制层OrderController、OrderDetailController
@RestController
@RequestMapping("/order")
@Slf4j
public class OrderController {
@Autowired
private OrderService orderService;
/**
* 用户下单
* @param orders
* @return
*/
@PostMapping("/submit")
public R<String> submit(@RequestBody Orders orders){
log.info("订单数据:{}",orders);
orderService.submit(orders);
return R.success("下单成功");
}
}
@RestController
@RequestMapping("/orderDetail")
@Slf4j
public class OrderDetailController {
}
四、项目优化
4.1 Git管理
1)创建仓库
2)复制仓库url
3)IDEA配置git管理
4)添加暂存区
5)提交到远程仓库
6)创建分支做优化
7)推送新分支到远程仓库
4.2 缓存优化
4.2.1 环境搭建
4.2.1.1 maven坐标
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
4.2.1.2 配置文件
spring:
redis:
host: 192.168.10.10
port: 6379
password: "000000"
database: 0
4.2.1.3 配置类
@Configuration
public class RedisConfig extends CachingConfigurerSupport {
@Bean
public RedisTemplate<Object,Object> redisTemplate(RedisConnectionFactory connectionFactory){
RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
// 默认序列化器为:JdkSerializationRedisSerializer
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setConnectionFactory(connectionFactory);
return redisTemplate;
}
}
4.2.2 缓存短信验证码
4.2.2.1 思路实现
前面已经实现了移动端手机验证码登录,随机生成的验证码是保存在HttpSession中的。现在需要改造为将验证码缓存在Redis中,具体的实现思路如下:
- 在服务端UserController中注入RedisTemplate对象,用于操作Redis
- 在服务端UserController的sendMsg方法中,将随机生成的验证码缓存到Redis中,并设置有效期为5分钟
- 在服务端UserController的login方法中,从Redis中获取缓存的验证码,如果登录成功则删除Redis中的验证码
4.2.2.2 代码改造
/**
* 用户管理
*/
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@Autowired
private UserService userService;
@Autowired
private RedisTemplate redisTemplate;
/**
* 发送手机短信验证码
*
* @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);
// 需要将生成的验证码保存到Session
// session.setAttribute(phone, code);
// 将生成的验证码缓存到Redis中,并设置有效期为5分钟
redisTemplate.opsForValue().set(phone,code,5, TimeUnit.MINUTES);
return R.success("手机验证码短信发送成功");
}
return R.error("短信发送失败");
}
/**
* 移动端用户登录
*
* @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);
// 从redis中获取缓存的验证码
Object codeInSession = redisTemplate.opsForValue().get(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());
// 如果用户登录成功,删除redis中缓存的验证码
redisTemplate.delete(phone);
return R.success(user);
}
return R.error("登录失败");
}
}
4.2.3 缓存菜品数据
4.2.3.1 实现思路
前面已经实现了移动端菜品查看功能,对应的服务端方法为DishController的list方法,此方法会根据前端提交的查询条件进行数据库查询操作。在高并发的情况下,频繁查询数据库会导致系统性能下降,服务端响应时间增长。现在需要对此方法进行缓存优化,提高系统的性能。
具体的实现思路如下:
- 改造DishController的list方法,先从Redis中获取菜品数据,如果有则直接返回,无需查询数据库;如果没有则查询数据库,并将查询到的菜品数据放入Redis。
- 改造DishController的save和update方法,加入清理缓存的逻辑
注意事项:在使用缓存过程中,要注意保证数据库中的数据和缓存中的数据一致,如果数据库中的数据发生变化,需要及时清理缓存数据。
4.2.3.2 代码改造
/**
* 菜品管理
*/
@RestController
@RequestMapping("/dish")
@Slf4j
public class DishController {
@Autowired
private DishService dishService;
@Autowired
private DishFlavorService dishFlavorService;
@Autowired
private CategoryService categoryService;
@Autowired
private RedisTemplate redisTemplate;
/**
* 新增菜品,清理缓存
*
* @param dishDto
* @return
*/
@PostMapping
public R<String> save(@RequestBody DishDto dishDto) {
log.info(dishDto.toString());
dishService.saveWithFlavor(dishDto);
// 清理所有菜品的缓存数据
// Set keys = redisTemplate.keys("dish_*");
// redisTemplate.delete(keys);
// 清理某个分类下面的菜品缓存数据
String key = "dish_" + dishDto.getCategoryId() + "_1";
redisTemplate.delete(key);
return R.success("新增菜品成功");
}
/**
* 菜品信息分页查询
*
* @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);
// 对象拷贝 忽略records(List<Dish>)属性
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);
}
/**
* 根据id查询菜品信息和对应的口味信息
*
* @param id
* @return
*/
@GetMapping("/{id}")
public R<DishDto> get(@PathVariable Long id) {
DishDto dishDto = dishService.getByIdWithFlavor(id);
return R.success(dishDto);
}
/**
* 修改菜品,清理缓存
*
* @param dishDto
* @return
*/
@PutMapping
public R<String> update(@RequestBody DishDto dishDto) {
log.info(dishDto.toString());
dishService.updateWithFlavor(dishDto);
// 清理所有菜品的缓存数据
// Set keys = redisTemplate.keys("dish_*");
// redisTemplate.delete(keys);
// 清理某个分类下面的菜品缓存数据
String key = "dish_" + dishDto.getCategoryId() + "_1";
redisTemplate.delete(key);
return R.success("新增菜品成功");
}
/**
* 根据条件查询对应的菜品数据
*
* @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);
}*/
/**
* 根据条件查询对应的菜品数据,添加到缓存
*
* @param dish
* @return
*/
@GetMapping("/list")
public R<List<DishDto>> list(Dish dish) {
List<DishDto> dishDtoList = null;
// 动态构造key
String key = "dish_" + dish.getCategoryId() + "_" + dish.getStatus(); // dish_1397844263642378242_1
// 先从redis中获取缓存数据
dishDtoList = (List<DishDto>) redisTemplate.opsForValue().get(key);
if (dishDtoList != null) {
// 如果存在,直接返回,无需查询数据库
return R.success(dishDtoList);
}
// 构造查询条件
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);
dishDtoList = list.stream().map((item) -> {
DishDto dishDto = new DishDto();
BeanUtils.copyProperties(item, dishDto);
// 分类id
Long categoryId = item.getCategoryId();
// 根据id查询分类对象
Category category = categoryService.getById(categoryId);
if (category != null) {
String categoryName = category.getName();
dishDto.setCategoryName(categoryName);
}
// 当前菜品id->查询口味
Long dishId = item.getId();
LambdaQueryWrapper<DishFlavor> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(DishFlavor::getDishId, dishId);
List<DishFlavor> dishFlavorList = dishFlavorService.list(lambdaQueryWrapper);
dishDto.setFlavors(dishFlavorList);
return dishDto;
}).collect(Collectors.toList());
// 如果不存在,需要查询数据库,将查询到的数据菜品数据缓存到redis
redisTemplate.opsForValue().set(key, dishDtoList, 60, TimeUnit.MINUTES);
return R.success(dishDtoList);
}
}
测试没问题后上传到仓库,合并分支
4.2.4 Spring Cache
4.2.4.1 Spring Cache介绍
Spring cache是一个框架,实现了基于注解的缓存功能,只需要简单地加一个注解,就能实现缓存功能。
Spring Cache提供了一层抽象,底层可以切换不同的cache实现。具体就是通过CacheManager接口来统一不同的缓存技术。
CacheManager是Spring提供的各种缓存技术抽象接口。
针对不同的缓存技术需要实现不同的CacheManager:
CacheManager | 描述 |
---|---|
EhCacheCacheManager | 使用EhCache作为缓存技术 |
GuavaCacheManager | 使用Google的GuavaCache作为缓存技术 |
RedisCacheManager | 使用Redis作为缓存技术 |
4.2.4.2 Spring Cache常用注解
注解 | 说明 |
---|---|
@EnableCaching | 开启缓存注解功能,启动类注解 |
@Cacheable | 在方法执行前spring先查看缓存中是否有数据,如果有数据,则直接返回缓存数据;若没有数据,调用方法并将方法返回值放到缓存中 |
@CachePut | 将方法的返回值放到缓存中 |
@CacheEvict | 将一条或多条数据从缓存中删除 |
key=“缓存key”,value=“缓存名称,每个缓存名称可以有多个key”,condition=“满足条件则缓存”,unless=“满足条件则不缓存”,allEntries="true"清除该key所有数据
在spring boot项目中,使用缓存技术只需在项目中导入相关缓存技术的依赖包,并在启动类上使用@EnableCaching开启缓存支持即可。
例如,使用Redis作为缓存技术,只需要导入Spring data Redis的maven坐标即可。
4.2.5 缓存套餐数据
4.2.5.1 实现思路
前面已经实现了移动端套餐查看功能,对应的服务端方法为SetmealController的list方法,此方法会根据前端提交的查询条件进行数据库查询操作。在高并发的情况下,频繁查询数据库会导致系统性能下降,服务端响应时间增长。现在需要对此方法进行缓存优化,提高系统的性能。
具体的实现思路如下:
- 导入Spring Cache和Redis相关maven坐标
- 在application.yml中配置缓存数据的过期时间
- 在启动类上加入@EnableCaching注解,开启缓存注解功能
- 在SetmealController的list方法上加入@Cacheable注解
- 在SetmealController的save和delete方法上加入CacheEvict注解
4.2.5.2 代码改造
①
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
②
spring:
cache:
redis:
time-to-live: 1800000 # 设置缓存数据的过期时间
③
@Slf4j
@SpringBootApplication
@ServletComponentScan
@EnableTransactionManagement
@EnableCaching
public class ReggieApplication {
public static void main(String[] args) {
SpringApplication.run(ReggieApplication.class, args);
log.info("项目启动成功...");
}
}
④
/**
* 根据条件查询套餐
*
* @param setmeal
* @return
*/
@GetMapping("/list")
@Cacheable(value = "setmealCache", key = "#setmeal.categoryId + '_' + #setmeal.status")
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);
}
R对象序列化
@Data
public class R<T> implements Serializable {}
⑤
/**
* 删除套餐,清理缓存
*
* @param ids
* @return
*/
@DeleteMapping
@CacheEvict(value = "setmealCache",allEntries = true)
public R<String> delete(@RequestParam List<Long> ids) {
log.info("ids:{}", ids);
setmealService.removeWithDish(ids);
return R.success("套餐数据删除成功");
}
/**
* 新增套餐,清理缓存
*
* @param setmealDto
* @return
*/
@PostMapping
@CacheEvict(value = "setmealCache",allEntries = true)
public R<String> save(@RequestBody SetmealDto setmealDto) {
log.info("套餐信息:{}", setmealDto);
setmealService.saveWithDish(setmealDto);
return R.success("新增套餐成功");
}
4.3 读写分离
当前:
实现:
4.3.1 Mysql主从复制
4.3.1.1 介绍
MySQL主从复制是一个异步的复制过程,底层是基于Mysql数据库自带的二进制日志功能。就是一台或多台MysQL数据库(slave,即从库)从另一台MysQL数据库〈master,即主库)进行日志的复制然后再解析日志并应用到自身,最终实现从库的数据和主库的数据保持一致。MySQL主从复制是MySQL数据库自带功能,无需借助第三方工具。
MySQL复制过程分成三步:
- master将改变记录到二进制日志(binary log)
- slave将master的binary log拷贝到它的中继日志(relay log)
- slave重做中继日志中的事件,将改变应用到自己的数据库中
4.3.1.2 配置
准备两台安装好mysql的虚拟器
1)配置-主库Master
①修改Mysql数据库的配置文件/etc/my.cnf
[mysqld]
log-bin=mysql-bin #[必须]启用二进制日志
server-id=10 #[必须]服务器唯一ID
②重启Mysql服务
systemctl restart mysqld
③创建新用户并授予权限
-- 设置密码级别
mysql> set global validate_password_policy=0;
mysql> set global validate_password_length=4;
-- 配置slave访问master的用户的ip权限
mysql> GRANT REPLICATION SLAVE ON *.* to 'xiaoming'@'%' identified by '000000';
扩展:
GRANT
[权限]
ON [库.表]
TO [用户名]@[IP]
IDENTIFIED BY [密码]
# WITH GRANT OPTION;
GRANT命令说明:
(1)ALL PRIVILEGES
表示所有权限,你也可以使用select、update等权限。
(2)ON
用来指定权限针对哪些库和表。
(3)*.*
中前面的号用来指定数据库名,后面的号用来指定表名。
(4)TO
表示将权限赋予某个用户。
(5)@
前面表示用户,@后面接限制的主机,可以是IP、IP段、域名以及%,%表示任何地方。
(6)IDENTIFIED BY
指定用户的登录密码。
(7)WITH GRANT OPTION
这个选项表示该用户可以将自己拥有的权限授权给别人。
④查看File和Position
mysql> show master status;
⑤暂时不要操作此数据库保证File和Position不变
2)配置-从库Slave
①修改Mysql数据库的配置文件/etc/my.cnf
[mysqld]
server-id=11 #[必须]服务器唯一ID
②重启Mysql服务
systemctl restart mysqld
③配置和master通信
mysql> change master to master_host='192.168.10.10',master_user='xiaoming',master_password='000000',master_log_file='mysql-bin.000001',master_log_pos=433;
mysql> start slave;
master_log_file和master_log_pos要和主库中的File和Position保持一致
④查看File和Position
mysql> show slave status\G;
注意:如果虚拟机是克隆的,Mysql中的UUID会相等,需要修改其中一台虚拟机的UUID
4.3.2 读写分离案例
4.3.2.1 背景
面对日益增加的系统访问量,数据库的吞吐量面临着巨大瓶颈。对于同一时刻有大量并发读操作和较少写操作类型的应用系统来说,将数据库拆分为主库和从库,主库负责处理事务性的增删改操作,从库负责处理查询操作,能够有效的避免由数据更新导致的行锁,使得整个系统的查询性能得到极大的改善。
4.3.2.2 sharding-JDBC介绍
Sharding-JDBC定位为轻量级Java框架,在Java的JDBC层提供的额外服务。它使用客户端直连数据库,以jar包形式提供服务,无需额外部署和依赖,可理解为增强版的JDBC驱动,完全兼容JDBC和各种ORM框架。
使用Sharding-JDBC可以在程序中轻松的实现数据库读写分离。
- 适用于任何基于JDBC的ORM框架,如:JPA, Hibernate, Mybatis, Spring JDBC Template或直接使用JDBC。
- 支持任何第三方的数据库连接池,如:DBCP,C3P0,BoneCP, Druid , HikariCP等。
- 支持任意实现JDBC规范的数据库。目前支持My5QL,Oracle,sQLServer,PostgreSQL以及任何遵循5QL92标准的数据库。
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>sharding-jdbc-spring-boot-starter</artifactId>
<version>4.0.O-RC1</version>
</dependency>
4.3.2.3 入门案例
使用Sharding-JDBC实现读写分离步骤:
- 导入maven坐标
- 在配置文件中配置读写分离规则
- 在配置文件中配置允许bean定义覆盖配置项
4.3.3 项目实现读写分离
4.3.3.1 数据库环境准备(主从复制)
在主库中创建瑞吉外卖项目的业务数据库reggie并导入相关表结构和数据
4.3.3.2 代码改造
在项目中加入Sharding-JDBC实现读写分离步骤:
-
导入maven坐标
-
在配置文件中配置读写分离规则
-
在配置文件中配置允许bean定义覆盖配置项
pom:
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>sharding-jdbc-spring-boot-starter</artifactId>
<version>4.0.0-RC1</version>
</dependency>
yml:
server:
port: 8080
spring:
application:
# 应用的名称,可选
name: reggie_take_out
shardingsphere:
datasource:
names:
master,slave
# 主数据源
master:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.10.10:3306/reggie?characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false
username: root
password: "000000"
# 从数据源
slave:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.10.11:3306/reggie?characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false
username: root
password: "000000"
masterslave:
# 读写分离配置
load-balance-algorithm-type: round_robin #轮询
# 最终的数据源名称
name: dataSource
# 主库数据源名称
master-data-source-name: master
# 从库数据源名称列表,多个逗号分隔
slave-data-source-names: slave
props:
sql:
show: true #开启SQL显示,默认false
main:
allow-bean-definition-overriding: true # 允许bean定义覆盖
redis:
host: 192.168.10.10
port: 6379
password: "000000"
database: 0
cache:
redis:
time-to-live: 1800000 #设置缓存数据的过期时间
mybatis-plus:
configuration:
#在映射实体或者属性时,将数据库中表名和字段名中的下划线去掉,按照驼峰命名法映射
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: ASSIGN_ID
reggie:
path: D:\img\
4.4 Nginx
4.4.1 Nginx概述
4.4.1.1 Nginx介绍
Nginx是一款轻量级的web服务器/反向代理服务器及电子邮件(IMAP/POP3)代理服务器。其特点是占有内存少,并发能力强,事实上nginx的并发能力在同类型的网页服务器中表现较好,中国大陆使用nginx的网站有:百度、京东、新浪、网易、腾讯、淘宝等。
Nginx是由伊戈尔·赛索耶夫为俄罗斯访问量第二的Rambler.ru站点(俄文:Paw6nep)开发的,第一个公开版本0.1.0发布于2004年10月4日。
官网: https : //nginx.org/
4.4.1.2 Nginx下载和安装
可以到Nginx官方网站下载Nginx的安装包,地址为: https://nginx.org/en/download.html
安装过程:
- 安装依赖包yum -y install gcc pcre-devel zlib-devel openssl openssl-devel
- 下载Nginx安装包wget https : / /nginx.org/download/nginx-1.16.1.tar.gz
- 解压tar -zxvf nginx-1.16.1.tar.gz
- cd nginx-1.16.1
- ./ configure --prefix=/usr/local/nginx
- make && make install
4.4.1.3 Nginx目录结构
重点目录/文件:
- conf/nginx.conf:nginx配置文件
- html:存放静态文件( html、css、Js等)
- logs:日志目录,存放日志文件
- sbin/nginx:二进制文件,用于启动、停止Nginx服务
4.4.2 Nginx命令
1)查看版本
./nginx -v
2)检查配置文件
./nginx -t
3)启动和停止
# 启动
./nginx
# 停止
./nginx -s stop
# 查看进程
ps -ef |grep nginx
4)重新加载配置文件(修改Nginx配置文件后,需要重新加载)
./nginx -s reload
4.4.3 Nginx配置文件结构
Nginx配置文件(conf/nginx.conf)整体分为三部分:
-
全局块:和Nginx运行相关的全局配置
-
events块:和网络连接相关的配置
-
http块:代理、缓存、日志记录、虚拟主机配置
-
http全局块
-
Server块
-
server全局块
-
location块
-
-
注意:http块中可以配置多个Server块,每个Server块中可以配置多个location块。
4.4.3.1 全局块
worker_processes 1;
4.4.3.2 Events块
events {
worker_connections 1024;
}
4.4.3.3 Http块
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 80;
server_name localhost;
location / {
root html;
index index.html index.htm;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
4.4.4 Nginx具体应用
4.4.4.1 部署静态资源
Nginx可以作为静态web服务器来部署静态资源。静态资源指在服务端真实存在并且能够直接展示的一些文件,比如常见的html页面、css文件、js文件、图片、视频等资源。
相对于Tomcat,Nginx处理静态资源的能力更加高效,所以在生产环境下,一般都会将静态资源部署到Nginx中。将静态资源部署到Nginx非常简单,只需要将文件复制到Nginx安装目录下的html目录中即可。
server {
listen 80; #监听端口
server_name localhost; #服务器名称
location / { #匹配客户端请求url
root html; #指定静态资源根目录
index index.html; #指定默认首页
}
4.4.4.2 反向代理
1)正向代理
是一个位于客户端和原始服务器(origin server)之间的服务器,为了从原始服务器取得内容,客户端向代理发送一个请求并指定目标(原始服务器),然后代理向原始服务器转交请求并将获得的内容返回给客户端。
正向代理的典型用途是为在防火墙内的局域网客户端提供访问Internet的途径。
正向代理一般是在客户端设置代理服务器,通过代理服务器转发请求,最终访问到目标服务器。
2)反向代理
反向代理服务器位于用户与目标服务器之间,但是对于用户而言,反向代理服务器就相当于目标服务器,即用户直接访问反向代理服务器就可以获得目标服务器的资源,反向代理服务器负责将请求转发给目标服务器。
用户不需要知道目标服务器的地址,也无须在用户端作任何设定。
3)配置反向代理
server {
listen 82;
server_name localhost;
location / {
proxy_pass http://192.168.138.101:8080; #反向代理配置,将请求转发到指定服务
}
}
4.4.4.3 负载均衡
早期的网站流量和业务功能都比较简单,单台服务器就可以满足基本需求,但是随着互联网的发展,业务流量越来越大并且业务逻辑也越来越复杂,单台服务器的性能及单点故障问题就凸显出来了,因此需要多台服务器组成应用集群,进行性能的水平扩展以及避免单点故障出现。
- 应用集群:将同一应用部署到多台机器上,组成应用集群,接收负载均衡器分发的请求,进行业务处理并返回响应数据
- 负载均衡器:将用户请求根据对应的负载均衡算法分发到应用集群中的一台服务器进行处理
配置负载均衡:
upstream targetserver{ #upstream指令可以定义一组服务器
server 192.168.138.101:8080 weight=10;
server 192.168.138.101:8081 weight=5;
}
server {
listen 8080;
server_name localhost;location / {
proxy_pass http://targetserver;
}
}
负载均衡策略:
名称 | 说明 |
---|---|
轮询 | 默认方式 |
weight | 权重方式 |
ip_hash | 依据ip分配方式 |
least_conn | 依据最少连接方式 |
url_hash | 依据url分配方式 |
fair | 依据响应时间方式 |
4.5 前后端分离
4.5.1 前后端分离开发
1)介绍
前后端分离开发,就是在项目开发过程中,对于前端代码的开发由专门的前端开发人员负责,后端代码则由后端开发人员负责,这样可以做到分工明确、各司其职,提高开发效率,前后端代码并行开发,可以加快项目开发进度。目前,前后端分离开发方式已经被越来越多的公司所采用,成为当前项目开发的主流开发方式。
前后端分离开发后,从工程结构上也会发生变化,即前后端代码不再混合在同一个maven工程中,而是分为前端工程和后端工程。
2)开发流程
前后端分离开发后,面临一个问题,就是前端开发人员和后端开发人员如何进行配合来共同开发一个项目?可以按照如下流程进行:
接口(API接口)就是一个http的请求地址,主要就是去定义:请求路径、请求方式、请求参数响应数据等内容。
3)前端技术栈
开发工具:Visual Studio codeh;builder
技术框架:nodejs;VUE;ElementUl;mock;webpack
4.5.2 YApi
YApi是高效、易用、功能强大的api管理平台,旨在为开发、产品、测试人员提供更优雅的接口管理服务。可以帮助开发者轻松创建、发布、维护 APl,YApi还为用户提供了优秀的交互体验,开发人员只需利用平台提供的接口数据写入工具以及简单的点击操作就可以实现接口的管理。
YApi让接口开发更简单高效,让接口的管理更具可读性、可维护性,让团队协作更合理。
源码地址: https:/lgithub.com/YMFE/yapi
要使用YApi,需要自己进行部署
4.5.3 Swagger
1)介绍
使用Swagger你只需要按照它的规范去定义接口及接口相关的信息,再通过Swagger衍生出来的一系列项目和工具,就可以做到生成各种格式的接口文档,以及在线接口调试页面等等。
官网: https:/lswagger.io/
knife4j是为Java MVC框架集成Swagger生成Api文档的增强解决方案。
2)使用方式
操作步骤:
①导入knife4j的maven坐标
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<version>3.0.2</version>
</dependency>
②导入knife4j相关配置类
③设置静态资源,否则接口文档页面无法访问
@Slf4j
@Configuration
@EnableSwagger2
@EnableKnife4j
public class WebMvcConfig extends WebMvcConfigurationSupport {
/**
* 设置静态资源映射
*
* @param registry
*/
@Override
protected void addResourceHandlers(ResourceHandlerRegistry registry) {
log.info("开始静态资源映射...");
registry.addResourceHandler("doc.html").addResourceLocations("classpath:/META-INF/resources/");
registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
registry.addResourceHandler("/backend/**").addResourceLocations("classpath:/backend/");
registry.addResourceHandler("/front/**").addResourceLocations("classpath:/front/");
}
/**
* 扩展mvc框架的消息转换器
*
* @param converters
*/
@Override
protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
log.info("扩展消息转换器...");
// 创建消息转换器对象
MappingJackson2HttpMessageConverter messageConverter = new MappingJackson2HttpMessageConverter();
// 设置对象转换器,底层使用Jackson将Java对象转为json
messageConverter.setObjectMapper(new JacksonObjectMapper());
// 将上面的消息转换器对象追加到mvc框架的转换器集合中 index=0:放置到最前面
converters.add(0, messageConverter);
}
@Bean
public Docket createRestApi() {
// 文档类型
return new Docket(DocumentationType.SWAGGER_2)
.apiInfo(apiInfo())
.select()
.apis(RequestHandlerSelectors.basePackage("com.itheima.reggie.controller"))
.paths(PathSelectors.any())
.build();
}
private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title("瑞吉外卖")
.version("1.0")
.description("瑞吉外卖接口文档")
.build();
}
}
④在LoginCheckFilter中设置不需要处理的请求路径
String[] urls = new String[]{
"/employee/login",
"/employee/logout",
"/backend/**",
"/front/**",
"/common/**",
"/user/sendMsg",
"/user/login",
"/doc.html",
"/webjars/**",
"/swagger-resources",
"/v2/api-docs"
};
3)常用注解
注解 | 说明 |
---|---|
@Api | 用在请求的类上,例如Controller,表示对类的说明 |
@ApiModel | 用在类上,通常是实体类,表示一个返回响应数据的信息 |
@ApiModelProperty | 用在属性上,描述响应类的属性 |
@ApiOperation | 用在请求的方法上,说明方法的用途、作用 |
@ApilmplicitParams | 用在请求的方法上,表示一组参数说明 |
@ApilmplicitParam | 用在@ApilmplicitParams注解中,指定一个请求参数的各个方面 |
4.6 项目部署
4.6.1 部署架构
4.6.2 部署环境说明
服务器:
- 192.168.138.100(服务器A)
- Nginx:部署前端项目、配置反向代理Mysql:主从复制结构中的主库
- 192.168.138.101(服务器B)
- jdk:运行Java项目
- git:版本控制工具
- maven:项目构建工具
- jar: Spring Boot项目打成jar包基于内置Tomcat运行
- Mysql:主从复制结构中的从库
- 172.17.2.94(服务器C)
- Redis:缓存中间件
4.6.3 部署前端项目
①在服务器A中安装Nginx,将资料中的dist目录(前端项目打包后目录)上传到Nginx的html目录下
②修改Nginx配置文件nginx.conf
4.6.4 部署后端项目
①安装jdk、git、maven、MySQL
②使用git克隆远程仓库的代码
git clone ...
③编写shell脚本启动
#!/bin/sh
echo =================================
echo 自动化部署脚本启动
echo =================================
echo 停止原来运行中的工程
APP_NAME=reggie_take_out
tpid=`ps -ef|grep $APP_NAME|grep -v grep|grep -v kill|awk '{print $2}'`
if [ ${tpid} ]; then
echo 'Stop Process...'
kill -15 $tpid
fi
sleep 2
tpid=`ps -ef|grep $APP_NAME|grep -v grep|grep -v kill|awk '{print $2}'`
if [ ${tpid} ]; then
echo 'Kill Process!'
kill -9 $tpid
else
echo 'Stop Success!'
fi
echo 准备从Git仓库拉取最新代码
cd /opt/module/javaapp/reggie_take_out
echo 开始从Git仓库拉取最新代码
git pull
echo 代码拉取完成
echo 开始打包
output=`mvn clean package -Dmaven.test.skip=true`
cd target
echo 启动项目
nohup java -jar reggie_take_out-1.0-SNAPSHOT.jar &> reggie_take_out.log &
echo 项目启动完成
五、功能完善
六、问题总结
- long型数据精度缺失问题;页面中js处理long型数字只能精确到16位,雪花算法生成的id为19位,所以最终通过ajax请求提交给服务端的时候id精度缺失
在服务端给页面响应json数据时进行处理,将long型数据统一转为String字符串
- 前面已经完成了后台系统的员工管理功能开发,在新增员工时需要设置创建时间、创建人、修改时间、修改人等字段,在编辑员工时需要设置修改时间和修改人等字段。这些字段属于公共字段,也就是很多表中都有这些字段。能不能对于这些公共字段在某个地方统一处理,来简化开发呢?
使用Mybatis Plus提供的公共字段自动填充功能
- 自动填充如何动态获取当前登录用户的id?
可以使用ThreadLocal来解决此问题,它是JDK中提供的一个类
拓展:数据模型查看
employee员工表
字段名 | 字段说明 |
---|---|
id | 用户id(主键) |
name | 用户名称 |
username | 账号(唯一约束) |
密码 | |
phone | 电话号码 |
sex | 性别 |
id_number | 身份证号码 |
status | 状态(1:正常,0:禁用) |
create_time | 创建时间 |
update_time | 更新时间 |
create_user | 创建人 |
update_user | 修改人 |
category分类表
字段名 | 字段说明 |
---|---|
id | 主键 |
type | 类型 (1:菜品分类,2:套餐分类) |
name | 分类名称(唯一约束) |
sort | 顺序 |
create_time | 创建时间 |
update_time | 更新时间 |
create_user | 创建人 |
update_user | 修改人 |
dish菜品表
字段名 | 字段说明 |
---|---|
id | 主键 |
name | 菜品名称 |
category_id | 菜品分类id |
price | 价格 |
code | 商品码 |
image | 图片 |
description | 描述信息 |
status | 状态 0:停用 1:启用 |
sort | 顺序 |
create_time | 创建时间 |
update_time | 更新时间 |
create_user | 创建人 |
update_user | 修改人 |
is_deleted | 是否删除 |
dish_flavor菜品口味表
字段名 | 字段说明 |
---|---|
id | 主键 |
dish_id | 菜品id |
name | 口味名称 |
value | 口味数据list |
create_time | 创建时间 |
update_time | 更新时间 |
create_user | 创建人 |
update_user | 修改人 |
is_deleted | 是否删除 |
setmeal套餐表
字段名 | 字段说明 |
---|---|
id | 主键 |
category_id | 菜品分类id |
name | 套餐名称 |
price | 套餐价格 |
status | 状态 0:停用 1:启用 |
code | 编码 |
description | 描述信息 |
image | 图片 |
create_time | 创建时间 |
update_time | 更新时间 |
create_user | 创建人 |
update_user | 修改人 |
isDeleted | 是否删除 |
setmeal_dish套餐菜品关联表
字段名 | 字段说明 |
---|---|
id | 主键 |
setmeal_id | 套餐id |
dish_id | 菜品id |
name | 菜品名称 (冗余字段) |
price | 菜品原价(冗余字段) |
copies | 份数 |
sort | 排序 |
create_time | 创建时间 |
update_time | 更新时间 |
create_user | 创建人 |
update_user | 修改人 |
is_deleted | 是否删除 |
user用户表
字段名 | 字段说明 |
---|---|
id | 主键 |
name | 姓名 |
phone | 手机号 |
sex | 性别 |
id_number | 身份证号 |
avatar | 头像 |
status | 状态 0:禁用,1:正常 |
address_book用户地址表
字段名 | 字段说明 |
---|---|
id | 主键 |
user_id | 用户id |
consignee | 收货人 |
sex | 性别(0:女 1:男) |
phone | 手机号 |
province_code | 省级区划编号 |
province_name | 省级名称 |
city_code | 市级区划编号 |
city_name | 市级名称 |
district_code | 区级区划编号 |
district_name | 区级名称 |
detail | 详细地址 |
label | 标签 |
is_default | 默认(0:否 1:是) |
create_time | 创建时间 |
update_time | 更新时间 |
create_user | 创建人 |
update_user | 修改人 |
isDeleted | 是否删除 |
shopping_cart购物车表
字段名 | 字段说明 |
---|---|
id | 主键 |
name | 名称 |
image | 图片 |
user_id | 用户id |
dish_id | 菜品id |
setmeal_id | 套餐id |
dish_flavor | 口味 |
number | 数量 |
amount | 金额 |
create_time | 创建时间 |
orders订单表
字段名 | 字段说明 |
---|---|
id | 主键 |
number | 订单号 |
status | 订单状态 1待付款,2待派送,3已派送,4已完成,5已取消 |
user_id | 下单用户 |
address_book_id | 地址id |
order_time | 下单时间 |
checkout_time | 结账时间 |
pay_method | 支付方式 1微信,2支付宝 |
amount | 实收金额 |
remark | 备注 |
phone | 电话号码 |
address | 地址 |
user_name | 用户名称 |
consignee | 收货人名称 |
order_detail订单明细表
字段名 | 字段说明 |
---|---|
id | 主键 |
name | 菜品/套餐名称 |
image | 图片 |
order_id | 订单id |
dish_id | 菜品id |
setmeal_id | 套餐id |
dish_flavor | 口味 |
number | 数量 |
amount | 金额 |
常,0:禁用) |
| create_time | 创建时间 |
| update_time | 更新时间 |
| create_user | 创建人 |
| update_user | 修改人 |
category分类表
字段名 | 字段说明 |
---|---|
id | 主键 |
type | 类型 (1:菜品分类,2:套餐分类) |
name | 分类名称(唯一约束) |
sort | 顺序 |
create_time | 创建时间 |
update_time | 更新时间 |
create_user | 创建人 |
update_user | 修改人 |
dish菜品表
字段名 | 字段说明 |
---|---|
id | 主键 |
name | 菜品名称 |
category_id | 菜品分类id |
price | 价格 |
code | 商品码 |
image | 图片 |
description | 描述信息 |
status | 状态 0:停用 1:启用 |
sort | 顺序 |
create_time | 创建时间 |
update_time | 更新时间 |
create_user | 创建人 |
update_user | 修改人 |
is_deleted | 是否删除 |
dish_flavor菜品口味表
字段名 | 字段说明 |
---|---|
id | 主键 |
dish_id | 菜品id |
name | 口味名称 |
value | 口味数据list |
create_time | 创建时间 |
update_time | 更新时间 |
create_user | 创建人 |
update_user | 修改人 |
is_deleted | 是否删除 |
setmeal套餐表
字段名 | 字段说明 |
---|---|
id | 主键 |
category_id | 菜品分类id |
name | 套餐名称 |
price | 套餐价格 |
status | 状态 0:停用 1:启用 |
code | 编码 |
description | 描述信息 |
image | 图片 |
create_time | 创建时间 |
update_time | 更新时间 |
create_user | 创建人 |
update_user | 修改人 |
isDeleted | 是否删除 |
setmeal_dish套餐菜品关联表
字段名 | 字段说明 |
---|---|
id | 主键 |
setmeal_id | 套餐id |
dish_id | 菜品id |
name | 菜品名称 (冗余字段) |
price | 菜品原价(冗余字段) |
copies | 份数 |
sort | 排序 |
create_time | 创建时间 |
update_time | 更新时间 |
create_user | 创建人 |
update_user | 修改人 |
is_deleted | 是否删除 |
user用户表
字段名 | 字段说明 |
---|---|
id | 主键 |
name | 姓名 |
phone | 手机号 |
sex | 性别 |
id_number | 身份证号 |
avatar | 头像 |
status | 状态 0:禁用,1:正常 |
address_book用户地址表
字段名 | 字段说明 |
---|---|
id | 主键 |
user_id | 用户id |
consignee | 收货人 |
sex | 性别(0:女 1:男) |
phone | 手机号 |
province_code | 省级区划编号 |
province_name | 省级名称 |
city_code | 市级区划编号 |
city_name | 市级名称 |
district_code | 区级区划编号 |
district_name | 区级名称 |
detail | 详细地址 |
label | 标签 |
is_default | 默认(0:否 1:是) |
create_time | 创建时间 |
update_time | 更新时间 |
create_user | 创建人 |
update_user | 修改人 |
isDeleted | 是否删除 |
shopping_cart购物车表
字段名 | 字段说明 |
---|---|
id | 主键 |
name | 名称 |
image | 图片 |
user_id | 用户id |
dish_id | 菜品id |
setmeal_id | 套餐id |
dish_flavor | 口味 |
number | 数量 |
amount | 金额 |
create_time | 创建时间 |
orders订单表
字段名 | 字段说明 |
---|---|
id | 主键 |
number | 订单号 |
status | 订单状态 1待付款,2待派送,3已派送,4已完成,5已取消 |
user_id | 下单用户 |
address_book_id | 地址id |
order_time | 下单时间 |
checkout_time | 结账时间 |
pay_method | 支付方式 1微信,2支付宝 |
amount | 实收金额 |
remark | 备注 |
phone | 电话号码 |
address | 地址 |
user_name | 用户名称 |
consignee | 收货人名称 |
order_detail订单明细表
字段名 | 字段说明 |
---|---|
id | 主键 |
name | 菜品/套餐名称 |
image | 图片 |
order_id | 订单id |
dish_id | 菜品id |
setmeal_id | 套餐id |
dish_flavor | 口味 |
number | 数量 |
amount | 金额 |