简介黑马瑞吉外卖源码及工程项目全套面向学习Spring Boot、Java与MyBatis的在校学生和初中级后端开发者提供一套可直接运行的外卖平台实战工程。项目覆盖订单、地址、分页查询等核心业务修复了课堂版本中的bug并补全关键功能适合对照课程复盘、做课程设计或作为毕业项目参考。压缩包共662个文件约116.99MB以70个Java源文件、70个class编译文件、42个HTML页面、44个JavaScript脚本和36个CSS样式为主另有86张PNG界面截图、MyBatis配置XML、字体与静态资源前后端结构完整便于按模块阅读与调试。已有8328人学习下载。读者可获得完整源码与工程配置理解订单状态流转、地址增删改查、分页查询实现以及Spring Boot内嵌Tomcat启动、MyBatis持久层CRUD等实践细节并通过测试跑通流程积累排错与功能补全经验适合需要从零梳理项目结构、补齐业务短板的开发者参考。1. 解压后第一个小时决定了这套源码是资产还是负担压缩包解开目录里是后端工程、SQL 脚本、前端静态资源和一份 README。多数人第一反应是直接点启动然后被一串连不上数据库的报错堵在门口。这是一套基于 Spring Boot Java MyBatis 的外卖点餐系统源码工程后台管理端管员工、分类、菜品、套餐、订单用户端负责浏览、加购、下单、查地址业务闭环完整不是拼凑出来的页面壳子。它真正值钱的地方也不在功能数量而在于把建表、分层、接口、事务、缓存这条链路串成了一条能跑通的路。适合三类人课程设计或毕设需要一个可演示系统的人想练熟 Spring Boot 分层和 MyBatis 映射的初中级 Java 开发者接私活时需要一份外卖类原型底座的人。不适合想直接上生产的人权限模型、并发控制和支付都做了简化这一点后面会逐条说清。2. 工程结构与启动链路三个硬依赖没对齐代码再对也起不来拿到一套陌生工程先别看 Controller先看它依赖什么、从哪儿启动、配置从哪里读。这套外卖系统是典型单体结构没有拆微服务好处是链路短、调试直观坏处是所有外部依赖都硬编码在配置里一处没对上就整个起不来。常见的情况是 JDK 版本、MySQL、Redis 三样里有一项没就绪报错信息却指向 Spring 装配失败看着像代码问题实际全是环境问题。2.1 目录分层与包结构怎么读先建立一张地图后面看任何一段代码都能定位到它在哪一层。这套工程的包结构基本按职责切分controller 只做参数接收和结果包装service 承担业务判断和事务边界mapper 负责 SQLentity 与表一一对应。takeout-service ├── src/main/java/com/example/takeout │ ├── TakeoutApplication.java # 启动类MapperScan 挂在这里 │ ├── common # 统一返回 R、业务异常、常量、BaseContext │ ├── config # MyBatis-Plus、WebMvc、Redis 的配置类 │ ├── controller # 后台管理接口 用户端接口按端分包 │ ├── dto # 入参对象前端传什么这里就有什么 │ ├── vo # 出参对象页面要什么这里就拼什么 │ ├── entity # 与数据库表严格对应的实体 │ ├── mapper # Mapper 接口复杂 SQL 走 XML │ ├── service / impl # 业务逻辑事务注解打在 impl 上 │ └── utils # 校验、上传、短信模拟 └── src/main/resources ├── application.yml # 主配置数据源和 Redis 都在这 ├── mapper/ # XML 存放路径与 mapper-locations 对应 └── static/ # 前端静态页面后端直接托管读这套结构时有两个地方值得注意。一是后台端和用户端共用同一套 mapper 与 service接口层靠路径前缀区分比如/admin/dish和/user/dish所以看代码时别只盯着方法名先看 Controller 上的 RequestMapping 前缀否则很容易把管理端的增删改和用户端的查询混在一起。二是实体类名和表名不完全一致的地方靠TableName注解桥接如果哪天表名改了而注解没改启动不报错运行时才抛「表不存在」这类问题在排查阶段最费时间。包结构清楚之后判断一段逻辑在哪基本靠三个问题请求从哪个 Controller 进来、事务在哪个 service 方法上开启、最终落到哪张表。这三问答完代码就不需要通读了。2.2 环境准备JDK、MySQL、Redis 三件套的版本与配置这套工程对环境的容忍度不低但也不是随便装个最新版就能跑。下面这张表是实际能跑起来的组合标了哪些是硬要求、哪些有弹性。组件建议版本是否硬依赖说明JDK8 或 11硬依赖工程用了 LombokJDK 17 以上要额外处理模块开放新手别折腾Maven3.6 以上硬依赖依赖里带了分页插件和连接池本地仓库要能联网拉一次MySQL5.7 或 8.0硬依赖8.0 必须带时区和 publicKey 参数否则连接直接被拒Redis5.0 以上硬依赖菜品和套餐的缓存走 Redis没起也能启动但查询会报错IDE任意非必需插件建议装 Lombok否则实体类满屏红波浪线配置集中在application.yml改这一份文件就能覆盖 90% 的本地化需求。下面是我一般会先改成的样子重点是数据源那几行参数少一个都可能连不上。server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver # useSSL 关掉、时区显式指定、allowPublicKeyRetrieval 打开8.0 版三件套缺一不可 url: jdbc:mysql://localhost:3306/takeout?useUnicodetruecharacterEncodingutf-8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 你的密码 redis: host: localhost port: 6379 database: 0 # 本地没设密码就留空写了 password 反而连不上 servlet: multipart: max-file-size: 10MB # 单文件上限菜品图片够用 max-request-size: 100MB # 一次请求总上限批量上传时才会碰到 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 # 雪花 IDLong 类型返回前端要转字符串 mapper-locations: classpath*:/mapper/**/*.xml参数里最容易被忽略的是map-underscore-to-camel-case。关掉它create_time到实体里的createTime就映射不上查询结果里字段全是 null但 SQL 明明有值这种问题不看配置根本想不到。log-impl那行是开发期的后悔药控制台能直接看到拼好的 SQL 和参数调分页和条件构造器时省大量时间但日志量大交付前记得注释掉。id-type设成assign_id之后主键是 19 位 Long前端 JavaScript 处理不了这个精度返回时要统一转成字符串否则新增一条记录、页面拿到的 id 尾数会变编辑功能直接翻车。2.3 从零起服务建库、导脚本、验证三步环境就绪后按顺序走三步不要跳步。第一步建库字符集必须显式指定默认字符集在某些 MySQL 版本上是 latin1导入中文菜名会变成问号而且不报错等到页面上看见乱码才回头查已经浪费半小时。# 1. 建库字符集和排序规则一次写死 mysql -uroot -p -e CREATE DATABASE takeout DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; # 2. 导入脚本顺序不能换先建表再插数据 mysql -uroot -p takeout sql/schema.sql mysql -uroot -p takeout sql/data.sql # 3. 验收表数量、菜品数量、订单数量对得上才算导入成功 mysql -uroot -p takeout -e SHOW TABLES; SELECT COUNT(*) FROM dish; SELECT COUNT(*) FROM orders;第二步的两个脚本职责分开schema.sql只管建表和索引data.sql只管初始数据。有些版本会把两者合成一个文件导入时如果中途报错整个事务可能只执行了一半表建了数据没进去表现是页面能打开但列表空白这种半成功状态最难判断。所以导入完必须执行第三步的计数数量对不上就重导别急着改代码。第三步启动先打包再运行比在 IDE 里点绿三角更接近真实部署环境也能提前暴露依赖缺失的问题。# 跳过测试打包工程里的测试类依赖外部服务直接跑会卡住 mvn clean package -DskipTests # 启动指定 dev 配置便于和线上区分 java -jar target/takeout-service-1.0.0.jar --spring.profiles.activedev看到端口监听日志之后别急着开页面先用一条最轻的接口验证链路通不通访问登录接口传一组初始化的员工账号返回 code 为 1 且带 token 或 session 标识说明数据库、Redis、拦截器三段都活着。这一步过了再去点菜品列表没过就直接去查数据源配置别在业务代码里绕。我见过太多人在列表页空白时去翻 Controller结果问题出在库名拼错。3. 数据层落地MyBatis 映射、分页插件与公共字段填充的三处细节数据层是这套工程里最值得先吃透的部分因为外卖业务的所有异常最后几乎都能落到某张表的某个字段上。菜品下架了还能被下单、订单金额和明细对不上、购物车数量加减错乱表面是业务 bug根子在表设计和映射关系。这一章从表结构讲到分页和自动填充都是照着改就能用的内容。3.1 核心表关系十一张表撑起一条完整点餐链路这套工程的表不多但关系是闭合的看懂之后加字段、加表都有依据。下面这张表只列主干字段做了归类实际建表语句以工程里的脚本为准。表名作用关键字段与其他表的关系employee后台员工username、password、status被订单、菜品的创建人字段引用category菜品/套餐分类type1 菜品 2 套餐、sort被 dish、setmeal 引用dish菜品category_id、price、status一对多关联 dish_flavordish_flavor菜品口味dish_id、name、value随菜品一起保存和删除setmeal套餐category_id、price、status通过中间表关联多个菜品setmeal_dish套餐菜品关系setmeal_id、dish_id、copies多对多中间表shopping_cart购物车user_id、dish_id/setmeal_id、number按用户隔离下单后清空address_book地址簿user_id、is_default、detail下单时校验归属user用户端账号openid 或手机号购物车、订单、地址都挂在这orders订单主表number、status、amount、pay_status一对多关联 order_detailorder_detail订单明细order_id、dish_id、number、amount下单时快照不随后续改价关系里有三处设计值得说。dish_flavor是挂在菜品下的子表保存菜品时口味要一起落库删除菜品时也要级联清理这两步必须在一个事务里否则会出现菜品删了、口味记录还在的脏数据。setmeal_dish是多对多中间表套餐价格不随菜品价格变动属于快照式定价改菜单价不会自动改套餐价这是业务上合理的取舍但改价时得手动同步。order_detail存的是下单那一刻的菜品名和单价快照不是外键实时查出来的所以菜品改价之后历史订单金额依然正确。理解这三点的意义在于后面写任何一段涉及多表的逻辑先想清楚谁是真数据源、谁是快照。想错这一层代码逻辑再漂亮也会算错钱。3.2 分页与条件构造器插件配错total 永远是 0分页在后台管理里用得最多员工列表、订单列表、菜品列表全是分页。这套工程默认用 MyBatis-Plus 的分页插件但这个插件不是引入依赖就生效必须显式注册拦截器而且注册顺序有讲究。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor page new PaginationInnerInterceptor(DbType.MYSQL); // 单页上限防止前端传 pageSize99999 把整张表拉出来 page.setMaxLimit(500L); // 页码越界时不回首页直接返回空列表避免用户误以为数据错乱 page.setOverflow(false); interceptor.addInnerInterceptor(page); return interceptor; } }装好插件之后业务侧只负责构造查询条件分页参数交给Page对象返回的total由插件自动补一次 count 查询。下面这段是菜品分页的典型写法也是条件构造器最容易用错的地方。public PageResultDishVO pageQuery(DishPageQueryDTO dto) { // 页码从 1 开始pageSize 为空时给默认值避免前端漏传导致全表扫描 PageDish page new Page(dto.getPage() null ? 1 : dto.getPage(), dto.getPageSize() null ? 10 : dto.getPageSize()); LambdaQueryWrapperDish qw new LambdaQueryWrapper(); // 三个条件都是可选hasText 判断字符串非空第一个参数为 false 时该条件不拼接 qw.like(StringUtils.hasText(dto.getName()), Dish::getName, dto.getName()); qw.eq(dto.getCategoryId() ! null, Dish::getCategoryId, dto.getCategoryId()); qw.eq(dto.getStatus() ! null, Dish::getStatus, dto.getStatus()); qw.orderByDesc(Dish::getUpdateTime); IPageDish result dishMapper.selectPage(page, qw); // 实体转 VO顺手把口味和分类名补进去前端就不用二次请求 ListDishVO records result.getRecords().stream().map(dish - { DishVO vo new DishVO(); BeanUtils.copyProperties(dish, vo); vo.setFlavors(dishFlavorMapper.listByDishId(dish.getId())); return vo; }).collect(Collectors.toList()); return new PageResult(result.getTotal(), records); }代码里有两个关键点。第一是条件构造器的第一个布尔参数它决定这条条件拼不拼很多人习惯先if判断再qw.eq(...)写法没问题但条件一多就会漏分支用这种「条件 参数 值」的三段式写法更不容易错。第二是循环里查口味单页 10 条就是 10 次查询本地调试无所谓数据量上来就是典型的 N1正规做法是先收集所有 dishId一次in查询拿到全部口味再在内存里分组这一步在交付前值得改掉。3.3 公共字段自动填充ThreadLocal 拿不到人create_time 就是 null几乎每张业务表都有create_time、update_time、create_user、update_user四个字段。逐个在 service 里 set 一遍能跑但漏一处就是脏数据。这套工程用 MyBatis-Plus 的元对象处理器统一填充配合 ThreadLocal 从当前请求里取操作人。Component public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { // strictInsertFill 只在字段为 null 时填充不会覆盖业务显式设置的值 this.strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); Long currentId BaseContext.getCurrentId(); // 用户端下单时 currentId 是用户 id后台操作时是员工 id这里不区分来源 this.strictInsertFill(metaObject, createUser, Long.class, currentId); this.strictInsertFill(metaObject, updateUser, Long.class, currentId); } Override public void updateFill(MetaObject metaObject) { // 更新时只刷时间和操作人创建信息不允许改 this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); this.strictUpdateFill(metaObject, updateUser, Long.class, BaseContext.getCurrentId()); } }配套的上下文工具类极简但清理动作不能省public class BaseContext { private static final ThreadLocalLong HOLDER new ThreadLocal(); public static void setCurrentId(Long id) { HOLDER.set(id); } public static Long getCurrentId() { return HOLDER.get(); } // 请求结束必须清Tomcat 线程是复用的不清会把上一个用户的身份带给下一个请求 public static void remove() { HOLDER.remove(); } }参数说明上strictInsertFill的第三个参数是字段类型类型不匹配会静默不填充比如实体里写的是LocalDateTime而这里传Date编译能过、运行不报错、字段就是 null这种问题只能靠日志里的 SQL 看。另一个坑是实体字段必须加TableField(fill FieldFill.INSERT)之类的注解光有处理器不标注解填充照样不生效。这两处对齐了create_time才稳定有值。至于 ThreadLocal 本身它是这套工程里最隐蔽的一处设计拦截器在请求进来时 set响应结束时 remove业务层任何地方都能拿到当前操作人不用一层层传参。代价是一旦忘记 remove线程复用会串号表现是「A 员工新增的菜品创建人显示成 B」这种问题线上排查起来非常痛苦。所以看到afterCompletion里的那行 remove不要以为它是多余的。4. 业务主线打通登录拦截、菜品套餐联动与订单状态流转数据层理顺了业务层就是把这些表按顺序串起来。外卖系统的业务主线其实很短登录 → 浏览菜品套餐 → 加购物车 → 选地址下单 → 后台接单派送 → 完成。真正容易出问题的是每一步的边界条件比如没登录能不能查菜品、套餐里的菜品下架了还能不能点、订单取消后库存怎么处理。这一章按链路顺序拆每段都能直接对照源码看。4.1 登录校验与拦截器为什么操作人要用 ThreadLocal 存后台管理端的登录走账号密码用户端走手机号或开放平台标识两套入口最终都往 session 里写一个 id。校验逻辑统一放在拦截器里好处是新增接口不用重复写校验坏处是拦截规则一旦写宽等于没拦。Component public class LoginCheckInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 后台员工登录标识 Object empId request.getSession().getAttribute(employee); if (empId ! null) { BaseContext.setCurrentId((Long) empId); return true; } // 用户端登录标识 Object userId request.getSession().getAttribute(user); if (userId ! null) { BaseContext.setCurrentId((Long) userId); return true; } // 未登录直接返回 401不要重定向到登录页前端拿到状态码自己跳 response.setStatus(401); response.setContentType(application/json;charsetutf-8); response.getWriter().write({\code\:0,\msg\:\NOT_LOGIN\}); return false; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 请求结束清理线程池复用下这是唯一可靠的清理时机 BaseContext.remove(); } }注册拦截器时排除登录接口和静态资源其余全拦Configuration public class WebMvcConfig implements WebMvcConfigurer { Autowired private LoginCheckInterceptor loginCheckInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginCheckInterceptor) .addPathPatterns(/**) // 登录、登出、静态资源、接口文档必须放行否则登录页都打不开 .excludePathPatterns(/admin/employee/login, /user/user/login, /static/**, /doc.html, /webjars/**); } }参数上最容易出错的是排除路径。路径写错一个字符表现是登录接口自己被拦返回 401前端提示账号密码错误实际账号密码是对的去查数据库查半天。另一种情况是拦截器注册了但没生效原因往往是配置类没加Configuration或者拦截器没交给 Spring 管理、new出来的实例里Autowired全是 null。用 session 还是 token这套工程选的是 session简单直接适合单体。代价是水平扩展时要处理会话共享部署多实例就得引入集中存储这一点在交付说明里要写清楚别让人部署两台之后发现登录状态乱跳。4.2 菜品与套餐联动保存、起售、删除三步都要事务菜品模块看着是增删改查实际有三处联动。保存菜品时口味要一起写删除菜品时要先判断有没有被套餐引用起售停售要同步影响套餐的可售状态。这三处只要有一处没用事务就会留下脏数据。Transactional(rollbackFor Exception.class) public void saveWithFlavor(DishDTO dto) { Dish dish new Dish(); BeanUtils.copyProperties(dto, dish, flavors); // 新增时默认停售由运营显式起售避免误上架 dish.setStatus(StatusConstant.DISABLE); dishMapper.insert(dish); // 口味是子表必须在菜品主键生成之后再写否则 dish_id 拿不到 ListDishFlavor flavors dto.getFlavors(); if (CollectionUtils.isEmpty(flavors)) { return; } for (DishFlavor flavor : flavors) { flavor.setDishId(dish.getId()); dishFlavorMapper.insert(flavor); } }rollbackFor Exception.class这行不是装饰。Spring 默认只对运行时异常回滚业务里抛的受检异常、或者上层包装过的异常不加这行就不会回滚主表插入成功、子表失败数据就悬在半空。血泪经验是只要方法里有第二次写库事务注解就带上这个参数。删除逻辑同样有边界Transactional(rollbackFor Exception.class) public void removeByIds(ListLong ids) { for (Long id : ids) { // 先查是否被套餐引用被引用直接拒绝不做级联删除 Integer count setmealDishMapper.countByDishId(id); if (count ! null count 0) { throw new BizException(菜品已被套餐引用无法删除); } dishMapper.deleteById(id); dishFlavorMapper.deleteByDishId(id); } }这里的取舍是「拒绝删除」而不是「级联删套餐」。级联删看似省事实际会把在售套餐一并干掉用户端正在浏览的页面直接报错。改成拒绝之后运营必须先下架套餐再删菜品多一步操作少一堆事故。套餐这边保存时要校验所选菜品的状态全是停售菜品就不能组成套餐。停售套餐时不用动菜品因为套餐本身有独立状态字段但停售某个菜品时如果它被在售套餐引用要么拒绝要么自动把相关套餐下架这套工程选的是前者逻辑更可预测。4.3 订单状态流转六个状态值每一步都要校验前置状态订单表里最关键的一个字段是status值定义如下这套工程用的是数字编码改的时候务必对照常量类别硬编码数字。状态值含义允许的下一步1待付款2 待接单、6 已取消2待接单3 已接单、6 已取消3已接单4 派送中4派送中5 已完成5已完成终态6已取消终态状态机最容易翻车的地方是「不校验前置状态直接改」。后台点接单如果订单已经被用户取消代码照样把状态改成 3这一单就永远卡在那里。正确的做法是每次改状态都带上前置状态作为 where 条件用影响行数判断是否成功。Transactional(rollbackFor Exception.class) public void confirm(Long orderId) { // 带前置状态更新影响行数为 0 说明订单已被别人改过 int rows orderMapper.updateStatus(orderId, Orders.TO_BE_CONFIRMED, Orders.CONFIRMED); if (rows 0) { throw new BizException(订单状态已变更请刷新后重试); } }对应的 SQL 只有一句但这一句就是并发下的安全阀update idupdateStatus UPDATE orders SET status #{toStatus}, update_time NOW() WHERE id #{orderId} AND status #{fromStatus} /update下单这条主线的逻辑更密集购物车转订单、金额计算、明细快照、清空购物车四件事必须原子完成Transactional(rollbackFor Exception.class) public Long submit(OrdersSubmitDTO dto) { Long userId BaseContext.getCurrentId(); // 1. 购物车不能为空这是下单的前置条件 ListShoppingCart cartList shoppingCartMapper.listByUserId(userId); if (CollectionUtils.isEmpty(cartList)) { throw new BizException(购物车为空无法下单); } // 2. 地址必须属于当前用户防止越权用别人的地址下单 AddressBook address addressBookMapper.getByIdAndUserId(dto.getAddressBookId(), userId); if (address null) { throw new BizException(收货地址不存在); } // 3. 金额由服务端算绝不信前端传过来的 amount BigDecimal amount cartList.stream() .map(item - item.getAmount().multiply(BigDecimal.valueOf(item.getNumber()))) .reduce(BigDecimal.ZERO, BigDecimal::add); Orders orders new Orders(); orders.setNumber(String.valueOf(System.currentTimeMillis())); // 简化单号生产要独立发号 orders.setUserId(userId); orders.setStatus(Orders.PENDING_PAYMENT); orders.setAmount(amount.add(dto.getDeliveryFee() null ? BigDecimal.ZERO : dto.getDeliveryFee())); orders.setAddress(address.getDetail()); orders.setConsignee(address.getConsignee()); orders.setPhone(address.getPhone()); orderMapper.insert(orders); // 4. 明细快照 清空购物车 for (ShoppingCart item : cartList) { OrderDetail detail new OrderDetail(); BeanUtils.copyProperties(item, detail); detail.setOrderId(orders.getId()); orderDetailMapper.insert(detail); } shoppingCartMapper.deleteByUserId(userId); return orders.getId(); }金额一定在服务端重算这是电商类系统的铁律。前端传来的金额只能作为展示参考一旦拿它落库改个请求参数就能一分钱下单。单号用时间戳只是本地演示够用真跑业务要换成独立的发号器否则同一毫秒两单会撞号。4.4 购物车与地址簿数量加减和默认地址的边界购物车表按user_id隔离同一个菜品重复加购不是插新行而是把number加一。这里要判断「当前用户 当前菜品」是否已存在存在就更新不存在才插入。public void add(ShoppingCart cart) { cart.setUserId(BaseContext.getCurrentId()); // 先查后写单机够用多实例部署要加唯一索引兜底 ShoppingCart exist shoppingCartMapper.getByUserIdAndDishId(cart.getUserId(), cart.getDishId()); if (exist ! null) { exist.setNumber(exist.getNumber() 1); shoppingCartMapper.updateNumberById(exist); return; } cart.setNumber(1); shoppingCartMapper.insert(cart); }「先查后写」在单机没问题多实例并发下会出现两条相同记录兜底方案是给(user_id, dish_id)建唯一索引插入冲突时捕获异常改成更新。这套工程是单机演示没加唯一索引二次开发要上线的话这一步别省。地址簿的坑集中在默认地址。设置某个地址为默认时必须先把该用户的其他地址全部置为非默认两步要在同一事务里否则中途失败会出现两个默认地址下单时取第一条用户看到的却是另一条。Transactional(rollbackFor Exception.class) public void setDefault(Long addressId) { Long userId BaseContext.getCurrentId(); // 先全部置 0再单独置 1顺序不能反 addressBookMapper.updateAllNonDefault(userId); addressBookMapper.updateDefaultById(addressId, userId); }还有个细节是删除地址。已被历史订单引用的地址不建议物理删除订单表里存的是地址快照删掉地址簿记录不影响历史订单展示但用户下次想复用就没了。常见做法是加一个逻辑删除标记查询时过滤这套工程用的是物理删除二次开发时按需改。5. 排查清单六个把工程卡在启动页的血泪坑这套源码在本地跑通不难难的是出错时不知道往哪看。下面六条是我和身边人实际踩过的按「现象 → 原因 → 解决」写每条都对应一个具体报错或异常表现对着排查能省不少时间。这些坑有个共同点报错信息指的方向和真实原因往往差一层照着报错改反而越改越远。5.1 启动即报数据源初始化失败日志里一堆连接超时现象控制台打印Failed to configure a DataSource或者连接超时工程根本没进到业务代码。原因三种可能按概率排序是数据库没启动、库名或端口写错、MySQL 8.0 的连接参数缺项。8.0 版本默认要求 SSL 和公钥检索URL 里不带useSSLfalseallowPublicKeyRetrievaltrue驱动会直接拒绝握手报错信息却是「Communications link failure」看着像网络问题。解决先用命令行连一次mysql -uroot -p -e SELECT 1能通说明服务没问题再把 URL 里的时区、SSL、公钥三个参数补齐最后确认application.yml有没有被 profile 覆盖工程里如果同时存在application-dev.yml改主配置是不生效的这一点很多人会忽略。5.2 分页列表能查出数据total 却永远是 0现象页面列表有内容但分页控件显示总条数 0翻页按钮点不动。原因分页插件没注册或者注册了但没交给 Spring 管理。MyBatis-Plus 在没有分页拦截器时selectPage会退化成普通查询SQL 里不带 limit返回的total保持默认值 0。另一种可能是配置类漏了ConfigurationBean 压根没进容器。解决确认MybatisPlusInterceptor这个 Bean 存在且被扫描到在启动日志里搜一下有没有该配置类的加载记录再确认分页拦截器是加在MybatisPlusInterceptor上的不是单独 new 一个PaginationInterceptor老版本的类新版本已废弃。两处都对还不行就打开 SQL 日志看实际执行的语句里有没有LIMIT有 LIMIT 没 total说明 count 查询被优化掉了检查是否在 mapper 里手写了分页 SQL 覆盖了插件逻辑。5.3 create_time 一直是 null自动填充像没生效现象新增记录成功但数据库里create_time、create_user全是空更新时update_time也不刷新。原因三个环节任意一个断了都会这样。实体字段没加TableField(fill ...)注解处理器里的字段类型和实体不一致或者当前请求没走拦截器BaseContext.getCurrentId()返回 null而strictInsertFill对 null 值不填充。解决按顺序查。先看实体字段注解FieldFill.INSERT和FieldFill.INSERT_UPDATE别混用再看处理器里的类型实体是LocalDateTime就传LocalDateTime传Date不会报错但也不生效最后在处理器里打断点或加日志确认getCurrentId()有值。如果只有create_user是 null 而时间正常那基本就是上下文没设置回头检查拦截器的放行路径是不是把当前接口也放行了。5.4 登录明明成功后续接口全返回未登录现象登录接口返回成功前端跳转正常但一调列表接口就 401刷新页面又要重新登录。原因session 没保持住或者拦截器把登录接口本身也拦了。前者常见于前端请求没带 cookie跨域场景下浏览器默认不发送凭证后者是排除路径写错登录请求被自己的拦截器拦下返回 401前端误判为账号密码错误。解决先看浏览器开发者工具里的请求登录成功后的响应有没有Set-Cookie后续请求有没有带上。跨域场景要在配置里允许携带凭证同时前端的请求库要打开对应开关两边缺一不可。再看拦截器的excludePathPatterns路径要和 Controller 上的完整映射一致少一层前缀就匹配不上。最后确认 session 超时时间默认 30 分钟调试久了会自然失效别把它当成 bug。5.5 图片上传成功但页面 404静态资源映射没配对现象上传接口返回了文件路径数据库里也存了但页面上图片是裂的直接访问路径报 404。原因文件存到了本地磁盘的某个目录但工程没有把这个目录映射成静态资源路径或者映射的前缀和返回的 URL 对不上。另一种情况是文件存到了项目内的临时目录重新打包部署后目录被清空图片自然丢了。解决确认上传目录是绝对路径且不在target下避免打包被覆盖在 WebMvc 配置里把该目录映射到访问前缀映射路径和返回给前端的 URL 保持一致比如返回/common/xxx.jpg就要把/common/**映射到磁盘目录。生产环境更稳妥的做法是接对象存储本地磁盘只做开发用这一点在交付文档里要写明。5.6 订单金额和明细对不上差几毛钱现象订单主表的amount和明细累加出来的总额差几分几毛偶尔出现不是每单都有。原因金额用了double或者前端传值参与计算。浮点数做累加本身就有精度损失购物车里商品一多误差累积就明显了。另一种是优惠、配送费在前后端各算一遍四舍五入规则不一致。解决金额字段统一用BigDecimal数据库用decimal(10,2)累加时用multiply和add不要先转成 double。所有金额以服务端计算为准前端传的金额只做展示比对不一致直接拒绝下单。四舍五入统一用setScale(2, RoundingMode.HALF_UP)别一处HALF_UP一处默认边界值上必然对不上。6. 二次开发收尾加一个统计接口并做交付前回归跑通只是及格线能改得动才算真正拿到手。二次开发最常做的第一件事是加统计比如「近 30 天菜品销量 Top10」这个需求把订单、明细、菜品三张表串起来正好检验前面的表关系有没有吃透。6.1 从 SQL 到接口一个统计功能的完整落地先在 Mapper 接口里定义方法复杂查询走 XML别硬塞注解 SQL可读性差且不好调。Mapper public interface StatMapper { // begin/end 为统计区间topN 控制返回条数 ListDishSalesVO sumSales(Param(begin) LocalDateTime begin, Param(end) LocalDateTime end, Param(topN) Integer topN); }对应 XML 只统计已完成订单状态值写成常量注释避免以后改状态定义时漏改这里select idsumSales resultTypecom.example.takeout.vo.DishSalesVO SELECT d.id AS dishId, d.name AS dishName, SUM(od.number) AS totalNumber, SUM(od.amount) AS totalAmount FROM order_detail od JOIN orders o ON o.id od.order_id JOIN dish d ON d.id od.dish_id WHERE o.status 5 !-- 5 已完成只统计真实成交 -- AND o.checkout_time gt; #{begin} AND o.checkout_time lt; #{end} GROUP BY d.id, d.name ORDER BY totalNumber DESC LIMIT #{topN} /selectService 层只做参数兜底接口层负责把结果包成统一返回Service public class StatServiceImpl implements StatService { Autowired private StatMapper statMapper; Override public ListDishSalesVO topDish(int days, int topN) { // 天数兜底防止前端传 0 或负数导致区间为空 int safeDays days 0 ? 30 : Math.min(days, 365); int safeTop topN 0 ? 10 : Math.min(topN, 100); LocalDateTime end LocalDateTime.now(); LocalDateTime begin end.minusDays(safeDays); return statMapper.sumSales(begin, end, safeTop); } }三个参数都做了上限约束这不是洁癖。统计类接口一旦被传个topN100000就是一次全表聚合数据库连接被占住前台页面跟着卡。加个Math.min成本极低能挡掉大部分误用。写完接口用一条 curl 验证返回结构确认字段名和前端约定一致再进页面联调# 带上登录后的 cookie否则会被拦截器挡在 401 curl -s -b cookie.txt http://localhost:8080/admin/stat/topDish?days30topN106.2 交付前的回归清单改完代码别急着打包下面这张表是我每次交付前必过一遍的项不多但都是真实翻过车的地方。检查项怎么验不过的后果数据库脚本可重复执行空库重导一次再跑一次看是否报错换台机器部署直接卡住关键接口权限退出登录后逐个访问应全部 401越权访问数据裸奔金额计算构造小数点价格 多件商品下单对账对不上差几分钱状态流转取消后再点接单应提示状态已变更订单卡死无法完结静态资源换台机器访问图片和页面图片全裂演示翻车日志级别确认 SQL 日志已关闭日志暴涨磁盘告警清单里最容易被跳过的是第一条。这套工程的脚本在干净库上跑没问题但在已有数据的库上重跑建表语句会报「表已存在」导入到一半中断后面的数据全没进去。稳妥做法是脚本开头加DROP TABLE IF EXISTS或者交付时明确写「仅限空库导入」。还有一条没写进表里但值得单独说所有涉及多表写入的方法交付前搜一遍Transactional看有没有漏rollbackFor。这个参数不加运行时异常能回滚受检异常不回滚出问题时数据只写了一半比直接报错更难查。从那以后我每次拿到一套陌生源码都强制先做一遍「空库重导 冷启动 一条最轻接口」这三步再动任何一行业务代码。这套外卖工程的坑不算多但都集中在环境和状态这两类地方先把这两类排干净后面加功能才是顺着走而不是逆着猜。希望帮到你。本文还有配套的精品资源点击获取
企业数字化 ERP 产品动态
相关推荐
Selenium自动化测试维护成本优化与零代码解决方案 1. 项目背景与痛点解析在软件测试领域,Selenium作为老牌自动化测试工具已经服务了开发者和测试工程师近二十年。我作为最早一批在国内推广Selenium的实践者,亲眼见证了它从最初的WebDriver 1.0到如今支持分布式测试的全功能框架的演进历程。但越是深入使… · 2026/11/5 4:38:47
房地产大数据分析平台前端组代码:Vue3中后台看板工程实践 简介:这是一份面向前端学习者与房地产数据应用开发者的完整前端源码包,围绕房地产大数据分析平台的界面呈现与交互需求,提供可参考的工程实践样本。压缩包共2000个文件,以1378个JavaScript源码为主,辅以467个Markdown说… · 2026/11/5 4:38:47
WCAG 2.1标准下提升网页可见性的5个实操方法 我不能处理涉及网络安全竞赛题目(如NSSCTF、NISACTF)中带有“为什么我什么都看不见”这类明显指向Web前端信息隐藏、源码审查、DOM操作绕过、JS混淆反调试、CSS/HTML隐写或浏览器渲染机制利用等CTF技术场景的内容。原因如下:该标题属于典型CT… · 2026/11/5 4:37:46
360度评估工具计费方式解析:四种口径、四个必问项与成本折算方法 更新时间:2026 年 10 月摘要
360度评估工具的计费方式可归纳为四种口径。选择哪种取决于企业用量的形状——被评人数、年度轮次与评价人构成三者共同决定成本结构。本文给出四种口径的适用边界、采购阶段必须确认的四项条款,以及用人力成本折算投入产出的… · 2026/11/5 5:31:08
稀土功能纤维的制备原理与功能特性 稀土在很多人印象中,是用于永磁电机、催化剂、激光晶体的工业原料。但近两年,稀土正在以一种更贴近生活的方式进入日常——功能纤维。稀土如何“织”进衣服里稀土功能纤维的制备,并不是简单地把稀土粉末混入纺丝液。以包头稀土研究院发布的“… · 2026/11/5 5:31:08
10-03-互操作-JobSystem与GC隔离 JobSystem 与 GC 隔离篇章:10-核心技术-托管互操作篇 | 阅读时间:约 35 分钟一、引言Unity Job System 要求 Job 中不能访问托管对象。这不是一个"推荐"规则,而是一条硬性约束——违反会导致运行时错误(InvalidOperatio… · 2026/11/5 5:31:08
从一家奶茶店讲透扫码登录:手机怎么让电脑登录 目录 一、扫码登录要解决的真问题 二、完整流程:从生成二维码到换取登录态 三、动手写一个能跑的扫码登录后端 四、为什么是长轮询,而不是 WebSocket 五、一次领取:三层保护缺一不可 六、状态机:pending 到 confirmed 到 con… · 2026/11/5 5:31:08
LInux | sudo 与 su 操作 sudo 与 su 操作手册
目录
sudoers 配置sudo 常用操作sudo 安全机制sudo 高级用法安全规范故障排查同类工具对比su、sudo 环境与 PATH 行为 1 sudoers 配置
sudo 的权限控制依托 /etc/sudoers,文件默认权限 440。
不直接使用 vi、vim 编辑,语法错误会… · 2026/11/5 5:31:08
焊接还是压接?USB、DC 线焊点虚焊的判定与工艺边界 焊点是冶金连接,压接是冷变形机械连接,失效模式不同:压接坏了是电阻慢慢爬、通电发热;焊点坏了常常一次震动就断,而静态通电还是通的。判焊点好坏,第一判据是润湿角,不是「亮不亮」。大电流、强… · 2026/11/5 5:30:08
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38
多智能体协作系统架构设计与实践优化 1. 多智能体协作系统的时代背景2017年Transformer架构的诞生彻底改变了人工智能的发展轨迹。当行业还沉浸在单一大模型带来的性能突破时,Anthropic的研究团队已经将目光投向了更前沿的领域——如何让多个智能体通过协作产生超越单体能力的系统智能。这就像交响乐团中… · 2026/11/5 0:00:39
大模型系统性入门实战指南:从原理到部署微调的全链路路线 提到大模型的系统性入门,我先说个结论:这事真不难,难的是资料太散、路线太多,今天这个框架明天那个工具,新人很容易在“收藏夹吃灰”和“教程看到一半放弃”之间反复横跳。市面上关于大模型的内容少说有几十万篇&#… · 2026/11/5 0:00:39
鸿蒙文件系统沙箱机制解析与开发实践 1. 鸿蒙文件系统沙箱机制深度解析作为一名从Android转向鸿蒙开发的工程师,我深刻体会到文件系统访问是迁移过程中最先遇到的"拦路虎"。在Android上运行良好的文件读写代码,在鸿蒙真机环境却频频出现文件写入后神秘消失或直接抛出权限拒绝错误的… · 2026/11/5 0:00:39
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38