0.准备工作及前置知识:
0.1 版本控制
Git在IDEA中的使用(详细图文全解)_idea操作git-CSDN博客
Gitee仓库:CMD137/sky-take-out
0.2 Nginx:反向代理与负载均衡
可以发现前端请求URL与后端设计的URL并不相同,但能“连接”起来。
这是由Nginx反向代理实现的:将前端发送的动态请求转发到后端服务器。
优点:提高访问速度(nginx缓存)、进行负载均衡(即此处通过反向代理实现)、保证后端服务安全
具体实现:
nginx.conf中:
#配置负载均衡的服务器
upstream webservers{
server 127.0.0.1:8080 weight=90 ;
#server 127.0.0.1:8088 weight=10 ;
}
server {
listen 80;
server_name localhost;
#反向代理部分
location /api/ {
proxy_pass http://localhost:8080/admin/;
#proxy_pass http://webservers/admin/;
}
location /user/ {
proxy_pass http://webservers/user/;
}
location /ws/ {
proxy_pass http://webservers/ws/;
proxy_http_version 1.1;
proxy_read_timeout 3600s;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "$connection_upgrade";
}
}
0.3 在APIfox中导入接口文档
以项目为单位:
- 管理端接口
- 用户端接口
0.4 Swagger:Knife4j 生成API文档
用于后端测试
使用:
- pom导入knife4j maven坐标
- 在配置类中加入相关配置
- 设置静态资源映射
0.5理解初始代码:
0.5.1 application.yml及相关配置文件、配置类:
application.yml
:
server:
port: 8080
spring:
profiles:
active: dev
main:
allow-circular-references: true
datasource:
druid:
driver-class-name: ${sky.datasource.driver-class-name}
url: jdbc:mysql://${sky.datasource.host}:${sky.datasource.port}/${sky.datasource.database}?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
username: ${sky.datasource.username}
password: ${sky.datasource.password}
mybatis:
#mapper配置文件
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.sky.entity
configuration:
#开启驼峰命名
map-underscore-to-camel-case: true
logging:
level:
com:
sky:
mapper: debug
service: info
controller: info
sky:
jwt:
# 设置jwt签名加密时使用的秘钥
admin-secret-key: itcast
# 设置jwt过期时间
admin-ttl: 7200000
# 设置前端传递过来的令牌名称
admin-token-name: token
spring: profiles: active: dev
profiles
用于根据不同的环境(如开发、测试、生产等)来加载不同的配置。这里激活了dev
环境配置,意味着在项目中有对应application-dev.yml
等以dev
为后缀区分的配置文件时,会优先加载和应用这些配置文件中的设置(用于覆盖当前配置文件中相同配置项的内容),方便在不同环境下灵活切换配置。spring: datasource: druid: ...
配置数据库连接相关信息- 通过使用占位符如
${sky.datasource.driver-class-name}
,表示实际的数据库驱动类名会从上文提到的application-dev.yml
中配置,如下:
- 通过使用占位符如
application-dev.yml
:
sky:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
host: localhost
port: 3306
database: sky_take_out
username: root
password: **********
- sky.jwt及配置类
- 设置 jwt 签名加密时使用的秘钥
- 设置 jwt 过期时间
- 设置前端传递过来的令牌名称
在对应的配置类中使用:
JwtPreperties,java:
@Component
@ConfigurationProperties(prefix = "sky.jwt")//对应配置文件中
@Data
public class JwtProperties {
/**
* 管理端员工生成jwt令牌相关配置
*/
private String adminSecretKey;
private long adminTtl;
private String adminTokenName;
/**
* 用户端微信用户生成jwt令牌相关配置
*/
private String userSecretKey;
private long userTtl;
private String userTokenName;
}
此处通过配置类,将配置信息放在配置文件中,遵循配置与代码分离原则。
1 新增员工
1.1 新知识点:
1.1.1 :ThreadLocal
ThreadLocal并不是一个Thread,而是Thread的局部变量。对于每一个线程单独提供一个存储空间,只能在这个线程内获取。
每次请求都是一个单独的线程
ThreadLocal 常用方法:
- public void set (T value):用于设置当前线程的线程局部变量的值。
- public T get ():能够返回当前线程所对应的线程局部变量的值。
- public void remove ():可以移除当前线程的线程局部变量。
在本节封装为BaseContext,用于在拦截器中获取token中的id,提供给EmployeeServiceImpl。
另外,此处也可以直接获取请求对象,从请求中解析并获取id,可见Javaweb-AOP – CMD137’s Blog。
1.2 实现:
机械化的操作不再赘述。
教程中处理用户名已存在而不进行注册的异常时,直接在全局异常处理器中处理了SQLIntegrityConstraintViolationException并直接处理报错信息字符串返回error,觉得过于粗暴,做以下改进:
这里自定义一个异常UsernameAlreadyExistsException,在server中add时先查询判断用户名是否已存在,若已经存在,则抛出UsernameAlreadyExistsException,否则写入数据库。
UsernameAlreadyExistsException.java:
package com.sky.exception;
/**
* 用户名是否已存在异常,使用于新增员工
*/
public class UsernameAlreadyExistsException extends BaseException {
public UsernameAlreadyExistsException() {
}
public UsernameAlreadyExistsException(String msg) {
super(msg);
}
}
EmployeeServiceImpl:
public void addEmp(EmployeeDTO employeeDTO) {
Employee employee = new Employee();
//对象属性拷贝
BeanUtils.copyProperties(employeeDTO,employee);
employee.setStatus(StatusConstant.ENABLE);
employee.setCreateTime(LocalDateTime.now());
employee.setUpdateTime(LocalDateTime.now());
//设置默认密码:123456
employee.setPassword(DigestUtils.md5DigestAsHex(PasswordConstant.DEFAULT_PASSWORD.getBytes()));
//使用ThreadLocal获取存储在token中的id
employee.setCreateUser(BaseContext.getCurrentId());
employee.setUpdateUser(BaseContext.getCurrentId());
//判断用户名是否已存在,若已经存在,则抛出UsernameAlreadyExistsException,否则写入数据库
if (employeeMapper.getByUsername(employee.getUsername())!=null){
throw new UsernameAlreadyExistsException("用户名"+employee.getUsername()+"已存在");
}
employeeMapper.addEmp(employee);
}
2 员工分页查询
使用pagehelper
PageHelper
的核心原理是通过拦截 MyBatis 的 SQL 执行过程,动态地在原始 SQL 语句上添加分页逻辑(如 LIMIT
或 ROWNUM
),从而实现分页查询。
- controller对象类型应为PageResult
- service对象类型应为Page(T)
2.1 新知识点:
通过在 WebMvcConfiguration 中扩展Spring MVC的消息转换器,统一对日期类型进行格式化处理
写法固定:
/**
* 扩展Spring MVC 框架消息转换器
* @param converters
*/
protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
log.info("扩展消息转换器...");
//创建一个消息转换器对象
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
//为消息转换器设置一个对象转换器,对象转换器可以将java对象序列化为json数据
converter.setObjectMapper(new JacksonObjectMapper());
//将自定义的消息转换器加入converters容器,0用来设定优先级
converters.add(0,converter);
}
3 启用禁用员工账号
再实现service层时,考虑到通用性,不单独实现只修改status的mapper层,而直接在mapper编写通过id更新的mapper供该service使用。
3.1 新知识点:@Builder
@Builder
是 Lombok 库中的一个注解,用于自动生成一个构建器(Builder)模式的类。使用这个注解可以简化对象的创建过程
例:
Employee employee=Employee.builder()
.status(status)
.id(id)
.build();
4 编辑员工
不只是修改,还要回显(根据id查询信息)
注意需要再次检查用户名是否已存在的问题。
5 分类管理模块
- 分类名称唯一,所以对于修改和新增同样需要检查分类名称。与之前员工username同样,添加CategoryNameAlreadyExistsException异常
- 删除分类注意条件
6 公共字段自动填充
- 问题:解决公共字段更新代码冗余不易维护的问题。
- 实现思路:
- 自定义注解AutoFill,用于标识需要进行公共字段自动填充的方法
- 自定义切面类AutoFillAspect,统一拦截标注了AutoFill注解的方法,通过反射为公共字段赋值
- 在Mapper层标注AutoFill
自定义注解AutoFill:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFill {
//指定数据库操作类型:Update;Insert
OperationType value();
}
自定义切面类AutoFillAspect:重点!!!
package com.sky.aspect;
import com.sky.annotation.AutoFill;
import com.sky.constant.AutoFillConstant;
import com.sky.context.BaseContext;
import com.sky.enumeration.OperationType;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
/**
* 自定义切面,实现公共字段自动填充
*/
@Aspect
@Component
@Slf4j
public class AutoFillAspect {
//切面=切入点+通知
/**
* 切入点
*/
@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
public void autoFillPointCut(){}
/**
* 前置通知,为公共字段赋值
*/
@Before("autoFillPointCut()")
public void autoFill(JoinPoint joinPoint) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
//获取当前被拦截方法的注解value值
MethodSignature signature = (MethodSignature)joinPoint.getSignature();//通过连接点获取方法签名对象
OperationType value = signature.getMethod().getAnnotation(AutoFill.class).value();//通过方法签名对象获取注解对象,再获取注解对象的值
//获取方法的参数中的实体
Object[] args = joinPoint.getArgs();
//此处约定了拦截方法只有一个参数,即为所需实体
Object obj=args[0];
//通过反射为实体对象的公共属性赋值,分两种情况、INSERT;UPDATE
LocalDateTime now = LocalDateTime.now();
Long id = BaseContext.getCurrentId();
if(value==OperationType.INSERT){
log.info("为公共字段赋值:INSERT");
Method setCreateTime = obj.getClass().getMethod(AutoFillConstant.SET_CREATE_TIME, LocalDateTime.class);
setCreateTime.invoke(obj, now);
Method setCreateUser = obj.getClass().getMethod(AutoFillConstant.SET_CREATE_USER, Long.class);
setCreateUser.invoke(obj, id);
Method setUpdateTime = obj.getClass().getMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
setUpdateTime.invoke(obj, now);
Method setUpdateUser = obj.getClass().getMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);
setUpdateUser.invoke(obj, id);
}else if (value==OperationType.UPDATE){
log.info("为公共字段赋值:UPDATE");
Method setUpdateTime = obj.getClass().getMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
setUpdateTime.invoke(obj, now);
Method setUpdateUser = obj.getClass().getMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);
setUpdateUser.invoke(obj, id);
}
}
}
7 新增菜品:(云OSS存储)
7.1 文件上传
1.阿里云oss创建bucket
2.在yml文件中进行endpoint、accesskey:id,secret;bucket-name的配置
3.创建OssConfiguration配置类并将AliOssUtil对象交给容器管理(应用启动就会创建对象)
4.在CommonController中进行处理:
@PostMapping("/upload")
@ApiOperation("文件上传")
public Result<String> upload(MultipartFile file){
log.info("文件上传{}",file);
try {
//原始文件名
String originalFilename = file.getOriginalFilename();
//截取原始文件名的拓展名
String extension = originalFilename.split("\\.")[1];
//构建新文件名称
String objectName = UUID.randomUUID().toString() + extension;
//文件请求路径
String filePath = aliOssUtil.upload(file.getBytes(), objectName);
return Result.success(filePath);
} catch (IOException e) {
log.error("文件上传失败:{}",e);
}
return Result.error(MessageConstant.UPLOAD_FAILED);
}
7.2 新增菜品 此处操作两张表:菜品表—-1:n—>口味表
涉及多表操作,需要@Transactional事务注解保证方法原子性(在启动类@EnableTransactionManagement
注解来启用基于注解的事务管理)
操作两张表,则需两个Mapper。每次新增菜品,在DishMapper中插入一条数据,在DishFlavorMapper中插入n条数据(使用批量插入):
<insert id="insertBatch">
insert into dish_flavor (dish_id, name, value) values
<foreach collection="flavors" item="df" separator=",">
(#{df.dishId},#{df.name},#{df.value})
</foreach>
</insert>
在插入口味数据时,需要获取dishId,则需要在插入dish时主键回显:
useGeneratedKeys = true
:表示使用数据库自动生成的主键。keyProperty = "id"
:指定将生成的主键值赋给实体类中的哪个属性。
<insert id="add" useGeneratedKeys="true" keyProperty="id">
INSERT INTO dish (name, category_id, price, image, description, create_time, update_time, create_user, update_user, status)
VALUES (#{name}, #{categoryId}, #{price}, #{image}, #{description}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser}, #{status})
</insert>
主键回显后,生成的主键值会被设置到传入的实体对象中。则可在service中操作实体对象获取ID。
8 菜品分页查询 (多表查询)
由于categoryName字段,需要多表查询,这里使用dish表左外连接category表
注意:对应 DishVO,需要给 c.name 用 as 起别名 categoryName
<select id="pageQuery" resultType="com.sky.vo.DishVO">
SELECT d.*,c.name as categoryName from dish d left outer join sky_take_out.category c on d.category_id = c.id
<where>
<if test="name !=null">
and d.name like concat('%',#{name},'%')
</if>
<if test="categoryId !=null">
and d.category_id = #{categoryId}
</if>
<if test="status !=null">
and d.status = #{status}
</if>
</where>
order by d.create_time DESC
</select>
9 删除菜品
由于是批量删除菜品,前端请求参数:ids string 菜品id,之间用逗号分隔。示例值: 1,2,3
使用@RequestParam
自动类型转换:小知识点合集(持续更新) – CMD137’s Blog见16
@DeleteMapping
@ApiOperation("批量删除菜品")
public Result delete(@RequestParam List<Long> ids){
log.info("批量删除菜品:{}",ids);
dishService.delete(ids);
return Result.success();
}
在Service中:
/**
* 批量删除菜品
* @param ids
* @return
*/
@Transactional
public void deleteBatch(List<Long> ids) {
//此处业务逻辑设定:当其中一个不能删除时,其他满足删除规则的也不删除,抛出不可删除异常
//判断当前菜品是否能够删除
//是否起售中?
for (Long id:ids){
Dish dish = dishMapper.getById(id);
if (dish.getStatus().equals(StatusConstant.ENABLE))
throw new DeletionNotAllowedException(MessageConstant.DISH_ON_SALE);
}
//是否属于某个套餐
List<Long> setmealIds = setmealDishMapper.getSetmealIdsByDishIds(ids);
if(!setmealIds.isEmpty())
throw new DeletionNotAllowedException(MessageConstant.DISH_BE_RELATED_BY_SETMEAL);
//删除菜品关联的口味数据 和 菜品表中的菜品数据
for (Long id:ids){
dishFlavorMapper.deleteById(id);
dishMapper.deleteById(id);
}
}
当前代码是通过循环遍历 ids
列表,对每个 id
分别调用 deleteById
方法进行删除操作,这会产生多次数据库交互,增加了数据库的负担和操作的时间开销。更好的做法是使用批量删除功能,减少与数据库的交互次数。进行优化:
mapper:
根据菜品Id查询套餐Id
<!-->原始SQL为: select setmeal_id from setmeal_dish where setmeal_id in (1,2,65,29,...) <!-->
<select id="getSetmealIdsByDishIds" resultType="java.lang.Long">
select setmeal_id from setmeal_dish where dish_id in
<foreach collection="dishIds" item="dishId" separator="," open="(" close=")">
#{dishId}
</foreach>
</select>
10 更新菜品
修改菜品时:可直接update
修改菜品口味时:可能会有新增口味、删除口味或修改口味等多种操作。先删除原有的口味记录,再重新插入新的口味记录,可以更方便地处理这些复杂的操作,避免了逐个判断哪些口味需要更新、哪些需要删除、哪些需要新增的复杂逻辑。
public void updateWithFlavor(DishDTO dishDTO) {
Dish dish = new Dish();
BeanUtils.copyProperties(dishDTO, dish);
List<DishFlavor> flavors = dishDTO.getFlavors();
//更新菜品
dishMapper.update(dish);
//更新菜品对应的口味
dishFlavorMapper.deleteById(dish.getId());
//由于接口中dishId可选(实际原因是新的口味没有dishId),需要重新设置dishId
if(flavors != null && !flavors.isEmpty()){
flavors.forEach(dishFlavor -> {
dishFlavor.setDishId(dish.getId());
});
dishFlavorMapper.insertBatch(flavors);
}
}
10 套餐相关:
修改套餐模块开发时发现要检查字段唯一性时逻辑与新增并不完全相同,不能简单的CV:
//判断套餐名称name是否已存在,若已经存在,则抛出SetmealNameAlreadyExistsException
if (setmealMapper.getByName(setmealDTO.getName()) != null){
throw new SetmealNameAlreadyExistsException("套餐名称"+setmealDTO.getName()+"已存在");
}
这样写在新增操作时正确,但是在修改操作中,当前代码直接检查套餐名称是否已存在,这会导致一个问题:如果用户只是修改了套餐的其他信息,而套餐名称没有改变,此时检查也会判定名称已存在并抛出异常。应该还要排除当前要修改的套餐本身:(为赶进度,此处对之前的更新操作不再做出修正)
//排除name不变,判断套餐名称name是否已存在,若已经存在,,则抛出SetmealNameAlreadyExistsException
if (setmealMapper.getById(setmealDTO.getId()).getName().equals(setmealDTO.getName())){
//name不变
}else if (setmealMapper.getByName(setmealDTO.getName()) != null){
throw new SetmealNameAlreadyExistsException("套餐名称"+setmealDTO.getName()+"已存在");
}
11 店铺营业状态设置(Redis)
注意:
使用配置文件,则在启动时必须指定所使用的配置文件。如果不使用配置文件,Redis 会使用默认参数运行;
由于操作简单,所以直接在controller层处理:
此处对教程作出改动:添加了默认营业状态
@GetMapping("/status")
@ApiOperation("获取营业状态")
public Result<Integer> getShopStatus(){
log.info("Admin获取店铺营业状态");
Object value = redisTemplate.opsForValue().get(KEY);
if (value == null) {
// 若 Redis 中没有值,返回默认营业状态
return Result.success(DEFAULT_SHOP_STATUS);
}
return Result.success((Integer) value);
}
12 微信登陆
12.1 登录流程:
通过微信登录的流程,如果要完成微信登录的话,最终就要获得微信用户的openid。在小程序端获取授权码后,向后端服务发送请求,并携带授权码,这样后端服务在收到授权码后,就可以去请求微信接口服务。最终,后端向小程序返回id、openid、token数据。
12.2 代码:
Controller:
@PostMapping("/login")
@ApiOperation("微信登录")
public Result<UserLoginVO> login(@RequestBody UserLoginDTO userLoginDTO) {
log.info("微信登录:{}",userLoginDTO);
//请求微信接口
User user = userService.wxLogin(userLoginDTO);
//为微信用户生成jwt令牌
Map<String, Object> claims =new HashMap<>();
claims.put("userId",user.getId());
String token = JwtUtil.createJWT(jwtProperties.getUserSecretKey(), jwtProperties.getUserTtl(), claims);
UserLoginVO userLoginVO = UserLoginVO.builder()
.id(user.getId())
.token(token)
.openid(user.getOpenid())
.build();
return Result.success(userLoginVO);
}
Service:
public User wxLogin(UserLoginDTO userLoginDTO) {
//1.调用微信接口,发送appid+appsecret+code,获取session_key和openid
//编写请求
Map<String, String> maps=new HashMap<>();
maps.put("appid",weChatProperties.getAppid());
maps.put("secret",weChatProperties.getSecret());
maps.put("js_code",userLoginDTO.getCode());
maps.put("grant_type","authorization_code");
//发送、接收请求并处理结果(得到openid)
String json = HttpClientUtil.doGet(WX_LOGIN, maps);
JSONObject jsonObject = JSON.parseObject(json);
String openid = jsonObject.getString("openid");
//判断openid是否为空,如果为空则表示登录失败,抛出业务异常
if (openid == null){
throw new LoginFailedException(MessageConstant.LOGIN_FAILED);
}
//判断当前用户是否为新用户
User user= userMapper.getByOpenid(openid);
//如果是新用户,则注册
if (user==null){
user = User.builder()
.openid(openid)
.createTime(LocalDateTime.now())
.build();
userMapper.insert(user);
}
return user;
}
此外,还有面向用户端的拦截器及拦截器的注册
13 商品浏览功能代码选择直接导入。
14 缓存菜品:
14.1 查询和缓存
@GetMapping("/list")
@ApiOperation("根据分类id查询菜品")
public Result<List<DishVO>> list(Long categoryId) {
log.info("根据分类id查询菜品:{}",categoryId);
String key = "dishes_"+categoryId;
//先在缓存中查询
List<DishVO> list = (List<DishVO>) redisTemplate.opsForValue().get(key);
//如果查到,直接返回
if (list!=null&&list.size()>0){
return Result.success(list);
}else {
//如果缓存中没有,查询数据库,再写入缓存
Dish dish = new Dish();
dish.setCategoryId(categoryId);
dish.setStatus(StatusConstant.ENABLE);//查询起售中的菜品
list = dishService.listWithFlavor(dish);
redisTemplate.opsForValue().set(key,list);
return Result.success(list);
}
}
14.2 清理缓存
为保证数据一致性,在管理端的新增、修改、删除操作都需要清理缓存。
public Result add(@RequestBody DishDTO dishDTO){
log.info("新增菜品");
dishService.add(dishDTO);
//清理缓存数据:新增菜品所在的list的数据
String key="dishes_"+dishDTO.getCategoryId();
redisTemplate.delete(key);
return Result.success();
}
/**
* 清理缓存数据
* @param pattern
*/
private void cleanCache(String pattern){
Set keys = redisTemplate.keys(pattern);
redisTemplate.delete(keys);
}
15 缓存套餐(Spring Cache)
使用@Cacheable注解时注意有两个包,导入spring官方的。记得在启动类加上@EnableCaching
16 购物车:
16.1 添加购物车:
注意体会这里给已存在数据直接数量+1的写法。
public void addShoppingCart(ShoppingCartDTO shoppingCartDTO) {
ShoppingCart shoppingCart = new ShoppingCart();
BeanUtils.copyProperties(shoppingCartDTO, shoppingCart);
//只能查询自己的购物车数据
shoppingCart.setUserId(BaseContext.getCurrentId());
//判断当前商品是否在购物车中 (此处返回结果只有一条,用list是为了方法复用)
List<ShoppingCart> shoppingCartList = shoppingCartMapper.list(shoppingCart);
if (shoppingCartList != null && shoppingCartList.size() == 1) {
//如果已经存在,就更新数量,数量加1
shoppingCart = shoppingCartList.get(0);
shoppingCart.setNumber(shoppingCart.getNumber() + 1);
shoppingCartMapper.updateNumberById(shoppingCart);
} else {
//如果不存在,插入数据
//判断当前添加到购物车的是菜品还是套餐
Long dishId = shoppingCartDTO.getDishId();
if (dishId != null) {
//添加到购物车的是菜品
Dish dish = dishMapper.getById(dishId);
shoppingCart.setName(dish.getName());
shoppingCart.setImage(dish.getImage());
shoppingCart.setAmount(dish.getPrice());
} else {
//添加到购物车的是套餐
Setmeal setmeal = setmealMapper.getById(shoppingCartDTO.getSetmealId());
shoppingCart.setName(setmeal.getName());
shoppingCart.setImage(setmeal.getImage());
shoppingCart.setAmount(setmeal.getPrice());
}
shoppingCart.setNumber(1);
shoppingCart.setCreateTime(LocalDateTime.now());
shoppingCartMapper.insert(shoppingCart);
}
}
16.2 删除购物车中一个商品:
public void subOne(ShoppingCartDTO shoppingCartDTO) {
ShoppingCart shoppingCart = new ShoppingCart();
BeanUtils.copyProperties(shoppingCartDTO,shoppingCart);
//设置查询条件,查询当前登录用户的购物车数据
shoppingCart.setUserId(BaseContext.getCurrentId());
List<ShoppingCart> list = shoppingCartMapper.list(shoppingCart);
if(list != null && list.size() > 0){
shoppingCart = list.get(0);
Integer number = shoppingCart.getNumber();
if(number == 1){
//当前商品在购物车中的份数为1,直接删除当前记录
shoppingCartMapper.deleteById(shoppingCart.getId());
}else {
//当前商品在购物车中的份数不为1,修改份数即可
shoppingCart.setNumber(shoppingCart.getNumber() - 1);
shoppingCartMapper.updateNumberById(shoppingCart);
}
}
}
17 地址簿功能直接导入
18 用户下单:
18.1 业务逻辑:
1.处理业务异常:地址簿为空异常、购物车为空异常
2.向订单表添加1条数据
3.向订单明细表添加n条数据
4.清空用户购物车
涉及多表操作,注意@Transactional
19 订单支付
个人开发者无法使用
开发指引_小程序支付|微信支付商户文档中心
内网穿透工具:cpolar
参考:修改后,下单则直接支付成功。
黑马苍穹外卖项目day8 – 跳过 微信小程序订单支付/微信支付流程 – 哔哩哔哩
20 用户端历史订单模块
20.1 查询历史订单
注意返回参数!是orderVO的list,OrderVo=order+它拥有的OrderDetail。
实现逻辑:
通过pagehelper得到order的list,然后遍历每个order,得到其所有的OrderDetail,并封装进一个OrderVO的list并将其返回。
service:
public PageResult pageQuery4User(Integer pageNum, Integer pageSize, Integer status) {
PageHelper.startPage(pageNum,pageSize);
OrdersPageQueryDTO ordersPageQueryDTO = new OrdersPageQueryDTO();
ordersPageQueryDTO.setUserId(BaseContext.getCurrentId());
ordersPageQueryDTO.setStatus(status);
// 分页条件查询得到order中的list结果
Page<Orders> page = orderMapper.pageQuery(ordersPageQueryDTO);
//最后的结果是OrderVO类型,OrderVO = Order + orderDetail
List<OrderVO> list = new ArrayList();
// 查询出订单明细,并封装入OrderVO进行响应
if (page != null && page.getTotal() > 0) {
for (Orders orders : page.getResult()) {
Long orderId = orders.getId();// 订单id
// 查询订单明细
List<OrderDetail> orderDetails = orderDetailMapper.getByOrderId(orderId);
OrderVO orderVO = new OrderVO();
BeanUtils.copyProperties(orders, orderVO);//OrderVO继承了Orders
orderVO.setOrderDetailList(orderDetails);
list.add(orderVO);
}
}
//注意此处返回的第二个参数不再是 page.getResult(),而是再次集合后的 VO 的 list
return new PageResult(page.getTotal(), list);
}
其他没什么好说的,催单功能在后面实现。
21 商家端订单管理模块
21.1 再来一单
public void repeat(Long id) {
Long userId= BaseContext.getCurrentId();
//通过id获取当前订单详细数据:菜品和套餐
List<OrderDetail> orderDetailList = orderDetailMapper.getByOrderId(id);
//根据当前订单数据复制得到购物车list
List<ShoppingCart> shoppingCartList = new ArrayList<>();
for (OrderDetail orderDetail: orderDetailList){
ShoppingCart shoppingCart=new ShoppingCart();
BeanUtils.copyProperties(orderDetail,shoppingCart,"id");//注意不要复制id
shoppingCart.setUserId(userId);
shoppingCart.setCreateTime(LocalDateTime.now());
shoppingCartList.add(shoppingCart);
}
shoppingCartMapper.insertBatch(shoppingCartList);
}
21.2 管理端订单搜索
public PageResult conditionSearch(OrdersPageQueryDTO ordersPageQueryDTO) {
PageHelper.startPage(ordersPageQueryDTO.getPage(),ordersPageQueryDTO.getPageSize());
//得到order的分页查询结果
Page<Orders> page=orderMapper.pageQuery(ordersPageQueryDTO);
//对于每个order得到其对应的detailList,处理为一个字符串,并封装进最后要返回的VOList
List<OrderVO> voList = new ArrayList<>();
for (Orders orders:page.getResult()){
OrderVO orderVO = new OrderVO();
//将查询到的order信息封装给vo
BeanUtils.copyProperties(orders,orderVO);
StringBuilder sb = new StringBuilder();
//获取detail,得到订单菜品
List<OrderDetail> orderDetails=orderDetailMapper.getByOrderId(orders.getId());
for (OrderDetail od:orderDetails){
sb.append(od.getName()+'*'+od.getNumber()+';');
}
orderVO.setOrderDishes(sb.toString());
voList.add(orderVO);
}
return new PageResult(page.getTotal(),voList);
}
其他功能基本就是体力活,不再赘述。
22 校验收货地址是否超出配送范围
//TODO
百度平台暂未验证
23 订单状态定时处理
23.1 订单:“待支付”->“已取消”
- 通过定时任务每分钟检查一次是否存在支付超时订单(下单后超过15分钟仍未支付则判定为支付超时订单),如果存在则修改订单状态为“已取消”
@Scheduled(cron = " 0 * * * * ?")//每分钟检查一次
@Transactional //有多次修改操作
public void processTimeoutOrder(){
log.info("处理支付超时订单:{}", LocalDateTime.now());
//订单时间<当前时间-15min,即为超时订单
LocalDateTime deltaTime = LocalDateTime.now().minusMinutes(15);
List<Orders> ordersList = orderMapper.getOrdersByStatusAndOrderTimeLT(Orders.PENDING_PAYMENT,deltaTime);
if (ordersList != null && ordersList.size()>0) {
for (Orders o : ordersList){
o.setStatus(Orders.CANCELLED);
o.setCancelReason("支付时间超时,订单自动取消");
o.setCancelTime(LocalDateTime.now());
orderMapper.update(o);
}
}
}
23.2 订单:“派送中”->“已完成”
- 通过定时任务每天凌晨1点检查一次是否存在“派送中”的订单,如果存在则修改订单状态为“已完成”
@Scheduled(cron = "0 0 1 * * ?")//每天凌晨1点执行
@Transactional //有多次修改操作
public void processDeliveryOrder(){
log.info("处理派送中订单:{}",LocalDateTime.now());
LocalDateTime deltaTime = LocalDateTime.now().minusMinutes(60);
List<Orders> ordersList = orderMapper.getOrdersByStatusAndOrderTimeLT(Orders.DELIVERY_IN_PROGRESS,deltaTime);
if (ordersList != null && ordersList.size()>0) {
for (Orders o : ordersList){
o.setStatus(Orders.COMPLETED);
orderMapper.update(o);
}
}
}
24 websocket:
通过websocket实现管理端与后端服务器的长连接:
当用户端进行下单支付,后端提醒管理端有新的订单。
当用户端进行催单操作,后端提醒管理端由用户催单。
24.1 来单提醒
添加在支付成功方法中:
//通过websocket向管理端推送由用户下单的消息
Map map = new HashMap();
map.put("type", 1);//消息类型,1表示来单提醒
map.put("orderId", orders.getId());
map.put("content", "订单号:" + outTradeNo);
webSocketServer.sendToAllClient(JSON.toJSONString(map));
24.2 用户催单
public void reminder(Long id) {
// 查询订单是否存在
Orders orders = orderMapper.getById(id);
if (orders == null) {
throw new OrderBusinessException(MessageConstant.ORDER_NOT_FOUND);
}
//基于WebSocket实现催单
Map map = new HashMap();
map.put("type", 2);//2代表用户催单
map.put("orderId", id);
map.put("content", "订单号:" + orders.getNumber());
webSocketServer.sendToAllClient(JSON.toJSONString(map));
}
但是语音播报并不稳定,有时候需要刷新浏览器才有反应,暂未解决。
25 数据统计:(Apache ECharts)
25.1 营业额统计:
//dateList 即为两个时间的闭区间的日期,turnoverList 通过日期和订单状态查询
List<LocalDate> dateList = new ArrayList<>();
List<Double> turnoverList = new ArrayList<>();
while(!begin.isAfter(end)){
//得到单个日期的开始时间与结束时间
LocalDateTime beginInDay = LocalDateTime.of(begin, LocalTime.MIN);
LocalDateTime endInDay = LocalDateTime.of(begin, LocalTime.MAX);
Map map = new HashMap<>();
map.put("begin",beginInDay);
map.put("end",endInDay);
map.put("status", Orders.COMPLETED);
//得到单个日期内的总营业额
Double turnover = orderMapper.getSumByMap(map);
turnover = turnover==null? 0.0:turnover;
dateList.add(begin);
turnoverList.add(turnover);
//begin日期+1,遍历直到end(包括end)
begin= begin.plusDays(1);
}
return TurnoverReportVO.builder()
.dateList(StringUtils.join(dateList,","))
.turnoverList(StringUtils.join(turnoverList,","))
.build();
}
25.2 用户统计
体力活
25.3 订单统计
public OrderReportVO ordersStatistics(LocalDate begin, LocalDate end) {
List<LocalDate> dateList = new ArrayList<>();
List<Integer> orderCountList = new ArrayList<>();
List<Integer> validOrderCountList = new ArrayList<>();
int orderCount = 0,orderCountInDay = 0;
int validOrderCount =0,validOrderCountInDay = 0;
while(!begin.isAfter(end)){
//得到单个日期的开始时间与结束时间
LocalDateTime beginInDay = LocalDateTime.of(begin, LocalTime.MIN);
LocalDateTime endInDay = LocalDateTime.of(begin, LocalTime.MAX);
Map map = new HashMap<>();
map.put("begin",beginInDay);
map.put("end",endInDay);
//得到单个日期内的订单数
orderCountInDay = orderMapper.countByMap(map);
//得到单个日期内的有效订单数
map.put("status", Orders.COMPLETED);
validOrderCountInDay= orderMapper.countByMap(map);
//统计
dateList.add(begin);
orderCountList.add(orderCountInDay);
validOrderCountList.add(validOrderCountInDay);
orderCount+=orderCountInDay;
validOrderCount+=validOrderCountInDay;
//begin日期+1,遍历直到end(包括end)
begin= begin.plusDays(1);
}
//注意除以0异常和double类型转换
double orderCompletionRate = 0.0;
if (orderCount!=0)
orderCompletionRate = (double) validOrderCount/orderCount;
return OrderReportVO.builder()
.dateList(StringUtils.join(dateList,","))
.orderCountList(StringUtils.join(orderCountList,","))
.validOrderCountList(StringUtils.join(validOrderCountList,","))
.totalOrderCount(orderCount)
.validOrderCount(validOrderCount)
.orderCompletionRate(orderCompletionRate)
.build();
}
25.4 销售额排行
主要是SQL语句:
<select id="getTop10" resultType="com.sky.dto.GoodsSalesDTO">
select od.name name,sum(od.number) number from order_detail od ,orders o
where od.order_id = o.id
and o.status = 5
<if test="begin != null">
and order_time >= #{begin}
</if>
<if test="end != null">
and order_time <= #{end}
</if>
group by name
order by number desc
limit 0, 10
</select>
26 工作台代码导入
27 导出运营数据 (Apache POI)
Apache POI 中文使用指南_apache poi中文文档-CSDN博客
public void exportBusinessData(HttpServletResponse response) {
LocalDate begin = LocalDate.now().minusDays(30);
LocalDate end = LocalDate.now().minusDays(1);
//查询概览运营数据,提供给Excel模板文件
BusinessDataVO businessData = workspaceService.getBusinessData(LocalDateTime.of(begin,LocalTime.MIN), LocalDateTime.of(end, LocalTime.MAX));
InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream("template/运营数据报表模板.xlsx");
try {
//基于提供好的模板文件创建一个新的Excel表格对象
XSSFWorkbook excel = new XSSFWorkbook(inputStream);
//获得Excel文件中的一个Sheet页
XSSFSheet sheet = excel.getSheet("Sheet1");
sheet.getRow(1).getCell(1).setCellValue(begin + "至" + end);
//获得第4行
XSSFRow row = sheet.getRow(3);
//获取单元格
row.getCell(2).setCellValue(businessData.getTurnover());
row.getCell(4).setCellValue(businessData.getOrderCompletionRate());
row.getCell(6).setCellValue(businessData.getNewUsers());
row = sheet.getRow(4);
row.getCell(2).setCellValue(businessData.getValidOrderCount());
row.getCell(4).setCellValue(businessData.getUnitPrice());
for (int i = 0; i < 30; i++) {
LocalDate date = begin.plusDays(i);
//准备明细数据
businessData = workspaceService.getBusinessData(LocalDateTime.of(date,LocalTime.MIN), LocalDateTime.of(date, LocalTime.MAX));
row = sheet.getRow(7 + i);
row.getCell(1).setCellValue(date.toString());
row.getCell(2).setCellValue(businessData.getTurnover());
row.getCell(3).setCellValue(businessData.getValidOrderCount());
row.getCell(4).setCellValue(businessData.getOrderCompletionRate());
row.getCell(5).setCellValue(businessData.getUnitPrice());
row.getCell(6).setCellValue(businessData.getNewUsers());
}
//通过输出流将文件下载到客户端浏览器中
ServletOutputStream out = response.getOutputStream();
excel.write(out);
//关闭资源
out.flush();
out.close();
excel.close();
}catch (IOException e){
e.printStackTrace();
}
}
此处在ReportService里调用了另一个service,不违反规范。
新知识:
HttpServletResponse
:这是 Servlet API 中的一个接口,代表了服务器对客户端请求的响应。在这个方法中,它用于向客户端发送生成的 Excel 文件。Servlet 容器(如 Tomcat)在调用这个方法时,会自动传入一个实现了HttpServletResponse
接口的对象,该对象封装了与客户端通信的相关信息和操作方法。
2025/2/27 ,完结撒花