☰
苍穹外卖员工管理模块:从登录鉴权到数据留痕的完整实践
2026/10/20 16:55:28 网站建设 项目流程

接手苍穹外卖这个项目的时候,我一开始真没把员工管理模块当回事。毕竟从功能菜单上看,它就是一个典型的管理后台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创建人ID
update_userbigint修改人ID

四个公共字段(create_time、update_time、create_user、update_user)是跟业务表保持风格一致,方便统一处理。这里有一个小细节:创建人和修改人ID在新增员工的时候,填的是“当前登录员工”的ID,而不是新员工自己的ID。很多新手在写插入语句时会把这两个字段搞混,导致数据被记到新员工头上。

2.2 状态字段:为什么用status而不是物理删除

员工管理里有一个明显区别于普通数据维护的点:员工账号不能轻易删除。原因很实际——历史业务数据都关联着员工ID,物理删除会让所有历史记录的create_user、update_user变成悬空引用,以后做数据统计或者回溯时全是坑。

我采用的是逻辑状态控制:status=1代表启用,status=0代表禁用。禁用不等于删除,账号还在数据库里,业务数据关联不破坏,只是不允许登录。这个设计跟大多数后台管理系统一致,也是外卖这类运营系统最稳妥的方式。如果后续有离职员工,我们可以把他的账号禁用,同时保留历史数据,这个员工ID就能永久作为历史操作记录的标识存在。

2.3 唯一索引与并发隐患的源头

username在业务上代表员工唯一的登录标识,所以我在username字段上加了唯一索引:

ALTER TABLE employee ADD UNIQUE INDEX uk_username (username);

这一步非常关键。代码层面就算做了用户名查重,也拦不住两个请求同时到达的情况——两个请求都查到“用户名不存在”,然后同时插入成功。数据库唯一索引是最后的兜底防线。后面我会在踩坑章节专门讲这个并发问题。

3. 登录鉴权的完整链路:从Controller到Redis

3.1 登录接口的流程拆分

员工登录看起来只是一个接口,实际上是一条跨模块链路。苍穹外卖的登录逻辑分四步:

  1. 接收username和password,用DTO对象接收并做非空校验;
  2. 根据username查询员工表,比对MD5加密后的密码;
  3. 校验员工状态,发现status=0直接抛出“账号已被禁用”异常;
  4. 校验通过后生成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}为key,token为value,并设置和JWT过期时间一致的过期时间(比如2小时)。拦截器校验JWT后,再去Redis对比token是否一致,不一致就判定未登录。这样禁用账号时,只要删掉Redis里的key,token立刻失效,不需要等它自然过期。

为了优化用户体验,我还在每次请求校验通过后刷新Redis里的过期时间,做一个“滑动过期”:用户只要持续在操作,登录态就不会掉线;一旦超过2小时没操作,自动退出。

4. 员工增删改查的实战拆解

4.1 新增员工:用户名查重与入参校验

新增员工的接口我接收一个EmployeeDTO,包含姓名、用户名、手机号、性别、身份证号等字段。Service层逻辑如下:

  1. 校验username是否为空;
  2. 按username查询数据库,如果已存在,抛出“用户名已存在”的异常;
  3. 密码设置为默认密码123456,MD5加密后入库;
  4. 设置status=1,默认启用;
  5. 补全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); List<Employee> list = employeeMapper.pageQuery(name); Page<Employee> pageInfo = (Page<Employee>) 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. 踩坑实录:三个让我排查半天的Bug

6.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全过一遍,员工管理模块基本就稳了。

做苍穹外卖的这段过程,让我最深刻的体会是:校验逻辑宁可写在底层,也不要指望前端帮你兜底。前端可以控制按钮显隐,可以提示用户输入格式,但真正到了接口层面,服务端必须自己有完整的判断。把员工管理做稳了,后面的菜品、订单模块开发起来会顺手很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询