苍穹外卖这个系列我打算从零开始把一整套外卖系统完整跑通。名字虽然是“外卖”但真正做进去你会发现它不只是一个点餐软件而是用户、门店、调度、履约、结算全链路都在里面。很多同学跟着教程做项目最大的问题是只照着敲代码不知道每一步为什么这样做最后换一个需求又不会了。所以这个系列的每一篇我都会把“为什么”讲清楚再给可以落地验证的代码和配置。01篇先把地基打起来业务边界、技术选型、项目骨架、数据库设计以及第一个真正能登录的接口。1. 苍穹外卖要做的不是“点餐”而是一整套履约链路1.1 业务边界谁在用在用什么流程接到“外卖系统”这个需求时如果第一反应是写“用户点餐、商家出餐、骑手配送”三个模块方向没错但颗粒度太粗。真实约束比这复杂一个门店可能同时接多个平台渠道一个用户在不同地址有不同的配送条件一个订单从用户下单到结算要经历十几个状态变更。如果一开始不把闭环画清楚后面每接一个模块都要返工。从使用角色来看苍穹外卖至少覆盖四个端用户端的小程序/H5主要是浏览门店和菜品、加购、下单支付、查看订单商家端的管理后台负责门店信息、菜品上下架、订单接单自营骑手端负责接单、取餐、送达再往后还有运营后台管用户、管门店、管活动。01篇不需要立刻实现四个端但数据库和项目结构要为这四个端预留清晰的边界否则后面推倒重来的成本非常高。我把核心业务链路画在纸上纸上画不算违规建议你也画一遍用户打开小程序 → 定位/选择门店 → 查看分类和菜品SKU→ 加入购物车 → 提交订单 → 生成待支付订单 → 支付回调 → 订单进入商家工作台 → 商家接单/出餐 → 骑手取餐/配送 → 用户确认送达 → 订单完成 → 对账结算。1.2 项目拆分从一个单体原型开始的节奏很多团队一上来就拆微服务用户服务、订单服务、支付服务、门店服务。我不反对微服务但对于一个从零起步、目标是用最快速度验证业务闭环的项目单体应用是最理性的选择。原因很简单单体部署简单、调试直观、事务边界好控制外卖核心流程对事务一致性要求高比如下单要同时扣库存、生成订单、清空购物车在一个事务里比跨服务去协调要稳得多。苍穹外卖01篇的计划范围是搭建单体后端工程、完成基础公共模块、设计核心表、跑通小程序登录。除此之外的通告、秒杀、骑手派单留给后续章节。这个节奏的目的不是“少做”而是先把最小可用闭环跑通让后面的功能都有地基可放。1.3 01篇做到哪里算合格给一个可量化的验收标准你别用“能启动就算成功”敷衍自己第一条启动Spring Boot应用后能通过统一入口访问到健康检查接口返回统一结果封装。第二条连接MySQL数据库按章节4的表结构执行建表脚本后能在IDE和命令行客户端里看到完整的表。第三条小程序登录接口能在数据库内新增用户、返回Token并且带Token访问用户信息接口能拿到当前用户不带Token则被拦截并返回标准错误体。如果你按01篇走完这三件事都满足说明项目的基础层已经立住了后续写任何业务功能都只是往上堆砖。如果第三条还没通先别急着往下看登录态是几乎所有业务接口的前提这里偷懒后面全是坑。2. 技术选型我为什么坚持Spring Boot MyBatis这套“保守组合”2.1 选型标准先问团队和后续维护技术选型不是“哪个火用哪个”而是结合团队熟悉度、招聘难度、运维成本、生态成熟度来做选择题。苍穹外卖这种业务特点是CRUD密集、状态流转复杂、多表查询多但对并发的要求不是“从第一分钟就要扛百万QPS”这种级别。Spring Boot MyBatis MySQL Redis这个组合最大优点是生态完备、文档多、招人容易。遇到问题网上能搜到的实战案例非常多这对项目长期维护是很重要的一件事。Java体系里的MyBatis半自动SQL的特点适合外卖这种查询条件多变的场景你可以在XML里手写联表查询把SQL执行计划完全掌控在自己手里不像全自动ORM在复杂查询时生成出灾难SQL。2.2 关键依赖清单与版本策略01篇我用的是Spring Boot 2.x系列JDK用1.8。如果你是新机器也可以用JDK17和Spring Boot 3.x但要注意3.x里javax命名空间改成了jakarta很多老教程代码不能直接复制。为了少踩版本坑我这里给出一份能直接用的依赖清单。spring-boot-starter-web提供Web容器和Spring MVCmybatis-spring-boot-starterMyBatis官方提供的Spring Boot整合包mysql-connector-javaMySQL驱动注意8.x驱动在URL里要加时区参数spring-boot-starter-data-redis后面做缓存和登录态会用到01篇先把Redis连上jjwt-api / jjwt-impl / jjwt-jackson生成和解析JWT这个组合比老旧的jjwt 0.9.1更规范lombok减少实体类样板代码但记住它只是编译期工具别让团队对它的心智依赖过重spring-boot-starter-validation参数校验登录接口的入参非空校验就靠它springfox-boot-starter 或 knife4j接口文档我建议直接上knife4j对OpenAPI3的支持更省心2.3 为什么不用那些更“时髦”的框架有人问为什么不用Spring Cloud Alibaba全套为什么不用MyBatis-Plus为什么不用GraphQL我的回答是如果你的系统连单机并发都还没撑住就不要为微服务治理的复杂度买单。Spring Cloud全家桶至少引入注册中心、配置中心、网关、链路追踪运维成本直接上一个数量级01篇的核心目标是业务闭环不是证明你会用中间件。MyBatis-Plus我本身不反对单表CRUD确实快但外卖核心查询大量是报表、聚合、多表联查你迟早要回到XML手写SQL。如果团队更习惯MP用它做单表操作、手写XML做复杂查询也完全可以选型原则是“别让自己被框架绑架”。GraphQL则是另一个极端对外卖这种后台管理会员端场景收益低、复杂度高RESTful在这个体量下依然是性价比最高的方案。3. 项目目录与后端骨架先把模块边界画清楚3.1 controller-service-mapper 三层划分的细节很多初学者把项目结构写成放一堆包每个包叫utils、common、entity、controller、service、mapper看起来挺标准但实际写起来会出现“controller里写业务、service里面全是if-else、mapper接口对应几百行XML”的现象。苍穹外卖的工程结构我建议按业务维度做顶层分包再在包内做三层划分。一种很好用的结构如下com.sky启动类com.sky.controller接入层入口用户端和商家端可以进一步拆user、admin两个子包因为后面很多接口虽然操作同一张表但权限和返回体完全不同com.sky.service业务逻辑层每个业务用例对应一个服务方法不要一个Service类几十个方法方法超过两屏就要考虑拆类com.sky.mapper数据访问层只做SQL映射不要在里面写业务判断com.sky.entity对应数据库表的实体字段类型必须和数据库严格对应com.sky.dto / com.sky.vo入参对象和出参对象不要直接把Entity抛给前端因为前端不需要的字段和内部状态暴露出去既不安全也浪费流量com.sky.common公共结果封装、异常定义、工具类、全局异常处理com.sky.config配置类比如WebMvc配置、Redis配置、Knife4j配置、拦截器注册为什么要把DTO和VO单独拆出来以订单为例数据库订单表里有支付平台回调的签名信息、内部备注、结算状态这些字段如果直接序列化给用户端等于把内部逻辑泄露出去。电商和外卖公司非常在意接口返回结构的一致性前端靠JSON解包你后端今天多返回个null、明天少返回个字段前端就要跟着改一次所以对外契约要稳定内部表结构随便变没关系。3.2 公共模块统一返回体、异常处理、JWT工具不管哪个接口对外返回格式一定要统一。我用的返回体很简单Data public class ResultT { private Integer code; private String msg; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.setCode(1); r.setMsg(ok); r.setData(data); return r; } public static T ResultT error(String msg) { ResultT r new Result(); r.setCode(0); r.setMsg(msg); return r; } }这里的code我建议不要用HTTP状态码业务码和传输状态码混在一起会让前端很痛苦。前端只关心“我是1还是0”其他业务状态通过data里的字段继续细分。全局异常处理也很关键不然MyBatis抛一个SQL语法异常时用户收到的就是一堆堆栈信息既不安全也不友好。我写了一个全局异常处理类捕获自定义业务异常后只返回msg捕获参数校验异常后返回第一条具体错误信息未知异常打日志后返回“系统繁忙”。JWT工具类我直接封装了生成、解析、获取userId三个方法。注意JWT的payload里不要放敏感信息签名密钥要放到配置文件中而不是写死在代码里否则部署时泄露密钥所有Token都能被伪造。3.3 配置文件yml里容易踩的坑application.yml里最容易被坑的是MySQL8的时区问题。如果你在连接串里不加serverTimezoneAsia/Shanghai大概率启动时会报“The server time zone value ‘йʱ’ is unrecognized”的乱码错误。我的配置server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/sky_take_out?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf8useSSLfalseallowPublicKeyRetrievaltrue username: root password: 123456 data: redis: host: localhost port: 6379 database: 0 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.sky.entity configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case这个配置和mapUnderscoreToCamelCase一定要开不然数据库字段user_id在实体类里映射不上userId你会查出一堆null。mapper.xml我是放在resources/mapper目录下别和Java接口类的包路径搞混前者是XML后者是接口位置不同但命名要对应上。4. 数据库设计核心表不是订单而是门店和SKU4.1 表清单与实际作用外卖系统最核心的表我个人的理解不是订单表而是门店表和SKU表。因为订单是“结果”门店和菜品才是“源头”没有门店覆盖用户根本不会下单没有SKU的库存和价格订单就没有可计算的对象。所以01篇建表时优先把这五类表设计好。user用户表userId作为主键openId在第三方授权登录场景下必须唯一加唯一索引手机号允许为空但唯一创建时间。shop门店表门店名称、地址、营业状态、配送范围、评分、起送价、配送费。category分类表分类名称、类型1表示菜品分类2表示套餐分类、排序、状态。dish菜品表和setmeal套餐表菜品表存基础信息、价格、图片、分类套餐表对应多个菜品要额外建setmeal_dish关系表维护套餐和菜品的关系。orders订单表订单号、用户id、门店id、订单状态、支付状态、总金额、地址快照、下单时间。order_detail订单明细表记录每个菜品快照、数量、价格。shopping_cart购物车表用户加购记录其中冗余了菜品名称和图片避免查询购物车时反复联表。address_book地址簿用户地址信息标签家/公司/学校是否默认地址。4.2 订单表为什么必须冗余快照我先说一个很多人踩过坑的结论订单明细里的菜品名称、价格、图片一定要在下单那一刻把当时的菜品数据复制一份进来而不是只存dishId查询时再去菜表拿最新数据。为什么因为菜品是可以上下架、改价、改名的。用户下单时商品是25元商家晚上改成了30元如果明细表只存一个dishId用户查订单时看到的是30元客诉就来了。从履约角度看订单是企业发生交易的凭证单据里记录的信息必须是生成那一刻的快照而不是未来随时变化的状态。这个思想不只适用于外卖电商、票务、酒店全通用。另外建议给orders表加一个order_number业务上展示给用户的是它而不是自增主键。自增主键会泄露业务量比如今天注册用户10001和10002很容易推断出新增规模。随机字符串订单号虽然冗长但对用户和运营来说更安全。4.3 金额、状态、时间的字段规范外卖和钱相关的字段是重点规范定下来后整个团队都要遵守金额一律用Decimal类型Java实体对应BigDecimal禁止用float和double存金额浮点数在二进制下无法精确表达1.0 - 0.9可能会得到0.09999999999999998对账时差一分钱都查得头大。订单总金额、配送费、实付金额这几列默认值是0.00不允许null。状态一律用tinyint并且在Java常量类里定义不要散落在代码各个位置。比如订单状态1待付款、2待接单、3待配送、4配送中、5已完成、6已取消。为什么用状态值而不是字符串枚举值占空间小、索引效率高而且不会出现“已发货”和“发货完成”这种语义模糊。所有表必须有create_time和update_time并让数据库默认值自动维护实体类映射LocalDateTime。update_time用ON UPDATE CURRENT_TIMESTAMP自动更新这样排查数据问题时知道是什么时候变的。建表脚本我不在这里全部贴上篇幅太长但给一张核心表结构参考后面的代码都依赖这个schema表名核心字段说明useropenid, phone, nicknameopenid唯一索引手机号允许空但唯一shopname, status, delivery_range营业状态给商家端即时上下架用categorytype, name, sorttype区分菜品和套餐dishshop_id, category_id, price, status菜品价格Decimal(10,2)setmealdish_ids, price, status套餐价格同样Decimalordersorder_number, user_id, shop_id, status, amount订单号唯一索引amount精度Decimal(10,2)order_detailorder_id, dish_name, dish_price, quantity菜品快照冗余shopping_cartuser_id, dish_id, quantity, amount用户维度加购记录address_bookuser_id, consignee, phone, address, is_default默认地址用唯一布尔字段注意同一用户只能有一个默认地址5. 第一个能跑通的功能小程序登录的完整链路5.1 登录流程拆解01篇的业务功能就从登录开始。苍穹外卖的登录本质是“用第三方平台的授权身份换取我们后端颁发的Token”。我把流程拆成四步每一步你都要知道为什么第一步前端调用第三方平台获得登录凭证code。第二步后端拿着code调用平台接口获取用户在该平台的唯一标识openid。第三步用openid去user表里查查得到就说明老用户直接走登录成功查不到就自动创建一个新用户再走登录成功。第四步生成Token返回给前端前端后续请求带上这个Token后端解析通过后就知道当前用户是谁。这个方案最核心的价值在于后端永远不用存用户密码也接触不到用户的原始登录凭证。认证事务交给平台我们只做业务账号关联。哪怕user表泄露也只是openid列表不涉及密码。5.2 后端接口与JWT签发登录接口的Controller长这样为了演示方便我把第三方码换成了一个模拟参数真实项目里要在这个位置完成平台code的校验RestController RequestMapping(/user/user) public class UserController { Autowired private UserService userService; PostMapping(/login) public ResultLoginVO login(RequestBody LoginDTO dto) { User user userService.login(dto.getCode()); String token JwtUtils.generateToken(user.getId()); LoginVO vo new LoginVO(); vo.setToken(token); vo.setNickname(user.getNickname()); return Result.success(vo); } }Service的核心逻辑Override public User login(String code) { // 这里在真实项目里要调用平台接口解析code String mockOpenid mock_openid_ code; User user userMapper.getByOpenid(mockOpenid); if (user null) { user new User(); user.setOpenid(mockOpenid); user.setNickname(新用户); user.setCreateTime(LocalDateTime.now()); userMapper.insert(user); } return user; }如果你在本地调试时不方便真正对接平台接口我建议用一个以mock开头的固定openid来跑通后续逻辑但上线前一定要替换成真实解析逻辑使用mock数据时也要确认生产环境配置不能误用。JWT生成用的是jjwt版本public static String generateToken(Long userId) { Date now new Date(); Date expire new Date(now.getTime() 7 * 24 * 3600 * 1000L); return Jwts.builder() .setSubject(userId.toString()) .setIssuedAt(now) .setExpiration(expire) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }有效期设为7天比较合理。太短了用户体验差动不动要求重新登录太长了安全隐患大Token泄露后有很长的攻击窗口。如果后面要做“记住我”可以再拆分accessToken和refreshToken但那是后话01篇先不给自己加戏。5.3 登录态校验拦截器与ThreadLocal登录接口本身不需要校验Token但用户信息接口、购物车接口、下单接口都必须校验。我用了Spring MVC的HandlerInterceptor统一处理只拦截/user/下的请求并排除/loginpublic class JwtTokenInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); } try { Long userId JwtUtils.parseToken(token); UserContext.set(userId); return true; } catch (Exception e) { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:0,\msg\:\未登录\}); return false; } } Override public void afterCompletion(...) { UserContext.remove(); } }这里有个细节经验分享UserContext用ThreadLocal保存当前登录用户拦截器里设置好controller和service直接用但千万要在afterCompletion里remove。因为Tomcat的线程池是复用的如果ThreadLocal不清理下一次同一个线程处理别的用户请求时还能读到上一个用户的信息这就是严重的越权漏洞。别问我怎么知道的排查这种偶发串号问题极其折磨人。注册拦截器时记住排除登录、退出、接口文档相关的路径。Knife4j的静态资源路径如果被拦截你打开文档页面时可能会看到空白或无法访问这个坑很常见。5.4 接口自测用Debug和工具模拟请求整个链路写完先别急着写前端页面。用IDE的Debug启动然后直接调接口。我通常的做法是先用一个HTTP工具发起POST /user/user/loginBody是JSON比如{code:test001}。看返回结果是否包含token。拿到Token后发起GET /user/user/info在Header里加上Authorization: Bearer 返回的Token看能否正确返回当前用户信息。如果返回401优先检查拦截器的排除路径写没写对以及Token的Header名称是不是一致。如果返回500去看日志重点看MyBatis的mapper有没有映射上用DEBUG级别把SQL日志打开它会打印执行的SQL和参数能很清楚地发现字段名不匹配。基于01篇的项目结构下一个功能无论是做门店列表还是菜品展示核心套路都一样建表、写mapper、写service、写controller、走登录态自测。你可以把这一篇当作一张地图后续所有功能都贴着这张地图走方向就不会歪。