目录

  • 项目刨析简介
    • 技术栈
    • 项目介绍
    • 项目源码
  • 一.架构搭建
    • 1.初始化项目结构
    • 2.数据库表结构设计
    • 3.项目基本配置信息添加
      • 公共字段的自动填充
      • 全局异常处理类
      • 返回结果封装的实体类
  • 二.管理端业务开发
    • 1.员工管理相关业务
      • 1.1员工登录
      • 1.2员工退出
      • 1.3过滤器拦截
      • 1.4员工信息修改
      • 1.5员工信息分页查询
      • 1.6新增员工
    • 2.分类管理相关业务
      • 2.1分类的分页查询
      • 2.2新增分类
      • 2.3菜品或套餐的分类修改
      • 2.4菜品或套餐的分类删除
    • 3.菜品管理相关业务
      • 3.1分页查询
      • 3.2图片上传下载
      • 3.3新增菜品
      • 3.4修改菜品
      • 3.5删除菜品
      • 3.6菜品停售与起售(补充)
    • 4.套餐管理相关业务
      • 4.1分页查询
      • 4.2新增套餐
      • 4.3修改套餐
      • 4.4删除套餐
      • 4.5套餐停售与起售(补充)
    • 5.订单明细(补充)
  • 三.移动端业务开发
    • 1.用户登录与退出(退出为补充)
    • 2.阿里云短信验证码
    • 3.收货地址
    • 4.菜品和套餐展示
    • 5.菜品选规格
    • 6.套餐点击展示(补充)
    • 7.购物车
    • 8.下订单
    • 9.收货地址删除(补充)
    • 10.用户支付后查看订单(补充)
    • 11.再来一单(补充)
  • 四.项目优化
    • 1.使用Redis缓存
      • 1.1缓存验证码
      • 1.2缓存菜品查询数据
      • 1.3Spring Cache缓存套餐数据
    • 2.读写分离
      • 2.1mysql主从复制
      • 2.2Sharding-JDBC实现读写分离
      • 2.3项目实现读写分离
    • 3.使用Nginx服务器
      • 3.1Nginx部署静态资源
      • 3.2反向代理
      • 3.3负载均衡
    • 4.前后端分离开发
      • 4.1YApi
      • 4.2Swagger
      • 4.3项目部署

项目刨析简介

#2022年末了,记录一下学习的项目实战经验和笔记吧
这个是瑞吉外卖项目,补充一些视频里面没有定义的功能和记录一些功能实现逻辑的笔记;仅供学习参考,本人代码可能不太规范,也有可能自己写了有些错误自己没有察觉,但是功能自己测试是没有问题的;感谢各位的阅览,如有问题欢迎指正,如有遗漏后续继续补充


技术栈

涉及到的技术有Spring,Springboot,Mybatis-plus,MySQL,Redis,Linux,Git,Spring Cache,Sharding-JDBC,Nginx,Swagger。(Apifox这些工具应该不算技术吧,用的工具就不列举了)


项目介绍

该项目是一个外卖点餐系统,它分为后台管理端和用户移动端两方面开发,后台管理端为商家提供管理菜品套餐的服务,移动端为用户提供点菜下单功能。最终通过git管理项目,并用nginx部署前端,tomcat部署后端,使用mysql主从复制,从库读取,主库写入,再用shell脚本部署到服务器上。


项目源码

项目码云地址:https://gitee.com/dkgk8/reggie-git


一.架构搭建

1.初始化项目结构

新建一个springboot项目
pom导入的坐标

     <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency><dependency><groupId>com.github.xiaoymin</groupId><artifactId>knife4j-spring-boot-starter</artifactId><version>3.0.2</version></dependency><!--      <dependency>-->
<!--         <groupId>org.apache.shardingsphere</groupId>-->
<!--         <artifactId>sharding-jdbc-spring-boot-starter</artifactId>-->
<!--         <version>4.1.1</version>-->
<!--     </dependency>--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-cache</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId><scope>compile</scope></dependency><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>3.4.2</version></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>1.18.20</version></dependency><dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId><version>1.2.76</version></dependency><dependency><groupId>commons-lang</groupId><artifactId>commons-lang</artifactId><version>2.6</version></dependency><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId><scope>runtime</scope></dependency><dependency><groupId>com.alibaba</groupId><artifactId>druid-spring-boot-starter</artifactId><version>1.1.23</version></dependency><dependency><groupId>com.aliyun</groupId><artifactId>aliyun-java-sdk-core</artifactId><version>4.5.16</version></dependency><dependency><groupId>com.aliyun</groupId><artifactId>aliyun-java-sdk-dysmsapi</artifactId><version>2.1.0</version></dependency>

yml配置文件添加的信息

server:port: 8080
spring:
#  application:
#    name: reggie_take_outdatasource:druid:driver-class-name: com.mysql.cj.jdbc.Driverurl: jdbc:mysql://localhost:3306/reggie?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=trueusername: rootpassword: 123456redis:host: localhostport: 6379database: 0cache:redis:time-to-live: 1800000  #ms ->30minmybatis-plus:configuration:map-underscore-to-camel-case: truelog-impl: org.apache.ibatis.logging.stdout.StdOutImplglobal-config:db-config:id-type: ASSIGN_ID
reggie:path: D:\SpringBoot_Reggie\reggie_take_out\src\main\resources\static\front\hello\

我后面将项目运行在服务器上了所以用了多环境开发,本地跑的不用在意这步

项目大致结构如下

感觉使用mybatis-plus之后就是
实体类->mapper->service->serviceImpl->controller
这个步骤写程序了

2.数据库表结构设计



不每个表展示了,这里拿典型的员工表来看

3.项目基本配置信息添加

导入前端资源
在默认页面和前台页面的情况下,直接把这俩拖到resource目录下直接访问是访问不到的,因为被mvc框架拦截了,其实用springboot,可以直接放在static目录下,但是仍然不能直接访问前端页面,所以这里也可以直接放行static就好了
所以我们要编写一个映射类放行这些资源
WebMvcConfig类

公共字段的自动填充

这个我在另一篇文章写了很详细,链接:自动填充公共字段

全局异常处理类

虽然遇到异常后可以使用try-catch来处理,但是,代码量一大起来,许多的try catch就会很乱,代码也不简洁,不容易阅读,所以我们使用全局异常处理,在Common包下

自定义异常类

返回结果封装的实体类

为了便于前后端数据传递,使用对象的形式封装数据更合适

@Data
public class R<T> implements Serializable {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;}}

二.管理端业务开发

1.员工管理相关业务

1.1员工登录

登录逻辑如下

    @PostMapping("/login")public R<Employee> login(HttpServletRequest request, @RequestBody Employee employee){//1.将页面提交的明文密码进行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){return R.error("账号异常,已锁定");}//6.登录成功,将员工id存入Session  并返回登录成功结果request.getSession().setAttribute("employee",emp.getId());return R.success(emp);}

1.2员工退出

就是清除员工登录时存入session的员工id

    @PostMapping("/logout")public R<String> logout(HttpServletRequest request){//1.清理Session中保存的当前登录员工idrequest.getSession().removeAttribute("employee");return R.success("退出成功");}

1.3过滤器拦截

现在没有过滤器,用户直接不用登录通过url+资源名可以随便访问,所以要加个过滤器,没有登陆时,拦截请求,不给访问,自动跳转到登陆页面
过滤器处理逻辑

在启动类上添加注解@ServletComponentScan
过滤器配置类注解@WebFilter(filterName=“拦截器类名首字母小写”,urlPartten=“要拦截的路径,比如/*”)

判断用户是否已经登录,之前因为存入session里面有一个名为employee的对象,里面放的时用户id,那么只需要用getAttribute,看看session里get的数据是否为null就知道他是否在登陆状态

这里提一嘴
调用Spring核心包的字符串匹配类的对象,对路径进行匹配,并且返回比较结果
如果相等就为true

public static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();


直接上代码

/*** 检查用户是否登录的过滤器*/@WebFilter(filterName = "loginCheckFilter",urlPatterns = "/*")
@Slf4j
public class LoginCheckFilter implements Filter {//路径匹配器,支持通配符public static final AntPathMatcher PATH_MATCHER =new AntPathMatcher();@Overridepublic void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {HttpServletRequest request=(HttpServletRequest) servletRequest;HttpServletResponse response=(HttpServletResponse) servletResponse;//1.获取本次请求uriString requestURI = request.getRequestURI();//定义不需要处理的请求路径String[] urls=new String[]{"/employee/login","/employee/logout","/backend/**","/front/**","/common/**","/user/sendMsg","/user/login","/doc.html","/webjars/**","/swagger-resources","/v2/api-docs"};//2.判断本次请求是否需要处理boolean check = check(urls, requestURI);//3.如果不需要处理则直接放行if (check){filterChain.doFilter(request,response);return;}//4-1.判断登录状态,如果已经登录,则直接放行if (request.getSession().getAttribute("employee")!=null){Long empId = (Long) request.getSession().getAttribute("employee");BaseContext.setCurrentId(empId);filterChain.doFilter(request,response);return;}//4-2.判断移动端登录状态,如果已经登录,则直接放行if (request.getSession().getAttribute("user")!=null){Long userId = (Long) request.getSession().getAttribute("user");BaseContext.setCurrentId(userId);filterChain.doFilter(request,response);return;}//5如果未登录则,通过输出流方式向客户端页面响应数据response.getWriter().write(JSON.toJSONString(R.error("NOTLOGIN")));return;}/*** 路径匹配,检查本次请求是否需要放行*/public boolean check(String[] urls,String requestURI){//遍历的同时调用PATH_MATCHER来对路径进行匹配for (String url : urls){boolean match = PATH_MATCHER.match(url,requestURI);if (match){//匹配到了可以放行的路径,直接放行return true;}}return false;}
}

1.4员工信息修改

员工状态修改

遇到了问题,数据库id根据雪花算法有19位,而js对Long型数据处理时会丢失精度,只能保证前16位
解决办法: 服务端给页面响应json数据时,将Long型数据统一转为String字符串


将Long型的Id转换为String类型的数据

/*** 对象映射器:基于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配置类中扩展一个消息转换器

    /*** 扩展mvc框架的消息转换器* @param converters*/@Overrideprotected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {//创建消息转换器对象MappingJackson2HttpMessageConverter messageConverter=new MappingJackson2HttpMessageConverter();//设置对象转换器,底层使用Jackson将Java对象转为jsonmessageConverter.setObjectMapper(new JacksonObjectMapper());//将上面的消息转换器对象追加到mvc框架的转换器集合中converters.add(0,messageConverter);}

员工信息修改
修改逻辑分为数据回显和数据保存
数据回显就是根据传来的员工id查询员工信息,返回员工对象

    /*** 回显用户信息到修改框*/@GetMapping("/{id}")public R<Employee> getById(@PathVariable Long id){Employee employee = employeeService.getById(id);if (employee!=null){return R.success(employee);}else {return R.error("没查到该员工");}}

数据保存就是将更改后的数据再update到员工表中

    /*** 修改员工信息*/@PutMappingpublic R<String> update(@RequestBody Employee employee){log.info(employee.toString());employeeService.updateById(employee);return R.success("员工信息修改成功");}

测试结果

1.5员工信息分页查询

分页查询,老生常谈了
分页查询业务逻辑

浏览器发送的url

分页插件配置类
先弄个MP分页插件配置类,config包下创建MybatisPlusConfig类

/*** 配置MP的分页插件*/
@Configuration
public class MybatisPlusConfig {@Beanpublic MybatisPlusInterceptor mybatisPlusInterceptor(){MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor();mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor());return mybatisPlusInterceptor;}
}


page对象内部

上代码

    /*** 员工信息的分页查询*/@GetMapping("/page")public R<Page> page(int page, int pageSize, String name){log.info("page={},pageSize={},name={}",page,pageSize,name);//构造分页构造器Page pageInfo = new Page(page,pageSize);//构造条件构造器LambdaQueryWrapper<Employee> queryWrapper=new LambdaQueryWrapper();//添加过滤条件queryWrapper.like(StringUtils.isNotBlank(name),Employee::getName,name);//添加排序条件queryWrapper.orderByDesc(Employee::getUpdateTime);//执行查询employeeService.page(pageInfo,queryWrapper);return R.success(pageInfo);}

1.6新增员工

前端传递过来的数据,这里我们可以用一个employee员工对象将数据全部接收到
请求 URL: http://localhost:9001/employee (POST请求)

基本上都是mp封装好的CRUD,直接调用save方法就行了,这里不需要改造Employee实体类,通用id雪花自增算法来新增id,不需要像下面一样,因为最开始我们yml中已经配置了雪花自增算法来新增id,如下图,当然也可以两个方法任选其一。

    /*** 新增员工*/@PostMappingpublic R<String> save(@RequestBody Employee employee){log.info("新增员工,员工信息:{}",employee.toString());//设置初始密码123456,需要进行md5加密处理employee.setPassword(DigestUtils.md5DigestAsHex("123456".getBytes()));employeeService.save(employee);return R.success("新增员工成功");}

2.分类管理相关业务

2.1分类的分页查询


还是那几步,老生常谈

1.创建分页构造器 Page pageInfo = new Page(page,pageSize);
2.如果有需要条件过滤的加入条件过滤器LambaQueryWarpper
3.注入的service对象(已经继承MP的BaseMapper接口)去调用Page对象
4.service对象.page(分页信息,条件过滤器)
返回结果就可以了

    @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);}

2.2新增分类


根据前端发送的请求接收传递的数据,再调用mp封装好的crud,往数据库表里插入数据就完了,没什么好说的

    @PostMappingpublic R<String> save(@RequestBody Category category){log.info("category:{}",category);categoryService.save(category);return R.success("新增分类成功");}

2.3菜品或套餐的分类修改

修改又是老套路,先回显数据再修改数据

前端发送的请求

就两步走,都是调用mp封装的方法

2.4菜品或套餐的分类删除


完善一下,如果当前菜品分类下有菜品的话,就不许删除
删除之前需要先做判断才可以删除,若当前分类下有菜品,我们要抛出异常进行提示
因为没有返回异常信息的类,我们这里要做一个自定义的专门返回异常信息的类CustomerException
因为我们之前创建了一个全局异常处理,也要用上,因为要拦截异常统一处理

    /*** 根据id删除分类,删除之前需要进行判断是否由关联* @param id*/@Overridepublic 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<>();setmealLambdaQueryWrapper.eq(Setmeal::getCategoryId,id);int count2 = setmealService.count(setmealLambdaQueryWrapper);if (count2>0){//已关联套餐,抛出一个业务异常throw new CustomException("当前分类下关联了套餐,不能删除");}//正常删除分类super.removeById(id);}

3.菜品管理相关业务

3.1分页查询

这个分页查询不是老生常谈,如果只是菜品表的分页查询,你会发现,最后分页查询出来的菜品分类一栏为空白,因为前端需要的菜品分类名称的数据,dish表的分页查询数据中并没有,所以我们需要使用到DishDto的分页查询了,根据dish表的分类id来条件查询

打开dish表可以看到,只有菜品分类id字段,并没有菜品名称

创建一个DishDto类

这个是经典的Dto的分页查询,上代码

    /*** 菜品管理的分页查询*/@GetMapping("/page")public R<Page> page(int page, int pageSize, String name){//构造分页构造器对象Page<Dish> pageInfo = new Page<>(page,pageSize);Page<DishDto> dishDtoPage = new Page<>();//条件构造器LambdaQueryWrapper<Dish> queryWrapper=new LambdaQueryWrapper<>();//添加过滤条件queryWrapper.like(name!=null,Dish::getName,name);//添加排序条件queryWrapper.orderByDesc(Dish::getUpdateTime);dishService.page(pageInfo,queryWrapper);BeanUtils.copyProperties(pageInfo,dishDtoPage,"records");List<Dish> records = pageInfo.getRecords();List<DishDto> list = records.stream().map((item)->{DishDto dishDto = new DishDto();BeanUtils.copyProperties(item,dishDto);Long categoryId = item.getCategoryId();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.2图片上传下载

具体的存储路径写在配置文件里了,用@Value注入到业务里就可以了



此时我们上传图片后,是存放在临时位置,关闭浏览器,图片文件就不存在了,无法再次浏览,我们需要将上传的图片下载到本地磁盘存储,这样浏览器上就可以进行图片回显,访问的时候才能看到图片

前端展示图片发送请求的代码

用到了I/O的输入输出流,算是复习了

    /*** 文件下载,图片回显浏览器*/@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){//用while循环一直写,写到-1证明写完了outputStream.write(bytes,0,len);outputStream.flush();}//关闭资源outputStream.close();fileInputStream.close();} catch (Exception e) {e.printStackTrace();}}

3.3新增菜品

场景描述

开发逻辑


type为1是菜品,type为2是套餐

    /*** 根据条件查询分类数据,返回到菜品管理的下拉框里去*/@GetMapping("/list")public R<List<Category>> list(Category category){//条件构造器LambdaQueryWrapper<Category> queryWrapper = new LambdaQueryWrapper<>();//添加条件 type为1是菜品,为2是套餐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);}

接下来是多表存入,mp没有提供对应的api接口,需要自己写,老套路,service接口声明方法,实现类完善实现业务,控制层的controller直接调用

注意:这里还要加入事务进行控制,防止多表操作崩溃,同成功或同失败。@Transactional 开启事务;
@EnableTransactionManagement 在启动类加入,支持事务开启
根据前端传递数据,我们可以用DishDto对象来接收
分两张表存入,先将基本信息存入dish表,再将口味信息存入dish_flavor表
根据dish_flavor的表结构可以看到,我们还需要把dish_id存入dish_flavor表中

    /*** 新增菜品,同时保存对应的口味数据* @param dishDto*/@Overridepublic void saveWithFlavor(DishDto dishDto) {//保存菜品的基本信息到菜品表dish中this.save(dishDto);Long dishId = dishDto.getId();//菜品口味List<DishFlavor> flavors = dishDto.getFlavors();flavors = flavors.stream().map((item)->{item.setDishId(dishId);//把dish_id存入dish_flavor表中return item;}).collect(Collectors.toList());//保存菜品口味数据到菜品表中去dish_flavordishFlavorService.saveBatch(flavors);}

3.4修改菜品

修改菜品第一步回显数据,第二步更新数据

这里回显数据就涉及到了多表联查,先根据前端传递的菜品id查询出菜品基本信息,将菜品信息拷贝到dishDto对象中,再根据菜品id对dish_flavor表进行条件查询,将查询出来的口味信息也拷贝到dishDto对象里,最终返回dishDto

更新数据也是两表分别更新,先调用mp的updateById方法,更新dish表里的数据;然后dish_flavor表需要先清除该菜品下的口味信息,再将修改的口味信息插入到dish_flavor表中,更新dish_flavor表时,对于前端没有传递的字段数据,需要我们自己set进去


回显代码

    @Overridepublic DishDto getByIdWithFlavor(Long id) {//查询菜品基本信息,从dish表查询Dish dish = this.getById(id);DishDto dishDto = new DishDto();//将菜品基本信息拷贝到dishDto中BeanUtils.copyProperties(dish,dishDto);//查询当前菜品对应的口味信息,从dish_flavor表查询LambdaQueryWrapper<DishFlavor> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(DishFlavor::getDishId,dish.getId());//将该菜品的口味信息查询出来存入list集合中List<DishFlavor> flavors = dishFlavorService.list(queryWrapper);//set到dto的属性里dishDto.setFlavors(flavors);return dishDto;}

更新代码

    @Override@Transactionalpublic void updateWithFlavor(DishDto dishDto) {//更新dish表的基本信息this.updateById(dishDto);//清理当前菜品对应口味数据---dish_flavor表的delete操作LambdaQueryWrapper<DishFlavor> queryWrapper=new LambdaQueryWrapper<>();queryWrapper.eq(DishFlavor::getDishId,dishDto.getId());dishFlavorService.remove(queryWrapper);//添加当前提交过来的口味数据---dish_flavor表的insert操作List<DishFlavor> flavors = dishDto.getFlavors();flavors = flavors.stream().map((item)->{item.setId(IdWorker.getId());item.setDishId(dishDto.getId());return item;}).collect(Collectors.toList());dishFlavorService.saveBatch(flavors);//批量保存}

3.5删除菜品

前端发送请求的url

在DishFlavor实体类中,在private Integer isDeleted;字段上加上@TableLogic注解,表示删除是逻辑删除,由mybatis-plus提供的,由于删除前需要判断菜品的售状态,这里将remove方法抽出来写再service实现类中

   /***套餐批量删除和单个删除* @param ids*/@Override@Transactionalpublic void deleteByIds(List<Long> ids) {//构造条件查询器LambdaQueryWrapper<Dish> queryWrapper = new LambdaQueryWrapper<>();//先查询该菜品是否在售卖,如果是则抛出业务异常queryWrapper.in(ids!=null,Dish::getId,ids);List<Dish> list = this.list(queryWrapper);for (Dish dish : list) {Integer status = dish.getStatus();//如果不是在售卖,则可以删除if (status == 0){this.removeById(dish.getId());}else {//此时应该回滚,因为可能前面的删除了,但是后面的是正在售卖throw new CustomException("删除菜品中有正在售卖菜品,无法全部删除");}}}

controller直接调用service中的方法即可

    /*** 套餐批量删除和单个删除* @return*/@DeleteMappingpublic R<String> delete(@RequestParam("ids") List<Long> ids){//删除菜品  这里的删除是逻辑删除dishService.deleteByIds(ids);//删除菜品对应的口味  也是逻辑删除LambdaQueryWrapper<DishFlavor> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.in(DishFlavor::getDishId,ids);dishFlavorService.remove(queryWrapper);//清理所有菜品的缓存数据Set keys = redisTemplate.keys("dish_*");redisTemplate.delete(keys);return R.success("菜品删除成功");}

3.6菜品停售与起售(补充)

前端发送的url

业务逻辑
将前端传递的id集合来进行菜品的条件查询,然后遍历查询出来的数据集合,将前端传递的status直接set到每个dish对象中,完成菜品状态修改

    /*** 对菜品批量或者是单个 进行停售或者是起售* @return*/@PostMapping("/status/{status}")
//这个参数这里一定记得加注解才能获取到参数,否则这里非常容易出问题public R<String> status(@PathVariable("status") Integer status,@RequestParam List<Long> ids){//log.info("status:{}",status);//log.info("ids:{}",ids);LambdaQueryWrapper<Dish> queryWrapper = new LambdaQueryWrapper();queryWrapper.in(ids !=null,Dish::getId,ids);//根据传入的id集合进行批量查询List<Dish> list = dishService.list(queryWrapper);for (Dish dish : list) {if (dish != null){dish.setStatus(status);dishService.updateById(dish);}}//清理所有菜品的缓存数据Set keys = redisTemplate.keys("dish_*");redisTemplate.delete(keys);return R.success("售卖状态修改成功");}

4.套餐管理相关业务

4.1分页查询

和菜品分页差不多,将套餐信息分页查询出来,通过stream流方式,将套餐信息拷贝到SetmealDto中,再根据套餐id查询套餐分类对象,将套餐分类信息也拷贝到SetmealDto中,最后返回一个dtoPage,和菜品管理的分页几乎一样,就不上代码了

4.2新增套餐

和新增菜品差不多,根据前端发送的url请求,这里也是多表的操作,分别操作setmeal表和setmeal_dish表,前端提交的数据save到setmeal_dish表,再自己set补全etmeal_dish表数据。

4.3修改套餐

老套路,将setmeal表和setmeal_dish表两表数据查询出来,回显;再分别更新两表内容。和dish菜品管理一样的套路,不再赘述
我把它俩都抽到service实现类里写了

    @Overridepublic SetmealDto getByIdWithDishes(Long id) {//查询套餐基本信息,从setmeal表查询Setmeal setmeal = this.getById(id);SetmealDto setmealDto = new SetmealDto();BeanUtils.copyProperties(setmeal,setmealDto);//查询当前套餐对应的菜品信息,从setmeal_dish表查询LambdaQueryWrapper<SetmealDish> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(SetmealDish::getSetmealId,setmeal.getId());List<SetmealDish> dishes = setmealDishService.list(queryWrapper);setmealDto.setSetmealDishes(dishes);return setmealDto;}@Override@Transactionalpublic void updateWithDishes(SetmealDto setmealDto) {//更新setmeal表的基本信息this.updateById(setmealDto);//清理当前套餐对应的菜品数据---setmeal_dish表的delete操作LambdaQueryWrapper<SetmealDish> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(SetmealDish::getSetmealId,setmealDto.getId());setmealDishService.remove(queryWrapper);//添加当前提交过来的菜品数据---setmeal_dish表的insert操作List<SetmealDish> dishes = setmealDto.getSetmealDishes();dishes = dishes.stream().map((item)->{item.setId(IdWorker.getId());item.setSetmealId(setmealDto.getId());return item;}).collect(Collectors.toList());setmealDishService.saveBatch(dishes);}

4.4删除套餐

和删除菜品一样,也是需要先判断套餐状态,删除的时候,套餐下的关联关系也要删除掉,要处理setmeal和setmeal_dish两张表

   /*** 删除套餐,同时删除套餐和菜品关联数据* @param ids*/@Transactional@Overridepublic void removeWithDish(List<Long> ids) {//select count(*) from setmeal where id in (1,2,3) and status = 1;//查询套餐状态,确定是否可以删除LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.in(Setmeal::getId,ids);queryWrapper.eq(Setmeal::getStatus,1);int count = this.count(queryWrapper);if (count>0){//如果不能删除,抛出一个业务异常throw new CustomException("套餐正在售卖中,不能删除");}//如果可以删除,先删除套餐表中的数据——setmealthis.removeByIds(ids);//delete from setmeal_dish where setmeal_id in (1,2,3)LambdaQueryWrapper<SetmealDish> lambdaQueryWrapper = new LambdaQueryWrapper<>();lambdaQueryWrapper.in(SetmealDish::getSetmealId,ids);//删除关系表中的数据——setmeal_dish}

4.5套餐停售与起售(补充)

前端发送的url请求

跟菜品的起售和停售差不多

    /*** 批量起售停售*/@PostMapping("/status/{status}")@CacheEvict(value = "setmealCache", allEntries = true)public R<String> status(@PathVariable("status") Integer status,@RequestParam List<Long> ids) {LambdaQueryWrapper<Setmeal> queryWrapper=new LambdaQueryWrapper<>();queryWrapper.in(ids!=null,Setmeal::getId,ids);List<Setmeal> setmeals = setmealService.list(queryWrapper);for (Setmeal setmeal : setmeals) {if (setmeal!=null){setmeal.setStatus(status);setmealService.updateById(setmeal);}}return R.success("售卖状态修改成功");}

5.订单明细(补充)

根据后台管理端的订单明细发出的url,可以判断就是个order表的分页查询


其实这样就很简单了,只是order的单表分页查询

    /*** 后台显示订单信息*/@GetMapping("/page")public R<Page> page(int page, int pageSize, Long number, String beginTime, String endTime) {log.info("page={},pageSize={},number={},beginTime={},endTime={}",page,pageSize,number,beginTime,endTime);//分页构造器对象Page<Orders> pageInfo = new Page<>(page,pageSize);//构造条件查询对象LambdaQueryWrapper<Orders> queryWrapper = new LambdaQueryWrapper<>();//链式编程写查询条件queryWrapper.like(number!=null,Orders::getNumber,number)//前面加上判定条件是十分必要的,用户没有填写该数据,查询条件上就不添加它.gt(StringUtils.isNotBlank(beginTime),Orders::getOrderTime,beginTime)//大于起始时间.lt(StringUtils.isNotBlank(endTime),Orders::getOrderTime,endTime);//小于结束时间ordersService.page(pageInfo,queryWrapper);return R.success(pageInfo);}

但是我们发现一个问题,后端展示数据并没有用户名

其实没有用户名很正常因为我们的order表中并没有username字段,所以自然是查不出来,但是可以看到order表里是有user_id字段的,但是user表里也没有username这个字段,因此我想到了两个方法

方法一:user表中先添加username字段,然后后台显示订单信息这个分页查询得数据username

方法二:我喜欢简单(偷懒)点,直接把order表里的consignee(收货人)的名字取出来作为这里分页查询页面的用户名


这里将前端的username换成consignee,就可以显示用户名了

后台订单状态的修改

携带参数为status,这样就很明了,是根据订单id修改订单的状态,就是一个修改操作

    /*** 修改订单状态*/@PutMappingpublic R<String> orderStatusChange(@RequestBody Map<String,String> map){String id = map.get("id");Long orderId = Long.parseLong(id);//将接收到的id转为Long型Integer status = Integer.parseInt(map.get("status"));//转为Integer型if(orderId == null || status==null){return R.error("传入信息非法");}Orders orders = ordersService.getById(orderId);//根据订单id查询订单数据orders.setStatus(status);//修改订单对象里的数据ordersService.updateById(orders);return R.success("订单状态修改成功");}

三.移动端业务开发

1.用户登录与退出(退出为补充)

用户登录
点击登录发送请求

负载将手机号和验证码一起提交过来,我们可以用map的key-value的形式来接收,key是phone,value是code

代码实现

用户退出,根据请求的url编写退出功能

    /*** 用户退出* @param request* @return*/@PostMapping("/loginout")public R<String> loginout(HttpServletRequest request){request.getSession().removeAttribute("user");return R.success("退出成功");}

2.阿里云短信验证码

utils包下导入这个两个工具类,其实也可以去阿里云api文档复制,

至于如何获取accessKeyId,如下步骤




给新建用户添加权限

在UserController的发送验证码的方法中,调用阿里云提供的短信发送的api

@PostMapping("/sendMsg")public R<String> sendMsg(@RequestBody User user, HttpSession session){//获取手机号String phone = user.getPhone();if (StringUtils.isNotBlank(phone)){//生成随机的4位验证码String code = ValidateCodeUtils.generateValidateCode(4).toString();log.info("code={}",code);//调用阿里云提供的短信服务API完成发送短信SMSUtils.sendMessage("你自己的签名","你自己的模板code",phone,code);//需要将生成的验证码保存到Session//session.setAttribute(phone,code);//将将生成的验证码保存到Redis中,并且设置有效期为5分钟   phone是key,code是valueredisTemplate.opsForValue().set(phone,code,5, TimeUnit.MINUTES);return R.success("手机验证码短信发送成功");}return R.error("手机验证码短信发送失败");}

最后效果如下

3.收货地址

首先分析这个地址管理要实现的功能,这里肯定是要把所有地址展示出来,所以需要一个查询所有的功能,其次就是新增地址有一个新增操作,还有修改地址,修改地址逻辑又分回显和修改,回显就是根据传的id查询,基本的sql可以通过mp提供的方法直接调用即可。
注意:这个查询所有,并不能直接用mp提供的list方法来查询,需要使用到条件查询,因为这里是根据user_id来查询的,并不是要展示地址表里所有的数据


查询所有的代码如下

@GetMapping("/list")public R<List<AddressBook>> list(AddressBook addressBook) {addressBook.setUserId(BaseContext.getCurrentId());log.info("addressBook:{}", addressBook);//条件构造器LambdaQueryWrapper<AddressBook> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(null != addressBook.getUserId(), AddressBook::getUserId, addressBook.getUserId());queryWrapper.orderByDesc(AddressBook::getUpdateTime);//SQL:select * from address_book where user_id = ? order by update_time descreturn R.success(addressBookService.list(queryWrapper));}

还有一个是将地址设置为默认地址,其实就是根据条件修改地址表里的is_default字段,给设置为1即为默认地址,但默认地址只能由一个,其逻辑为,若要更改默认地址,则将该用户的所有地址的is_default字段给更新为0,在把用户传来要设置成默认地址的地址id来修改该条地址的is_default字段为1即可。

    @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);}

下面是查询默认地址的代码

    /*** 查询默认地址*/@GetMapping("default")public R<AddressBook> getDefault() {LambdaQueryWrapper<AddressBook> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(AddressBook::getUserId, BaseContext.getCurrentId());queryWrapper.eq(AddressBook::getIsDefault, 1);//SQL:select * from address_book where user_id = ? and is_default = 1AddressBook addressBook = addressBookService.getOne(queryWrapper);if (null == addressBook) {return R.error("没有找到该对象");} else {return R.success(addressBook);}}

4.菜品和套餐展示

此时后端已经将数据传递过来了,从负载可以看到传来的json,但前端没有进行展示

但是现在之前写后台管理端的那段查询所有菜品的代码已经不适用了,因为移动端还需要展示分类名称和口味数据,所以这里查询不能再返回dish对象,应该使用DishDto,返回一个DishDto泛型的list集合,代码需要修改一下

    @GetMapping("/list")public R<List<DishDto>> list(Dish dish){List<DishDto> dishDtoList = null;//动态构造keyString key = "dish_" + dish.getCategoryId() + "_" + dish.getStatus();//dish_13494852934_1//从redis中获取缓存数据(移动端使用redis缓存,将每个分类下查询的数据都放到缓存,避免重复查询,降低服务器压力)dishDtoList = (List<DishDto>) redisTemplate.opsForValue().get(key);//如果从redis获取的数据不为空,证明redis缓存了该数据,直接取出来返回,就无需查询数据库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);Long dishId = item.getId();LambdaQueryWrapper<DishFlavor> lambdaQueryWrapper=new LambdaQueryWrapper<>();lambdaQueryWrapper.eq(DishFlavor::getDishId,dishId);//SQL: select * from dish_flavor where dish_id = ?List<DishFlavor> dishFlavorList = dishFlavorService.list(lambdaQueryWrapper);dishDto.setFlavors(dishFlavorList);return dishDto;}).collect(Collectors.toList());//如果redis不存在该数据,需要查询数据库,将查询菜品数据缓存到redis中redisTemplate.opsForValue().set(key,dishDtoList,60, TimeUnit.MINUTES);return R.success(dishDtoList);}

套餐数据的展示也是类似,根据发送的url,用setmeal对象来接收数据,写后端代码,展示的套餐没有口味之类的数据,所以直接返回Setmeal的list集合就行

    @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);}

5.菜品选规格

点击菜品旁边的选规格,需要展示选择口味数据弹窗,在list查询方法追加以下代码

代码debug调试可以看到

6.套餐点击展示(补充)

当点击套餐时,会发送一个get请求,url如下,我猜测应该是展示该套餐中的菜品数据,我们可以给他返回一个DishDto,因为这个展示还涉及到一个菜品份数数据,根据f12响应那栏看出,这个数据光dish是展示不出来的,所以我们返回DishDto对象。

    /*** 点击查看套餐中的菜品*/@GetMapping("/dish/{id}")public R<List<DishDto>> dish(@PathVariable("id") Long SetmealId) {LambdaQueryWrapper<SetmealDish> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(SetmealDish::getSetmealId, SetmealId);//获取套餐里面的所有菜品  这个就是SetmealDish表里面的数据List<SetmealDish> list = setmealDishService.list(queryWrapper);List<DishDto> dishDtos = list.stream().map((setmealDish) -> {DishDto dishDto = new DishDto();//将套餐菜品关系表中的数据拷贝到dishDto中BeanUtils.copyProperties(setmealDish, dishDto);//这里是为了把套餐中的菜品的基本信息填充到dto中,比如菜品描述,菜品图片等菜品的基本信息Long dishId = setmealDish.getDishId();Dish dish = dishService.getById(dishId);//将菜品信息拷贝到dishDto中BeanUtils.copyProperties(dish, dishDto);return dishDto;}).collect(Collectors.toList());return R.success(dishDtos);}

7.购物车

购物车的表结构

记得把之前main.js下的注释打开,前面再展示菜品套餐时给注释了。

需求分析
按下图来看,首先加入购物车,是新增操作,往购物车表里save数据,还有购物车展示,是查询该用户下的购物车所有数据,按用户id查询,加和减,就是对应的修改表中的number字段。

但是注意加的时候,如果购物车没有数据,就需要save,如果有数据则直接修改number字段加1即可;减的时候如果购物车中的number为1了,再减就是remove该条数据,否则,直接修改number字段减1即可。

    /*** 从购物车中减掉*/@PostMapping("/sub")public R<String> remove(@RequestBody ShoppingCart shoppingCart){//设置用户id,指定当前时哪个用户的购物车数据Long currentId = BaseContext.getCurrentId();shoppingCart.setUserId(currentId);//查询当前菜品或者套餐是否在购物车中Long dishId = shoppingCart.getDishId();LambdaQueryWrapper<ShoppingCart> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(ShoppingCart::getUserId,currentId);if (dishId!=null){//添加到购物车的是菜品queryWrapper.eq(ShoppingCart::getDishId,dishId);}else {//添加到购物车的是套餐queryWrapper.eq(ShoppingCart::getSetmealId,shoppingCart.getSetmealId());}//SQL:select * from shopping_cart where user_id = ? and dish_id = ?ShoppingCart cartServiceOne = shoppingCartService.getOne(queryWrapper);if (cartServiceOne.getNumber()>1){//如果已经存在,就在原来数量基础上减去一Integer number = cartServiceOne.getNumber();cartServiceOne.setNumber(number-1);shoppingCartService.updateById(cartServiceOne);}else {shoppingCartService.remove(queryWrapper);}return R.success("减去成功");}

清空购物车,就是根据user_id,remove该用户下的所有数据

    @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);}
    /*** 清空购物车*/@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("清空购物车成功");}

8.下订单

需求分析

点击去支付根据发送的url请求,根据前端传递的数据,我们可以用order对象来接收

由于传过来的只有三条数据,order对象的其他属性需要我们补全再插入到订单表中

具体编码步骤如下
1.获取当前用户id
2.查询当前用户的购物车数据
3.查询用户数据
4.查询地址数据
5.向订单表插入数据,一条数据(由于前端只传递了addressBookId,payMethod,remark三条数据,其他的需要我们上面查询出来的购物车,用户,地址数据set到order对象中去,然后调用mp的save方法向数据库存入order)
6.向订单明细表插入数据,多条数据(和上面一样,需要手动将数据存入orderDetails对象中,然后调用mp的saveBatch方法将orderDetails插入表中)
7.清空购物车数据

9.收货地址删除(补充)

根据浏览器发送的url,不难看出大概就是一个地址表根据id删除的操作,但这个ids不是restful风格,是直接拼接的形式,所以形参那里传值为 @RequestParam(“ids”) Long id

    @DeleteMapping()public R<String> detele(@RequestParam("ids") Long id){log.info("id={}",id);//        if (id == null){//            return R.error("请求异常");
//        }  //感觉这个判断没太大必要,前端传的id必不能为空,为空的话地址就不会展示出来,更不会有这个删除按钮存在,简单说为空的话,连删除的机会都没有,所以判断没太大必要//        LambdaQueryWrapper<AddressBook> queryWrapper = new LambdaQueryWrapper<>();
//        queryWrapper.eq(AddressBook::getId,id).eq(AddressBook::getUserId,BaseContext.getCurrentId());
//        addressBookService.remove(queryWrapper);//别人说直接使用这个removeById不太严谨,但我个人认为就是没登录状态进入该页面,是执行不了删除操作的,别说删除连查询,这个地址信息都不会展示,全被过滤器拦截了//所以用上面的条件查询好像意义不大,当然你也可以放弃这个简单的removeById,用上面注释的条件删除addressBookService.removeById(id);return R.success("删除成功");}

10.用户支付后查看订单(补充)

浏览器发送的url如下,就是将order_detail表里的数据根据订单id进行条件查询,本来以为只是一个简单单表分页查询,结果踩坑了

orderDetail的表结构

order.html前端页面还需要下面的数据,需要后端传递过去,只是一个单表分页查询出来的orderDetail对象是没有订单名称等数据的

因此这里我们需要使用OrderDto对象,将订单的数据和订单明细的数据都存入OrderDto对象中,返回的是Dto分页查询的数据
创建一个OrderDto

@Data
public class OrderDto extends Orders {private List<OrderDetail> orderDetails;
}

OrderService接口声明一个根据订单id来查询订单明细的数据的方法

public List<OrderDetail> getOrderDetailListByOrderId(Long orderId);

OrdersServiceImpl实现类中实现这个条件查询方法

    public List<OrderDetail> getOrderDetailListByOrderId(Long orderId){LambdaQueryWrapper<OrderDetail> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(OrderDetail::getOrderId, orderId);//根据order表的条件查询出order_detail的数据,因为一个订单可能有多条菜品数据List<OrderDetail> orderDetailList = orderDetailService.list(queryWrapper);return orderDetailList;}


订单数据的分页查询

OrderController类下,支付后查看订单功能的代码

    @GetMapping("/userPage")public R<Page> page(int page, int pageSize){//分页构造器对象Page<Orders> pageInfo = new Page<>(page,pageSize);Page<OrderDto> pageDto = new Page<>(page,pageSize);//构造条件查询对象LambdaQueryWrapper<Orders> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(Orders::getUserId, BaseContext.getCurrentId());//这里是直接把当前用户分页的全部结果查询出来,要添加用户id作为查询条件,否则会出现用户可以查询到其他用户的订单情况//添加排序条件,根据更新时间降序排列queryWrapper.orderByDesc(Orders::getOrderTime);//这里是把所有的订单分页查询出来ordersService.page(pageInfo,queryWrapper);//对OrderDto进行属性赋值List<Orders> records = pageInfo.getRecords();List<OrderDto> orderDtoList = records.stream().map((item) ->{//item其实就是分页查询出来的每个订单对象OrderDto orderDto = new OrderDto();//此时的orderDto对象里面orderDetails属性还是空 下面准备为它赋值Long orderId = item.getId();//获取订单id//调用根据订单id条件查询订单明细数据的方法,把查询出来订单明细数据存入orderDetailListList<OrderDetail> orderDetailList = ordersService.getOrderDetailListByOrderId(orderId);BeanUtils.copyProperties(item,orderDto);//把订单对象的数据复制到orderDto中//对orderDto进行OrderDetails属性的赋值orderDto.setOrderDetails(orderDetailList);return orderDto;}).collect(Collectors.toList());//将订单分页查询的订单数据以外的内容复制到pageDto中,不清楚可以对着图看BeanUtils.copyProperties(pageInfo,pageDto,"records");pageDto.setRecords(orderDtoList);return R.success(pageDto);}

因为前端页面传的是分页数据,所以后端就实现了OrderDto的分页查询,其实我也有想过只查询当前支付的这个订单,就是根据订单id,查询OrderDto,也不是很难,就根据订单id将查询的订单数据和订单明细数据都存入DishDto对象里,然后返回DishDto对象就完了。

但前端传递过来的是分页查询的数据有pageSize和page,摆明了是分页查询,并没有传订单id参数,所以我也无法判断当前订单对应的id,无法根据订单id查询订单明细数据。

11.再来一单(补充)

传递过来的是订单id


①通过上面传递的orderId获取订单明细的数据
②把订单明细的数据的数据塞到购物车表中,不过在此之前要先把购物车表中的数据给清除(清除的是当前登录用户的购物车表中的数据)

    /*** 再来一单* @param map* @return*/@PostMapping("/again")public R<String> againSubmit(@RequestBody Map<String,String> map){String ids = map.get("id");long id = Long.parseLong(ids);LambdaQueryWrapper<OrderDetail> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(OrderDetail::getOrderId,id);//获取该订单对应的所有的订单明细表List<OrderDetail> orderDetailList = orderDetailService.list(queryWrapper);//通过用户id把原来的购物车给清空,这里的clean方法就是之前的购物车清空方法,我给写到service中去了,这样可以通过接口复用代码shoppingCartService.clean();//获取用户idLong userId = BaseContext.getCurrentId();List<ShoppingCart> shoppingCartList = orderDetailList.stream().map((item) -> {//把从order表中和order_details表中获取到的数据赋值给这个购物车对象ShoppingCart shoppingCart = new ShoppingCart();shoppingCart.setUserId(userId);shoppingCart.setImage(item.getImage());Long dishId = item.getDishId();Long setmealId = item.getSetmealId();if (dishId != null) {//如果是菜品那就添加菜品的查询条件shoppingCart.setDishId(dishId);} else {//添加到购物车的是套餐shoppingCart.setSetmealId(setmealId);}shoppingCart.setName(item.getName());shoppingCart.setDishFlavor(item.getDishFlavor());shoppingCart.setNumber(item.getNumber());shoppingCart.setAmount(item.getAmount());shoppingCart.setCreateTime(LocalDateTime.now());return shoppingCart;}).collect(Collectors.toList());//把携带数据的购物车批量插入购物车表  这个批量保存的方法要使用熟练!!!shoppingCartService.saveBatch(shoppingCartList);return R.success("操作成功");}

在order.html中可以看见这样一段前端代码:


<div class="btn" v-if="order.status === 4">  //状态是4才会让你点击下面这个再来一单<div class="btnAgain" @click="addOrderAgain(order)">再来一单</div>
</div>


由于这里没有写后台的确认订单功能,所以这里通过数据库修改订单状态来完成测试!

测试结果,购物车回显了数据

四.项目优化

1.使用Redis缓存

1.1缓存验证码

1.pom中导入redis坐标

     <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>

2.UserController中加入对应操作
先自动装配RedisTemplate

@Autowiredprivate RedisTemplate redisTemplate;

然后把之前验证码放入session中给替换成redis

redisTemplate.opsForValue().set(phone,code,5, TimeUnit.MINUTES);

接下来在用户登录的方法里,我们需要从redis中获取验证码,而且登录成功后把该验证码删除

Object codeInSession = redisTemplate.opsForValue().get(phone);
            //如果登录成功,删除redis中的验证码redisTemplate.delete(phone);

创建一个redis的配置类,对key进行序列化,不然redis客户端中可以看到key并非就是你创建的key的名称,它是\xAC\xED\x00\x05t\x00\key名,大概是这种类型,不便于阅读,我们让它序列化,value就不必序列化了,因为最后idea会对其序列化

@Configuration
public class RedisConfig extends CachingConfigurerSupport {@Beanpublic RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) {RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();//默认的Key序列化器为:JdkSerializationRedisSerializerredisTemplate.setKeySerializer(new StringRedisSerializer()); // key序列化//redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); // value序列化redisTemplate.setConnectionFactory(connectionFactory);return redisTemplate;}
}

1.2缓存菜品查询数据

首先分析移动端菜品查询,每个分类,比如湘菜,川菜,每次点击都需要再次重新查询数据库,不仅压力更大而且造成资源浪费,我们可以把这些查询的数据,按菜品分类给存入redis中,设置其30分钟生存周期,这样再次点击查看,就不会再查询数据库,直接从redis中获取数据,降低服务器压力也避免资源浪费。

缓存逻辑
我们先动态构造唯一key值,然后根据key来获取value,接下来判断value是否为空,若不为空则表示redis中有该分类下的数据,直接返回;若为空则需要去数据库查询数据,然后再把查询的数据放入redis缓存中,下次再查询直接走redis缓存,不用再次查询数据库。

可以看到redis已经缓存了菜品信息

但是使用缓存的话,修改,新增和删除是要清理缓存的,不然如果后端数据更改后,再次查询仍然是走的缓存,而缓存的数据没有改变,查出来的就不是最新的数据了,造成了数据偏差。因此我们在修改,新增,删除方法中清理缓存

        //清理所有菜品的缓存数据//Set keys = redisTemplate.keys("dish_*");//redisTemplate.delete(keys);//只清理该修改菜品的分类下缓存的数据,精确清理,因为redis可能已经缓存了好几个分类下的数据,全删太浪费String key = "dish_" + dishDto.getCategoryId() + "_1";redisTemplate.delete(key);

1.3Spring Cache缓存套餐数据

1.pom文件中导入Spring Cache坐标

     <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-cache</artifactId></dependency>

2.启动类上加上注解开启缓存功能

@EnableCaching

3.使用Spring Cache注解的方式开启缓存

注意:这里方法返回的结果R,它的类需要实现序列化接口,否则会报错,无法缓存。

缓存查询的套餐信息

@Cacheable(value = "setmealCache", key = "#setmeal.categoryId + '_' + #setmeal.status")

清除缓存,需要在新增,修改,删除方法上添加该注解

@CacheEvict(value = "setmealCache", allEntries = true)

再次访问,可以看到redis下已经有了套餐数据,仍然是按套餐分类缓存的

2.读写分离

读与写所有压力都由一台数据库承担,压力大,数据库服务器磁盘损坏或数据丢失,单点故障

2.1mysql主从复制

MySQL主从复制就是一个异步复制过程,从库slave从主库master进行日志的复制然后再解析日志并应用到自身,最终实现从库数据和主库数据保持一致。MySQL主从复制是MySQL数据库自带功能。

注意:这里至少要有两台服务器分别安装MySQL并且启动成功,可以用虚拟机再克隆出一个作为从库

1.master将改变记录到二进制日志binary log
修改MySQL数据库的配置文件 vim /etc/my.cnf
[mysqld]下添加如下代码

log-bin=mysql-bin  #启用二进制日志
server-id=100  #id作为服务器唯一标识,不一定要100,只要不重复即可


保存退出后重启mysql服务

systemctl restart mysqld

我们需要在master下创建一个用户,给他授予权限,slave才能通过该用户来拷贝它记录的日志文件,先登录到mysql

mysql -uroot -p

创建一个用户叫xiaoming,密码是Root@123456,并给该用户授予REPLICATION SLAVE权限

GRANT REPLICATION SLAVE ON *.* to 'xiaoming'@'%' identified by 'Root@123456';

如果报错

mysql8需要先创建用户才能授权,用下面的代码

create user xiaoming identified by 'Root@123456';
grant replication slave on *.* to xiaoming;

查看主库状态

show master status;


接下里主库就不要操作了,一旦执行操作,记录位置会发生变化,就不是698了,这个位置和文件名一会在从库中使用到。

2.slave将master的binary log拷贝到它的中继日志relay log
先修改MySQL数据库的配置文件 vim /etc/my.cnf
[mysqld]下添加如下代码

server-id=101 #必须是唯一的id,不能重复

保存退出后重启mysql服务

systemctl restart mysqld

登录MySQL,执行以下代码

mysql -uroot -p
change master to master_host='填入master的ip',master_user='上面创建的用户',master_password='上面设置的',master_log_file='主库刚查的日志名称',master_log_pos=刚查的记录位置;

启动slave

start slave

查看从库状态

show slave status\G

如果你是MySQL8,且报错信息为

可以看看这个解决方法https://www.modb.pro/db/29919

Slave_IO_Running和Slave_SQL_Running都为no,可以看看这个解决方法https://www.cnblogs.com/MENGSHIYU/p/11978489.html
完事后一定要重启MySQL服务

systemctl restart mysqld

改好后从库的两个io都为yes即可

3.slave重做中继日志中的事件,将改变应用到自身的数据库中

Navicat中根据ip新建主从两个连接,主库创建数据库和表后,从库刷新直接显示出来

主库对表的任何修改操作,从库的表F5即可看到更新。

2.2Sharding-JDBC实现读写分离

主库执行写操作,从库执行读操作,Sharding-JDBC是轻量级java框架,在java的JDBC层提供服务,只需要导入它的坐标即可使用它封装的api,轻松实现数据库的读写分离。
1.pom文件导入Sharding-JDBC坐标

     <dependency><groupId>org.apache.shardingsphere</groupId><artifactId>sharding-jdbc-spring-boot-starter</artifactId>  </dependency>

2.yml配置文件中添加配置信息,ip换成你自己的主从库ip,password也替换成自己的密码

spring:shardingsphere:datasource:names:master,slave# 主数据源master:type: com.alibaba.druid.pool.DruidDataSourcedriver-class-name: com.mysql.cj.jdbc.Driverurl: jdbc:mysql://192.168.231.128:3306/rw?characterEncoding=utf-8username: rootpassword: 123456# 从数据源slave:type: com.alibaba.druid.pool.DruidDataSourcedriver-class-name: com.mysql.cj.jdbc.Driverurl: jdbc:mysql://192.168.231.129:3306/rw?characterEncoding=utf-8username: rootpassword: 123456masterslave:# 读写分离配置load-balance-algorithm-type: round_robin  #轮询策略,负载均衡# 最终的数据源名称name: dataSource# 主库数据源名称master-data-source-name: master# 从库数据源名称列表,多个逗号分隔slave-data-source-names: slaveprops:sql:show: true #开启SQL显示,默认false

3.配置文件中开启允许bean定义覆盖
因为Sharding-JDBC和Druid都有数据源定义的配置类,都想创建数据源对象,产生冲突,我们要打开允许bean的覆盖,后创建的datasource会覆盖前面的,具体代码如下

spring:main:allow-bean-definition-overriding: true

配置完毕就可以了,自动读写分离,查询走从库,增删改走主库。

2.3项目实现读写分离

1.在前面建主库中,新建一个reggie数据库,运行之前的sql文件,刷新可以看到项目的表结构和数据已经导入了,从库刷新,数据也出来了。
2.和上面一样,导坐标,yml加入对应的配置信息,把数据库名字改成reggie就行了,就完成了读写分离。

3.使用Nginx服务器

Nginx是一款轻量级Web服务器/反向代理服务器及电子邮件代理服务器。其优点是占用内存少,并发能力强
nginx的配置文件分为三个部分
全局块:events块之前的配置,和nginx运行相关的全局配置
events块:和网络连接相关配置
http块:代理,缓存,日志记录,虚拟主机配置,一般主要是配置这块内容

3.1Nginx部署静态资源

只需要把静态资源直接放在nginx的html目录下即可

访问时就是ip/页面名称

3.2反向代理

正向代理,说简单点就是梯子,客户端通过代理服务器向目标服务器访问,发生在客户端,客户端知晓代理服务。

反向代理是客户端访问代理服务器,代理服务器转发给目标服务器,发生在服务端,客户端并不知道代理服务器的存在。
正向代理隐藏的是用户,反向代理隐藏的是服务器

配置反向代理就是在反向代理的服务器上,配置其nginx.conf配置文件,在http块中加入以下代码

    server {listen       82;server_name  localhost;location / {proxy_pass http://目标服务器ip:8080;#将请求转发到指定服务器}}

并开放代理服务器的82端口号,重新加载防火墙

firewall-cmd --zone=public --add-port=82/tcp --permanentfirewall-cmd --reload

重新加载nginx
注意这里nginx要给配置成环境变量,详情见nginx的基本配置

nginx -s reload

访问时访问的是反向代理服务器的ip,反向代理服务器再把请求转发到目标服务器上

3.3负载均衡

应用集群:将同一应用部署到多台服务器上,组成应用集群,接收负载均衡器分发的请求,进行业务处理并返回响应数据
负载均衡器:将用户请求根据对应的负载均衡算法分发到应用集群中的一台服务器进行处理

负载均衡服务器上配置nginx.conf配置


默认是轮询策略,第一次这台服务器,第二次下台服务器
配置完毕后重新加载nginx服务

nginx -s reload

这样使用集群方式,降低单台服务器的压力,提高访问效率,避免了单点故障问题。

4.前后端分离开发

工程结构进行拆分,项目部署也发生变化

开发流程

接口(API接口)就是一个http的请求地址,主要就是去定义:请求路径,请求方式,请求参数,响应数据等内容。

4.1YApi

就是提供接口管理服务,让接口开发更简单高效,让接口管理更具可读性和维护性。感觉还不如Apifox方便,这里我就用Apifox了。

4.2Swagger

1.pom导入swagger的解决方案Knife4j的坐标

     <dependency><groupId>com.github.xiaoymin</groupId><artifactId>knife4j-spring-boot-starter</artifactId><version>3.0.2</version></dependency>

2.导入kknife4j相关配置(WebMvcConfig)
WebMvcConfig配置类下开启swagger文档功能加上以下注解

@EnableKnife4j
@Configuration

定义以下两个方法

@Beanpublic Docket createRestApi() {// 文档类型return new Docket(DocumentationType.SWAGGER_2).apiInfo(apiInfo()).select().apis(RequestHandlerSelectors.basePackage("com.controller"))//扫描controller包下的所有api接口.paths(PathSelectors.any()).build();}private ApiInfo apiInfo() {return new ApiInfoBuilder().title("瑞吉外卖").version("1.0").description("瑞吉外卖接口文档").build();}

3.设置静态资源映射,否则接口文档页面无法访问
就是在addResourceHandlers方法中添加以下两行代码

        registry.addResourceHandler("doc.html").addResourceLocations("classpath:/META-INF/resources/");registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");

4.LoginCheckFilter过滤器设置放行这些url,设置为不需要处理的请求路径
在LoginCheckFilter的放行列表中添加以下url

                "/doc.html","/webjars/**","/swagger-resources","/v2/api-docs"

项目启动后直接访问http://localhost:8080/doc.html即可看到。

4.3项目部署

部署架构

部署环境说明,三台服务器

前端项目部署
1.前端服务器安装nginx,将前端资源上传到nginx下的html目录中

2.配置conf目录下的nginx.conf文件
配置信息如下

server{listen 80;server_name localhost;
#静态资源配置location /{root html/dist;index index.html;}
#请求转发代理,重写URL+转发location ^~ /api/{rewrite ^/api/(.*)$ /$1 break;proxy_pass http://后端服务ip:端口号;}
#其他error_page 500 502 503 504 /50x.html;location = /50x.html{root html;}
}

反向代理的配置分析

后端项目部署
采用脚本自动部署
1.后端服务器要安装jdk,maven,git,mysql,从git仓库克隆项目下来

git clone 远程仓库的url

2.添加一个reggieStart脚本,具体代码如下,放在/usr/local/app目录下,执行脚本即可自动拉取代码,打包并后台部署。

#!/bin/sh
echo =================================
echo  自动化部署脚本启动
echo =================================echo 停止原来运行中的工程
APP_NAME=reggie_take_outtpid=`ps -ef|grep $APP_NAME|grep -v grep|grep -v kill|awk '{print $2}'`
if [ ${tpid} ]; thenecho '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} ]; thenecho 'Kill Process!'kill -9 $tpid
elseecho 'Stop Success!'
fiecho 准备从Git仓库拉取最新代码
cd /usr/local/app/reggie_take_outecho 开始从Git仓库拉取最新代码
git pull
echo 代码拉取完成echo 开始打包
output=`mvn clean package -Dmaven.test.skip=true`cd targetecho 启动项目
nohup java -jar reggie_take_out-0.0.1-SNAPSHOT.jar &> server.log &
echo 项目启动完成

给脚本授予执行权限

chmod 777 reggieStart.sh

运行脚本即完成后台项目部署,具体的jar包名称在项目文件夹下的target目录下

不知不觉已经写了5w字了,后面如果我对项目有新的理解,再继续补充完善吧,个人水平有限,不足之处多多包涵,也欢迎各位指正,感谢您的阅览。

瑞吉外卖项目详细分析笔记及所有功能补充代码相关推荐

  1. 做完瑞吉外卖项目的一点笔记和源码

    源码在 https://gitee.com/pluto8/take-out 一.软件开发整体介绍 1.软件开发流程 需求分析 :产品原型,需求规格说明书(文档形式) 设计:产品文档.UI界面设计.概要 ...

  2. 瑞吉外卖项目 基于spring Boot+mybatis-plus开发 超详细笔记,有源码链接

    本项目是基于自学b站中 黑马程序员 的瑞吉外卖项目:视频链接: 黑马程序员Java项目实战<瑞吉外卖>,轻松掌握springboot + mybatis plus开发核心技术的真java实 ...

  3. 瑞吉外卖项目笔记+踩坑1——基础功能

     导航: [黑马Java笔记+踩坑汇总]JavaSE+JavaWeb+SSM+SpringBoot+瑞吉外卖+SpringCloud/SpringCloudAlibaba+黑马旅游+谷粒商城 目录 1 ...

  4. 黑马瑞吉外卖项目开发笔记

    目录 软件开发整体介绍 开发流程 角色分工 软件环境 瑞吉外卖项目介绍 项目介绍 产品原型展示 技术选型 功能架构 角色 开发环境搭建 数据库环境搭建 Maven环境搭建 1.直接创建maven项目( ...

  5. 瑞吉外卖项目学习笔记:P1-项目介绍

    瑞吉外卖项目介绍 1.项目介绍 1.1开发步骤 1.2产品原型 1.3技术选型 1.4项目功能架构 1.5角色 1.项目介绍 1.1开发步骤 实现基本要求,移动端应用通过H5实现,用户可通过浏览器访问 ...

  6. 【SpringBoot项目实战+思维导图】瑞吉外卖①(项目介绍、开发环境搭建、后台登陆/退出功能开发)

    文章目录 软件开发整体介绍 软件开发流程 角色分工 软件环境 瑞吉外卖项目介绍 项目介绍 产品原型 技术选型 功能架构 角色 开发环境搭建 数据库环境搭建 创建数据库 数据库表导入 数据库表介绍 Ma ...

  7. 瑞吉外卖项目剩余功能补充

    目录 菜品启售和停售 菜品批量启售和批量停售 菜品的批量删除 菜品删除逻辑优化 套餐管理的启售,停售 套餐管理的修改 后台按条件查看和展示客户订单 手机端减少购物车中的菜品或者套餐数量(前端展示有一点 ...

  8. 瑞吉外卖项目——瑞吉外卖

    软件开发整体介绍 软件开发流程 需求分析:产品原型.需求规格说明书 设计:产品文档.UI界面设计.概要设计.详细设计.数据库设计 编码:项目代码.单元测试 测试:测试用例.测试报告 上线运维:软件环境 ...

  9. 瑞吉外卖项目(一)软件开发流程设计及环境搭建

    第一章 软件开发整体介绍 软件开发流程 软件开发流程 需求分析:产品原型.需求规格说明书 设计:产品文档,ui界面设计,概要设计,详细设计,数据库设计 编码:项目代码,单元测试 测试:测试用例,测试报 ...

最新文章

  1. mysql dba系统学习(10)innodb引擎的redo log日志的原理
  2. hdu 1532(最大流)
  3. 2017.7.18可变/不可变类型,符号运算及其流程控制
  4. PHP中strtotime函数有范围吗,PHP中使用strtotime函数注意事项
  5. ngrok服务器搭建_利用暴露在外的API,无法检测的Linux恶意软件将矛头指向Docker服务器...
  6. java之Hibenate中监听事件的重写和二级cache缓存
  7. docker常用命令_docker常用命令整理
  8. 长江存储发布PCle4.0 固态硬盘致态TiPro7000,顺序读取7400MB/s
  9. 我一个弱女子在欧洲大街上拉屎了,因为便秘惹的祸
  10. mysql主动自增可以_Mysql join联表及id自增实例解析
  11. Oracle用户和权限管理
  12. LeetCode 228. Summary Ranges
  13. sql语句 替换数据库中某字段中的特定字符
  14. EasyRecovery2022版支持电脑, 硬盘, U盘, 内存卡, 回收站等设备数据恢复
  15. word文档怎么找回误删的文件_Word文档删除了怎么恢复?Word误删除恢复技巧
  16. 自定义安装官方Microsoft Office 2019
  17. C#程序设计与应用课程教学总结:自评与改进
  18. h2o api java_H2O框架简介
  19. JS实现一个打点计时器
  20. Android 2020年经典面试题

热门文章

  1. 【思维导图】高等数学思维导图总览
  2. 前端面试之JavaScript
  3. 区块链相关专业术语集合
  4. 音视频ffmpeg——ffmpeg 滤镜(添加水印)
  5. phpstudy安装sg11组件_服务器php安装SG11扩展组件的详细图文教程
  6. java获取Date类型时间的前3个月,后3个月,前3天,后3天
  7. matlab中的随机矩阵及其相关函数的使用
  8. 2020年QS世界大学排名发布,CS传统三强地位稳固,清华表现亮眼
  9. Java“别踩白方块”脚本
  10. linux怎么写用拼音写中文为什么出错,linux上的搜狗拼音用不了啦?快来看怎么解决...