news 2026/9/10 3:17:26

Spring Boot苍穹外卖新增菜品实战:从表单到数据库的完整链路

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot苍穹外卖新增菜品实战:从表单到数据库的完整链路

做了苍穹外卖项目的新增菜品功能,前后折腾了几天,踩了文件上传、多表事务、口味数据关联这几个坑,最终把整个流程跑通。这篇文章就把新增菜品从页面表单到数据库落地的完整链路拆开讲清楚,重点放在Controller、Service、Mapper三层代码怎么协作,以及那些文档里不会写的实操细节。

你要是正在学苍穹外卖,或者是准备把这类管理系统功能做得更扎实,这篇内容应该能帮你少走不少弯路。我会把代码逐段拿出来说,把每个关键选择背后的原因讲透,顺带附上我实际测试过程中的踩坑记录。

1. 内容整体设计与思路拆解

1.1 苍穹外卖项目概览与新增菜品在业务中的位置

苍穹外卖是一个典型的外卖管理系统,现在很多人在学习阶段都会拿它做练手项目。整个系统分为用户端和管理端,用户端负责浏览菜品、下单支付,管理端则承担菜品管理、分类管理、订单管理等后台职能。新增菜品这个功能,正是管理端菜品管理模块里的核心操作,也是最容易出问题的地方。

这个功能表面上看起来就是"往数据库里插一条记录",但实际落地远不止这么简单。一个菜品要有名称、分类、价格、图片、描述,还可能要设置口味(比如辣度、甜度),这些信息不是存到一张表就完事的。菜品基本信息要落到dish表,口味数据要落到dish_flavor表,两张表通过dish_id关联。一次新增操作,最少是两个表的数据落地,如果涉及口味数据,还可能一次插入多条。

另外,管理端上传菜品图片也是这个功能的一部分。图片不能随手丢到本地磁盘,而是需要上传到对象存储服务,再把返回的URL存进数据库。这一套流程下来,涉及文件上传服务、业务逻辑层、数据访问层,任何一个环节处理不当,新增菜品就会失败或者数据不完整。

1.2 需求清单梳理与方案设计

我在动手写代码之前,先把需求拆成了这样一份清单:

  • 管理员进入菜品新增页面,填写菜品名称、分类、价格、图片、描述,勾选口味。
  • 点击保存,系统校验所有必填字段,校验通过后将菜品基本信息保存到dish表。
  • 生成菜品主键ID,再把口味列表循环写入dish_flavor表,每条口味记录都带上dish_id作为外键。
  • 菜品保存成功后,需要清理Redis中菜品分类相关的缓存,保证用户端能及时看到新菜品。
  • 整个流程要么全部成功,要么全部回滚,不能在dish表有数据但dish_flavor表没数据,或者反过来。

基于这份需求,我选了Spring Boot + MyBatis的标准分层架构,Controller只做参数接收,Service做业务编排和事务控制,Mapper做数据库操作。这个方案的优点是职责清晰、测试方便,后续如果要在Service层加缓存逻辑或者做权限校验,都不需要动数据库访问层。

2. 核心代码实现与多层结构拆解

2.1 项目分层结构与DTO/VO设计

在动手之前,先把项目结构理清楚。我采用的是标准的三层架构,代码按照controller、service、mapper这三个包来组织:

com.sky.controller.admin com.sky.service com.sky.mapper com.sky.dto com.sky.entity com.sky.vo

新增菜品这个功能,前后端交互的数据落在DTO和Entity上。DTO是前端传给后端的数据载体,在这个场景里就是DishDTO;Entity是数据库实体的映射,对应dish表、dish_flavor表。很多初学者容易把DTO和Entity混着用,Controller直接接收Entity去操作数据库,这样做在字段少的时候还能凑合,菜品这个场景字段多、还要嵌套口味列表,混用就会很难受。

我定义的DishDTO如下:

package com.sky.dto; import lombok.Data; import java.io.Serializable; import java.math.BigDecimal; import java.util.List; @Data public class DishDTO implements Serializable { private Long id; // 菜品名称 private String name; // 菜品分类id private Long categoryId; // 菜品价格 private BigDecimal price; // 图片URL private String image; // 描述信息 private String description; // 状态 0停售 1起售 private Integer status; // 口味列表 private List<DishFlavorDTO> flavors; }

DishFlavorDTO长这样:

package com.sky.dto; import lombok.Data; import java.io.Serializable; @Data public class DishFlavorDTO implements Serializable { private Long id; // 菜品id private Long dishId; // 口味名称 private String name; // 口味值 private String value; }

Entity这边,dish表对应Dish实体,dish_flavor表对应DishFlavor实体,字段和数据库列一一对应。DTO和Entity分离的好处是灵活——前端传什么字段、不传什么字段,都由DTO来控制;数据库表结构变更时,也不一定需要修改接口协议。

2.2 Controller层:参数接收与请求校验

Controller层要做的事情不多:接收前端JSON数据,转成DishDTO,交给Service处理,最后返回统一结果对象。在苍穹外卖里,统一返回对象是Result,成功时返回Result.success(),失败时返回Result.error("错误信息")。

新增菜品的Controller代码如下:

package com.sky.controller.admin; import com.sky.dto.DishDTO; import com.sky.result.Result; import com.sky.service.DishService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.RequestBody; @RestController @RequestMapping("/admin/dish") @Api(tags = "菜品管理相关接口") @Slf4j public class DishController { @Autowired private DishService dishService; @PostMapping @ApiOperation("新增菜品") public Result save(@RequestBody DishDTO dishDTO) { log.info("新增菜品:{}", dishDTO); dishService.saveWithFlavor(dishDTO); return Result.success(); } }

这里有几个细节要提醒:

@RestController注解意味着这个类下的方法返回的都是JSON数据。@RequestMapping("/admin/dish")统一了菜品的访问路径前缀,新增和查询共用一个路径,通过请求方法(GET/POST)来区分操作。@PostMapping接收POST请求,对应前端的新增提交。

@RequestBody这个注解很关键,它告诉Spring把前端请求体中的JSON字符串自动反序列化成DishDTO对象。如果忘了加这个注解,Spring会尝试从表单参数里去匹配字段,结果就是dishDTO的所有字段都是null,数据库里就会插入一条全是空值的记录。

关于参数校验,这里只做了最基本的非空校验,更好的做法是引入Spring的@Validated注解,在DTO字段上添加@NotBlank、@NotNull等约束。不过这需要在pom.xml里引入spring-boot-starter-validation依赖,然后在Controller参数前加@Validated。我在第二个版本里补上了这个校验,后面会详细说。

2.3 Service层:业务编排与事务控制

Service层是整个新增流程的核心,也是最能体现一个开发者基本功的地方。

我们的业务规则是这样的:

  • 菜品基本信息保存到dish表。
  • 保存成功后拿到dish表生成的主键ID。
  • 遍历口味列表,把每个口味对象设置上dishId,然后批量插入dish_flavor表。
  • 清理菜品分类相关的缓存。

对应代码如下:

package com.sky.service.impl; import com.sky.dto.DishDTO; import com.sky.dto.DishFlavorDTO; import com.sky.entity.Dish; import com.sky.entity.DishFlavor; import com.sky.mapper.DishFlavorMapper; import com.sky.mapper.DishMapper; import com.sky.service.DishService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.List; @Service @Slf4j public class DishServiceImpl implements DishService { @Autowired private DishMapper dishMapper; @Autowired private DishFlavorMapper dishFlavorMapper; @Autowired private RedisTemplate redisTemplate; @Override @Transactional public void saveWithFlavor(DishDTO dishDTO) { // 1. 菜品基本信息入库 Dish dish = new Dish(); BeanUtils.copyProperties(dishDTO, dish); dishMapper.insert(dish); // 2. 获取菜品主键 Long dishId = dish.getId(); // 3. 保存口味数据 List<DishFlavorDTO> flavors = dishDTO.getFlavors(); if (flavors != null && !flavors.isEmpty()) { List<DishFlavor> dishFlavorList = new ArrayList<>(); for (DishFlavorDTO dishFlavorDTO : flavors) { DishFlavor dishFlavor = new DishFlavor(); BeanUtils.copyProperties(dishFlavorDTO, dishFlavor); dishFlavor.setDishId(dishId); dishFlavorList.add(dishFlavor); } dishFlavorMapper.insertBatch(dishFlavorList); } // 4. 清理缓存 String key = "dish_" + dishDTO.getCategoryId(); redisTemplate.delete(key); } }

@Transactional注解是这段代码的护身符。只要这个方法内部抛出了RuntimeException,Spring就会回滚整个事务,dish表和dish_flavor表的数据都不会落库。这样就不会出现菜品信息保存了、但口味数据没保存的脏数据。

BeanUtils.copyProperties是我强烈推荐用的工具方法,它能把dishDTO里同名的字段自动拷贝到dish对象。如果没有这个工具,你得手动写一行行的setter赋值,字段一多就是一大坨重复代码。

注意dishMapper.insert(dish)执行完后,dish.getId()才有值。这依赖MyBatis的useGeneratedKeys配置,后面在Mapper层部分会展开讲。

2.4 Mapper层:insert主键回显与批量insert语法

Mapper层是直接跟数据库打交道的地方,同样也是坑最多的地方。

DishMapper接口:

package com.sky.mapper; import com.sky.entity.Dish; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Options; @Mapper public interface DishMapper { @Insert("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})") @Options(useGeneratedKeys = true, keyProperty = "id") void insert(Dish dish); }

@Options(useGeneratedKeys = true, keyProperty = "id")这段话很多人会忽略,实际上不可或缺。它告诉MyBatis:数据库自动生成的主键要回填到Dish对象的id字段上,这样你调完insert之后,dish.getId()才能拿到值。如果你不加这个注解,dish.getId()返回null,后面给口味记录设置dishId时全都会变成null,数据关联就断了。

口味表批量插入的Mapper:

package com.sky.mapper; import com.sky.entity.DishFlavor; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Insert; import java.util.List; @Mapper public interface DishFlavorMapper { void insertBatch(List<DishFlavor> flavors); }

对应的XML文件在resources/mapper/DishFlavorMapper.xml:

<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.sky.mapper.DishFlavorMapper"> <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> </mapper>

为什么不写单条insert循环插入,而是要用foreach批量插入?原因很简单:性能。假设一个菜品有5个口味,单条插入就是5次数据库往返,批量插入只有1次。在管理端操作场景下差别不明显,但这是一个好的编码习惯,数据量一大优势就出来了。

还有个需要注意的点:批量插入的SQL语句长度是有限的。虽然MySQL默认的max_allowed_packet有4M大小,一般口味数据远远到不了这个量级,但如果以后有其他批量插入场景,数据条目特别多时,需要考虑分批插入,避免一次性拼接SQL超出长度限制。

3. 文件上传与图片存储方案

3.1 为什么选择对象存储而不是本地磁盘

新增菜品有一个图片上传的环节。我在第一次实现时用的是本地上传,把文件保存到项目目录下的upload文件夹,然后拼一个访问URL。本地方案在开发环境跑通没问题,但有几个隐患:

  • 项目重新部署时,上传的文件可能被清掉。
  • 应用服务器磁盘空间有限,图片一多就扛不住。
  • 本地文件路径在前后端联调时不好处理,前端需要能通过URL访问到图片。

所以我后来果断切换到了阿里云OSS对象存储。对象存储的做法是:前端把选中的图片文件传到你自己的后端接口,后端拿到MultipartFile后调用OSS SDK,把文件传上去,OSS返回一个可访问的URL,后端再把这个URL保存到数据库。这套流程的好处是图片托管在云上,不占用应用服务器磁盘,访问也稳定。

3.2 OSS上传接口的实现与参数配置

OSS相关配置放到application.yml里:

sky: alioss: endpoint: oss-cn-hangzhou.aliyuncs.com access-key-id: 你的AccessKeyId access-key-secret: 你的AccessKeySecret bucket-name: 你的Bucket名称

然后创建一个配置类来读取这些配置:

package com.sky.config; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; @Component @ConfigurationProperties(prefix = "sky.alioss") @Data public class AliOssProperties { private String endpoint; private String accessKeyId; private String accessKeySecret; private String bucketName; }

具体上传工具的代码:

package com.sky.utils; import com.aliyun.oss.OSS; import com.aliyun.oss.OSSClientBuilder; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; import javax.annotation.Resource; import java.io.IOException; import java.util.UUID; @Component public class AliOssUtil { @Resource private AliOssProperties aliOssProperties; public String upload(MultipartFile file) { String endpoint = aliOssProperties.getEndpoint(); String accessKeyId = aliOssProperties.getAccessKeyId(); String accessKeySecret = aliOssProperties.getAccessKeySecret(); String bucketName = aliOssProperties.getBucketName(); try { // 生成唯一的objectName,避免文件名冲突 String originalFilename = file.getOriginalFilename(); String extension = originalFilename.substring(originalFilename.lastIndexOf(".")); String objectName = UUID.randomUUID().toString() + extension; // 创建OSS客户端 OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret); ossClient.putObject(bucketName, objectName, file.getInputStream()); ossClient.shutdown(); return "https://" + bucketName + "." + endpoint + "/" + objectName; } catch (IOException e) { throw new RuntimeException("文件上传失败"); } } }

注意这里的objectName我用UUID生成,而不是直接用原始文件名。原因很直接:不同用户可能上传同名文件,比如都叫"宫保鸡丁.jpg",如果直接用原始文件名,后上传的会把先上传的覆盖掉。加UUID前缀基本可以保证全球唯一。

把上传接口接到Controller里:

@PostMapping("/upload") @ApiOperation("上传图片") public Result<String> upload(MultipartFile file) { log.info("文件上传:{}", file); String url = aliOssUtil.upload(file); return Result.success(url); }

这个接口接收的参数不是JSON了,而是multipart/form-data格式,前端上传组件用FormData方式提交。

3.3 本地存储与云存储切换的灵活设计

我实际开发中还遇到一个场景:本地联调时不想每次传图都走OSS流量,或者没有OSS环境时怎么处理。我的做法是封装一个文件存储策略接口,本地环境和云环境各实现一个,通过配置切换。

这个设计在项目初期可能显得多余,但当你部署到测试环境、生产环境,或者多人协作开发时,好处就体现出来了。否则每个人本地都要配置一份AccessKey,既不安全也不方便。

4. 常见问题与排查技巧实录

4.1 事务失效:为什么回滚没生效

我在联调的时候遇到过这么一个问题:故意让口味插入失败,结果dish表的数据还是保存进去了。检查了一圈发现,问题出在类内部方法调用上。

Spring的事务是通过代理对象实现的。当你在同一个类里,通过this直接调用另一个加了@Transactional的方法时,事务注解是不生效的,因为this对象不是Spring生成的代理对象。

解决办法有两个:

  • 把事务注解加在对外暴露的入口方法上,让整个方法处于事务范围内。
  • 把需要事务的方法拆到另一个Service类里,通过注入的代理对象去调用。

我在上面的代码里就是把@Transactional直接加在saveWithFlavor上,这个方法是从Controller层调用进来的,能正确命中Spring代理,事务才能发挥作用。

还有一个容易忽视的细节:@Transactional默认只会回滚RuntimeException和Error。如果你在方法里捕获了异常并正常返回,Spring认为业务执行成功了,不会触发回滚。所以在Service层不要随意try-catch吞掉异常,要么不捕获让框架处理,要么捕获后抛出RuntimeException。

4.2 参数校验:如何在入口处拦截空值

不校验参数的代码在正常操作时可能没感觉,但一旦前端漏传某个字段,数据就会出现问题。菜品名称为空、分类ID为空,这些都是基础问题。

我后来给DTO加上了校验注解,引入依赖后在Controller方法参数前加@Validated:

@PostMapping @ApiOperation("新增菜品") public Result save(@Validated @RequestBody DishDTO dishDTO) { dishService.saveWithFlavor(dishDTO); return Result.success(); }

在DishDTO字段上加约束:

@NotBlank(message = "菜品名称不能为空") private String name; @NotNull(message = "分类不能为空") private Long categoryId; @NotNull(message = "价格不能为空") private BigDecimal price; @NotBlank(message = "图片不能为空") private String image;

加上之后,如果前端提交的数据不符合要求,Spring会在进入Controller方法之前就抛出MethodArgumentNotValidException,配合全局异常处理器就能统一返回参数校验错误信息。这样脏数据在入口处就被拦截了,不用等到数据库层面去报错。

4.3 口味数据丢失:批量插入执行了但查不到

有个同事遇到过这个问题,他执行的insertBatch方法,日志里显示SQL执行成功了,没有报错,但数据库里查不到口味记录。最后排查发现,数据库隔离级别是默认的REPEATABLE READ,他在同一个事务里,前边insert dish,后边insert dish_flavor,SQL都执行成功了。但代码里他忘记调用commit,数据库事务一直没有提交,另外一个查询连接自然看不到未提交的数据。

这个问题其实回到Spring事务机制上:只要方法上有@Transactional,事务提交是Spring帮你做的,不需要手动commit。但如果方法上没有加注解,MyBatis的SqlSession默认是不会自动提交的,数据只是停留在事务缓存里,一直没有真正落库。所以排查口味数据丢失问题时,第一反应应该是:service方法上有没有加@Transactional?如果没有,先加上。

4.4 缓存清理时机:新增菜品后用户端看不到

苍穹外卖的用户端菜品展示通常会加一层Redis缓存,菜品分类变化之后要记得清理。我自己一开始就没清理缓存,导致新增菜品后用户端还是只显示旧菜品。这里的问题是:缓存key的设计要跟查询缓存时保持一致,否则你清了一个不存在的key,真正被读取的那个缓存还是老数据。建议在写缓存时统一把key的定义放到一个常量类里,两边都从常量读,就不会写成两个不同的字符串了。

我清理缓存的策略是:根据新增菜品所属的分类ID,删除对应的缓存key。这样一次新增只清一个分类的缓存,不会把其他分类的缓存也误删掉,也不会清得不够。虽然简单,但在高并发场景下缓存穿透的概率很低,因为这个操作只在管理端触发,频率有限。

5. 前后端联调与接口调试细节

5.1 Swagger文档与接口自测

我在开发菜品管理功能时,用的苍穹外卖项目里已经集成好了Swagger。接口写好之后,访问doc.html就能看所有接口列表,直接点"调试"就能测试。

新增菜品的接口在Swagger里是POST /admin/dish,参数体是JSON,结构要跟DishDTO对齐:

{ "name": "宫保鸡丁", "categoryId": 17, "price": 28.00, "image": "https://xxx.oss-cn-hangzhou.aliyuncs.com/xxx.jpg", "description": "经典川菜,微辣", "status": 1, "flavors": [ { "name": "辣度", "value": "微辣,中辣,特辣" }, { "name": "忌口", "value": "不要葱,不要蒜" } ] }

在Swagger里测试的好处是,前后端还没连上的时候,你可以直接模拟前端请求,快速验证后端逻辑是否正常。一个比较实用的习惯:每次修改Service或Mapper之后,先用Swagger做一轮自测,再去找前端联调,这样可以省下很多联调中来回沟通的时间。

5.2 前端提交数据格式与后端的对应关系

前端新增菜品页面的保存逻辑大致是把表单数据组装成对象,然后在flavors数组里动态添加口味条目,用axios POST提交。

需要注意的细节有:

  • 图片上传需要独立的上传组件,上传成功后返回的URL要回填到表单的image字段。
  • 分类用下拉框,选中的是分类ID,提交时是categoryId字段。
  • 不同口味的值用逗号分隔存成一个字符串,比如"微辣,中辣,特辣",存到flavors[].value里。
  • 状态字段status用switch控件,起售传1,停售传0。

如果前端提交的数据结构和后端DTO不一致,最常见的报错就是JSON反序列化失败,Spring会提示HTTP 400。这时先检查前端的字段名是不是和后端DTO完全对应,大小写和驼峰命名都要对上。

5.3 统一异常处理:让联调更顺畅

联调时最容易出现的问题就是接口报错但返回的信息不友好。前端拿到的是500 + 一串异常堆栈,根本不知道哪里出错了。我建议在项目里配置全局异常处理器。

苍穹外卖的框架里有CommonExceptionHandler,核心逻辑是捕获业务异常、参数校验异常、未知异常,统一封装成Result对象返回给前端,并记录日志。

有了这个处理器之后,业务上抛出的异常会以"菜品名称已存在"这种可读的文案返给前端,前端可以直接将message弹窗给用户。联调效率会高很多。

6. 代码优化与后续扩展建议

6.1 Service层代码的进一步精简

当前saveWithFlavor方法里用for循环做DTO到Entity的转换,代码还比较啰嗦。这种场景直接用Stream,代码更简洁:

List<DishFlavor> dishFlavorList = dishDTO.getFlavors().stream() .map(dishFlavorDTO -> { DishFlavor dishFlavor = new DishFlavor(); BeanUtils.copyProperties(dishFlavorDTO, dishFlavor); dishFlavor.setDishId(dishId); return dishFlavor; }) .collect(Collectors.toList());

不过要注意,如果团队里有人不熟悉Stream,for循环的可读性其实也不错。代码优化不是越花哨越好,而是让后来接手的人能一眼看懂。

6.2 菜品分类名称冗余存储的取舍

在菜品列表查询的时候,需要关联分类表查出分类名称。从表结构设计上讲,这是一个标准的连表查询。但在高频列表页,每次都JOIN分类表会增加查询开销。有些项目会直接冗余一个category_name字段到dish表,新增菜品时一并写入,查询时就不用再连表。

不过冗余字段带来的问题是数据不一致风险:分类名称改了,菜品表里的冗余字段不会自动更新。在菜品这个场景下,我更倾向于保持连表查询,数据一致性优先于查询性能。当然,如果到百万级数据量、查询性能成为瓶颈时,再考虑冗余方案也不迟。

6.3 后续可以扩展的方向

新增菜品功能跑通之后,还可以往这些方向再完善:

  • 菜品启售停售状态切换,对应更新dish表status字段。
  • 菜品编辑功能,需要把一个已存在的菜品整体更新,口味一般做法是删掉旧的再批量插入新的。
  • 菜品批量删除,要注意检查菜品是否在售,避免用户端看到异常状态。
  • 菜品分类管理,分类改变后要联动刷新缓存。

每一块都不复杂,但都能加深你对这套项目代码结构的理解。

7. 最后的经验总结

新增菜品这个功能,虽然只是苍穹外卖里的一个管理端操作,但它把Spring Boot的后端开发中几个关键知识点都串起来了:MVC分层、JSON数据交互、MyBatis主键回显、MyBatis动态SQL批量插入、声明式事务、对象存储文件上传、Redis缓存清理。每一个点单独看都不难,但组合在一起,就考验你对整个调用链路的理解了。

我个人在实际操作中最大的体会是:不要把新增菜品简单理解成一次insert。你要想清楚数据流是怎么走的,从Controller接收参数,到Service编排业务,到Mapper落地数据,再到缓存更新,每一步都有它存在的理由。搞清楚这一条链路之后,同样的思路完全可以复用到苍穹外卖里其他模块的开发,比如新增套餐、新增分类,甚至订单模块的代码你也能更快看懂。

最后再分享一个小技巧:代码写完一定要做一次完整的自测,不只要测happy path,还要故意传缺失参数、错误类型的数据来测异常场景,看看服务会不会报出清晰的错误信息。把这些问题提前暴露出来,比上线后再被用户发现要好处理得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 3:15:50

Python调用ChatGPT中转API全指南:从入门到生产环境

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 3:13:15

2026年路由器选购指南:五款Wi-Fi 7路由器真实推荐

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华