资讯详情

资讯详情

苍穹外卖第三天:Redis缓存、公共字段填充与文件上传实战解析

苍穹外卖做到第三天,算是真正进入状态了。第一天搭环境、建表、把前后端跑通,第二天开始动手写员工登录和JWT令牌,到第三天迎来的是一大波“批量式”的业务代码:分类管理、菜品管理、Redis缓存、文件上传。很多同学在这里明显感觉代码突然变多,一个接口接着一个接口,一整天下来写了好几千行,却说不清自己到底学了什么。这篇文章我把Day 3的完整思路拆开来讲,包含每个功能为什么会这么做、代码怎么写、踩了哪些坑,尽量让已经做完的同学查漏补缺,也让还没做到这一天的朋友有个心理预期。1. 今日功能范围与核心难点第三天的功能排期在不同的课程版本里略有差异,但主旋律基本一致:把“分类”和“菜品”这两个基础业务模块拉通,同时引入Redis解决一些热点数据的缓存问题,再把图片上传的链路跑通,让你第一次体验到“前端传文件、后端存文件、页面再回显图片”的完整闭环。这里要提前打个预防针:Day 3的代码量是前两天的两倍以上。原因在于“分类管理”和“菜品管理”都是标准的CRUD,而且每个模块都覆盖了新增、分页查询、修改、删除四个基本动作。你以为只是复制粘贴,实际写起来会发现每个动作背后都藏着不同的细节。比如新增菜品要同时写菜品表和口味表,删除分类要检查该分类下是否还有菜品,修改员工时密码要不要跟着更新,这些逻辑判断才是真正耗时间的地方。从学习目标来看,Day 3重点解决了三个核心问题:如何在Spring Boot工程里接入Redis,并且用缓存扛住高频查询请求;如何通过公共字段自动填充技术,统一处理创建时间、创建人、更新时间、更新人这类每张表都有的字段;如何完成一次完整的文件上传与回显,理解本地存储方案的利弊。掌握了这三个问题,后面的订单、用户端逻辑再复杂,你至少不会被基础技术栈拦住。这一天的内容也是整个项目里少有的“纯后端就能自己验证完”的模块,不需要等前端页面完全就绪,用Swagger就能把每个接口调通。对初学者来说,这是建立信心很关键的一天。2. Redis接入与缓存实战2.1 为什么第三天突然引入Redis前面两天你操作的数据量很小,查一条、写一条,MySQL应付起来非常轻松。但到了分类和菜品这一步,数据量开始增加,而且用户端小程序一打开就要加载全部分类以及分类下的菜品。如果每次请求都去数据库查一遍,一旦并发稍微上来,数据库的查询压力就会非常明显。Redis作为内存级数据库,读取速度是MySQL的几十倍,把那些“读多写少”的数据放进去,是最典型的性能优化手段。Day 3引入Redis还有一个业务上的原因:管理端对分类和菜品的修改频率不高,但用户端查询频率极高。这种“一个数据被反复读,但很少改”的场景,几乎是缓存技术最理想的使用环境。课程在这里安排Redis,不是让你背几个命令,而是让你真正理解“缓存是给谁用的、缓存应该在什么时候更新”。理解了这一点,后面做订单状态缓存、购物车缓存的时候你就会有迁移能力。2.2 Redis环境准备与项目集成不管你用的是Windows本机、Linux服务器还是Docker,第一步永远是先把Redis跑起来。我建议直接在Docker里起一个,干净利落,不会给电脑留下什么系统服务残留。docker run -d --name redis -p 6379:6379 redis:6.2启动之后,再确认项目里已经引入了Spring Data Redis依赖。如果你用的是Maven,在pom.xml里加上这一段:dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency然后在application.yml里配置连接信息:spring: redis: host: localhost port: 6379 password: database: 0这里有个很容易被忽视的小坑:Spring Boot 2.x之后,底层连接Redis默认用的是Lettuce,不需要额外引依赖就能直接用。但如果你用的是Spring Boot 1.x,就得手动把Jedis切过来。多数课程用的是2.x,基本不会遇到这个问题,但如果你是跟着老版本视频做的,一定要看清楚。配置完以后,怎么验证Redis真的连上了?最直观的方法是往里面塞一个字符串再取出来。SpringBootTest public class RedisTest { Autowired private StringRedisTemplate stringRedisTemplate; Test public void testSet() { stringRedisTemplate.opsForValue().set(name, sky-take-out); System.out.println(stringRedisTemplate.opsForValue().get(name)); } }跑通这个测试,Redis集成就算完成。接下来要做的不是急着写业务,而是先想清楚,项目里我们到底要让Redis承担什么角色。2.3 用Redis缓存菜品分类数据先看场景:用户端小程序首页需要展示分类列表,比如“热销”“新品”“家常菜”这些。这个列表在管理端可能一天才改一次,但用户端每一秒都在查。按Day 3的代码设计,最常规的做法是:第一次请求时把分类列表从MySQL查出来,存到Redis里并设置过期时间;后续请求直接走Redis,查不到再回源数据库。关键代码如下:Service public class CategoryServiceImpl implements CategoryService { Autowired private CategoryMapper categoryMapper; Autowired private StringRedisTemplate stringRedisTemplate; Override public ListCategory listByType(Integer type) { String key category:type: type; // 1. 先查缓存 String json stringRedisTemplate.opsForValue().get(key); if (StringUtils.hasText(json)) { return JSON.parseArray(json, Category.class); } // 2. 缓存没有,查数据库 ListCategory list categoryMapper.listByType(type); // 3. 写入缓存,设置过期时间10分钟 stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(list), 10, TimeUnit.MINUTES); return list; } }这段代码看起来很简单,但里面有一个我当初完全没注意的坑:分类列表这个数据在管理端被修改之后,用户端拿到的还是旧缓存。比如管理员把“热销”改成了“招牌菜”,但小程序里过十分钟才更新,用户看到的就是错的内容。这个问题的标准解决方案是“缓存双删”或者“先更新数据库再删除缓存”。在Day 3的代码里,常见的做法是:管理端执行更新分类的接口时,把对应的缓存key直接删掉,让下一次用户请求重新查出最新数据写入Redis。这种“更新数据库删除缓存”的策略实现简单,基本能满足教学项目的需求。Transactional public void update(CategoryDTO categoryDTO) { // 1. 更新数据库 categoryMapper.update(categoryDTO); // 2. 清理缓存 String key category:type: categoryDTO.getType(); stringRedisTemplate.delete(key); }注意:清理缓存时,不要只删一个具体id的key,而要根据业务维度删。比如分类的修改只影响当前type下的列表,那就删对应的type维度key。如果你删的是全量分类key,每次修改都会把所有缓存都冲掉,性能优势就没了。2.4 Redis序列化问题与Key命名习惯不少同学第一次往Redis里存对象时,会直接使用默认的JdkSerializationRedisSerializer,结果发现Redis里存了一堆\xAC\xED\x00\x05t...之类的乱码,瞬间怀疑人生。这是因为Spring Data Redis默认序列化器会把对象序列化成二进制。虽然程序能正常读出来,但你开个可视化客户端一看,全是乱码,排查问题非常痛苦。解决办法是手动配置RedisTemplate的序列化器,统一使用Jackson的JSON序列化:Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); // 使用Jackson2JsonRedisSerializer替换默认序列化器 Jackson2JsonRedisSerializerObject jacksonSerializer new Jackson2JsonRedisSerializer(Object.class); ObjectMapper om new ObjectMapper(); om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); om.activateDefaultTyping(om.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL); jacksonSerializer.setObjectMapper(om); // key使用StringRedisSerializer template.setKeySerializer(new StringRedisSerializer()); // value使用Jackson序列化器 template.setValueSerializer(jacksonSerializer); template.afterPropertiesSet(); return template; } }这个配置配好之后,你往Redis里存对象,打开客户端看到的就是正常的JSON字符串,调试体验直接提升一个档次。关于Key命名,我建议从一开始就形成自己的规范。比如cache:category:list:1、cache:dish:list:101这种,冒号分隔是Redis社区最常见的做法,既方便阅读,也能在可视化工具里按层级折叠。课程自带的写法通常是category:type:1之类,够用,但你以后真正做项目时,加上项目名前缀会更安全,避免多个应用共用一套Redis时互相覆盖。3. 公共字段自动填充的落地实现3.1 为什么要做公共字段自动填充你去看数据库表结构,会发现员工表、分类表、菜品表、套餐表里都有一模一样的四个字段:create_time、create_user、update_time、update_user。每写一条数据,都要手动set这四个字段;每更新一条数据,又要再set后面两个。这种重复劳动写几天就会让人崩溃,而且不同人写的接口可能还会漏掉其中某一个字段。Day 3的课程在这里安排了一个非常实用的解决方案:使用MyBatis的MetaObjectHandler接口,在insert和update语句执行之前,自动把公共字段填充进去。这样一来,你在业务代码里只需要关心真正的业务字段,公共字段完全交给框架处理。3.2 实现步骤与核心代码第一步,定义一个注解标识哪些字段需要自动填充。一般有两种做法,一种是用MyBatis-Plus自带的TableField(fill FieldFill.INSERT),另一种是手写一个AutoFill注解结合AOP切面。Day 3的课程通常会在员工管理和分类管理里选一个做演示,但如果你的课程版本比较全,两种方式都会让你看到。我用MyBatis-Plus的风格做个演示,先定义实体类:Data public class Category { private Long id; private Integer type; private String name; private Integer status; private Integer sort; TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT) private Long createUser; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; TableField(fill FieldFill.INSERT_UPDATE) private Long updateUser; }第二步,实现MetaObjectHandler:Component public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { LocalDateTime now LocalDateTime.now(); Long currentId BaseContext.getCurrentId(); this.setFieldValByName(createTime, now, metaObject); this.setFieldValByName(updateTime, now, metaObject); this.setFieldValByName(createUser, currentId, metaObject); this.setFieldValByName(updateUser, currentId, metaObject); } Override public void updateFill(MetaObject metaObject) { this.setFieldValByName(updateTime, LocalDateTime.now(), metaObject); this.setFieldValByName(updateUser, BaseContext.getCurrentId(), metaObject); } }这里最关键的一点是BaseContext.getCurrentId(),它从ThreadLocal里取当前登录用户的id。JWT拦截器在拦截到请求时,会把解析出来的用户id放进ThreadLocal,然后业务层在填充字段时取出来,从而知道这条数据是谁创建的、谁修改的。3.3 自动填充踩坑记录这个功能我第一次实现时出了一个特别隐蔽的问题:手动在新增接口里也set了createTime等字段。因为MetaObjectHandler是在SQL执行前通过反射覆盖的,如果你在业务代码里set了一个值,填充处理器又会用现在的时间去覆盖它,导致两个值不一致,看起来就像时间错乱。更严重的是,如果某个字段在实体类里不存在,填充处理器会直接报异常。另一个很容易踩的坑是ThreadLocal的OOM风险。如果你在拦截器里往ThreadLocal塞了用户id,却没有在请求结束时清理,在高并发场景下线程复用会导致旧数据被新请求读取。Day 3的课程一般会提供一套BaseContext工具类,里面用ThreadLocalLong保存用户id,但真正规范的工程里会用finally块在拦截器afterCompletion里调用BaseContext.remove()来清理。public class BaseContext { private static ThreadLocalLong threadLocal new ThreadLocal(); public static void setCurrentId(Long id) { threadLocal.set(id); } public static Long getCurrentId() { return threadLocal.get(); } public static void remove() { threadLocal.remove(); } }在JWT拦截器的afterCompletion方法里顺手调一下BaseContext.remove(),这个习惯现在养成,以后做多线程编程会少很多麻烦。4. 分类管理与菜品管理的核心操作4.1 分类管理:不只是简单的单表查询分类表结构本身不复杂,字段无非是id、类型、名称、排序、状态、创建时间和更新时间。但它的接口设计里有一个业务约束必须处理:删除分类时,如果该分类下已经挂了菜品,就不允许删除。这个逻辑在Service层写判断,很多人第一次会直接:Transactional public void deleteById(Long id) { categoryMapper.deleteById(id); }这样写的问题在于,如果分类下有菜品,删除后菜品就变成了“孤儿数据”,前端页面会一片混乱。正确的写法是先去菜品表查一下:Transactional public void deleteById(Long id) { Integer count dishMapper.countByCategoryId(id); if (count 0) { throw new CustomException(该分类下还有菜品,无法删除); } categoryMapper.deleteById(id); }善用数据库外键也能实现约束,但业务系统里通常不推荐外键,因为会严重影响后期拆库和分表的灵活性。用代码判断,虽然多一次查询,但可控性更好。分类的分页查询是标准的PageHelper或MyBatis-Plus分页。我建议在这里把分页写熟练,因为后面菜品、订单、用户都是同一个套路。以MyBatis-Plus为例:public PageResult pageQuery(CategoryPageQueryDTO dto) { PageCategory page new Page(dto.getPage(), dto.getPageSize()); LambdaQueryWrapperCategory wrapper new LambdaQueryWrapper(); wrapper.eq(dto.getName() ! null, Category::getName, dto.getName()) .eq(dto.getType() ! null, Category::getType, dto.getType()) .orderByAsc(Category::getSort); categoryMapper.selectPage(page, wrapper); return new PageResult(page.getTotal(), page.getRecords()); }注意分页查询返回的total是总记录数,前端分页组件靠它算总页数,Records是当前页数据。这两个字段命名在苍穹外卖的前端代码里是写死的,你后端的对象字段名必须对齐,否则页面就显示不出数据。4.2 新增菜品:一张主表加一张子表的联合作战菜品业务比分类复杂一个级别,因为一个菜品可能对应多个口味。比如“宫保鸡丁”可以有“不辣、微辣、中辣、特辣”四种口味,这些口味单独放在dish_flavor表里,通过dish_id关联主表dish。所以新增菜品这个接口,实际要执行两次插入:先插dish表拿到自增id,再遍历口味列表逐个插入dish_flavor表。很多第一次接触多表插入的同学,容易忘记加Transactional。如果第一次插入成功、第二次插入失败,数据库里就会多出一条没有口味的菜品,而且你根本不知道问题出在哪儿。加上事务之后,要么全成功,要么全回滚。Transactional public void saveWithFlavor(DishDTO dishDTO) { Dish dish new Dish(); BeanUtils.copyProperties(dishDTO, dish); // 插入菜品主表 dishMapper.insert(dish); // 获取自增id Long dishId dish.getId(); // 插入口味数据 ListDishFlavor flavors dishDTO.getFlavors(); if (flavors ! null !flavors.isEmpty()) { flavors.forEach(flavor - { flavor.setDishId(dishId); }); dishFlavorMapper.insertBatch(flavors); } }这里有一个非常经典的坑:BeanUtils.copyProperties会把DTO里的flavors列表也拷到Dish对象里去,而dish表根本没有flavors字段。如果直接用Dish去走MyBatis-Plus的自动insert,不会报错,但口味列表会丢失。更危险的是,如果DTO里有一个categoryName字段而实体没有,复制时也会被忽略,不会抛异常,只在页面上看到分类名称显示为空时才意识到问题。所以写多表插入时,不要懒,该手写set就手写set,或者用BeanUtils只拷贝基础字段,然后显式把flavors单独处理。4.3 菜品分页查询:联表查还是不联表查菜品分页查询的需求是:表格里要显示菜品的分类名称,但菜品表里只有category_id,没有分类名。于是你面临两条路:第一种:先查出菜品列表,再拿每个菜品的category_id去分类表查一次分类名称。这样会造成N1次查询,数据量大了性能很难看。第二种:直接写JOIN查询,一次性查出菜品和分类名称。Day 3的课程普遍采用这种方案,也是面试中很爱问的“你如何处理关联查询”。用XML写一个联表查询也很直接:select idpageQuery resultTypecom.sky.vo.DishVO SELECT d.*, c.name AS category_name FROM dish d LEFT JOIN category c ON d.category_id c.id where if testname ! null and name ! AND d.name LIKE CONCAT(%, #{name}, %) /if if testcategoryId ! null AND d.category_id #{categoryId} /if if teststatus ! null AND d.status #{status} /if /where ORDER BY d.create_time DESC /select在返回对象上,课程通常会定义一个DishVO,它比Dish实体多出一个categoryName字段。这个VO是一个“视图对象”,专门服务于前端页面展示,不要跟数据库表结构混在一起。养成“实体类对应表、VO对应页面”的习惯,后面订单的VO几十个字段你也不会慌。4.4 修改菜品:把删除和新增组合起来修改菜品的接口逻辑稍微绕一点。因为用户在前端可能改了菜品名称、价格、状态,也可能改了口味列表。口味可能增加、减少、调整顺序。如果你试图在原来的口味数据上做更新,就要一条一条比较哪些是新增的、哪些是删除的,很麻烦。课程里普遍采用一个“先删除后新增”的思路:修改菜品主表的同时,把该菜品原有的口味全部删掉,再按前端传来的新口味列表重新插入。Transactional public void updateWithFlavor(DishDTO dishDTO) { // 1. 修改菜品主表 Dish dish new Dish(); BeanUtils.copyProperties(dishDTO, dish); dishMapper.updateById(dish); // 2. 删除原口味 dishFlavorMapper.deleteByDishId(dishDTO.getId()); // 3. 插入新口味 ListDishFlavor flavors dishDTO.getFlavors(); if (flavors ! null !flavors.isEmpty()) { flavors.forEach(flavor - flavor.setDishId(dishDTO.getId())); dishFlavorMapper.insertBatch(flavors); } }听上去简单,但这里藏着一个隐患:如果更新菜品时前端没有传口味数据,而数据库里原本有口味,执行“删除再插入”后,口味就被清空了。所以在删除之前,最好先判断一下flavors是否非空;如果为空,是否应该保留原有口味,得看业务规则。有些课程在这里会直接写死“有口味才删除,没口味就跳过”,但实际项目里,你必须先把需求问清楚。5. 文件上传与图片回显的完整链路5.1 本地存储方案的实现菜品和分类都需要图片。Day 3最直观的做法是把文件保存到本地磁盘,然后在数据库里只存文件的访问路径。前端上传图片时,通过HTTP请求把文件二进制流发送到后端,后端把它写在某个目录下,并返回一个URL,前端拿这个URL去展示图片。Controller层接收文件的写法如下:PostMapping(/upload) public ResultString upload(MultipartFile file) { // 原始文件名 String originalFilename file.getOriginalFilename(); // 获取后缀,比如 .jpg String suffix originalFilename.substring(originalFilename.lastIndexOf(.)); // 生成新文件名,防止重名覆盖 String objectName UUID.randomUUID().toString() suffix; // 保存文件到本地 String basePath D:/sky/uploads/; File dir new File(basePath); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(basePath objectName)); // 返回给前端可访问的URL return Result.success(http://localhost:8080/upload/ objectName); }新文件名用UUID生成,是一个稳妥的做法。因为直接用原始文件名,两个用户上传同一个a.jpg,后传的会把先传的覆盖掉。虽然课程里可能就是这么写的,但你不能跟着学。UUID随机性保证了文件名不会冲突,即使别人猜出你的文件名规则,也无法轻易枚举你的图片目录。5.2 静态资源映射:从404到图片显示文件保存在本地磁盘之后,前端访问http://localhost:8080/upload/xxxxx.jpg时,如果直接报404,是因为Spring Boot默认不会把磁盘上的任意目录暴露成静态资源。你需要加一个配置类,告诉Spring Boot把某个路径映射到真实目录。Configuration public class WebMvcConfiguration implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file:D:/sky/uploads/); } }这个配置的意思是:所有/upload/开头的请求,从D:/sky/uploads/目录下面找对应文件。路径最后一定不要漏掉末尾的斜杠,否则映射会失败,这是一个特别阴的坑。我第一次写时写成了file:D:/sky/uploads,结果访问一直404,排查了半天才发现少了斜杠。你还要注意Windows和Linux环境的路径差异。Windows下是D:/sky/uploads/,Linux下是/home/sky/uploads/。课程里写在application.yml中配一个属性,方便不同环境修改。这一步提前做好,后面部署到服务器时会省很多事。5.3 云存储改造的扩展思路本地存储虽然简单,但真实上线项目一般不会这么干,因为本地磁盘容量有限,而且应用部署成多台服务器时,用户上传的文件只在一台机器上,负载均衡会把请求分到另一台机器,图片就加载不出来。这时候比较通用的方案是上传到对象存储,比如阿里云OSS、腾讯云COS、MinIO等。如果你已经完成了Day 3的本地上传,改成云存储其实不难。核心还是Controller层的那个MultipartFile接收参数,只是把“保存到本地目录”换成“调用OSS客户端上传”,然后返回一个云端URL:String url ossClient.upload(file); return Result.success(url);课程一般只教本地方案,但你在简历上如果想写“文件上传”这门技术,最好提前掌握至少一种云存储的对接方式。以后做实战项目时,这个技能直接就能用。6. 常见问题与避坑清单我把这几天群里被问到最多的问题汇总成一张表,每个问题都附上解决建议,你对照着自己的代码检查一遍:常见问题现象原因与解决方案Redis缓存取出来是乱码Redis客户端看到一堆二进制没有配置String/Jackson序列化器,参考2.4节配置RedisTemplate修改分类后用户端缓存没更新小程序里分类名称还是旧的更新数据库后没有删除Redis缓存key,改用“先更新再删缓存”策略公共字段create_user为null新增数据时创建人字段为空BaseContext里没有用户id,检查JWT拦截器是否解析了token并set值删除分类时提示“分类下有菜品”明明删了但还报这个错判断条件用了count 0,但分类id与菜品表的关联字段写错,检查category_id上传图片后前端访问404页面图片加载不出来静态资源映射路径写错,确认addResourceLocations末尾带斜杠修改菜品后口味全部丢失前端传了口味但页面上没有修改接口没有传flavors字段,或者DTO里flavors参数名与前端不一致分页查询total返回0页面没有数据分页对象Page的current和size没有正确接收前端参数,检查page/size字段名事务不生效插入菜品成功但插入口味失败Service方法没有加Transactional,或者方法被同类内部调用导致代理失效6.1 缓存穿透与脏读的简单避免在Day 3阶段,你只要做到“先查缓存,缓存没有再查库并把结果写回缓存”以及“更新数据库后删除相关缓存”这两件事,基本就不会有太大问题。但如果之后你深入做性能优化,一定要注意两个概念:缓存穿透:查询一个根本不存在的分类id,缓存里没有,数据库也没有,每次请求都会穿透到数据库。解决方法是把空结果也缓存起来,设置很短的过期时间,或者用布隆过滤器。缓存脏读:更新数据库成功了,但删除缓存失败,导致旧缓存被继续读到。解决方法是删除缓存时可以重试几次,或者在数据库和缓存之间引入可靠的消息队列做最终一致。Day 3不理解透没关系,知道这两个词是什么概念,后面的章节里还会再次遇到。6.2 事务失效的场景排查多表插入时你加了Transactional,看起来没问题,但事务可能在下面几种情况下失效:Service类没有被Spring容器管理,也就是没加Service注解;Transactional加在了private方法上,因为Spring默认用JDK动态代理,私有方法不会被代理;同一个类里的另一个方法调用了带事务的方法,绕过代理,事务失效;抛出的是Exception的子类,但rollbackFor没有包含它。比如你抛的是CustomException,如果它不是RuntimeException的子类,默认事务不会回滚。再分享一个我实测过的场景:Spring Boot项目中,Transactional默认只在抛出运行时异常时才回滚。如果你在方法里catch住了异常并打印日志、然后返回了一个错误结果,事务是绝对不会回滚的。所以多表操作时,要么不catch,要么catch之后手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。6.3 SQL日志开启与调试技巧写完这一大批增删改查之后,你可能会遇到“SQL执行成功但结果不对”的问题。这时候第一件事不是看逻辑,而是把MyBatis的SQL日志打开,直接看真实执行的语句是什么。在application.yml里加这样一段:logging: level: com.sky.mapper: debug这样控制台会打印所有Mapper接口执行的SQL和参数。比如你发现更新员工时update语句里set了update_time但没setupdate_user,通过日志一眼就能看出来。另一个很实用的配置是mybatis-plus.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl,它能把带有参数值的SQL打印出来。不过日志太多也会影响性能,只在开发环境开,上线前务必关掉。6.4 关于前端联调的一个心态建议Day 3后半段你一定会进入前后端联调阶段。前端页面点“新增菜品”,弹窗里有图片上传、分类下拉框、口味列表,你会第一次感受到“前端只要传错一个字段名,后端就接收不到”。你以为是自己后端代码写错了,折腾半天发现是前端传的是flaver,而后端要用的是flavor。面对这种情况,建议你在浏览器开发者工具里看一下Network面板的请求体。如果请求体里的参数名和你后端DTO里的字段名对不上,那就是前端的问题;如果请求体里是对的但后端还是接不到,再回头查自己的实体类和DTO。联调不是某一方的活,谁擅长谁就主动去排查,千万不要一边改后端、一边猜前端,那样效率极低。7. 实操心得与个人总结Day 3做完以后,你应该有一种明显的感觉:精力开始被分散到各种“业务规则”上,比如删除分类前要查菜品、修改菜品时要重建口味、更新数据时要维护Redis缓存。这些业务规则本身不难,但它们带来的代码组织问题开始浮现。我的体会是,写这部分代码最重要的不是把每个接口写完,而是养成三个习惯:一是写接口前先确认返回的VO对象有哪些字段,别让前端等了半天才发现字段名对不上;二是所有写操作统一加上事务注解,并且明确回滚策略;三是每操作完一个模块,用Swagger把所有接口完整跑一遍,不要只测成功场景,删除有菜品的分类、上传空文件、修改不存在的数据这类异常场景,至少看一次报错信息。另外,我强烈建议你从Day 3开始记录自己的“踩坑日志”。不需要写得多正式,就记下报错信息、原因、解决方法,比如那条“静态资源映射路径少了末尾斜杠导致404”的坑,如果当时不记录下来,下次再遇到可能又要花一个小时。后面做到的订单、统计报表,坑只会越来越多,到时候这本日志就是最宝贵的复习资料。如果时间允许,可以挑战一下把文件上传改造到MinIO或者本地Docker部署的Redis上去跑。这些扩展实验不一定在课程范围内,但对你理解“开发环境”和“生产环境”的差异很有帮助。Day 3只是项目的中点,后面还有用户端、订单、支付等大量复杂逻辑在等你,把这一天的基本功打扎实,后面你只会越写越顺。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →