
day01把登录接口跑通的时候我还挺得意的毕竟从建表到JWT签发那一整套链路不是白折腾的。结果到了day02看到需求文档里“员工管理”和“分类管理”两个模块心里反而是另一种紧张——登录只是一个点这两个模块是整个项目里第一套完整的业务CRUD你要处理分页、状态、新增、修改、删除还要考虑和其他表的关联关系。说实话很多人在这个阶段就掉队了不是因为代码写不出来而是因为不知道这些功能背后真正的坑在哪。这篇文章把我做day02时踩过的、查过的、最终沉淀下来的东西完整梳理一遍包括JWT拦截器在哪一步起作用、分页插件PageHelper那个隐蔽的生效机制、员工状态管理的设计意图、分类删除时外键约束怎么处理最后附带一个很多人关心的点这套项目部署到Linux服务器上之后会遇到哪些教程里没写的问题。适合正在跟苍穹外卖项目的同学也适合想把CRUD写明白的初级后端开发者。1. 登录校验的真正主场员工管理才是JWT拦截器的第一个战场1.1 登录接口只是发了一张通行证校验动作发生在“下一个请求”里day01的登录接口做的是什么接收用户名密码校验通过后生成一个JWT返回给前端。很多新手以为登录做完就万事大吉了其实登录本身永远不会帮你拦截任何人——它只是发了一张“通行证”。真正发挥作用的地方是员工管理模块里每一个需要登录身份的请求。这就是为什么苍穹外卖项目里员工管理的代码刚进场就要先写一个JwtTokenAdminInterceptor。这个拦截器做的事很纯粹从请求头里把前端传回来的token拿出来验签、解析、取出员工id然后放行。而员工管理的所有接口——分页查询、新增员工、启用禁用、编辑——都需要走这一层校验。这里有个很重要的设计逻辑值得多说一句为什么校验要放在拦截器里而不是在每个Controller里都写一遍解析token的代码想象一下如果员工模块、菜品模块、套餐模块、订单模块每个都复制一遍解析逻辑后期想要调整token过期时间或者加一层权限控制你得改多少个文件拦截器的意义就是把“所有需要登录的后台接口”统一收敛到一个入口这也是实际企业项目里的常规做法。1.2 BaseContext与ThreadLocal为什么不把员工id直接塞在Session里拦截器解析JWT之后拿到的是员工id问题来了——这个id怎么传给后面的Service层很多初学时的第一反应是用Session或者直接把这个id作为参数一路传下去。但苍穹外卖这里用的是BaseContextThreadLocal的组合这个设计值得单独拎出来讲。ThreadLocal的本质是把数据保存在“当前线程”内部。Java Web应用中一个请求从头到尾由同一个线程处理所以在这里面存数据等于给这一次请求开辟了一个私有的、线程安全的储物柜。相比Session它不依赖容器相比参数传递它不污染方法签名。项目里的BaseContext就是封装了ThreadLocal的存取操作拦截器里BaseContext.setCurrentId(empId)Service里想拿操作人id时直接BaseContext.getCurrentId()。这里有一个很容易被忽略的点用完必须清理。ThreadLocal的本意是线程隔离但如果线程被复用了Tomcat的线程池就是这样上一次请求留下的数据就会泄露到下一次请求里。所以项目里拦截器的afterCompletion方法中一定要调BaseContext.removeCurrentId()把这个“储物柜”清空。这个细节虽然代码只有一行但漏掉它后面排查数据串号问题会非常痛苦。1.3 拦截器注册里的路径排除坑拦截器写好了还得注册到配置里。这里有个典型错误把登录接口的路径也拦住了。你想想登录的时候前端还没拿到token呢如果拦截器路径配置不对登录接口自己反而先被拦截直接出现“未登录”的报错查半天都找不到原因。苍穹外卖项目里登录路径是/admin/employee/login注册拦截器时需要对它放行。正确做法是拦截/admin/**然后排除/admin/employee/login。同时要注意静态资源路径比如/doc.html、/webjars/**如果你配了Knife4j之类的接口文档这些路径也要排除否则连API调试页面都打不开。我的习惯是每次改完拦截路径配置立刻用登录和未登录两种状态各测一遍别等到后面模块多了再排查那时候你已经分不清是谁拦的了。2. 员工管理分页查询DTO设计、PageHelper和Long精度问题2.1 为什么查询参数要封装成DTO而不是直接甩Map给Mapper员工分页查询是day02的第一个核心功能接口接收的参数有页码、每页条数、员工姓名和状态。新手最容易图省事直接写一个Controller方法参数一股脑接收然后传到Mapper的XML里拼SQL。这样做的问题不是“不能运行”而是“没法维护”。姓名是可选的状态也是可选的查询条件多起来之后方法签名会越来越离谱而且Map取参数时如果key拼错了编译期完全不会报错只有运行时才炸。苍穹外卖这里用了EmployeePageQueryDTO来封装查询参数这个DTO是纯数据对象专门服务于“从接口到Mapper”这段路的参数传递。实操中有一个细节DTO字段类型要设计好。页码和每页条数在前端传过来时是字符串Spring会自动转成Integer但如果你DTO里写的是int前端漏传这个参数时接口直接400。用Integer包装类型反而更安全为null时可以在Service层给个默认值。这种小事在实战里经常影响接口的健壮性随手处理好后期少很多麻烦。2.2 PageHelper只在第一条SQL生效分页插件的默认机制分页查询的核心是PageHelper。很多教程只告诉你“调一下 PageHelper.startPage()再查list就行”但没告诉你它背后的机制所以后面一旦出错你根本无从下手。PageHelper的工作原理是当你调用PageHelper.startPage(pageNum, pageSize)时它会在当前线程的ThreadLocal里放一个分页参数又是ThreadLocal。紧接着执行的下一条SQLMyBatis的拦截器会拦截到这条SQL然后动态拼接LIMIT语句。这个机制决定了它的两个使用铁律startPage后必须直接跟着你要分页的那条查询中间不要插任何其他查询因为如果中间还查了别的分页参数可能被那个查询消耗掉。如果你在一个Service方法里先查了一个统计用的SQL再查真正要分页的list那分页就失效了或者作用在了错误的SQL上。苍穹外卖的mapper里员工分页查询是一条SQL完成的所以没问题。但你自己扩展的时候要注意尽量让分页查询保持“孤军奋战”的状态。分页结果集还需要做一件事把查询出的员工记录转成VO返回给前端。这涉及到密码字段的处理——员工列表不能把密码加密串暴露出去。项目里用EmployeeVO来控制返回的字段Service层把查询出的对象拷贝到VO并去掉敏感字段。这种做法值得养成习惯任何时候都不要图省事直接返回实体类给前端因为实体类的字段往往比前端真正需要看到的要多。2.3 一个让人头疼的隐藏坑雪花算法ID与前端精度丢失这是我在做分页查询时踩得最莫名其妙的一个坑。数据库里员工表的主键用的是雪花算法生成的Long类型比如1725634123456789504这样一个19位数字。前端拿到这个数字后如果你打开控制台看会发现最后几位变成了...500精度丢了。原因是JavaScript的Number类型能安全表达的最大整数是2的53次方减1也就是9007199254740991只有16位而雪花ID通常是19位。超出的部分被四舍五入精度就丢了。这个问题的直接后果是把这条数据的ID当作参数去调编辑接口、删除接口时后端收到的ID和数据库里的对不上操作失败。解决办法是在JSON序列化层面统一处理给Long类型字段加一个转为String的序列化器这样前端拿到的就是字符串不会丢精度。苍穹外卖里用Jackson的自定义配置针对Long和long类型绑定ToStringSerializer这个配置一旦加上整个项目都生效后续菜品表、套餐表的主键都不用担心这个问题。如果你是自己搭项目这个配置建议从一开始就加上不要等前端报“更新失败”再加。3. 新增、启用禁用、编辑状态操作背后的设计细节3.1 密码加密存储MD5虽然不完美但业务约定就是它新增员工时前端传过来的表单里通常没有密码默认密码是123456。但你不能真的明文存123456后面员工用这个密码登录时day01登录逻辑里怎么校验数据库里就得存什么格式。苍穹外卖项目用的是Spring自带的DigestUtils.md5DigestAsHex也就是MD5加盐后再转十六进制存储。我知道你要说MD5不安全彩虹表攻击确实存在。但作为学习项目这个选择是有原因的一是实现简单不用额外引BCrypt依赖二是在教学场景里重点是让你理解“密码不能明文存储”这个原则。到了真实企业项目建议至少换成BCrypt它内部自动加盐每次加密结果都不同安全性高得多。这里有个实操细节新增员工时你要先把默认密码加密再去判断用户名是否已存在。顺序不能反。如果先查用户名再加密再插入中间任何一步抛异常事务回滚倒是没问题但先判重可以让代码逻辑更清晰也避免数据库层报“唯一索引冲突”这种不太友好的错误。项目里设计了全局异常处理器统一捕获这类业务异常返回给前端“用户名已存在”的提示而不是让前端看到一条裸的SQL异常。3.2 启用禁用为什么改状态也要一个独立接口员工管理里有个“启用/禁用”开关前端点一下调用的就是一个单独的状态修改接口。有些新手会疑惑这不就是update employees set status ? where id ?吗为什么不直接复用编辑接口我的理解是状态修改是一个高频、语义明确的操作前端只需要传员工id和目标状态不需要把员工的其他字段都传一遍。如果复用编辑接口前端必须把所有字段都带上来万一某个字段传漏了员工信息就被覆盖成空或者默认值了。独立接口反而更安全也更符合接口语义设计。实现上项目里是在启禁用员工时加了一个判断逻辑不允许操作当前登录的这个账号。你想你要是能把自己禁用掉那系统里的管理员就全被锁在门外了这种业务规则的兜底非常重要。很多教程不会写这个细节但真实项目里这种“自我保护”逻辑很常见我建议你以后做任何系统都保留这个意识——凡是可能把系统带进“无人可用”状态的操作都要特殊处理。另外状态字段的更新需要注意SQL写法。项目里是直接update employee set status #{status} where id #{id}完全没有把整个实体查出来再更新的必要。你写代码时少一次查询数据库压力就小一点这种性能观念要从简单功能就开始培养。3.3 编辑员工时哪些字段可以动哪些不能碰编辑员工是最后一个常规CRUD操作它比新增多了一层考虑更新范围。员工表里有用户名username、密码password、姓名name、手机号phone、性别sex、身份证号idNumber、状态status、创建时间等字段。编辑接口能改的是姓名、手机号、性别、身份证号这些基础信息用户名和密码不应该出现在这个接口里。为什么用户名通常作为登录标识不允许随便改密码必须走专门的修改密码流程可能需要旧密码验证、确认新密码等。如果把这两个字段放进编辑接口的DTO里就意味着任何一个能操作员工管理的账号都能直接改别人密码这就是越权漏洞了。所以你在设计DTO字段时要反问自己一句这个字段真的需要出现在这个接口里吗不需要的坚决不放。项目里编辑功能除了更新基础信息还会把更新时间改成当前时间操作人记录通过BaseContext取出来这些公共逻辑在day02开始就要形成固定套路。4. 分类管理看起来比员工简单删除操作却让人查了一天4.1 菜品分类和套餐分类一张表拆两个类型的思路分类管理在苍穹外卖里特指菜品分类和套餐分类分类表category里用一个type字段区分1代表菜品分类2代表套餐分类。很多人第一次看这个设计觉得奇怪为什么不分成两张表拆成两张表当然可以但从业务上看菜品分类和套餐分类的结构完全一样——都是id、类型、名称、排序、状态。用同一张表加type区分查询菜品分类时带上type1套餐分类带上type2公共代码能复用一大片分类的新增、编辑、删除逻辑可以一套代码同时兼容两种分类。这就是典型的“结构相同的业务模型优先考虑单表多态”的设计思路。排序字段sort在这个模块里很有存在感前端展示分类列表时按sort排序数字小的排在前面。新增分类时sort由前端指定后端只需要正序返回。这个小字段如果你漏了分类顺序就会走默认的id排序跟业务期望完全不一致。做管理后台的时候凡是涉及“展示顺序”的一定要记得有个排序字段别指望数据库的插入顺序就一定是用户想要的顺序。4.2 删除分类被外键拦住service层自己查还是等数据库报错分类管理里最经典的问题就是删除。分类下面可能挂着菜品或者套餐如果直接把分类删了菜品就变成了“孤儿数据”前端再展示菜品时找不到分类名称就会出现一堆“未知分类”。苍穹外卖里删除分类前要检查分类下是否关联了菜品如果关联了直接抛出业务异常告诉前端“当前分类下有菜品不能删除”。这里的关键是这个检查逻辑放在哪儿项目里的做法是在Service层先调用Mapper去数这个分类id在菜品表里出现的次数大于0就抛异常。为什么不干脆靠数据库外键约束让MySQL报错理论上可行但数据库报错返回的错误信息是给程序看的技术性错误用户看到“外键约束失败”根本不知道发生了什么。在Service层做业务判断能给出“当前分类下有菜品不能删除”这种清晰的中文提示体验完全不一样。这也是分层设计的意义——数据库管数据完整性Service管业务表达。如果你自己扩展项目删除前需要检查多个关联表建议逐个检查并给出具体的提示比如分类下还有菜品时说菜品还有套餐时说套餐这样用户操作时才知道到底该去清掉哪边。不要一个笼统的“有关联不能删”排查起来浪费时间。4.3 物理删除vs逻辑删除分类模块里怎么选分类管理在苍穹外卖里用的是物理删除也就是真的把这一行记录从表里删掉。但实际企业项目里很多业务数据是不允许物理删除的尤其涉及历史订单、审计追踪的场景。为什么这里可以物理删因为分类本质上就是一个字典类型的配置数据后面还有套餐模块如果分类删了会影响历史数据展示那就要考虑逻辑删除——给表加一个deleted字段删除时置为1查询时统一过滤。判断一个模块该用哪种删除方式最简单的问题是删掉之后还有没有历史数据需要引用这条记录如果有必须逻辑删除如果没有物理删除就够。分类管理模块在当前业务里没有历史单据引用的问题所以物理删除是合理的便宜方案。你要是以后自己做大一点的项目建议所有“主数据”都先按逻辑删除设计因为一旦上线运行想从物理删除改成逻辑删除是所有数据迁移里最麻烦的一类改动。5. 面向Linux部署的几个隐藏问题时区、连接串、打包路径5.1 时间字段差8小时先查数据库连接时区很多同学本地跑得好好的部署到Linux服务器上之后发现新增员工创建时间晚8小时。这个问题大概率不是代码问题而是MySQL连接串里没用对时区参数。本地开发时MySQL默认时区可能和系统保持一致偏差不明显。服务器上如果系统时区是UTC而数据库存的是UTC时间前端展示时没做转换看起来就“慢了8小时”。解决办法是在JDBC连接串上显式指定时区苍穹外卖项目建议用serverTimezoneAsia/Shanghai同时MySQL服务端也可以设置default-time-zone 08:00。两边统一之后时间问题一般就消失了。这种问题排查起来最怕“到处猜”用show variables like %time_zone%先看数据库当前时区再对比代码里拿到的当前时间的格式化结果基本就能定位是哪一层不对。别上来就改Java代码里的日期工具类大概率不是那里的问题。5.2 打包成jar之后配置文件路径和端口别再用相对路径本地用IDEA启动项目时配置文件读取的是项目目录下的application.yml一切正常。部署到Linux上用java -jar运行时如果你在代码里写了相对路径去读文件比如new File(upload/image.jpg)这个路径是相对于你执行java命令的那个目录而不是jar包所在的目录。很多人在这里翻车明明文件就在jar旁边程序就是找不到。最稳妥的方式是把所有外部文件读写路径统一配置成绝对路径或者通过System.getProperty(user.dir)动态拼接。苍穹外卖项目里涉及图片上传、日志输出这些路径在生产环境要提前规划好。建议在服务器上固定一个目录专门放这些文件比如/opt/sky-take-out/data配置文件里用占位符管理部署的时候只需要改config文件不用改代码重新打包。端口方面要注意SpringBoot默认8080如果不想用就在部署环境的启动参数里用--server.portxxxx覆盖或者写在application.yml中。防火墙记得放行对应端口否则外部访问不到。这个不是项目本身的坑但几乎所有第一次部署的同学都会问“为什么启动成功但访问不了”防火墙和云安全组是最容易漏掉的两个点。5.3 启动脚本与日志为什么建议用nohup配合输出重定向部署到Linux上最简单的启动方式是java -jar sky-take-out.jar但你一关终端程序就跟着退了。真正长期运行要用nohup java -jar sky-take-out.jar app.log 21 这种方式让进程在后台挂住日志输出到文件里即使终端断开也不影响。日志文件的查看技巧也要掌握。程序跑起来之后遇到问题先去app.log里看异常栈不要盯着控制台看。如果日志里打印了MyBatis的SQL配合日志位置就能很快定位到是SQL写错了还是参数传错了。如果你在代码里用了Slf4j打印业务日志生产环境排查问题时这些日志能救你一命。很多初学同学把日志当成多余的代码等真正上了Linux部署才发现没有日志的排查难度是地狱级的。6. 做完day02最值得沉淀的是一套“CRUD模块复制法”employee和category这两个模块做完你会发现它们的套路惊人地相似Controller接收请求Service处理业务逻辑Mapper操作数据库DTO做参数传递VO做返回封装中间穿插全局异常处理和公共字段填充。这不是巧合而是项目分层架构的必然结果。从我带项目的经验来看day02最大的价值不在于员工管理本身而在于你第一次完整地经历了一个业务模块从接口设计到异常处理的全流程。只要你在做的时候留心总结后续菜品管理、套餐管理、订单管理几乎都是同一套模板在复用可能只是多了几张关联表、多了点复杂查询。我建议你做完day02之后把自己写的代码整理出一份“模块清单”——新建一个模块要写哪些文件、每个文件的职责、大概的命名规律下次做新模块直接照着清单走效率能提升一大截。这个项目越往后你会发现基础打得牢不牢就看day02这些细节你有没有真正吃透。分页、状态、删除保护、线程变量清理、JSON精度这些不是考试知识点是每一个真实项目里都会反复遇到的基础功。手边有环境的话建议你亲手把这两个模块再完整写一遍不要直接抄参考答案。踩坑踩得越早后面写项目就越稳。