手头这套外卖点餐配送系统是我从需求分析一直跟到部署上线的完整项目技术栈就是标题里那套SpringBoot Vue Spring Cloud典型的微服务分布式组合。业务角色覆盖用户端、商户端、骑手端、平台管理端核心链路包含下单、支付、接单、配送、结算跑在微服务架构上确实不是噱头而是业务复杂度撑到了那个程度。今天聊的内容不打算停留在架构图层面我把项目中真正动过手的地方捡重点讲服务怎么拆、员工账号体系怎么设计、以及一个看着不起眼但细节极多的功能——员工忘记密码。这个功能如果只是在单体项目里写个update语句那没什么好说的但如果把它放到微服务、分布式、前后端分离的背景下涉及验证码、Redis、JWT、网关路由、前端状态机水就深了。这篇文章把我当时的方案、代码、踩过的坑全部摊开供正在做类似系统的朋友参考。1. 项目定位与技术选型为什么这套外卖系统不是单体项目1.1 先盘清楚系统里到底有哪些角色凡是做过外卖类系统的都知道这种项目表面看是“用户下单、商家出餐、骑手配送”一条线实际落地时每个端都有自己的独立诉求和操作节奏。用户端要的是下单快、支付顺、订单状态实时可查。商户端要管菜品、库存、接单、打印小票。骑手端要抢单、取餐、送餐、上报异常。平台管理端则负责运营、审核、员工账号管理、数据统计。光是这几类角色业务模块就明显不是一个单体应用能优雅承载的——不是说单体做不了而是当团队并行开发、模块独立发布、流量峰值隔离这些需求出现时单体的耦合成本会越来越高。这里有一点必须先说清楚员工账号不等于普通用户账号。员工是平台的运营人员、客服、审核员、以及各个站点的管理者他们登录的是管理后台权限各不相同账号由管理员开通密码策略也更严格。所以员工模块里必须包含“忘记密码”这类账号自服务功能否则管理员只能手动重置密码效率低不说安全上也容易留隐患。1.2 微服务选型与真实取舍技术选型上我直接锁定了 Spring Cloud Alibaba 这套生态。理由很实际Nacos 同时承担注册中心和配置中心部署简单中文文档全OpenFeign 做服务间调用写起来和本地方法没区别Gateway 做统一入口路由、鉴权、跨域都收敛在这一层分布式事务用 Seata 的 AT 模式对业务侵入最小。核心组件版本我贴一下方便参考组件版本用途Spring Boot2.7.x基础框架Spring Cloud2021.0.x微服务基础组件Spring Cloud Alibaba2021.0.4.0Nacos、Seata 适配Nacos2.1.x注册中心 配置中心Spring Cloud Gateway2021.0.xAPI 网关OpenFeign2021.0.x服务间 HTTP 调用Redis6.x验证码、分布式锁、缓存RabbitMQ3.x订单事件异步解耦MinIO8.x文件存储选型时也考虑过 Dubbo但团队更熟悉 Spring 体系而且 OpenFeign 在调试时可以直接看到 HTTP 请求对排错更友好。注意微服务不是为了“看起来高级”才用的。我在这个项目里坚持微服务核心原因是希望订单服务承担高并发压力时可以独立扩容配送服务读取位置时不拖累其他模块员工管理的高频数据库操作也不至于和用户下单抢连接资源。如果你做的系统每天就几百单单体完全够用别为了技术情怀给自己找麻烦。1.3 整体架构与请求流转路径先看一条完整请求怎么走前端页面发请求到 NginxNginx 将 /api 前缀的请求转发到 Gateway 网关网关做路由匹配、全局鉴权、参数校验然后根据服务名转发到具体微服务微服务内部通过 Feign 互相调用数据落在 MySQL热点数据进 Redis异步消息走 RabbitMQ。前端项目用的是 Vue3 Vite Element Plus Pinia。之所以没选 Vue2一个是 Vue3 的组合式 API 在管理复杂状态时更干净另一个是 Element Plus 对 Vue3 的支持已经很成熟表格、表单、弹窗这些后台常用组件开箱即用。2. 服务拆分与核心数据模型设计2.1 六个核心微服务如何划分服务划分是微服务项目里最需要谨慎的环节拆太细会导致调用链过长拆太粗又退化成“带分布式的单体”。我最终划成了六个服务每个服务都对应一条清晰的业务域服务名端口职责gateway-service8080路由转发、全局鉴权、限流auth-service8081员工登录、验证码、Token签发、账号管理user-service8082C端用户注册、地址、订单查询merchant-service8083商户入驻、菜品、店铺、审核order-service8084下单、支付回调、订单状态流转delivery-service8085骑手管理、派单、配送轨迹file-service8086MinIO文件上传与访问订单服务和配送服务之间用 RabbitMQ 解耦用户下单后 order-service 发送消息delivery-service 消费消息后创建配送单。这样即使用户订单量突增也不会把配送服务打挂。这里有一个容易被忽略的点员工管理到底应该放在哪个服务里我把员工账号、角色权限、登录鉴权统一收在 auth-service 里。商户端和骑手端的“员工”登录也要走 auth-service避免每个服务都维护一套登录逻辑。2.2 员工表设计与账号安全基础员工表我拆了两张员工主表 employees 和角色表 roles中间用 employee_role 关联。原因很简单一个员工可能同时拥有多个角色比如既是审核员又是客服专员。employees 表核心字段长这样CREATE TABLE employees ( id bigint(20) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 登录账号, password varchar(100) NOT NULL COMMENT BCrypt密文, password_version int(11) NOT NULL DEFAULT 0 COMMENT 密码版本号, phone varchar(20) DEFAULT NULL COMMENT 手机号, email varchar(100) DEFAULT NULL COMMENT 邮箱, real_name varchar(50) DEFAULT NULL COMMENT 真实姓名, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 1启用 0禁用, last_login_time datetime DEFAULT NULL, create_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT员工表;三个字段的细节必须说清楚password 字段一律存 BCrypt 密文不允许存明文或简单 MD5。即便数据库被拖库攻击者拿到的是随机盐混淆后的哈希值破解成本高很多。password_version 字段这个字段我强烈建议加上。员工修改密码后直接让该员工名下所有旧 Token 失效——JWT 是无状态的服务端无法主动销毁它但通过校验版本号可以做到逻辑失效。status 字段员工离职后禁用账号而不是删除保留操作审计记录。2.3 员工权限与登录态设计员工端登录后签发 JWTClaims 里放 userId、username、roleCode 和 passwordVersion。网关的全局过滤器会解析 JWT把 userId 放进请求头传给下游服务。这里要提一个和密码版本号配套的细节JWT 的有效期我设成 2 小时但员工改密码后密码版本号会 1网关校验 JWT 时会调用 auth-service 的方法核对版本号。如果 JWT 里的版本号小于当前值直接判定为无效。这样就不需要维护一个庞大的黑名单列表来记录“被强制下线”的 Token省心很多。员工权限控制采用 RBAC 模型前端用路由守卫做菜单级控制后端用 Spring Security 的 PreAuthorize 注解做接口级控制。两者结合避免“前端隐藏了按钮但接口还能直接调”这种低级风险。3. 员工忘记密码的完整闭环设计3.1 找回密码流程的状态流转员工忘记密码这个功能表面上是“发个验证码-改密码”两步但我实际设计时拆成了五个步骤每一步都有独立的校验逻辑防止被恶意利用。流程如下员工进入登录页点击“忘记密码”输入账号、手机号和图形验证码。后端校验图形验证码再校验手机号是否与账号匹配匹配后发送短信验证码。员工输入短信验证码后端校验通过后发放一个短时效的临时重置Token。员工进入重置密码页输入新密码和确认密码携带临时Token提交。后端校验Token有效性和新密码强度更新密码密码版本号1提示重新登录。为什么需要图形验证码因为短信接口一旦暴露最容易被刷的就是“疯狂发送验证码”这种攻击不仅消耗短信费用还会骚扰员工本人。图形验证码在前面挡一道相当于把一个容易自动化攻击的接口加了人工门槛。为什么要短时效临时Token短信验证码校验通过后到用户提交新密码之间还有一段时间。如果直接把“重置权限”绑定在短信验证码上用户只要在验证码有效期内提交修改即可但这样设计有几个问题一是验证码有效期通常只有5分钟用户在重置页面填表稍慢就过期体验差二是如果验证码在提交时刚被使用过语义不清晰。用临时Token把两个阶段解耦校验逻辑和过期策略都能独立控制体验和安全都更可控。3.2 Redis中验证码与限流key的设计分布式环境下验证码必须存储在 Redis 里不能放在本地内存。原因有两个auth-service 可能有多实例部署A 实例存的验证码 B 实例读不到Redis 天然支持过期时间省去定时清理任务。我设计的 key 分成四类Key 格式过期时间用途captcha:graphic:{uuid}5分钟图形验证码答案sms:code:{phone}5分钟短信验证码sms:limit:{phone}60秒手机号发送频率限制reset:token:{userId}15分钟重置密码临时Token登记短信验证码的生成逻辑要注意验证码必须是随机的6位数字而且要防止同一手机号在短时间内反复发送。我在代码里先检查 sms:limit:{phone} 是否存在存在说明60秒内已发过直接拒绝不存在才允许发送发送成功后写 sms:code 和 sms:limit 两个 key。每次发送验证码时新验证码会覆盖旧验证码。这是刻意设计的防止旧验证码在未过期时被恶意截获利用。3.3 为什么员工忘记密码要比用户找回更严格C端用户找回密码通常只需要手机号验证码但员工账号涉及平台管理权限我做了两处加强第一校验手机号和账号的绑定关系。用户输入账号后后端必须校验该账号绑定的手机号是否与输入一致不一致就提示“账号与手机号不匹配”防止有人拿着员工手机号去撞库。第二员工账号被禁用时不允许找回密码。status0 的员工在查询阶段就直接返回“账号异常请联系管理员”不会走到发送短信的环节。此外还有一个体验细节不管是“账号不存在”还是“验证码错误”前端提示文案要尽量统一。这不仅是产品体验问题更是安全问题——提示信息越精确越容易被攻击者用来探测账号是否存在。4. 后端关键代码与分布式配置落地4.1 验证码发送与校验接口实现短信验证码发送接口写在 auth-service 的 AuthController 里核心代码如下PostMapping(/staff/forgot-password/send-code) public R sendCode(RequestBody SendCodeRequest request) { // 1. 校验图形验证码 String graphicCode redisTemplate.opsForValue() .get(captcha:graphic: request.getCaptchaUuid()); if (graphicCode null || !graphicCode.equalsIgnoreCase(request.getCaptchaCode())) { return R.fail(图形验证码错误或已过期); } // 2. 校验手机号与账号的绑定关系 LambdaQueryWrapperEmployee wrapper new LambdaQueryWrapper(); wrapper.eq(Employee::getUsername, request.getUsername()) .eq(Employee::getPhone, request.getPhone()); Employee employee employeeMapper.selectOne(wrapper); if (employee null || employee.getStatus() ! 1) { return R.fail(账号信息异常请联系管理员); } // 3. 检查发送频率 String limitKey sms:limit: request.getPhone(); if (Boolean.TRUE.equals(redisTemplate.hasKey(limitKey))) { return R.fail(发送太频繁请稍后再试); } // 4. 生成验证码并存入Redis String code String.format(%06d, new Random().nextInt(999999)); redisTemplate.opsForValue() .set(sms:code: request.getPhone(), code, 5, TimeUnit.MINUTES); redisTemplate.opsForValue() .set(limitKey, 1, 60, TimeUnit.SECONDS); // 5. 调用短信服务发送 smsService.sendSms(request.getPhone(), code); return R.ok(验证码已发送); }这段代码里最容易出问题的是 Redis 过期时间的设置尤其是 TimeUnit 写错导致5分钟变5秒这种低级失误。我建议所有 Redis 操作统一封装一个方法入参直接是 Duration 类型减少误写概率。另外调用短信服务那一步生产环境用的是阿里云短信本地开发时没有真实短信通道我加了一个开关配置 sms.mocktrue打开后验证码直接打印在日志里。这样本地联调时不用真发短信效率高很多。这个开关上线前必须关闭不然验证码全打印到日志里安全上会出大问题。4.2 重置密码与Token失效机制短信验证码校验通过后接口返回临时 Token。这个 Token 我仍然用 JWT但 Claims 里只放 userId 和一个场景标识有效期设15分钟。这样即使 Token 泄露攻击者的利用窗口也有限。重置密码的核心逻辑PostMapping(/staff/forgot-password/reset) public R resetPassword(RequestBody ResetPasswordRequest request) { // 1. 解析临时Token Claims claims jwtUtil.parseToken(request.getResetToken()); if (claims null || !reset.equals(claims.get(scene))) { return R.fail(重置链接已失效请重新获取); } // 2. 校验新密码强度 if (!PasswordValidator.isStrong(request.getNewPassword())) { return R.fail(密码必须包含大小写字母和数字长度8位以上); } // 3. 更新密码和版本号 Long userId claims.get(userId, Long.class); Employee employee employeeMapper.selectById(userId); if (employee null || employee.getStatus() ! 1) { return R.fail(账号异常请联系管理员); } String newPassword BCrypt.hashpw(request.getNewPassword(), BCrypt.gensalt()); employee.setPassword(newPassword); employee.setPasswordVersion(employee.getPasswordVersion() 1); employeeMapper.updateById(employee); // 4. 删除Redis中的验证码和重置Token记录 redisTemplate.delete(sms:code: employee.getPhone()); redisTemplate.delete(reset:token: userId); return R.ok(密码重置成功请使用新密码登录); }一个容易忽略的细节重置密码后必须删除验证码 key。不然同一个验证码在有效期内还能被再次使用虽然此时密码已经被改了攻击场景有限但既然能避免就顺手做掉。同理临时 Token 是 JWT 无状态的无法主动让它失效所以我在 Redis 里保留了 reset:token:{userId} 的记录提交时校验这个 key 是否存在存在才允许通过。这样就实现了短期 Token 的主动注销。4.3 网关路由、Feign调用与全局异常处理Gateway 路由配置在 YAML 里核心是把 /api/auth/** 转发到 auth-service其他路径按前缀分别路由到对应服务spring: cloud: gateway: routes: - id: auth-service uri: lb://auth-service predicates: - Path/api/auth/** filters: - StripPrefix1 - id: order-service uri: lb://order-service predicates: - Path/api/order/** filters: - StripPrefix1注意 StripPrefix1 的含义去掉路径中的第一个前缀段。前端请求 /api/auth/staff/login网关转发给 auth-service 的路径是 /staff/login这正是 auth-service 里 Controller 的实际映射路径。Feign 调用最大的坑是超时配置。默认情况下 OpenFeign 的连接超时是10秒读超时是60秒但在订单查询场景里order-service 可能要 Feign 调用 merchant-service 去拉取菜品信息链路一长偶尔就会因为读超时抛出异常。我配置了全局超时ribbon: ReadTimeout: 5000 ConnectTimeout: 3000这里要提醒的是全局超时时间不要设得太长否则当某个服务出现故障调用方会长时间阻塞拖垮线程池。正确做法是接口级的超时兜底加上断路器。我在关键调用链上加了 Sentinel 的熔断降级超时阈值设3秒失败比例超过30%直接熔断10秒。全局异常处理这块微服务里不要每个服务各写一套。我建了一个公共模块 common-core里面统一定义了 R 响应体、错误码枚举、GlobalExceptionHandler。每个微服务依赖这个模块抛出的业务异常格式一致前端接受到的响应永远长这样{ code: 40001, message: 图形验证码错误或已过期, data: null }前端 axios 拦截器只需要判断 code 200 即可走成功分支否则统一弹出 message。5. 前端Vue页面实现与联调细节5.1 前端工程初始化和axios封装前端这块我用 Vue3 Vite Pinia Element Plus工程初始化用的是npm create vitelatest选 vue 模板后手动装上 vue-router 和 piniaelement-plus 用全量引入——考虑到是后台管理系统按需引入的收益不大全量引入反而省心。axios 封装是前期必须做好的基础工作。我在 src/utils/request.js 里统一创建 axios 实例baseURL 指向网关地址请求拦截器从 localStorage 取 Token 放到请求头响应拦截器统一处理业务码code 为 200 直接返回 datacode 为 401 执行退出登录并跳转登录页其他错误码统一弹 message。这里贴一下响应拦截器的关键部分service.interceptors.response.use( (response) { const res response.data if (res.code 200) { return res.data } if (res.code 401) { // Token过期或失效清空用户态并跳登录 userStore.clearState() router.push(/login) return Promise.reject(new Error(登录状态已过期)) } ElMessage.error(res.message || 系统错误) return Promise.reject(new Error(res.message)) }, (error) { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } )需要注意的是 401 的处理场景员工登录后 Token 有效期 2 小时但如果刚改完密码密码版本号已经变了老 Token 在网关层就被判定无效返回 401。前端这时候要做的是静默跳转到登录页并提示“登录状态已失效请重新登录”而不是弹个很突兀的报错框。5.2 忘记密码页面的状态机设计忘记密码页面我做成单页三步表单用步骤条展示当前进度状态在 Pinia 里维护。为什么要放在 Pinia 里而不是组件内部 state因为用户在第二步拿到临时Token后要跳到第三步表单如果刷新页面Token 会丢。我在 Pinia 里把临时 Token 暂存刷新后用 sessionStorage 恢复避免用户填了半天密码结果 Token 没了。页面状态结构大致如下const state { step: 1, // 1账号验证 2验证码 3重置密码 username: , phone: , captchaUuid: , resetToken: }第一步表单校验用户名、手机号、图形验证码获取图形验证码时调用后端接口生成 uuid 和图片 base64渲染到 canvas 区域点击刷新重新获取。第二步的关键交互是发送短信按钮的倒计时。用 Element Plus 的按钮加上 disabled 控制调用 send-code 接口前先做前端层面的空校验接口返回成功后再启动60秒倒计时。倒计时用setInterval实现在组件卸载时清理定时器不然页面切换后定时器还在跑控制台会报内存泄漏警告。第三步是密码输入和确认密码。确认密码用 Element Plus 的表单校验规则rules 里写 validator 比较两次输入是否一致。提交前再次校验密码强度与后端校验逻辑保持一致前端提前卡住能减少无谓的接口请求。5.3 路由守卫与员工菜单权限控制路由守卫是整个前端权限体系的枢纽。我在 vue-router 的全局前置守卫里做三件事判断目标路由是否在白名单里登录页、忘记密码页、验证码接口页。不在白名单且没有 Token跳转登录页并携带 redirect 参数。有 Token 但当前用户信息为空调用/staff/info拉取员工信息和权限列表动态注册菜单路由。动态注册路由这个点值得多说一句。后台管理系统的页面菜单不是写死的而是根据后端返回的权限码动态过滤出来的。我的做法是router.addRoute 在守卫中逐条添加有权限的子路由没有权限的路由即使手动输入 URL 也无法访问。这种前端控制无法替代后端接口鉴权但能显著改善用户体验——员工不会看到一个点了报错的菜单。6. 分布式环境中的真实踩坑记录6.1 服务间调用与Session共享问题微服务项目里最经典的问题Session 不同步。单体应用里 session 是同一个登录状态放 session 里随便取拆成微服务后auth-service 存的 session 在其他服务里根本读不到如果还抱着 session 的思路做认证必然踩坑。我的方案是弃用 session改用 JWT 并配合网关统一鉴权。但这里也有一个容易忽略的场景员工登录成功后后续请求带着 Token 依次经过网关、订单服务、配送服务如果某个内部服务通过 Feign 调用了另一个服务Feign 默认不会传递请求头Token 就丢了。这会导致目标服务拿不到员工身份无法做权限校验。解决方法是在 Feign 的请求拦截器里手动透传 TokenBean public RequestInterceptor requestInterceptor() { return requestTemplate - { ServletRequestAttributes attrs RequestContextHolder.getRequestAttributes(); if (attrs ! null) { String token attrs.getRequest().getHeader(Authorization); if (token ! null) { requestTemplate.header(Authorization, token); } } }; }这个坑不熟的人很容易踩本地单测时 Feign 调用一切正常因为测试方法里没有 ServletRequestAttributes拦截器直接跳过部署后一旦出现 401先查是不是 Feign 透传问题。6.2 分布式锁与幂等控制的常见错误员工登录接口、找回密码接口这些场景天然需要幂等和防重。最典型的场景是用户快速点了两次“获取短信验证码”虽然后端有 sms:limit 限流 key 做拦截但这里其实存在竞态条件两个并发请求同时执行到检查 limitKey 的代码都发现 key 不存在然后都去发验证码。解决这类问题最简单有效的办法是 Redis 分布式锁或者直接依赖 setIfAbsent 的原子性。我换成了一种更简洁的思路用setIfAbsent设置 limitKey返回值是 false 表示已存在直接拒绝Boolean success redisTemplate.opsForValue() .setIfAbsent(limitKey, 1, 60, TimeUnit.SECONDS); if (!Boolean.TRUE.equals(success)) { return R.fail(发送太频繁请稍后再试); }setIfAbsent是原子操作不会出现并发检查不存在的竞态问题。同理短信验证码的保存也直接覆盖写入后发的验证码覆盖先发的天然保证最新验证码有效。还有一个坑是 MySQL 的唯一索引在分布式环境下的表现。员工表 username 有唯一索引如果两个请求同时往库里插同名的员工记录其中一个会插入失败并抛出 DuplicateKeyException。捕获这个异常并转换为业务提示“账号已存在”比让前端拿 500 错误体验好得多。6.3 分布式事务与消息一致性问题外卖系统里最典型的分布式事务场景是“下单-扣减库存-创建配送单”。用户提交订单后order-service 要扣减商家菜品库存delivery-service 要生成配送单这跨了多个服务的数据库本地事务无法保证一致性。我第一版用 Seata 的 AT 模式直接管理效果挺好但由于项目部署在低配服务器上Seata 的全局事务开销在高峰期还是有点明显。后来我调整了方案下单主流程用本地事务保障订单数据和库存数据的一致库存表与订单表在同一库创建配送单改为 MQ 异步事件驱动配合消费端的幂等表去重。这样既保证了核心链路的强一致又降低了跨服务事务的范围。受益于这种设计员工忘记密码功能的实现也规避了这个复杂度它只涉及 auth-service 自己的数据库和 Redis是一个本地事务就能解决的简单场景。所以不要一上来就想着拿分布式事务硬套所有接口事务边界越小、链路越短系统越稳定。6.4 本地开发与测试环境联调的几个问题本地跑微服务项目最繁琐的是环境配置。我提供一个开发小技巧本地安装 Nacos 后直接启动MySQL 和 Redis 用 Docker 跑服务之间用 Nacos 服务名互相发现不需要启动所有依赖服务时在 application.yml 里把spring.cloud.nacos.discovery.enabled设为 false 就行。还有一个常见问题是 Windows 本地开发端口被占用。Gateway 默认占 8080前端 Vite 默认占 5173如果本机还跑着别的服务经常打架。我习惯在项目根目录写一个 docker-compose-dev.yml统一编排 MySQL、Redis、Nacos、MinIO保证环境可复制。团队新人加入时拉下来直接docker compose up -d省去一上午的装环境时间。联调时最容易卡壳的是跨域问题。前端跑在 5173 端口网关在 8080 端口直接用 axios 请求会触发 CORS。我的处理方式是在网关层统一加跨域过滤器允许本地调试的 origin 列表通过配置维护。这样每个微服务不需要各自配置跨域前端只有一个访问入口干净。6.5 上线后发现的一个低级但高影响的问题项目上线后有同事反馈员工在凌晨重置密码成功后登录接口报“Token 无效”。排查半天发现是应用服务器时间时区问题。服务器默认 UTC 时间而 JWT 的过期时间按 UTC 计算但 Redis 里的验证码过期时间是本地时间两者相差 8 小时。凌晨时段 JWT 签发和验证之间跨越了“本地时间的自然日切换”恰好触发时间计算差异导致看起来随机出现的登录失败。解决方法是统一服务器时区为 Asia/Shanghai启动参数加-Duser.timezoneGMT8同时 MySQL 连接串里加serverTimezoneAsia/Shanghai。这种问题看着小但排查成本极高上线前一定要检查所有服务器的时区配置不要依赖默认值。最后的实操心得这个外卖点餐配送系统做下来我最真实的一个体会是微服务架构的核心难点从来不在代码本身而在“边界”的把握。服务拆分的边界、事务的边界、职责的边界每一处边界画得是否清晰直接决定后续开发和排障的效率。员工忘记密码这个功能在需求清单里可能只占几行字但落地时牵出了图形验证码、短信频率控制、JWT 短期令牌、Redis 存储、密码版本号升级、前端状态机管理一整套链路。越是这种“小功能”越能体现一个系统整体设计的水准。如果你也在做类似的外卖或 O2O 系统我最后再分享一个建议先把员工账号体系设计明白再动手写其他业务。账号、权限、审计是所有后台业务的底座这块一旦返工影响的是每一个管理端页面。项目里我在员工表上加了密码版本号字段这个设计在后面很多次权限调整中都派上了用场强烈推荐各位在账号表设计阶段就把它加进去。