【 苍穹外卖day03 | 菜品管理 】

苍穹外卖 day03:从公共字段自动填充到菜品管理闭环

前言

这篇文章主要讲先用 AOP 解决公共字段重复赋值,再把文件上传、菜品新增、分页、删除、修改这些接口串成一个完整闭环。

一、先把重复代码收口:公共字段自动填充

在这个项目里,很多表都会带上下面四个字段:

字段名 含义 什么时候赋值
create_time 创建时间 insert
create_user 创建人 id insert
update_time 修改时间 insert、update
update_user 修改人 id insert、update

如果在每个 Service 方法里都手动写这些赋值逻辑,代码会出现两个问题:

  1. 同样的赋值逻辑会反复出现
  2. 后面一旦字段名、赋值规则或者取当前用户的方式变了,修改成本会很高

1.1 这个方案到底怎么落地

一共分三步:

  1. 定义一个 AutoFill 注解,标记"这个 Mapper 方法需要自动填充公共字段"。
  2. 定义 OperationType 枚举,区分当前是 insert 还是 update
  3. 在切面 AutoFillAspect 里拦截这些方法,在真正执行 SQL 之前,通过反射给实体对象补字段。

AutoFill 注解本身非常轻,它只是一个标记:

java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFill {
    OperationType value();
}

这里最关键的是 @Retention(RetentionPolicy.RUNTIME)。因为切面是在运行时拦截方法的,如果注解不保留到运行时,AOP 就拿不到这个标记,也就没法知道当前方法到底需不需要自动填充。

再看切点定义:

java 复制代码
@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
public void autoFillPointCut() {
}

这段表达式限制了两件事:

  1. 只能拦截 com.sky.mapper 包下的方法。
  2. 方法上必须显式标了 @AutoFill

这就意味着它不是无差别地拦所有数据库操作,而是只增强我们主动标记过的方法,边界比较清楚。

1.2 注解是如何执行的,底层逻辑怎么跑的

DishMapper.insert(Dish dish) 为例:

java 复制代码
@AutoFill(value = OperationType.INSERT)
void insert(Dish dish);

当业务层调用这个方法时,不是直接马上进 MyBatis 执行 SQL,而是先经过 Spring AOP 代理。因为这个方法同时满足"在 mapper 包下"和"带 @AutoFill 注解"这两个条件,所以会先进入 @Before("autoFillPointCut()") 对应的方法。

切面里的执行顺序大致是这样的:

  1. MethodSignature 中拿到当前方法。
  2. 读取方法上的 @AutoFill 注解。
  3. 取出注解中的 OperationType,判断是新增还是修改。
  4. 从方法参数里拿到实体对象,也就是这里的 Dish
  5. 通过 BaseContext.getCurrentId() 取到当前登录用户 id。
  6. 通过反射调用实体上的 setCreateTimesetCreateUsersetUpdateTimesetUpdateUser
  7. 切面执行完之后,MyBatis 再继续执行真正的 insertupdate SQL。

这就是"注解本身不执行业务,真正跑逻辑的是 AOP 切面"的完整链路。

1.3 为什么这里要用反射

切面拦截到的并不总是某一个固定实体,可能是 CategoryDishEmployee,也可能是后面别的业务对象。它们的共同点不是继承了某个统一父类,而是都具备这些 setter 方法。

所以项目里用了反射按方法名调用:

java 复制代码
Method setCreateTime = entity.getClass().getDeclaredMethod("setCreateTime", LocalDateTime.class);
setCreateTime.invoke(entity, now);

这样切面就可以不关心实体具体类型,只要约定好了字段名和 setter 名称,就能统一处理。

这一层的收益很直接:以后在 Service 层写新增和修改逻辑时,可以把注意力放在业务字段本身,而不是反复补公共字段。

二、OSS文件上传图片

页面上新增菜品时,用户通常会先上传图片,再录入分类、价格、描述、口味这些信息,所以文件上传其实是菜品管理闭环的前半段。

项目里上传接口写在 CommmonController

java 复制代码
@PostMapping("/upload")
public Result<String> upload(MultipartFile file) {
    String originalFilename = file.getOriginalFilename();
    String extension = originalFilename.substring(originalFilename.lastIndexOf("."));
    String objectName = UUID.randomUUID().toString() + extension;
    String filePath = aliOssUtil.upload(file.getBytes(), objectName);
    return Result.success(filePath);
}

这段代码做了三件事:

  1. 取原始文件后缀,保证上传后文件类型不变。
  2. UUID 生成新文件名,避免同名覆盖。
  3. 调用 AliOssUtil上传到阿里云 OSS,并把图片访问路径返回给前端。

对应的 Bean 装配在 OssConfiguration

java 复制代码
@Bean
@ConditionalOnMissingBean
public AliOssUtil aliOssUtil(AliOssProperties aliOssProperties) {
    return new AliOssUtil(
            aliOssProperties.getEndpoint(),
            aliOssProperties.getAccessKeyId(),
            aliOssProperties.getAccessKeySecret(),
            aliOssProperties.getBucketName());
}

这里的好处是,Controller 不需要自己关心 OSS 客户端怎么创建,只依赖一个工具类即可。配置值则通过 AliOssPropertiessky.aliyun.oss 下面读取。

application.yml 里这部分结构长这样:

yaml 复制代码
sky:
  aliyun:
    oss:
      endpoint: ${sky.aliyun.oss.endpoint}
      bucket-name: ${sky.aliyun.oss.bucket-name}
      region: ${sky.aliyun.oss.region}
      access-key-id: ${sky.aliyun.oss.access-key-id}
      access-key-secret: ${sky.aliyun.oss.access-key-secret}

写博客时这里一定要注意脱敏。像 access-key-idaccess-key-secret 这种字段,示例里最多保留占位结构,不能把真实值放出来。

三、新增菜品

Controller:

java 复制代码
@PostMapping
public Result save(@RequestBody DishDTO dishDTO) {
    dishService.saveWithFlavor(dishDTO);
    return Result.success();
}

这里用的是 @RequestBody,因为前端提交的是一个 JSON 对象,不只是单个字段。根据本次用 ctx7 查到的 Spring Framework 文档,@RequestBody 用来把 HTTP 请求体反序列化成 Java 对象,这正好对应 DishDTO 这种场景。

Service 层:

java 复制代码
@Transactional
public void saveWithFlavor(DishDTO dishDTO) {
    Dish dish = new Dish();
    BeanUtils.copyProperties(dishDTO, dish);

    dishMapper.insert(dish);
    Long dishId = dish.getId();

    List<DishFlavor> flavors = dishDTO.getFlavors();
    if (flavors != null && flavors.size() > 0) {
        flavors.forEach(dishFlavor -> dishFlavor.setDishId(dishId));
        dishFlavorMapper.insertBatch(flavors);
    }
}

新增菜品这里的 Mapper 其实是两部分,

一部分负责写入菜品表 dish,另一部分负责写入口味表 dish_flavor。

先是 DishMapper 接口:

java 复制代码
@Mapper
public interface DishMapper {

    /**
     * 插入菜品
     * @param dish
     */
    @AutoFill(value = OperationType.INSERT)
    void insert(Dish dish);
}

对应的 DishMapper.xml:

xml 复制代码
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
    insert into dish(name, category_id, price, image, description,
    status, create_time, update_time, create_user, update_user)
    values (#{name}, #{categoryId}, #{price}, #{image}, #{description},
    #{status}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser})
</insert>
  1. @AutoFill(OperationType.INSERT) 会让新增前自动补公共字段。
  2. useGeneratedKeys="true" keyProperty="id" 会把新增后的主键回填到 dish 对象里,后面插入口味时就能拿到 dishId。

然后是 DishFlavorMapper,负责批量插入口味:

java 复制代码
@Mapper
public interface DishFlavorMapper {

    /**
     * 批量插入口味数据
     * @param flavors
     */
    void insertBatch(List<DishFlavor> flavors);
}

对应的 DishFlavorMapper.xml

xml 复制代码
<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>

3.1 为什么要加事务

新增菜品时,会同时操作两张表:

  1. dish
  2. dish_flavor

如果第一步插入菜品成功了,第二步插入口味失败,那数据库里就会出现"菜品有了,但口味没了"的半成品数据。所以这里必须用 @Transactional 保证这两个动作要么一起成功,要么一起回滚。

另外项目启动类 SkyApplication 上已经加了 @EnableTransactionManagement,说明这里启用的是注解式事务管理。

四、分页查询

Controller 层写法是:

java 复制代码
@GetMapping("/page")
public Result<PageResult> page(DishPageQueryDTO dishPageQueryDTO) {
    PageResult pageResult = dishService.pageQuery(dishPageQueryDTO);
    return Result.success(pageResult);
}

这里没有写 @RequestParam,很多人第一次看到会疑惑:那 URL 上的 pagepageSizenamestatus 是怎么进来的?

答案是 Spring MVC 会把请求参数按字段名绑定到这个复杂对象上。比如:

text 复制代码
GET /admin/dish/page?page=1&pageSize=10&name=鱼&status=1

这些 query 参数会自动映射到 DishPageQueryDTO 对应属性里,所以这里不需要一个个手动写参数。

Service 层先调用 PageHelper

java 复制代码
PageHelper.startPage(dishPageQueryDTO.getPage(), dishPageQueryDTO.getPageSize());
Page<DishVO> page = dishMapper.pageQuery(dishPageQueryDTO);
return new PageResult(page.getTotal(), page.getResult());

这一段的理解重点不在"会写",而在"顺序不能乱"。PageHelper.startPage(...) 必须写在查询语句前面,它会对紧随其后的那次查询做分页拦截。

再看 DishMapper.xml 里的动态 SQL:

xml 复制代码
<select id="pageQuery" resultType="com.sky.vo.DishVO">
    select d.*, c.name as cate
    from dish d
    left outer join 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>

这里用动态 SQL 的好处是,前端传了什么条件就拼什么条件,没有传就不加,避免手写一堆字符串判断。

另外这个查询返回的是 DishVO,不是 Dish 实体。因为分页列表除了菜品本身的数据,还多查了分类名 cate,这类"给前端展示的结果集"更适合放在 VO 里。

VO 是传给前端的,DTO 是前端传回来的

五、删除菜品

删除逻辑最容易被写成"拿到 id 直接删",但我们要先判断他是否在套餐或未上架,若在则删除不了

Controller 层:

java 复制代码
@DeleteMapping
public Result delete(@RequestParam List<Long> ids) {
    dishService.deleteBatch(ids);
    return Result.success();
}

这里用的是 @RequestParam List<Long> ids。本次查到的 Spring 文档明确提到,@RequestParam 不只可以绑定单个参数,也可以绑定集合或数组。所以像下面这种请求是能直接接住的:

text 复制代码
DELETE /admin/dish?ids=1,2,3

Service 层逻辑分成三段判断:

java 复制代码
for (Long id : ids) {
    Dish dish = dishMapper.getByid(id);
    if (dish.getStatus() == StatusConstant.ENABLE) {
        throw new DeletionNotAllowedException(MessageConstant.DISH_ON_SALE);
    }
}

List<Long> setmealIds = setmealDishMapper.getSetmealids(ids);
if (setmealIds != null && setmealIds.size() > 0) {
    throw new DeletionNotAllowedException(MessageConstant.DISH_BE_RELATED_BY_SETMEAL);
}

dishMapper.deleteByIds(ids);
dishFlavorMapper.deleteByDishIds(ids);

这段代码说明删除前至少要做两道校验:

  1. 当前菜品是否处于起售状态。
  2. 当前菜品是否被套餐关联。

只有都通过了,才真正删除菜品表和口味表的数据。

六、修改菜品

修改功能不是单一接口,而是两段动作:

  1. 根据 id 查询菜品和口味,回显到前端页面。
  2. 用户改完之后,再提交修改请求。

6.1 根据 id 查询为什么返回 VO

Controller 中回显接口写法:

java 复制代码
@GetMapping("/{id}")
public Result<DishVO> getById(@PathVariable Long id) {
    DishVO dishVO = dishService.getbyidwithflavor(id);
    return Result.success(dishVO);
}

这里的 id 用的是 @PathVariable,也就是从路径模板 /dish/{id} 里取值。根据本次查到的 Spring 文档,这种注解就是专门拿 URI 路径变量的,所以像 GET /admin/dish/10 这种写法会直接把 10 绑定到方法参数 id

返回值之所以是DishVO ,不是 Dish,原因也很直接:前端修改页面不只要菜品基本信息,还要一并拿到口味集合。Service 里就是这么做的:

java 复制代码
Dish dish = dishMapper.getByid(id);
List<DishFlavor> flavors = dishFlavorMapper.getbydishid(id);

DishVO dishVO = new DishVO();
BeanUtils.copyProperties(dish, dishVO);
dishVO.setFlavors(flavors);

查询结果明显是"菜品基础信息 + 口味列表"的组合结构,这时候 VO 比实体类更合适。

6.2 修改为什么先删旧口味,再插新口味

修改接口入口还是 @RequestBody DishDTO

java 复制代码
@PutMapping
public Result update(@RequestBody DishDTO dishDTO) {
    dishService.updatewithFlavor(dishDTO);
    return Result.success();
}

Service 层处理逻辑:

java 复制代码
Dish dish = new Dish();
BeanUtils.copyProperties(dishDTO, dish);
dishMapper.update(dish);

dishFlavorMapper.deleteByDishId(dishDTO.getId());

List<DishFlavor> flavors = dishDTO.getFlavors();
if (flavors != null && flavors.size() > 0) {
    flavors.forEach(dishFlavor -> dishFlavor.setDishId(dishDTO.getId()));
    dishFlavorMapper.insertBatch(flavors);
}

为什么不一条条比对旧口味和新口味再更新?因为当前业务场景里,前端是把这道菜最新的整套口味数据一起提交回来。与其写复杂的差异比对,不如直接把旧口味删掉,再按新数据整批插入。

这个方案实现成本低,而且对"一个菜品对应多个口味"的场景足够稳定。

七、这节代码里顺手也能吃透三个常见注解

场景 HTTP 方法 常见写法 适合的注解
根据 id 查单个资源 GET /dish/10 @PathVariable
新增、修改这类提交完整对象 POST / PUT JSON 请求体 @RequestBody
删除、筛选这类 query 参数 GET / DELETE ?ids=1,2,3 @RequestParam

结合这个项目里的几个接口,可以直接对应起来:

text 复制代码
GET /admin/dish/page?page=1&pageSize=10
GET /admin/dish/10
POST /admin/dish
DELETE /admin/dish?ids=1,2,3
PUT /admin/dish

其中分页查询这个接口虽然没有显式写 @RequestParam,但本质上还是在接收 URL 参数,只不过 Spring MVC 自动把这些参数绑定到了 DishPageQueryDTO 里。

八、总结

如果只从功能表面看,day03 学到的是公共字段自动填充、OSS 上传、菜品新增、分页、删除、修改。

但如果从以后自己写项目的角度看,更值得记住的是这条顺序:

  1. 先把重复出现的通用逻辑抽到统一位置,例如公共字段自动填充。
  2. 再把一个业务闭环拆成入口、事务、主表、子表、回显、校验几个部分。
  3. 最后再用 DTO、VO、动态 SQL、分页插件这些工具把每一段衔接顺。

这样写出来的代码不会只是在"把功能做出来",而是能看出结构层次。以后再做套餐管理、订单管理,很多思路其实都可以沿着这套方式继续往下复用。

相关推荐
派大鑫wink1 小时前
Java 高级编程技巧(生产级实用,覆盖性能、并发、设计、JVM、语法、避坑)
开发语言·python
JSON_L1 小时前
PHP实现大文件分片上传
开发语言·php
雾削木1 小时前
B语言经典教程现代化重构
java·前端·stm32·单片机·嵌入式硬件
凤山老林1 小时前
JDK 11 升级至 JDK 17
java·开发语言·jdk17·jdk升级·jdk11
指令集梦境1 小时前
图解:单调栈算法模板(Java语言)
java·开发语言·算法
IronMurphy1 小时前
多线程问!
java·jvm·spring
小灰灰搞电子1 小时前
C++ boost::circular_buffer 详解:原理、用法与实战
开发语言·c++·boost
vx-Biye_Design1 小时前
springboot安阳地区研学旅游服务小程序-计算机毕业设计源码12785
java·vue.js·windows·spring boot·tomcat·maven·mybatis
whaledown1 小时前
Kafka 与 Java 消息队列入门:用订单场景理解核心机制
java·kafka·消息队列·springboot