苍穹外卖员工管理模块:从登录鉴权到数据留痕的完整实践
接手苍穹外卖这个项目的时候我一开始真没把员工管理模块当回事。毕竟从功能菜单上看它就是一个典型的管理后台CRUD登录、新增员工、编辑、分页查询、启用禁用似乎没有任何悬念。但真正动手做起来才发现员工管理恰恰是整套系统中“地基属性”最强的模块——所有菜品管理、分类管理、订单操作、数据统计都要依赖当前登录人是谁、有没有权限、登录态有没有失效。任何一个环节设计不到位后面全是连锁问题。这篇文章把我做苍穹外卖员工管理模块时沉淀下来的设计思路、关键代码逻辑和踩坑过程整理了一份。内容包括从数据库表设计到登录鉴权的完整链路、从员工增删改查的细节约束到启用禁用背后的业务防呆。无论你是刚学完Spring Boot准备练手还是已经在做类似的管理后台项目这些经验和排查思路应该都能直接用上。1. 员工管理模块外卖系统最容易被低估的“地基”1.1 模块定位它不只是登录功能苍穹外卖的管理端面向的是餐厅运营人员核心业务是菜品、套餐、订单、店铺营业状态这些。但你打开管理后台的第一件事是什么登录。登录之后你是谁、你能干什么、你操作的数据归属于哪个维度这些起点全部落在员工管理模块上。所以员工管理在实际项目里承担了三层职责身份认证账号密码是否合法、状态控制账号是否被禁用、操作留痕每条业务数据的创建人、修改人是谁。后面这层经常被忽略等到你做数据审计或者统计“哪个员工新增了多少菜品”的时候才会发现当初没把员工ID和业务表关联起来是多麻烦的事。1.2 操作人字段业务表的隐形外键苍穹外卖的业务表比如菜品表、分类表设计时都带了一组公共字段create_user、update_user分别记录这条记录的创建人和最后修改人。这两个字段存的是员工ID就是员工管理模块向外输出的“身份凭证”。我见过不少项目偷懒只存操作时间不存操作人。短期看没什么影响一旦运营问“这个套餐谁改的”“这批数据谁导的”就直接哑火。所以员工管理的核心价值不在于那张员工表本身而在于它能给全局业务提供可靠的“操作人语义”。1.3 为什么说它不是纯CRUD如果只是做增删改查当然很简单Controller三层结构一套就完事。但员工管理牵扯到几个特殊的业务规则用户名唯一约束、密码加密存储、默认密码与密码修改、登录Token的签发与校验、状态变更对在线会话的影响。这些规则分布在Controller、Service、拦截器、Redis、数据库多个层面任何一个点处理不当都会出现“登录成功了但接口马上401”“禁用账号之后还能继续操作”之类的诡异现象。所以这篇文章的核心思路是把员工管理当成一条完整链路来看而不是若干个孤立接口。2. 员工表设计从字段规划到账号状态机2.1 表结构设计我最终采用的方案在做苍穹外卖之前我参考过一些开源后台的表设计最后沉淀下来一张employee表。核心字段如下字段名类型说明idbigint员工ID自增主键namevarchar(32)员工姓名usernamevarchar(32)登录用户名业务唯一passwordvarchar(64)登录密码MD5加密后存储phonevarchar(11)手机号sexvarchar(2)性别男/女id_numbervarchar(18)身份证号管理员录入statusint状态1启用0禁用create_timedatetime创建时间update_timedatetime修改时间create_userbigint创建人IDupdate_userbigint修改人ID四个公共字段create_time、update_time、create_user、update_user是跟业务表保持风格一致方便统一处理。这里有一个小细节创建人和修改人ID在新增员工的时候填的是“当前登录员工”的ID而不是新员工自己的ID。很多新手在写插入语句时会把这两个字段搞混导致数据被记到新员工头上。2.2 状态字段为什么用status而不是物理删除员工管理里有一个明显区别于普通数据维护的点员工账号不能轻易删除。原因很实际——历史业务数据都关联着员工ID物理删除会让所有历史记录的create_user、update_user变成悬空引用以后做数据统计或者回溯时全是坑。我采用的是逻辑状态控制status1代表启用status0代表禁用。禁用不等于删除账号还在数据库里业务数据关联不破坏只是不允许登录。这个设计跟大多数后台管理系统一致也是外卖这类运营系统最稳妥的方式。如果后续有离职员工我们可以把他的账号禁用同时保留历史数据这个员工ID就能永久作为历史操作记录的标识存在。2.3 唯一索引与并发隐患的源头username在业务上代表员工唯一的登录标识所以我在username字段上加了唯一索引ALTER TABLE employee ADD UNIQUE INDEX uk_username (username);这一步非常关键。代码层面就算做了用户名查重也拦不住两个请求同时到达的情况——两个请求都查到“用户名不存在”然后同时插入成功。数据库唯一索引是最后的兜底防线。后面我会在踩坑章节专门讲这个并发问题。3. 登录鉴权的完整链路从Controller到Redis3.1 登录接口的流程拆分员工登录看起来只是一个接口实际上是一条跨模块链路。苍穹外卖的登录逻辑分四步接收username和password用DTO对象接收并做非空校验根据username查询员工表比对MD5加密后的密码校验员工状态发现status0直接抛出“账号已被禁用”异常校验通过后生成JWT令牌登录成功。校验失败时尽量不要把原因说得太具体防止别人通过接口爆破用户名。但内部日志一定要记录详细方便排查。我会把日志打全用户名、IP、失败原因这对处理运营反馈很有帮助。3.2 JWT的生成与解析细节登录成功后生成JWT我用的是jjwt库。一般做法是把员工ID放进token的subject而不是把整个员工对象塞进去。这样token体积小解析也快还能避免敏感信息泄露。String jwt Jwts.builder() .setSubject(String.valueOf(employee.getId())) .setExpiration(new Date(System.currentTimeMillis() 7200 * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();secretKey放在application.yml里长度有要求HS256算法需要key至少256位32字节。这里踩过一个坑key太短会启动报错或者运行到某个环境才报错。建议直接用openssl rand -base64 32生成一个固定字符串然后配置到开发、测试、生产各自的配置文件里。解析token时如果JWT过期或签名不一致会抛出异常我统一在全局异常处理器里拦截转成“登录状态已过期请重新登录”的提示。3.3 ThreadLocal与当前登录人的线程传递登录之后的每个请求都需要知道“当前操作人是谁”。我的方案是用拦截器解析token再把员工ID和员工姓名放入ThreadLocal。这里有个设计取舍为什么用ThreadLocal而不是直接往每个Controller方法里传参数因为我希望Service层也能拿到操作人信息特别是在写create_user、update_user这类字段时如果通过Controller层层传参代码会非常啰嗦而且拦截器、切面、MQ消费者这些场景根本传不了参数。ThreadLocal使用上有一个铁律请求处理完必须remove。否则Tomcat线程池复用时ThreadLocal会残留上一个请求的数据导致下一个请求拿到别人的员工ID。我是在拦截器的afterCompletion方法里统一清理。Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { BaseContext.removeCurrentId(); }3.4 Redis缓存登录态一个值得耐心打磨的点单用JWT有一个问题token签发之后服务端无法主动让它失效。也就是说账号被禁用之后只要token还没过期他依然能调接口。这就很危险了。我的处理方式是把token同步存到Redis登录成功时以login:emp:{employeeId}为keytoken为value并设置和JWT过期时间一致的过期时间比如2小时。拦截器校验JWT后再去Redis对比token是否一致不一致就判定未登录。这样禁用账号时只要删掉Redis里的keytoken立刻失效不需要等它自然过期。为了优化用户体验我还在每次请求校验通过后刷新Redis里的过期时间做一个“滑动过期”用户只要持续在操作登录态就不会掉线一旦超过2小时没操作自动退出。4. 员工增删改查的实战拆解4.1 新增员工用户名查重与入参校验新增员工的接口我接收一个EmployeeDTO包含姓名、用户名、手机号、性别、身份证号等字段。Service层逻辑如下校验username是否为空按username查询数据库如果已存在抛出“用户名已存在”的异常密码设置为默认密码123456MD5加密后入库设置status1默认启用补全create_time、update_time、create_user、update_user。这里有个细节新增时不需要让前端传密码。员工入职时统一用默认密码之后可以在个人中心自行修改。这个设计减少了运营人员的工作量也避免密码在网络里明文传输。4.2 默认密码与MD5加密的正确姿势苍穹外卖这个项目里密码加密用的是MD5。放在生产环境MD5并不够强但在教学和演示项目里它的优势是简单、结果固定、工具类直接调用即可。password DigestUtils.md5DigestAsHex(123456.getBytes());不过要提醒一句如果后面接真实生产环境建议改成BCrypt或者加盐MD5否则用户密码一旦泄露就是裸奔状态。项目里可以先保住功能闭环再考虑安全加固。这个我在上线前肯定会升级。4.3 编辑员工回显与更新的防呆设计编辑员工分成两个接口一个是回显详情一个是提交更新。回显接口按ID查询返回给前端展示。这里要注意一点不允许把username改成别人的username。用户名是登录账号原则上一旦创建就不应该再允许修改。我的做法是更新接口不接收username字段DTO里根本不包含它。另一个细节是身份证号。手机号和身份证号都属于敏感信息编辑时建议做脱敏回显中间几位打星号用户手动确认或输入新值。特别是身份证号录入之后很少变更。更新员工信息时我只更新非空字段使用动态SQL。如果前端某个字段没传就不去覆盖数据库里的旧值避免把原数据清空。4.4 分页条件查询PageHelper和DTO设计员工列表页需要分页和按姓名模糊搜索。我用的是PageHelper插件Service层代码非常简洁PageHelper.startPage(page, pageSize); ListEmployee list employeeMapper.pageQuery(name); PageEmployee pageInfo (PageEmployee) list;PageHelper.startPage只对下一条SQL生效所以一定不要在前面拼接其他SQL。这是一个非常经典的坑。另外列表查询返回给前端时不应该把password字段暴露出去我在实体类上用JsonProperty(access Access.WRITE_ONLY)让密码只允许写入、不允许输出避免被序列化到JSON里。5. 状态管理启用/禁用背后的业务防呆5.1 禁用当前登录账号典型的自锁问题员工状态变更接口前端传来一个员工ID和status值。这里有一个必须做的校验不能禁用当前登录账号自己。理由很简单如果运营主管手滑把自己账号禁用了当前会话虽然不会立刻断开因为JWT还有效但有一点很尴尬——所有需要写操作人字段的操作都会失败因为下次登录已经登录不进去了。所以Service层要有这样的判断if (status 0 id.equals(BaseContext.getCurrentId())) { throw new BusinessException(不能禁用当前登录账号); }5.2 禁用后Token如何快速失效状态变为禁用之后Redis里login:emp:{employeeId}这个key会被我直接删除。这样该员工下一次请求任意接口时拦截器里会发现Redis中查不到对应的token直接返回“账号已被禁用”的结果。前端收到这个状态码后会跳回登录页并给出提示。这个方案比单纯依赖JWT过期时间要可靠得多。否则一个被禁用账号还能继续调接口在权限敏感的管理系统里是不可接受的。5.3 状态变更的操作留痕员工状态的每次变更都建议记录日志。我的做法比较简单在Service里拦截状态变更操作把操作人、操作类型、目标员工ID、变更前状态、变更后状态写入一张操作日志表。查询某个员工的完整状态变更历史时直接按员工ID查这张表即可。有了日志运营人员反馈“账号被莫名其妙禁用”的时候可以直接通过日志定位操作人、操作时间不用再靠猜。6. 踩坑实录三个让我排查半天的Bug6.1 登录后获取不到当前登录用户这个Bug非常典型监听到拦截器偶发取不到当前登录用户导致写create_user时出现空指针。我一开始以为拦截器没生效打印日志后确认拦截器走了于是怀疑是token解析失败。最终定位到原因同一个请求在Filter和Interceptor里各解析了一次token而我在某处把解析出来的员工ID放到了不同名的ThreadLocal key上读和写没有对齐。排查方法就是“读代码不如加日志”。我在拦截器的preHandle和Service层分别打印了当前线程名称和ThreadLocal的值发现一个为空一个不为空立刻就知道是上下文传递断了。6.2 并发新增相同用户名代码查重挡不住功能上线前联调一切正常结果测试同事用脚本并发创建了3个相同用户名的账号数据库直接报Duplicate entry错误。原因我在前面已经提过两个请求同时查库都发现用户名不存在然后都执行插入唯一索引兜底拦住了。代码层面的修复方案是在新增方法上加同步锁但这样会让所有新增员工操作串行化性能不好。更好的做法是保留数据库唯一索引作为最终防线然后捕获DuplicateKeyException转换成业务异常“用户名已存在”返回给前端。这样既不会产生脏数据又能给用户友好的提示。6.3 分页查询丢条件PageHelper区域生效问题有一次前端反馈点第2页时数据变成了所有员工的分页结果姓名筛选条件失效。排查发现是PageHelper.startPage和业务查询之间夹了另一条查询SQL导致分页拦截器作用在了那条SQL上。这个坑非常容易踩而且不是每次都出现。我的经验是PageHelper.startPage后面紧跟目标查询中间不要插任何可能走MyBatis的操作包括查询其他表。如果业务确实需要先查别的数据就先查完再做分页查询。6.4 从这些问题里沉淀下来的通用经验员工管理模块看起来简单实际上开发时最容易出的问题都集中在“跨组件传递”上——请求到拦截器到Service到Redis到数据库链条每多一跳状态丢失的概率就高一分。给新手几个建议全局用一个ThreadLocal管理当前登录人读写用同一个工具类凡是涉及登录态的校验都要同时考虑JWT和Redis两层唯一性约束永远要落到数据库层面代码校验只是提示优化分页插件SaaS化分页插件。嗯实际上逻辑要足够好靠的是把所有外部接口的状态变化都捋成一条单向链路然后每一层提前埋好“如果这里是空我该提示什么”。然后我通常在写完一个模块后会用接口测试工具把每个异常路径都过一遍未登录、token过期、账号禁用、用户名重复、分页条件丢失、禁用自己。这些case全过一遍员工管理模块基本就稳了。做苍穹外卖的这段过程让我最深刻的体会是校验逻辑宁可写在底层也不要指望前端帮你兜底。前端可以控制按钮显隐可以提示用户输入格式但真正到了接口层面服务端必须自己有完整的判断。把员工管理做稳了后面的菜品、订单模块开发起来会顺手很多。