1. 项目背景与需求分析
"苍穹外卖"是一个典型的Java Web外卖订餐系统开发实战项目,Day04的核心任务是完善菜品管理模块并实现套餐的CRUD功能。作为Java全栈开发的关键环节,这部分内容涉及前后端数据联调、表单处理、数据库操作等核心技能点。
在实际开发中,删除菜品功能需要考虑级联删除的业务逻辑,比如当某个菜品被删除时,它在套餐中的关联关系如何处理。修改页面则涉及表单回显、数据校验等常见需求。而套餐的增删改查(CRUD)作为业务系统的标配功能,是检验开发者对Spring Boot+MyBatis技术栈掌握程度的试金石。
提示:商业级的外卖系统开发中,删除操作通常采用逻辑删除而非物理删除,即通过状态字段标记数据是否有效,这是实际项目与教学Demo的重要区别。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 开发环境准备
2.1 基础环境配置
确保已安装以下环境:
- JDK 17(注意版本兼容性)
- Maven 3.6+
- MySQL 8.0
- IntelliJ IDEA(推荐2023.1+版本)
在pom.xml中需要包含以下关键依赖:
xml复制<dependencies>
<!-- Spring Boot Starter Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
2.2 数据库设计
菜品表(dish)和套餐表(setmeal)的基本结构:
sql复制CREATE TABLE `dish` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(32) COLLATE utf8mb4_bin NOT NULL COMMENT '菜品名称',
`price` decimal(10,2) NOT NULL COMMENT '菜品价格',
`status` int DEFAULT '1' COMMENT '0停售 1启售',
`is_deleted` int DEFAULT '0' COMMENT '0未删除 1已删除',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
CREATE TABLE `setmeal` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(32) COLLATE utf8mb4_bin NOT NULL COMMENT '套餐名称',
`price` decimal(10,2) NOT NULL COMMENT '套餐价格',
`status` int DEFAULT '1' COMMENT '0停售 1启售',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
CREATE TABLE `setmeal_dish` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`setmeal_id` bigint NOT NULL COMMENT '套餐id',
`dish_id` bigint NOT NULL COMMENT '菜品id',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
3. 菜品删除功能实现
3.1 逻辑删除与物理删除的抉择
在商业项目中,我们通常采用逻辑删除方案:
java复制@PostMapping("/delete")
public Result<String> delete(Long id) {
// 检查菜品是否被套餐关联
Integer count = setmealDishService.lambdaQuery()
.eq(SetmealDish::getDishId, id)
.count();
if (count > 0) {
throw new CustomException("当前菜品关联了套餐,不能删除");
}
// 执行逻辑删除
dishService.update()
.set("is_deleted", 1)
.eq("id", id)
.update();
return Result.success("删除成功");
}
注意:这里使用了MyBatis-Plus的lambdaQuery和条件构造器,相比原生MyBatis可以大幅减少样板代码。
3.2 删除前的业务校验
删除菜品前需要检查:
- 菜品是否已停售(启售状态不能删除)
- 是否被套餐关联(关联套餐需先解除关系)
- 当前操作人是否有删除权限
建议封装统一的校验方法:
java复制private void checkBeforeDelete(Long dishId) {
Dish dish = dishService.getById(dishId);
if (dish == null || dish.getIsDeleted() == 1) {
throw new CustomException("菜品不存在或已被删除");
}
if (dish.getStatus() == 1) {
throw new CustomException("启售状态的菜品不能删除");
}
// 其他业务校验...
}
4. 菜品修改页面实现
4.1 表单回显技术方案
修改页面需要实现数据回显,前端通常通过以下方式获取数据:
javascript复制// Vue示例
created() {
if (this.$route.query.id) {
axios.get(`/dish/${this.$route.query.id}`).then(res => {
this.form = res.data.data
})
}
}
后端接口实现:
java复制@GetMapping("/{id}")
public Result<DishVO> getById(@PathVariable Long id) {
Dish dish = dishService.getById(id);
if (dish == null || dish.getIsDeleted() == 1) {
return Result.error("菜品不存在");
}
DishVO dishVO = new DishVO();
BeanUtils.copyProperties(dish, dishVO);
// 查询关联的口味数据等
List<DishFlavor> flavors = dishFlavorService.list(
new LambdaQueryWrapper<DishFlavor>()
.eq(DishFlavor::getDishId, id)
);
dishVO.setFlavors(flavors);
return Result.success(dishVO);
}
4.2 图片上传与回显处理
菜品图片上传的典型实现:
java复制@PostMapping("/upload")
public Result<String> upload(MultipartFile file) {
String originalFilename = file.getOriginalFilename();
String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
// 使用UUID生成新文件名
String fileName = UUID.randomUUID() + suffix;
try {
file.transferTo(new File("/upload/" + fileName));
return Result.success(fileName);
} catch (IOException e) {
log.error("文件上传失败", e);
return Result.error("上传失败");
}
}
前端回显时拼接完整URL:
html复制<img v-if="form.image" :src="'http://localhost:8080/upload/' + form.image">
5. 套餐CRUD完整实现
5.1 套餐分页查询
使用MyBatis-Plus的分页插件:
java复制@GetMapping("/page")
public Result<Page<SetmealVO>> page(int page, int pageSize, String name) {
Page<Setmeal> pageInfo = new Page<>(page, pageSize);
setmealService.lambdaQuery()
.like(StringUtils.isNotEmpty(name), Setmeal::getName, name)
.orderByDesc(Setmeal::getUpdateTime)
.page(pageInfo);
// 转换为VO对象
Page<SetmealVO> voPage = new Page<>();
BeanUtils.copyProperties(pageInfo, voPage, "records");
List<SetmealVO> voList = pageInfo.getRecords().stream().map(item -> {
SetmealVO vo = new SetmealVO();
BeanUtils.copyProperties(item, vo);
// 补充其他字段...
return vo;
}).collect(Collectors.toList());
voPage.setRecords(voList);
return Result.success(voPage);
}
5.2 新增套餐实现
处理套餐与菜品的关联关系:
java复制@PostMapping
public Result<String> save(@RequestBody SetmealDTO setmealDTO) {
// 保存套餐基本信息
Setmeal setmeal = new Setmeal();
BeanUtils.copyProperties(setmealDTO, setmeal);
setmealService.save(setmeal);
// 保存套餐菜品关系
List<SetmealDish> setmealDishes = setmealDTO.getSetmealDishes().stream()
.map(item -> {
item.setSetmealId(setmeal.getId());
return item;
}).collect(Collectors.toList());
setmealDishService.saveBatch(setmealDishes);
return Result.success("新增成功");
}
5.3 套餐修改的完整流程
java复制@PutMapping
public Result<String> update(@RequestBody SetmealDTO setmealDTO) {
// 1. 更新套餐基本信息
Setmeal setmeal = new Setmeal();
BeanUtils.copyProperties(setmealDTO, setmeal);
setmealService.updateById(setmeal);
// 2. 删除原有菜品关系
setmealDishService.remove(
new LambdaQueryWrapper<SetmealDish>()
.eq(SetmealDish::getSetmealId, setmeal.getId())
);
// 3. 添加新的菜品关系
List<SetmealDish> setmealDishes = setmealDTO.getSetmealDishes().stream()
.map(item -> {
item.setSetmealId(setmeal.getId());
return item;
}).collect(Collectors.toList());
setmealDishService.saveBatch(setmealDishes);
return Result.success("修改成功");
}
5.4 套餐删除的注意事项
套餐删除需要级联处理关联的菜品关系:
java复制@PostMapping("/delete")
public Result<String> delete(@RequestParam List<Long> ids) {
// 检查套餐状态
Integer count = setmealService.lambdaQuery()
.in(Setmeal::getId, ids)
.eq(Setmeal::getStatus, 1)
.count();
if (count > 0) {
throw new CustomException("有套餐处于启售状态,不能删除");
}
// 删除套餐
setmealService.removeByIds(ids);
// 删除套餐菜品关系
setmealDishService.remove(
new LambdaQueryWrapper<SetmealDish>()
.in(SetmealDish::getSetmealId, ids)
);
return Result.success("删除成功");
}
6. 实战中的常见问题与解决方案
6.1 批量操作的事务处理
当需要保证多个数据库操作的原子性时,必须添加事务注解:
java复制@Transactional
public void batchDeleteSetmeal(List<Long> ids) {
// 删除套餐
setmealService.removeByIds(ids);
// 删除关联关系
setmealDishService.remove(
new LambdaQueryWrapper<SetmealDish>()
.in(SetmealDish::getSetmealId, ids)
);
}
重要:Spring事务默认只对RuntimeException回滚,对于自定义的业务异常需要明确指定:
@Transactional(rollbackFor = Exception.class)
6.2 高并发下的数据一致性问题
当多个用户同时操作同一数据时,需要考虑并发控制。以修改套餐为例:
java复制@Transactional
public void updateSetmeal(SetmealDTO dto) {
// 使用版本号控制并发
Setmeal setmeal = setmealService.getById(dto.getId());
if (setmeal == null) {
throw new CustomException("套餐不存在");
}
if (!setmeal.getVersion().equals(dto.getVersion())) {
throw new CustomException("数据已被修改,请刷新后重试");
}
// 更新操作...
setmealService.updateById(setmeal);
}
对应的SQL需要包含版本号检查:
sql复制UPDATE setmeal SET
name = #{name},
price = #{price},
version = version + 1
WHERE id = #{id} AND version = #{version}
6.3 复杂查询的性能优化
对于包含多表关联的查询,建议:
- 使用MyBatis-Plus的@Select注解编写定制SQL
- 合理添加数据库索引
- 对于大数据量考虑分库分表
示例:
java复制@Select("SELECT s.*, COUNT(sd.dish_id) AS dish_count " +
"FROM setmeal s LEFT JOIN setmeal_dish sd ON s.id = sd.setmeal_id " +
"WHERE s.is_deleted = 0 GROUP BY s.id")
List<SetmealVO> listWithDishCount();
7. 前端与后端的协作要点
7.1 接口规范设计
建议采用统一的响应格式:
java复制public class Result<T> {
private Integer code; // 1成功,0失败
private String msg;
private T data;
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(1);
result.setData(data);
return result;
}
// 其他静态方法...
}
7.2 参数校验的最佳实践
使用Spring Validation进行参数校验:
java复制@Data
public class SetmealDTO {
@NotNull(message = "套餐ID不能为空")
private Long id;
@NotBlank(message = "套餐名称不能为空")
@Size(max = 32, message = "套餐名称最长32个字符")
private String name;
@NotNull(message = "套餐价格不能为空")
@DecimalMin(value = "0.01", message = "价格必须大于0")
private BigDecimal price;
@Valid
@NotEmpty(message = "套餐菜品不能为空")
private List<SetmealDishDTO> setmealDishes;
}
在Controller中添加@Valid注解:
java复制@PostMapping
public Result<String> save(@RequestBody @Valid SetmealDTO setmealDTO) {
// 业务逻辑
}
7.3 跨域问题的解决方案
在Spring Boot中配置全局CORS:
java复制@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.maxAge(3600);
}
}
对于更复杂的需求,可以结合Nginx进行跨域配置。
8. 项目进阶与扩展思考
8.1 缓存策略的引入
对于高频访问但更新不频繁的数据(如套餐列表),可以引入Redis缓存:
java复制public List<SetmealVO> listCache() {
String cacheKey = "setmeal:list";
// 1. 尝试从缓存获取
String cacheValue = redisTemplate.opsForValue().get(cacheKey);
if (StringUtils.isNotEmpty(cacheValue)) {
return JSON.parseArray(cacheValue, SetmealVO.class);
}
// 2. 查询数据库
List<SetmealVO> list = this.list();
// 3. 写入缓存
redisTemplate.opsForValue().set(
cacheKey,
JSON.toJSONString(list),
30, // 30分钟过期
TimeUnit.MINUTES
);
return list;
}
8.2 异步处理与消息队列
对于耗时操作(如生成报表),可以使用消息队列解耦:
java复制@Autowired
private RabbitTemplate rabbitTemplate;
public void asyncExportSetmeal(List<Long> ids) {
// 发送消息到队列
rabbitTemplate.convertAndSend(
"setmeal.export.queue",
JSON.toJSONString(ids)
);
}
8.3 微服务架构演进
当系统规模扩大时,可以考虑拆分为微服务:
- 用户服务
- 菜品服务
- 订单服务
- 支付服务
使用Spring Cloud Alibaba实现服务治理:
- Nacos:服务注册与发现
- Sentinel:流量控制
- Seata:分布式事务
9. 测试与部署实践
9.1 单元测试编写
使用JUnit5和Mockito编写测试:
java复制@ExtendWith(MockitoExtension.class)
class SetmealServiceTest {
@Mock
private SetmealMapper setmealMapper;
@InjectMocks
private SetmealServiceImpl setmealService;
@Test
void testDeleteWithInvalidStatus() {
Setmeal setmeal = new Setmeal();
setmeal.setStatus(1); // 启售状态
when(setmealMapper.selectById(anyLong())).thenReturn(setmeal);
assertThrows(BusinessException.class, () -> {
setmealService.deleteById(1L);
});
}
}
9.2 接口测试工具
推荐使用Postman或Apifox进行接口测试,可以:
- 创建测试集合
- 设置环境变量
- 编写测试脚本
- 自动化测试
9.3 持续集成部署
GitLab CI示例配置:
yaml复制stages:
- build
- test
- deploy
build:
stage: build
script:
- mvn clean package -DskipTests
test:
stage: test
script:
- mvn test
deploy:
stage: deploy
script:
- scp target/*.jar user@server:/app/
- ssh user@server "systemctl restart app-service"
10. 项目经验总结
在实际开发"苍穹外卖"项目的菜品和套餐管理模块时,有几个关键点值得特别注意:
-
事务边界:对于包含多个数据库操作的方法,一定要明确事务边界。我曾经遇到过因为忘记加@Transactional注解,导致套餐删除后关联菜品关系没有删除的数据不一致问题。
-
批量操作优化:当处理大批量数据时(如导出所有套餐),直接查询全部数据可能导致内存溢出。正确的做法是使用分页查询或MyBatis的游标查询。
-
缓存一致性:引入缓存后,必须处理好缓存与数据库的一致性。我们的解决方案是:在数据修改时,先更新数据库再删除缓存(Cache-Aside Pattern)。
-
接口幂等性:对于可能重复提交的请求(如网络超时后的重试),需要设计幂等接口。常用的方案有:
- 唯一业务编号
- 乐观锁机制
- Token机制
-
日志记录:关键业务操作一定要记录详细日志,包括操作人、操作时间、修改前后的数据等。这既是审计需要,也为排查问题提供了依据。
