简介这份《黑马瑞吉外卖源码及工程项目全套》面向正在学习 Spring Boot、Java 与 MyBatis 的开发者尤其适合希望通过完整外卖业务项目理解 Web 应用分层设计、订单流程和数据库操作的学习者。压缩包共 662 个文件约 116.99MB包含 70 个 Java 源文件、70 个 class 文件、86 张 png 图片以及 js、html、css、xml、yml、properties 和 jar 等前端页面、配置文件、静态资源与依赖包基本覆盖可运行工程所需的代码和构建产物。资源对课堂版本中的 bug 做了修复并补充完善订单系统、地址管理与分页查询等功能所有功能均测试跑通读者可据此梳理 Controller、Service、Mapper 分层结构理解订单状态流转、默认地址维护和分页查询实现也能参考已有工程进行复现、调试与二次开发。目前已有 8328 人学习适合作为课程设计、毕业设计或 Spring Boot 入门实战的参考工程。1. 一套能直接启动的外卖点餐系统源码最值钱的是完整下单链路一套现成的外卖点餐系统源码最值钱的地方在于它把「登录鉴权 → 菜品套餐管理 → 购物车 → 下单」这条链路完整串了起来而 CRUD 本身反倒不是重点。这份基于 Spring Boot MyBatis-Plus 的 Java 全栈工程后端是单体结构前端页面直接放在resources/static下配好数据库就能启动后台管理端和用户点餐端两套界面都在里面。它适合刚学完框架、想找个完整业务练手的人也适合要拿它当门店小系统原型或毕设底稿的开发者。下面按我实际拆代码的顺序讲结构怎么分层、登录怎么串起来、订单怎么落库、哪些地方容易出问题、二次开发从哪儿下手。2. 工程骨架从com.takeout包结构看一个单体项目怎么分层拿到源码后第一件事是检查项目目录比看 README 更有用。这套工程的分层很标准属于「教科书式但确实好用」的那种controller只负责接收参数和返回Rservice接口 impl承载业务mapper继承 MyBatis-Plus 的BaseMapperentity与数据库表一一对应dto承接前端传过来的、比表结构更复杂的对象。理解了这条线后面看任何接口都是同一个套路。2.1 目录结构controller / service / mapper / entity 各自管什么把src/main展开结构大致是这样src/main/java/com/takeout/ ├── TakeoutApplication.java # 启动类MapperScan ServletComponentScan ├── common/ # R 统一响应、BaseContext、CustomException、JacksonObjectMapper ├── config/ # MybatisPlusConfig、WebMvcConfig、MyMetaObjectHandler ├── controller/ # Employee、Category、Dish、Setmeal、ShoppingCart、Order、AddressBook ├── dto/ # DishDto、SetmealDto、OrdersDto、UserLoginDTO ├── entity/ # Employee、Category、Dish、DishFlavor、Setmeal、ShoppingCart、Orders、OrderDetail、User ├── filter/ # LoginCheckFilter ├── mapper/ # 每个实体一个 Mapper 接口 └── service/ # 接口 impl 两层 src/main/resources/ ├── application.yml ├── mapper/ # 少量手写 XML比如批量插入 └── static/ # backend/ 后台页面、front/ 用户端页面entity和dto分开是关键设计。比如菜品表dish里没有「口味」字段但新增菜品时前端会一起提交flavors数组这时候就用DishDto extends Dish加一个ListDishFlavor flavors字段来接。如果你偷懒直接用实体接收字段会静默丢失前端提交了口味却查不到排查起来非常浪费时间。common包里那个R是全局统一响应体所有接口都返回它前端拦截器只认code 1这样前端就能统一判断业务状态Data public class RT { private Integer code; // 1 成功0 失败 private String msg; // 失败原因 private T data; // 业务数据 private MapString, Object map new HashMap(); // 需要额外透传时用 public static T RT success(T object) { RT r new R(); r.code 1; r.data object; return r; } public static T RT error(String msg) { RT r new R(); r.code 0; r.msg msg; return r; } }这个类看着简单但它是前后端约定的一部分前端页面里到处是if (res.code 1)你后面如果加接口返回别的格式前端会直接静默失败页面就是空白、不报错这是新手最容易怀疑人生的一类问题。2.2 依赖与配置pom.xml和application.yml里真正要改的几处依赖不用全看认准这几个就够了依赖作用什么时候会碰到它spring-boot-starter-webMVC 内置 Tomcat启动、端口、静态资源mybatis-plus-boot-starter单表 CRUD、分页、自动填充几乎所有业务mysql-connector-java数据库驱动配数据源lombokData、Slf4j编译报错先查它druid-spring-boot-starter连接池与监控页连接池参数调优fastjson/jacksonJSON 序列化Long 精度、时间格式配置文件是跑通的第一道关卡真正需要改的其实就三段server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/takeout?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: root servlet: multipart: max-file-size: 10MB # 单文件上限图片够用 max-request-size: 20MB mybatis-plus: configuration: map-underscore-to-camel-case: true # 下划线转驼峰必须开 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发期打印 SQL global-config: db-config: id-type: assign_id # 雪花 ID19 位后面会引出一个经典问题 logic-delete-field: deleted # 若表里有逻辑删除字段 takeout: upload-dir: /data/takeout/upload/ # 图片落盘目录改成你机器上的绝对路径这里有两处值得提前留意。serverTimezone不写插入时间会差 8 小时订单时间全乱id-type: assign_id用雪花算法生成 19 位主键数据库没问题但前端 JS 的Number只有 53 位精度拿到的 ID 末位会变成 0详情页就查不到数据——这是这套源码里最经典的一个问题后面章节单独说。2.3 启动类、静态资源与ServletComponentScan启动类本身没什么逻辑但两个注解不能少Slf4j SpringBootApplication MapperScan(com.takeout.mapper) // 不加这个Mapper 注入不进来启动即失败 ServletComponentScan // 扫描 WebFilter登录过滤器靠它生效 public class TakeoutApplication { public static void main(String[] args) { SpringApplication.run(TakeoutApplication.class, args); log.info(后台管理端: http://localhost:8080/backend/index.html); log.info(用户点餐端: http://localhost:8080/front/index.html); } }ServletComponentScan是很多人漏掉的LoginCheckFilter上标了WebFilter但如果没有这个注解过滤器根本不会被注册表现就是「没登录也能进后台」而不是报错极具迷惑性。静态资源放resources/static下Spring Boot 默认就映射到根路径所以backend/index.html直接可访问不需要额外配WebMvcConfigurer。但如果你在WebMvcConfig里加了addResourceHandlers处理上传图片的访问前缀注意别把默认的静态资源映射覆盖掉覆盖了就会页面 404 而接口正常这个反差很容易把人带偏。3. 登录与权限链路一个请求从过滤器进来到拿到当前用户 ID这套工程的鉴权是「Session 过滤器 ThreadLocal」三件套没有 JWT、没有 Spring Security简单粗暴但链路清晰。它的价值在于你只要把这条链走通一遍后面任何需要「知道当前是谁在操作」的地方新增菜品填createUser、下单填userId都能顺着同一根线找下去。3.1 员工登录MD5、验证码与 Session 落点登录接口不长但每一步都有讲究PostMapping(/login) public REmployee login(RequestBody Employee employee, HttpSession session) { // 1. 前端传的是明文数据库存的是 MD5 摘要先转再比 String md5 DigestUtils.md5DigestAsHex( employee.getPassword().getBytes(StandardCharsets.UTF_8)); Employee emp employeeService.getOne( Wrappers.EmployeelambdaQuery().eq(Employee::getUsername, employee.getUsername())); if (emp null || !emp.getPassword().equals(md5)) { return R.error(登录失败); // 用户不存在和密码错统一提示别给探测机会 } if (emp.getStatus() 0) { return R.error(账号已被锁定); } // 2. 登录成功把主键塞进 session过滤器后续就是靠这个 key 判断 session.setAttribute(employee, emp.getId()); emp.setPassword(null); // 敏感字段不能回给前端 return R.success(emp); }参数说明RequestBody说明前端提交的是 JSONbackend页面里用 axios 发的就是 JSON如果你用表单方式提交会直接 415。session.setAttribute(employee, emp.getId())这个 key 名字必须和过滤器里读的一致抄代码时改了一个忘了另一个就是「登录成功但一刷新又跳登录页」。另外emp.setPassword(null)这行别省很多二开版本直接把整个Employee对象返回密码摘要泄露到前端属于典型的安全问题。3.2LoginCheckFilter的放行规则与 NOTLOGIN 响应过滤器是整个鉴权的核心逻辑就三句白名单直接放行、有 session 就放行并写入 ThreadLocal、都没有就返回未登录Slf4j WebFilter(filterName loginCheckFilter, urlPatterns /*) public class LoginCheckFilter implements Filter { private static final AntPathMatcher MATCHER new AntPathMatcher(); // 白名单登录、登出、静态页面、验证码、用户端登录 private static final String[] WHITE_LIST { /employee/login, /employee/logout, /backend/**, /front/**, /common/**, /user/login, /user/sendMsg }; Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; String uri req.getRequestURI(); // 1. 白名单放行注意用 AntPathMatcher 而不是 startsWith if (check(uri)) { chain.doFilter(req, resp); return; } // 2. 后台员工已登录 Object empId req.getSession().getAttribute(employee); if (empId ! null) { BaseContext.setCurrentId((Long) empId); chain.doFilter(req, resp); return; } // 3. 用户端已登录微信小程序侧的用户表 Object userId req.getSession().getAttribute(user); if (userId ! null) { BaseContext.setCurrentId((Long) userId); chain.doFilter(req, resp); return; } // 4. 都没登录返回固定 code前端拦截器据此跳转 resp.getWriter().write(JSON.toJSONString(R.error(NOTLOGIN))); } private boolean check(String uri) { for (String pattern : WHITE_LIST) { if (MATCHER.match(pattern, uri)) { return true; } } return false; } }关键点在于AntPathMatcher/backend/**这种通配只有它能正确匹配如果你图省事写uri.startsWith(/backend)那么/backendXxx这种路径也会被放行是个隐蔽的越权。另外R.error(NOTLOGIN)这个字符串是前端约定的暗号前端的响应拦截器里判断msg NOTLOGIN就跳登录页你改成中文提示前端就不跳了表现为「接口 200 但页面卡住」。3.3BaseContextThreadLocal 的存取和必须清理的理由BaseContext就三行核心代码但它决定了createUser、userId这些字段能不能自动填上public class BaseContext { private static final ThreadLocalLong THREAD_LOCAL new ThreadLocal(); public static void setCurrentId(Long id) { THREAD_LOCAL.set(id); } public static Long getCurrentId() { return THREAD_LOCAL.get(); } public static void remove() { THREAD_LOCAL.remove(); } }为什么用 ThreadLocal 而不是把 ID 一路当参数传因为一次请求会穿过 controller → service → mapper 好几层参数透传会把方法签名污染得很难看。Tomcat 处理一个请求就是一个线程ThreadLocal 天然跟着请求走。但这里有个必须处理的问题Tomcat 的线程是复用的。如果某次请求把 ID 塞进去处理过程中抛了异常没清理下一个请求用到同一个线程就会读到上一个用户的 ID表现是「A 的操作记到了 B 头上」。所以规范写法是在过滤器的try/finally里包一层try { BaseContext.setCurrentId((Long) empId); chain.doFilter(req, resp); } finally { BaseContext.remove(); // 线程归还前必须清否则 ID 串号 }这套源码里如果没写finally在并发压测下偶发数据错乱日志里又看不出任何异常是很难查的那种问题。二次开发时如果换成线程池跑异步任务ThreadLocal 更是完全传不过去得用TaskDecorator或者显式把 ID 当参数传。4. 核心业务链路从菜品管理到购物车下单MyBatis-Plus 的真实用法业务层是这套源码的主体。后台侧是分类、菜品、套餐、员工、订单管理用户侧是浏览、加购物车、下单。表不少但套路高度统一单表查询交给 MyBatis-Plus 的条件构造器多表关联用手写 XML 或者两次查询在内存里拼。搞清楚这几处剩下的接口基本是复制粘贴改字段。4.1 公共字段填充与分页插件两处必须配好的基础设施几乎所有表都有create_time、update_time、create_user、update_user四个字段。挨个set太蠢标准做法是交给MetaObjectHandlerComponent public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); strictInsertFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); strictInsertFill(metaObject, createUser, Long.class, BaseContext.getCurrentId()); strictInsertFill(metaObject, updateUser, Long.class, BaseContext.getCurrentId()); } Override public void updateFill(MetaObject metaObject) { // 更新时只填 update 相关create 字段不能动 strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); strictUpdateFill(metaObject, updateUser, Long.class, BaseContext.getCurrentId()); } }用strictXxxFill而不是setValue是因为前者会先校验实体上有没有这个字段没有就跳过setValue遇到不存在的属性会直接抛异常一个实体忘了加字段整个接口就 500。另外实体上必须标注TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime;注解和 handler 是配套的只写一边不会有任何提示只是字段一直是 null。分页需要单独注册插件忘记注册的表现很有迷惑性total永远是 0 或者返回全量数据页面上「共 X 条」不对但列表能显示。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 不指定 DbType 时某些版本会走错方言导致 limit 拼不出来 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }4.2 新增菜品口味表联动和Transactional的边界菜品和口味是一对多新增时要同时写两张表这是这套源码里最典型的多表写入场景Service public class DishServiceImpl extends ServiceImplDishMapper, Dish implements DishService { Autowired private DishFlavorMapper dishFlavorMapper; Override Transactional(rollbackFor Exception.class) // 默认只回滚 RuntimeException这里要放开 public void saveWithFlavor(DishDto dishDto) { // 1. 主表dishDto 继承自 Dish直接存 this.save(dishDto); Long dishId dishDto.getId(); // 2. 口味表必须回填 dishId否则这批口味是孤儿数据 ListDishFlavor flavors dishDto.getFlavors(); if (CollectionUtils.isNotEmpty(flavors)) { flavors.forEach(f - f.setDishId(dishId)); dishFlavorMapper.insertBatch(flavors); // 批量插别循环单条 insert } } }对应的批量插入 XMLinsert idinsertBatch insert into dish_flavor (id, dish_id, name, value, create_time, update_time) values foreach collectionlist itemitem separator, (#{item.id}, #{item.dishId}, #{item.name}, #{item.value}, #{item.createTime}, #{item.updateTime}) /foreach /insert几个参数值得说dishDto.getId()能取到值是因为this.save()之后 MyBatis-Plus 会把雪花 ID 回填到对象上这是它相对原生 MyBatis 最实用的一点。insertBatch的 XML 里没有useGeneratedKeys因为主键是应用层生成的不需要数据库返回。至于Transactional最典型的问题是在同一个类里this.saveWithFlavor()自调用代理不生效、事务失效看起来加了注解其实没加建议这类方法只从 controller 调别在 service 内部互调。4.3 文件上传图片落到本地磁盘并回显菜品要配图上传接口很朴素PostMapping(/upload) public RString upload(MultipartFile file) throws IOException { String original file.getOriginalFilename(); String suffix original.substring(original.lastIndexOf(.)); // 保留扩展名 // 用 UUID 重命名避免同名覆盖和中文文件名乱码 String fileName UUID.randomUUID().toString().replace(-, ) suffix; File dir new File(uploadDir); // uploadDir 从配置读必须是绝对路径 if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir, fileName)); return R.success(fileName); // 只回文件名前缀交给前端拼 }transferTo有个容易忽略的点传相对路径时它相对的是 Tomcat 的临时工作目录容器重启或临时目录被清理后文件就没了。所以upload-dir一定要配绝对路径并且和WebMvcConfig#addResourceHandlers里映射的目录保持一致Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // 访问 /common/download?namexxx 时实际去磁盘上找 registry.addResourceHandler(/common/download/**) .addResourceLocations(file: uploadDir); }file:前缀不能少少了会当成 classpath 去找文件明明在磁盘上也 404。这套源码里图片是本地存储二次开发时通常会换成对象存储改动点就在这个transferTo和上面这行映射业务代码不用动。4.4 购物车同菜品合并、加减与清空购物车表结构上一行就是「用户 菜品/套餐 数量」加购的核心逻辑是「有就加一没有就新建」PostMapping(/add) public RShoppingCart add(RequestBody ShoppingCart cart) { cart.setUserId(BaseContext.getCurrentId()); ShoppingCart one shoppingCartService.getOne( Wrappers.ShoppingCartlambdaQuery() .eq(ShoppingCart::getUserId, cart.getUserId()) // 菜品和套餐二选一另一个为 null 时这个条件要跳过 .eq(cart.getDishId() ! null, ShoppingCart::getDishId, cart.getDishId()) .eq(cart.getSetmealId() ! null, ShoppingCart::getSetmealId, cart.getSetmealId())); if (one null) { cart.setNumber(1); // 首次加购数量固定为 1 shoppingCartService.save(cart); return R.success(cart); } one.setNumber(one.getNumber() 1); // 已存在则累加 shoppingCartService.updateById(one); return R.success(one); }.eq(condition, column, value)这个三参数重载是关键条件是 false 时整段不拼进 SQL。如果你写成.eq(ShoppingCart::getDishId, cart.getDishId())加购套餐时会生成dish_id null等价于dish_id IS NULL查出来永远是 null于是每次加购都新建一条记录购物车里同一个套餐堆成好几行。这种 Bug 表面看是「合并不生效」实际上是 SQL 条件拼错了。减购/sub逻辑反过来数量减到 0 时根据业务决定是删行还是保留 0这套源码的处理是减到 0 直接把这条记录删掉前端刷新后该商品从列表消失。清空/clean按userId删全表即可注意一定要带userId条件忘了就是清空所有人的购物车这类误操作在测试环境里很难复现。4.5 提交订单金额计算、明细落库和状态流转下单是最能体现业务理解的一段步骤多但顺序固定Override Transactional(rollbackFor Exception.class) public OrderSubmitVO submit(Orders orders) { Long userId BaseContext.getCurrentId(); // 1. 取购物车空车不能下单 ListShoppingCart cart shoppingCartService.list( Wrappers.ShoppingCartlambdaQuery().eq(ShoppingCart::getUserId, userId)); if (CollectionUtils.isEmpty(cart)) { throw new CustomException(购物车为空不能下单); } // 2. 地址校验 AddressBook address addressBookService.getById(orders.getAddressBookId()); if (address null) { throw new CustomException(收货地址不存在); } // 3. 金额一律用 BigDecimal 累加禁止 double BigDecimal amount cart.stream() .map(c - c.getAmount().multiply(new BigDecimal(c.getNumber()))) .reduce(BigDecimal.ZERO, BigDecimal::add); // 4. 订单主表状态码要跟前端约定死 orders.setId(null); // 防止前端传 ID 覆盖 orders.setNumber(String.valueOf(System.currentTimeMillis())); orders.setOrderTime(LocalDateTime.now()); orders.setPayStatus(0); // 0 未支付 1 已支付 2 已退款 orders.setStatus(1); // 1 待付款 2 待接单 3 已接单 4 派送中 5 已完成 6 已取消 orders.setUserId(userId); orders.setAmount(amount); orders.setUserName(address.getConsignee()); orders.setPhone(address.getPhone()); orders.setAddress(address.getDetail()); this.save(orders); // 5. 明细表一个购物车行对应一条 OrderDetail ListOrderDetail details cart.stream().map(c - { OrderDetail d new OrderDetail(); d.setOrderId(orders.getId()); d.setName(c.getName()); d.setImage(c.getImage()); d.setDishId(c.getDishId()); d.setSetmealId(c.getSetmealId()); d.setNumber(c.getNumber()); d.setAmount(c.getAmount()); return d; }).collect(Collectors.toList()); orderDetailService.saveBatch(details); // 6. 清空购物车必须在同一事务里 shoppingCartService.remove( Wrappers.ShoppingCartlambdaQuery().eq(ShoppingCart::getUserId, userId)); return new OrderSubmitVO(orders.getId(), orders.getNumber(), amount, orders.getOrderTime()); }orders.setId(null)这行是有意加的兜底前端如果把上一次的订单 ID 带上来MyBatis-Plus 会当成更新静默覆盖历史订单。金额用BigDecimal而不是double是因为0.1 0.2在浮点里等于0.30000000000000004订单金额差几分钱对账时能让人排查到深夜。状态码是这套系统的骨架用户端只看status和payStatus决定按钮显示什么后台接单是把status从 2 改成 3派送改成 4完成改成 5。二次开发时如果要加「退款」别新造一套状态直接在payStatus上加 2前端按位判断即可改动面最小。订单号用时间戳生成在单机演示够用真上线要加用户 ID 后缀或序列否则同一毫秒两个请求会撞号。5. 排查与避坑这套源码跑不通时八成是这五个地方我拆这类完整工程的习惯是先跑通登录再跑通一个列表最后才看下单。中间必然出问题下面这五条是我在不同机器、不同 JDK 上反复遇到的按出现频率排序每条都给出「现象 → 原因 → 解决」的完整链路照着对一遍能省掉大半天。5.1 启动报Invalid bound statement (not found)现象项目能起来日志里也没异常但一调某个接口就抛org.apache.ibatis.binding.BindingException: Invalid bound statement (not found)报的方法名往往就是那个手写的insertBatch或者分页查询。原因MyBatis-Plus 的BaseMapper方法不需要 XML但你手写的方法必须有对应的insert idxxx。找不到只有三种可能——XML 没放在resources/mapper下、application.yml里没配mybatis-plus.mapper-locations、或者 XML 里的namespace和 Mapper 接口全限定名对不上比如com.takeout.mapper.DishFlavorMapper写成了com.takeout.mapper.DishFlavorDao。解决先看target/classes/mapper下有没有编译出来的 XML没有就是打包配置问题在pom.xml的resources里把**/*.xml包含进去有了再核对namespace与方法名一个字都不能差。5.2 前端拿到的主键 ID 末位变成 0现象列表能查出来但点「编辑」查不到数据或者提交时按 ID 更新影响 0 行。抓包看返回的 JSONID 是1732456789012345000这种而你直接查数据库是1732456789012345678。原因主键用的是雪花算法生成的 19 位Long超出 JavaScriptNumber的安全整数范围2^53-1前端解析时精度丢失末几位被抹成 0。解决在WebMvcConfig里统一注册 Jackson 序列化规则把所有Long转成String输出前端就当字符串传回来Override public void extendMessageConverters(ListHttpMessageConverter? converters) { MappingJackson2HttpMessageConverter converter new MappingJackson2HttpMessageConverter(); ObjectMapper om converter.getObjectMapper(); SimpleModule module new SimpleModule(); // 序列化Long - String反序列化String - Long module.addSerializer(Long.class, ToStringSerializer.instance); module.addSerializer(Long.TYPE, ToStringSerializer.instance); om.registerModule(module); om.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); converters.add(0, converter); // 必须放最前面否则被默认转换器抢先 }5.3 列表查不出数据SQL 里多了个IS NULL现象什么都没改某个列表接口突然返回空数组打开 SQL 日志看到的条件里有dish_id IS NULL之类的判断。原因条件构造器里用了eq(Entity::getXxx, value)而value是 null 时MyBatis-Plus 生成的是xxx IS NULL并不是「跳过这个条件」。在购物车这类「菜品和套餐二选一」的场景特别容易遇到。解决换成三参数重载.eq(value ! null, Entity::getXxx, value)或者用.eq(StringUtils.isNotBlank(name), ...)。改完看 SQL 日志条件里应该干净了。5.4createUser、updateUser一直是 null现象数据能插进去但公共字段全是空页面上「创建人」一列显示空白。原因三种可能——实体字段没加TableField(fill ...)、MetaObjectHandler没加Component导致没进容器、或者过滤器里没调BaseContext.setCurrentId()比如白名单路径。解决按顺序查。先确认实体注解再在 handler 里打个断点看有没有进去最后看过滤器是否真的写入了 ThreadLocal。注意白名单路径本来就不会写 ID所以那部分接口的createUser是 null属于正常现象别在这上面纠结。5.5 上传的图片过一段时间就 404现象刚上传完能看到图重启服务或者过几个小时再打开图片全挂。原因upload-dir配的是相对路径transferTo把它解析到了 Tomcat 临时目录容器重启被清理或者addResourceHandlers里少了file:前缀Spring 去 classpath 里找当然找不到。解决目录改绝对路径比如/data/takeout/upload/同时在映射里写file:/data/takeout/upload/。还有一点Linux 下目录属主要给运行用户否则mkdirs静默失败表现为上传接口 200 但文件不存在。6. 二次开发把 Session 鉴权换成 JWT再加一个营业统计接口这套源码最常被改造的两个方向一个是鉴权方式一个是加报表。前者是因为它现在强依赖 Session前后端分离部署或者多端接入时很别扭后者是门店真实需求老板要看的不是菜品列表而是「这个月卖了多少」。换 JWT 的改动面其实很小BaseContext那套不用动只换入口。先写工具类public class JwtUtil { private static final String SECRET takeout-demo-secret-change-me; public static String create(Long userId) { return Jwts.builder() .claim(userId, userId) .setExpiration(new Date(System.currentTimeMillis() 2 * 60 * 60 * 1000L)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Long parse(String token) { Claims claims Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); return claims.get(userId, Long.class); } }然后在过滤器里把「读 session」换成「读 header 里的 token」session.setAttribute换成登录成功后R.success(token)返回给前端。要注意两点SECRET必须从配置读硬编码进 Git 就是事故parse要包 try-catchtoken 过期会抛ExpiredJwtException不捕获就变成 500 而不是 401前端拿不到跳转信号。统计接口更简单一条 SQL 搞定-- 按天统计已完成订单的营业额注意过滤取消和未支付 SELECT DATE(order_time) AS day, COUNT(*) AS orderCount, SUM(amount) AS turnover FROM orders WHERE status 5 AND pay_status 1 AND order_time BETWEEN #{begin} AND #{end} GROUP BY DATE(order_time) ORDER BY day;对应一个GetMapping(/stat)返回ListMapString, Object就够了前端拿 ECharts 一画。验证方法我一般是这样先在数据库里手写同样的 SQL 跑一遍拿到数字再打接口对比然后造一个跨天的订单把order_time手动改到昨天看分组对不对最后造一个已取消的订单确认它没被算进去。三步走完这个接口基本就不会出问题。改造的通用原则就一句优先在原表上加字段别动状态码的含义。这套源码的状态是硬编码在 Java 里的魔法数字你一旦改了 3 的含义后台和用户端两个前端都会出问题而且不会报错只是页面显示错乱——这类问题比崩溃难查十倍。从那以后我每次动状态字段都强制先把前后端所有出现这个数字的地方列一遍改完再拿两个端各点一遍主流程才敢提交。希望帮到你。本文还有配套的精品资源点击获取