最近在苍穹外卖项目上做管理端的新增菜品功能,第一反应可能都是“往dish表插一条记录而已”。等真的把分类校验、口味列表、状态字段、操作人字段这些全部串起来之后,会发现这个接口的难点不在insert本身,而在于把一个前端传上来的JSON对象,完整、可靠、可追溯地落到两张表里。这篇东西想聊聊我在新增菜品代码开发时的完整思路,从业务拆解到Mapper写入,再到处处容易翻车的口味动态数据,适合刚接触苍穹外卖这类管理系统的同学,也适合已经在写后台接口、但想把这类“增删改查”写得更稳的人。
苍穹外卖这种项目,业务链路其实是比较典型的:管理端登录 → 按分类树找到要加菜的分类 → 填写菜品基础信息 → 选择图片和口味 → 保存。后端拿到的不只是几个散字段,而是一个带有嵌套列表的菜品DTO。如果直接撸一个insert into dish就收工,后面联调大概率会被口味丢数据、分类不能选、同分类菜名重复这些问题反复打脸。
1. 需求不是填个表:新增菜品背后有几个必须想清楚的业务动作
1.1 管理端新增菜品的实际链路:表单、分类、口味和图片
打开苍穹外卖管理端的菜品页面,左侧是分类树,右侧是菜品列表。点击“新增菜品”后,弹出的表单会包含:菜品名称、所属分类、价格、图片、描述、售卖状态,以及一个可以动态增删的“口味”区域。这个口味区域才是最有意思的地方,它不是固定字段,用户可以在界面上添加“辣度:不辣,微辣,中辣,特辣”,也可以添加“忌口:不要香菜,不要葱”,每一行都是一组“口味名 + 口味值”。
所以这个接口本质上做了几件事:
- 校验当前用户是否有操作权限,这个通常由拦截器或切面统一处理,开发服务层时会从上下文拿当前登录用户id。
- 校验提交的菜品分类是否真实存在且可用,别让用户选了一个已经被停用的分类还能往里塞数据。
- 校验同一个分类下有没有重名菜品,餐饮系统里“同分类下菜名唯一”是比较常见的潜规则,菜品重名会让顾客点单时完全分不清。
- 保存菜品主记录,拿到数据库生成的主键id。
- 把前端传过来的口味列表批量保存到口味子表,每条口味记录都要回填菜品id。
- 记录createTime、updateTime、createUser、updateUser这些公共字段。
这些动作全部要在一个事务里完成。主表插成功、口味表插失败,整个操作必须回滚,否则页面会看到一条“残废”的菜品,后面编辑、展示都会出问题。
1.2 从表单字段反推数据模型:菜品主表和口味子表
先看苍穹外卖项目里菜品相关的表设计。为了方便理解,我按常见版本简化一下:
sql复制CREATE TABLE `dish` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(32) NOT NULL COMMENT '菜品名称',
`category_id` bigint NOT NULL COMMENT '分类id',
`price` decimal(10,2) DEFAULT NULL COMMENT '售价',
`image` varchar(255) DEFAULT NULL COMMENT '图片地址',
`description` varchar(255) DEFAULT NULL COMMENT '描述',
`status` int DEFAULT '1' COMMENT '0 停售 1 起售',
`create_time` datetime DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
`create_user` bigint DEFAULT NULL,
`update_user` bigint DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜品表';
sql复制CREATE TABLE `dish_flavor` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`dish_id` bigint NOT NULL COMMENT '菜品id',
`name` varchar(32) DEFAULT NULL COMMENT '口味名称,如辣度、忌口',
`value` varchar(255) DEFAULT NULL COMMENT '口味值,如不辣,微辣,中辣',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜品口味关系表';
dish表存的是菜品本身,dish_flavor表存的是菜品的口味选项。两张表通过dish_id关联,一个菜品可以对应多条口味记录。不要把口味直接拼成一个字符串塞进dish表里,虽然那样查询时确实省事,但只要后续要做“按口味筛选菜品”,或者在编辑菜品时勾选口味,立刻会发现设计错了。
dish表里的status字段很容易被忽略。新增菜品时前端如果没显式传status,后端要有一个合理的默认值。我一般默认起售,也就是1。这里的潜在问题后面会单独说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 参数传递链路上的分层设计:DTO、Entity和VO各管一段
2.1 为什么不直接把前端参数怼到Entity上
刚开始写代码的同学最容易干一件事:Controller方法直接定义成一个Dish参数,前端传的JSON字段和Dish实体字段一模一样,然后直接把Dish丢给Mapper去insert。这个写法在“演示项目”里能跑通,但有一个很麻烦的隐患:实体类里如果存在数据库字段之外的东西,比如某个DTO里有个不存在的属性,Jackson解析时会直接报错;反过来,如果实体里某个字段不想让前端传,比如createUser、updateUser,前端是可以伪造的。
苍穹外卖这种项目通常不会用Entity去接收请求参数。我的习惯是:
- DTO负责接收前端参数,字段可以比Entity多一点业务含义,也可以加校验注解。
- Entity负责和数据库表映射,干净地对应表字段。
- VO负责把数据返回给前端,可以多组装一些前端展示需要的字段。
用DTO接收请求,再在Service里把DTO的属性拷到Entity上,这个过程非常常见。Spring提供了一个BeanUtils.copyProperties,很多教程也这么教,但它属于浅拷贝,而且在两个类字段名不一致时并不会帮你转换。比如DTO里叫categoryId,Entity里也最好叫categoryId,这样拷起来才省心。
其实我更建议不要过度依赖BeanUtils,尤其是后面菜品的数值类型、状态值可能要做额外处理时,手动set一眼就能看清哪些字段真正被赋值了。代码稍微长一点,但排查问题时会少掉很多“到底哪一行把值改没了”的困惑。
2.2 构造DishDTO和DishFlavor的实体映射
苍穹外卖里新增菜品的请求结构一般是这样的:
json复制{
"name": "鱼香肉丝",
"categoryId": 11,
"price": 28.00,
"image": "https://xxx.png",
"description": "经典川菜,下饭",
"status": 1,
"flavors": [
{
"name": "辣度",
"value": "不辣,微辣,中辣,特辣"
},
{
"name": "忌口",
"value": "不要香菜,不要葱"
}
]
}
后端对应定义:
java复制@Data
public class DishDTO {
private Long id;
private Long categoryId;
@NotBlank(message = "菜品名称不能为空")
private String name;
@NotNull(message = "菜品价格不能为空")
private BigDecimal price;
private String image;
private String description;
private Integer status;
private List<DishFlavor> flavors;
}
DishFlavor就是一张口味表对应的实体,一个菜品可以有多个DishFlavor,所以DTO里用了List。直接复用Entity类来接收嵌套口味数据不算大问题,因为口味子表本身字段很少,前端传的就是id、dishId、name、value这几个,不会造成额外信息泄露。但如果你讲究一点,也可以定义List<DishFlavorDTO>再转换,只是这个项目里口味太简单了,复用Entity通常可以接受。
在这种设计下,写代码最忌讳的是绕晕。我建议先画一张图,不画流程都行,但心里一定要有一条线:
DishController → DishService#saveWithFlavor(DishDTO) → DishMapper.insert(dish) → 回填id → DishFlavorMapper.insertBatch(flavorList)。
后面所有代码都是为这条线服务的。
3. 核心代码落地:从Controller到SQL,一条完整的新增链路
3.1 Controller层只做接收、校验和响应
苍穹外卖这类接口,Controller里的代码量应该非常少。它是整个流程的入口,但不要让它承担任何业务逻辑。我写的控制器大致是:
java复制@RestController
@RequestMapping("/admin/dish")
@Api(tags = "菜品管理接口")
public class DishController {
@Autowired
private DishService dishService;
@PostMapping
@ApiOperation("新增菜品")
public Result<Long> save(@RequestBody @Valid DishDTO dishDTO) {
Long dishId = dishService.saveWithFlavor(dishDTO);
return Result.success(dishId);
}
}
这里的Result是项目里统一封装的后端返回对象,苍穹外卖里经常能看到Result.success(data)这种风格。返回新生成的dishId是我自己的习惯,因为前端保存后很可能需要跳转到编辑页或者做二次回显,如果后端只返回Result.success(),前端要么刷新列表,要么再查一次,比较绕。当然如果项目接口文档里规定新增接口不需要返回数据,返回Result.success()也可以,关键是和前端保持一致。
校验注解上要稍微留心。@NotBlank只对字符串生效,@NotNull适合Long、BigDecimal这类对象,千万别用@NotBlank去校验价格,会直接报类型不匹配。如果有多个字段需要校验,一般还会在DTO类上加分组,但新增菜品这个场景里字段不多,默认分组就够了。
Controller层如果被塞入了业务判断,比如在这里查重名、判断分类状态,代码会越来越臃肿。我的原则很简单:Controller只做HTTP协议层的事情,参数转换、调用Service、包一层Result返回。这样也方便后面在Service上直接加事务和做单元测试。
3.2 Service层:业务规则、操作人填充和事务编排
Service层是新增菜品这个功能的主战场。先定义接口:
java复制public interface DishService {
Long saveWithFlavor(DishDTO dishDTO);
}
实现类里,我习惯把顺序排成:先做前置校验,再转换和补全字段,然后插入主表,最后处理口味。代码是这样的:
java复制@Service
@Slf4j
public class DishServiceImpl implements DishService {
@Autowired
private DishMapper dishMapper;
@Autowired
private DishFlavorMapper dishFlavorMapper;
@Autowired
private CategoryMapper categoryMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public Long saveWithFlavor(DishDTO dishDTO) {
// 1. 分类必须存在且可用
Category category = categoryMapper.selectById(dishDTO.getCategoryId());
if (category == null || category.getStatus() == null || category.getStatus() != 1) {
throw new BusinessException("当前分类不存在或已停用,请重新选择");
}
// 2. 同分类下不能有同名菜品
int count = dishMapper.countByCategoryIdAndName(dishDTO.getCategoryId(), dishDTO.getName());
if (count > 0) {
throw new BusinessException("当前分类下已存在同名菜品");
}
// 3. DTO转Entity,补全默认字段
Dish dish = new Dish();
BeanUtils.copyProperties(dishDTO, dish);
if (dish.getStatus() == null) {
dish.setStatus(1);
}
LocalDateTime now = LocalDateTime.now();
Long currentUserId = BaseContext.getCurrentId();
dish.setCreateTime(now);
dish.setUpdateTime(now);
dish.setCreateUser(currentUserId);
dish.setUpdateUser(currentUserId);
// 4. 插入主表,回填dishId
dishMapper.insert(dish);
Long dishId = dish.getId();
// 5. 处理口味列表并插入子表
List<DishFlavor> flavors = dishDTO.getFlavors();
if (flavors != null && !flavors.isEmpty()) {
List<DishFlavor> validFlavors = new ArrayList<>();
for (DishFlavor flavor : flavors) {
if (flavor == null || StringUtils.hasText(flavor.getName()) == false) {
continue;
}
flavor.setId(null);
flavor.setDishId(dishId);
validFlavors.add(flavor);
}
if (!validFlavors.isEmpty()) {
dishFlavorMapper.insertBatch(validFlavors);
}
}
log.info("新增菜品成功,id={}, name={}", dishId, dish.getName());
return dishId;
}
}
这个实现里有两个点值得展开。
一是BaseContext.getCurrentId()。苍穹外卖项目里,用户登录后会生成JWT令牌,后面每次请求经拦截器解析出用户id并存入BaseContext这种ThreadLocal工具类。Service里新增时拿这个id去塞createUser和updateUser。如果不对公共字段做处理,会出现新增记录后操作人全是null,后面做数据审计时根本不知道这条菜是谁录的。
二是@Transactional(rollbackFor = Exception.class)。Spring默认只在RuntimeException时回滚,如果把rollbackFor省略,遇到受检异常时事务不一定会回滚。我建议凡是涉及多表写入的服务方法,都显式写成rollbackFor = Exception.class,让代码意图更明确,也避免别人误改。虽然本项目里抛出的BusinessException本身是RuntimeException,但养成显式指定的习惯没坏处。
另外还要说明一下,如果你们项目用了MyBatis-Plus,公共字段填充可以交给MetaObjectHandler自动处理,那Service里就不用手动set。但苍穹外卖常见版本是Spring Boot + MyBatis + XML/注解映射,手动填充反而更透明,出问题更容易定位。
3.3 Mapper层:主表insert和口味批量insert的实现
Mapper层不写业务,但“主键回填”很容易被漏掉。
DishMapper接口:
java复制@Mapper
public interface DishMapper {
int insert(Dish dish);
int countByCategoryIdAndName(@Param("categoryId") Long categoryId, @Param("name") String name);
}
对应XML里:
xml复制<insert id="insert" parameterType="com.sky.entity.Dish" 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>
useGeneratedKeys="true"和keyProperty="id"这两句非常关键。加了之后,MyBatis执行完insert,会把数据库自增的主键值回填到传入的Dish对象的id属性上。Service里执行完dishMapper.insert(dish),紧接着调用dish.getId()拿到的才是真实主键。如果漏了这两行配置,dish.getId()永远是null,后面的口味表就会插入一堆dish_id为null的数据,或者直接报数据库字段不能为空的错误。
DishFlavorMapper的批量插入,如果项目用的是注解写法,可以这样:
java复制@Mapper
public interface DishFlavorMapper {
void insertBatch(@Param("flavors") List<DishFlavor> flavors);
}
XML里遍历:
xml复制<insert id="insertBatch">
insert into dish_flavor (dish_id, name, value)
values
<foreach collection="flavors" item="flavor" separator=",">
(#{flavor.dishId}, #{flavor.name}, #{flavor.value})
</foreach>
</insert>
一条SQL插入所有口味记录,性能上比循环调用单条insert好很多。日常开发中,几百条以内的批量插入,用foreach拼一条SQL完全够用。如果真的有几万条数据,那就需要考虑分批插入,否则SQL长度和数据库参数数量会撑不住。菜品口味这种场景一般一单最多几十条,foreach最合适。
数据库层也应该对dish表加一个联合唯一约束,比如(category_id, name)。我建议在表设计阶段就加上:
sql复制ALTER TABLE dish ADD UNIQUE KEY uk_category_name (category_id, name);
这样即使后端并发请求同时插入同名菜,数据库也能拦住。单靠Java里的count判断在并发下并不绝对安全,因为两个请求同时查到的count都是0,后手依然能插进去。
4. 菜品口味列表:动态行数据最容易翻车的地方
4.1 口味为什么要用name和value两个字段
前端页面上,口味列表是一行一行的动态表单。很多人第一次看dish_flavor表会有疑问:这个name和value到底存的什么?
举个例子,一份鱼香肉丝的口味配置可以是:
- name:辣度,value:不辣,微辣,中辣,特辣
- name:忌口,value:不要香菜,不要葱
也就是说,name是口味的维度名称,value是这个维度下所有可选项拼起来的字符串,多个选项用英文逗号分隔。这样一个结构可以适配绝大多数菜品:有的菜没有口味维度,有的菜有三四个维度,每个维度下的选项数量还不一样。
代码和前端接口约定清楚后,后端只需要做“接收list并原样写入子表”这一件事。但有一点务必要注意:请求里的口味列表可能为空,但也可能传了空对象、空字符串name、value为null等等。前端动态表单在极端操作下真的什么都能给你传上来。
4.2 插入前清洗口味数据:空值过滤、主键重置和dishId绑定
我在Service里对口味数据的处理方式如下:
java复制List<DishFlavor> validFlavors = new ArrayList<>();
for (DishFlavor flavor : dishDTO.getFlavors()) {
if (flavor == null) {
continue;
}
String name = flavor.getName();
String value = flavor.getValue();
if (!StringUtils.hasText(name) && !StringUtils.hasText(value)) {
continue;
}
if (!StringUtils.hasText(name) || !StringUtils.hasText(value)) {
throw new BusinessException("口味名称和口味值都必须填写完整");
}
flavor.setId(null);
flavor.setDishId(dishId);
validFlavors.add(flavor);
}
这里有三层意思。
第一,过滤完全为空的行。用户在前端加了一行口味但没填任何内容就点击保存,这种脏数据直接跳过,不要让它进数据库。
第二,如果填了一半,比如只填了name没填value,或者value为空,这属于用户漏填,应该抛出异常让前端提示,不能静默跳过。否则用户以为保存了“辣度”这个口味,实际数据库里没有,后面编辑菜品时列表里少东西,会很困惑。
我遇到过一种情况是value值本身是一个包含中文逗号的字符串,比如“不辣,微辣”,如果按英文逗号分隔,后面解析就全乱了。所以在和前端约定时,口味值里的多个选项必须用英文逗号,展示层再自己处理展示样式。
第三,重置id并绑定dishId。如果前端传上来的口味对象里带了id,通常是编辑场景下才需要用的,新增场景里这个id没有任何意义,应该强制置空,让数据库自动生成;dishId必须在主表插入完成拿到主键后再set进去。
口味这块其实还有一个隐含的“顺序”问题。前端动态添加的口味行是有先后顺序的,如果数据库表里没有sort字段,查询时只能靠主键id排序。批量插入时MyBatis的foreach会按List顺序执行,所以正常情况下先插入的id更小,后插入的id更大,查询时order by id基本能还原顺序。但如果表里的数据不是这个接口写入的,或者中间发生过删除重建,顺序就不能保证了。介意的话就再加一个sort字段,插入时把list的下标写进去,这样最稳。
5. 写完代码不等于完事:自测请求体与三个高频异常排查
5.1 用Postman或Apifox构造一份完整请求
苍穹外卖管理端的请求头通常需要带一个token,这个token由登录接口返回,后续请求通过拦截器校验。自测时先调用登录接口获取token,然后在请求头里加上:
code复制Authorization: eyJhbGciOi...
Content-Type: application/json
body用raw JSON,我用这份作为模板:
json复制{
"name": "测试鱼香肉丝",
"categoryId": 11,
"price": 28.5,
"image": "/upload/xxx.jpg",
"description": "自动化测试菜品,可删除",
"status": 1,
"flavors": [
{
"name": "辣度",
"value": "不辣,微辣,中辣,特辣"
},
{
"name": "忌口",
"value": "不要香菜,不要葱"
}
]
}
发送POST请求到/admin/dish,如果一切正常,返回体类似:
json复制{
"code": 1,
"msg": null,
"data": 1024
}
这里的data就是新增菜品的id。
拿到id后我通常会去数据库里跑两条SQL回查:
sql复制SELECT id, name, category_id, price, status, create_user, create_time FROM dish WHERE id = 1024;
SELECT dish_id, name, value FROM dish_flavor WHERE dish_id = 1024;
第一条看主表数据是否完整,第二条看口味是否都写进去了。自测时只盯着接口返回“成功”是最不够的,因为曾经出现过主表插入成功、口味表一条没插,接口返回还是成功——这就是我上面validFlavors为空时静默跳过导致的。所以回查子表数据这一步不能省。
5.2 我遇到过的三个典型异常
开发新增菜品时,最常遇到的异常大概是三小类。
第一类是参数解析失败:
code复制JSON parse error: Cannot deserialize value of type `java.math.BigDecimal` from String "28"
前端把价格传成了字符串,后端用的是BigDecimal,某些严格配置下会解析失败。解决办法是和前端明确基础类型,数字就传数字,不要传带引号的字符串。后端如果为了兼容也可以把字段类型定义成String再到Service里转换,但那是被迫妥协,不是好设计。
第二类是字段截断:
code复制Data truncation: Data too long for column 'description'
菜品描述超过255个字符时就会这样。接口层面最好在DTO的description字段上加上@Size(max = 255)之类的限制,在参数校验时直接给出友好提示,而不是等数据库层报一个晦涩的异常。
第三类是主键没有回填导致口味表报空值。如果看到类似:
code复制Column 'dish_id' cannot be null
基本上可以断定useGeneratedKeys或keyProperty配置丢了,或者insert之后直接new了一个Dish对象再拿id。排查方向往Mapper的insert配置看,不要盯着Service层死找。
自测时我还建议测试两种边界:一种是flavors完全不传,比如JSON里没有这个字段,后端要能正常新增;另一种是flavors传成[]空数组,后端也要能正常新增。这两种情况都不应该报错,因为没有口味的菜太常见了。
6. 这个功能让我重新重视的几个设计细节
6.1 菜品状态、分类状态和前端展示的联动
新增菜品时,如果填的status是0,代表新增出来后就是停售状态。前端列表默认可能只展示起售状态的菜品,用户新增一个停售的菜,保存成功后在列表里却看不到,第一反应会以为功能坏了。这种情况不算后端bug,但很影响体验。
苍穹外卖的菜品分类本身也有停用状态。如果这个分类已经停用了,前端按理说在下拉框里就不能再选到,但接口不能只依赖前端控制。我在Service里显式加了分类状态校验:分类不存在或status不是1,直接抛出业务异常。这样即使有人绕过前端手工调接口,也插不进去。
还有一点,菜品的status默认值不要放在前端。比如前端表单里状态是radio,默认选“起售”,它传1还好;如果前端没有默认值,它可能什么都不传,这时候如果后端不处理,dish.status就是null,数据库里状态字段又允许为空,这条数据会显得特别脏。处理方式有两种:数据库字段默认1,或者后端Service里判空后赋值1。两个都做最好。
6.2 唯一约束放在数据库,Java校验只是第一道闸
我在Service里做了count查重,又在数据库里加了(category_id, name)联合唯一索引,这是故意为之的双保险。同分类下重名菜品在业务上不允许,但如果不加数据库约束,两个并发请求同时过来时,Java代码里查到的count都是0,两个都通过了校验,最后就会插入两条同名记录。
加了唯一索引后,并发场景下第二条insert会抛出DuplicateKeyException。你需要考虑是否要在Service里把这个异常翻译成“当前分类下已存在同名菜品”这样的业务提示。正常节奏下,前端已经用count查重拦截了一步,数据库唯一索引是用来兜底的。在苍穹外卖这种偏向教学和练习的项目里,可能没那么在意并发,但我还是建议把这个约束加上,因为真实生产环境肯定要考虑这一点。
6.3 新增接口除了成功返回,还要回传主键id
之前一段时间我写新增接口喜欢返回void,后来被前端同学反馈了好几次,才改成返回主键id。新增菜品后前端很可能有这些需求:
- 保存后弹窗关闭,列表刷新,此时列表会重新请求分页接口,倒是不太需要id。
- 保存后希望立刻进入编辑态,比如“保存并继续编辑”,这时需要拿新菜品id去调详情接口。
- 保存后需要在当前页展示二维码或者分享卡片,也需要id。
- 如果图片是异步上传的,保存时要把图片地址一并提交,图片地址和菜品id可能要绑定,虽然一般不做。
返回id本身成本极低,只是把dishMapper.insert后回填的dish.getId()包进Result里而已。所以我在Controller的返回值类型上写了Result<Long>,而不是Result<Void>。接口文档里也应该明确标注data字段的含义,免得前端对接时猜来猜去。
另外考虑得再远一点,如果这个项目以后要做菜品审核、上下架联动、套餐引用等,新增菜品时就会在别的地方再加上逻辑。到时候同样会有一个核心问题,就是要保证新增操作和下游操作在同一事务里。所以把saveWithFlavor设计成一个事务方法,由Service编排,而不是让Controller里一个mapper一个mapper地裸调,非常重要。这一点是苍穹外卖这类项目里最值得反复体会的地方。
最后还有一个非常实际的建议:新增菜品接口的表结构字段如果有调整,记得同步检查一下数据库的默认值、索引和DTO校验注解,别只改Mapper XML里的字段列表。很多时候开发环境一切正常,测试环境因为数据库初始脚本版本旧,schema对不上,就会冒出一堆莫名其妙的报错。把公共字段、状态字段、唯一约束这些都明确下来,这个新增大接口才能真正算“稳”了。
