苍穹外卖 day03:从公共字段自动填充到菜品管理闭环
前言
这篇文章主要讲先用 AOP 解决公共字段重复赋值,再把文件上传、菜品新增、分页、删除、修改这些接口串成一个完整闭环。
一、先把重复代码收口:公共字段自动填充
在这个项目里,很多表都会带上下面四个字段:
| 字段名 | 含义 | 什么时候赋值 |
|---|---|---|
create_time |
创建时间 | insert |
create_user |
创建人 id | insert |
update_time |
修改时间 | insert、update |
update_user |
修改人 id | insert、update |
如果在每个 Service 方法里都手动写这些赋值逻辑,代码会出现两个问题:
- 同样的赋值逻辑会反复出现。
- 后面一旦字段名、赋值规则或者取当前用户的方式变了,修改成本会很高。
1.1 这个方案到底怎么落地
一共分三步:
- 定义一个
AutoFill注解,标记"这个 Mapper 方法需要自动填充公共字段"。 - 定义
OperationType枚举,区分当前是insert还是update。 - 在切面
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() {
}
这段表达式限制了两件事:
- 只能拦截
com.sky.mapper包下的方法。 - 方法上必须显式标了
@AutoFill。
这就意味着它不是无差别地拦所有数据库操作,而是只增强我们主动标记过的方法,边界比较清楚。
1.2 注解是如何执行的,底层逻辑怎么跑的
以 DishMapper.insert(Dish dish) 为例:
java
@AutoFill(value = OperationType.INSERT)
void insert(Dish dish);
当业务层调用这个方法时,不是直接马上进 MyBatis 执行 SQL,而是先经过 Spring AOP 代理。因为这个方法同时满足"在 mapper 包下"和"带 @AutoFill 注解"这两个条件,所以会先进入 @Before("autoFillPointCut()") 对应的方法。
切面里的执行顺序大致是这样的:
- 从 MethodSignature 中拿到当前方法。
- 读取方法上的 @AutoFill 注解。
- 取出注解中的 OperationType,判断是新增还是修改。
- 从方法参数里拿到实体对象,也就是这里的 Dish。
- 通过 BaseContext.getCurrentId() 取到当前登录用户 id。
- 通过反射调用实体上的 setCreateTime 、setCreateUser 、setUpdateTime 、setUpdateUser。
- 切面执行完之后,MyBatis 再继续执行真正的 insert 或 update SQL。
这就是"注解本身不执行业务,真正跑逻辑的是 AOP 切面"的完整链路。
1.3 为什么这里要用反射
切面拦截到的并不总是某一个固定实体,可能是 Category、Dish、Employee,也可能是后面别的业务对象。它们的共同点不是继承了某个统一父类,而是都具备这些 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);
}
这段代码做了三件事:
- 取原始文件后缀,保证上传后文件类型不变。
- 用 UUID 生成新文件名,避免同名覆盖。
- 调用 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 客户端怎么创建,只依赖一个工具类即可。配置值则通过 AliOssProperties 从 sky.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-id、access-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>
- @AutoFill(OperationType.INSERT) 会让新增前自动补公共字段。
- 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 为什么要加事务
新增菜品时,会同时操作两张表:
dishdish_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 上的 page、pageSize、name、status 是怎么进来的?
答案是 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);
这段代码说明删除前至少要做两道校验:
- 当前菜品是否处于起售状态。
- 当前菜品是否被套餐关联。
只有都通过了,才真正删除菜品表和口味表的数据。
六、修改菜品
修改功能不是单一接口,而是两段动作:
- 根据 id 查询菜品和口味,回显到前端页面。
- 用户改完之后,再提交修改请求。
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 上传、菜品新增、分页、删除、修改。
但如果从以后自己写项目的角度看,更值得记住的是这条顺序:
- 先把重复出现的通用逻辑抽到统一位置,例如公共字段自动填充。
- 再把一个业务闭环拆成入口、事务、主表、子表、回显、校验几个部分。
- 最后再用 DTO、VO、动态 SQL、分页插件这些工具把每一段衔接顺。
这样写出来的代码不会只是在"把功能做出来",而是能看出结构层次。以后再做套餐管理、订单管理,很多思路其实都可以沿着这套方式继续往下复用。