☰
苍穹外卖Day7实战:微信登录与Redis缓存设计解析
2026/10/9 3:04:14 网站建设 项目流程

苍穹外卖做到第 7 天,最大的感受是项目开始从“管理后台的增删改查”转向“真实用户会用的功能”。前六天基本都在管理端打转:员工登录、员工管理、分类管理、菜品管理、套餐管理、文件上传,说到底就是把商家后台那一亩三分地收拾利索。第 7 天开始搭用户端(小程序端)的地基:微信登录、店铺营业状态查询、按分类浏览菜品和套餐。这几个功能单个拆开都不难,但放在一起之后,我明显感觉到用户端和管理端的思维方式完全不同——管理端关心的是数据怎么高效录进去,用户端关心的是数据怎么准确、稳定地读出来。这篇文章就把第 7 天的技术要点、实现步骤和踩过的坑完整梳理一遍,给正在跟苍穹外卖这个项目的朋友做个参照。

1. 前六天攒下的技术底子:第 7 天直接能拎起来用的东西

1.1 苍穹外卖项目的整体架构回顾

这个项目整体是经典的前后端分离结构:后端 Spring Boot + MyBatis + MySQL,缓存用 Redis,前端分两部分——商家管理端是浏览器页面,通过 Nginx 做静态资源服务和反向代理,用户端是小程序。前六天把管理端的主要业务做完之后,后端工程里已经有了几套非常关键的基建,第 7 天几乎全都要用上。

第一套基建是 JWT 认证体系。管理端登录成功后会签发一个 JWT,请求头里带Authorization: Bearer <token>,后端用拦截器统一校验,校验通过后把当前登录员工 ID 放进 ThreadLocal,供 Controller 和 Service 直接取。这个模式第 7 天要原封不动地复制到用户端,只是换一套密钥。

第二套基建是统一的返回结果封装。所有接口返回的都是Result<T>,里面三个字段:code 状态码、msg 提示信息、data 业务数据。这套封装在前端交互时特别省事,小程序端拿到 code=1 就直接渲染 data,不用每个页面单独做状态判断。

第三套基建是 Redis 的基本操作封装。第 6 天做店铺营业状态时已经把 StringRedisTemplate 的读写摸熟了,第 7 天要在这个基础上继续做菜品的缓存查询,属于从“会用 Redis”到“会设计缓存”的过渡。这个过渡很关键,因为前六天对 Redis 的使用还停留在“存取一个值”的程度,第 7 天开始要考虑缓存什么时候失效、怎么保证和数据库一致、怎么防穿透。

1.2 第 7 天的任务清单拆解

第 7 天实际要完成的用户端功能,可以列成一张清单:

功能接口路径核心逻辑依赖
用户微信登录POST /user/user/logincode 换 openid,查询或创建用户,签发 JWT微信接口、JWT、ThreadLocal
查询店铺营业状态GET /user/shop/status从 Redis 读状态值返回Redis
查询某个分类下的菜品GET /user/dish/list?categoryId=xx只查启售菜品,带缓存Spring Cache、MyBatis
查询某个分类下的套餐GET /user/setmeal/list?categoryId=xx只查启售套餐,带缓存Spring Cache、MyBatis

管理端还需要配套两个接口:PUT /admin/shop/status/{status}修改营业状态,以及管理员对菜品增删改之后要做的缓存清理。所以第 7 天不是单纯写几个用户端查询,而是要把“端上读”和“后台写”打通,做成一套完整的读写链路。

1.3 为什么第 7 天开始要特别关注“读”的性能

管理端的接口多数是低频操作,一个管理员一天点不了几次分页查询,性能压力几乎可以忽略。用户端不一样——所有用户同时都在刷菜品列表,一个热门分类的菜品接口在饭点可能被几千人同时请求。如果不加缓存,每个请求都打 MySQL,数据库连接池很容易被击穿。

这就是第 7 天把“缓存”作为重点的原因。它不是炫技,而是用户端场景下绕不开的工程问题。后面在讲菜品缓存时会看到,这个项目的缓存方案已经考虑了大部分常见问题,但并不是最优解,我在最后一部分会聊聊它和真实生产环境的差距。

2. 微信登录认证链路:为什么是 code 换 openid,而不是账号密码

2.1 登录协议背后的安全逻辑

小程序端没有注册流程,也不可能弹个框让用户输账号密码。微信提供的方案是:小程序端调用wx.login(),拿到一个临时登录凭证 code,把 code 传给后端,后端再用 code 向微信服务器换取该用户的唯一标识 openid。

这个 code 有几个特点:有效期短,正常只有 5 分钟;一次性,用过就失效;和具体用户、具体小程序绑定。之所以不直接把 openid 暴露给前端,是为了防止接口被人抓包后伪造请求——如果前端直接拿 openid 来登录,任何人都能伪造别人的 openid 请求登录接口,账号体系就形同虚设。code 换 openid 的本质是让微信服务器做一次身份校验:只有微信官方能证明这个 code 是真的、对应哪个用户。

openid 是同一用户在同一 appid(小程序应用标识)下的唯一 ID,注意它不等于用户的微信号,也不是全局唯一的。同一个用户在不同小程序下的 openid 不同,如果需要跨小程序识别用户,得靠 unionid,这通常需要开放平台绑定。苍穹外卖这个项目里只需要 openid 就够了。

2.2 后端完整实现步骤

用户在微信登录的调用链是:小程序wx.login()拿到 code → 请求后端POST /user/user/login→ 后端用 code 调微信的jscode2session接口 → 拿到 openid → 查 user 表,不存在就插入新用户 → 签发 JWT 返回前端。

代码骨架如下:

@PostMapping("/login") public Result<UserLoginVO> login(@RequestBody UserLoginDTO userLoginDTO) { // 1. 调用微信接口,用 code 换取 openid String openid = wxService.getOpenid(userLoginDTO.getCode()); if (openid == null) { throw new LoginFailedException(MessageConstant.LOGIN_FAILED); } // 2. 根据 openid 查询用户 User user = userMapper.getByOpenid(openid); // 3. 判断是否为新用户,是则自动注册 if (user == null) { user = User.builder() .openid(openid) .createTime(LocalDateTime.now()) .build(); userMapper.insert(user); } // 4. 生成用户端 JWT Map<String, Object> claims = new HashMap<>(); claims.put("userId", user.getId()); String token = JwtUtil.createJWT( jwtProperties.getUserSecretKey(), jwtProperties.getUserTtl(), claims); // 5. 返回 token 和用户信息 UserLoginVO vo = UserLoginVO.builder() .id(user.getId()) .token(token) .build(); return Result.success(vo); }

打开微信接口这一步,项目里用的是 RestTemplate。因为 RestTemplate 已经由 Spring Boot 自动配置好了,直接注入就能用,没有额外引入依赖。调用地址是微信官方的https://api.weixin.qq.com/sns/jscode2session,参数固定五个:appid、secret、js_code(就是前端传的 code)、grant_type(固定 authorization_code)。返回值是一个 JSON 字符串,里面有 openid、session_key、unionid 等字段。

换成 openid 之后有个细节要注意:返回的 JSON 里可能有errcode,说明请求失败。常见的错误码是 40029(code 无效或已被使用)、45011(接口调用频率被限制)、40226(可能是开放平台问题)。调试时如果遇到 40029,多半是前端重复使用了同一个 code,或者 code 已经过期;遇到频率限制,基本就是短时间内请求太多次,等一会儿再试。

2.3 JWT 签发要与管理端严格隔离

很多人在这个项目里会犯一个错:用户端登录也复用管理端的 JWT 工具类和密钥。这会导致一个严重问题——管理端校验 token 的拦截器看到的是一套密钥,用户端签发用的却是另一套,两边互不认账;更危险的是如果共用一套密钥,员工 token 和用户 token 混用,权限隔离形同虚设。

正确的做法是两套密钥、两套过期时间、两个拦截器。管理端密钥配成sky.jwt.admin-secret-key,用户端密钥配成sky.jwt.user-secret-key,在 JwtProperties 里分别读取。用户端 token 的过期时间可以设得比管理端短,比如管理端 2 小时、用户端 1 天,用户端使用频率高但单个操作敏感度低。

用户端也需要一个独立的拦截器JwtTokenUserInterceptor,注册到/user/**路径下,同时把登录接口/user/user/login放进放行名单,否则会出现“登录接口还没拿到 token,拦截器先要求校验 token”的循环矛盾。校验通过后,把 userId 存进 ThreadLocal。这里要提醒一句:ThreadLocal 存的是当前线程的用户 ID,如果用了异步注解或者线程池,数据会串,Day 7 阶段还涉及不到,但等后面做订单通知时就要警惕。

3. 店铺营业状态:一个被低估的缓存应用场景

3.1 需求本质:高频读 + 低频写

店铺营业状态是整个项目里第一个“用 Redis 当数据源”的功能,不是缓存。管理端把状态写进 Redis,用户端直接从 Redis 读。需求说起来很简单:商家可以切换营业/打烊,用户端点进小程序第一屏就要显示店铺当前是否营业。

它的读写特征非常鲜明:读的频率极高,所有用户打开小程序都会触发;写的频率极低,商家一天最多改几次。这种场景如果每次都查数据库,虽然 MySQL 扛得住,但毫无必要。把状态值直接放在 Redis 里,读操作就是一次内存命中,延迟在毫秒级,这比查表再反序列化实体快一个数量级。

3.2 存储设计与实现细节

存储上不用建表,直接用一个固定 key 就行。我在项目里用的是常量SHOP_STATUS,value 是字符串"1"或"0"。之所以用 String 而不是 Integer,是为了避免 RedisTemplate 序列化时类型不一致带来的麻烦,字符串是通用的。

查询接口的代码:

@GetMapping("/status") public Result<Integer> getStatus() { String status = stringRedisTemplate.opsForValue().get(KEY); Integer statusNumber = status == null ? 0 : Integer.parseInt(status); return Result.success(statusNumber); }

修改接口的代码:

@PutMapping("/status/{status}") public Result<String> updateStatus(@PathVariable Integer status) { stringRedisTemplate.opsForValue().set(KEY, status.toString()); return Result.success(); }

这里要特别注意 Redis 里没有值的情况。Redis 是内存存储,服务重启后 key 会全部丢失。如果用户端查询时拿到 null 却直接返回成功,前端会显示不出任何状态。我处理的方式是:取到 null 时兜底返回 0(打烊),宁可不营业也不能显示错误状态——打烊状态下用户点不了单,但不至于把错误数据当真。更稳妥的方案是修改状态时同步写一份数据库,查询 Redis 没有就回源数据库,但苍穹外卖的代码里没有这张表,所以用兜底值是最简单可靠的做法。

3.3 用 Redis 直接当数据源的风险边界

这种“Redis 即数据源”的模式,在真实生产环境是有争议的。如果 Redis 挂了,整个状态查询就废了。所以生产上要么做 Redis 高可用(主从 + 哨兵),要么加一层数据库兜底。但在单体项目、学习项目里,直接用 Redis 存状态完全可行,而且能让人真正理解“读多写少用缓存/内存扛”的架构思想。

另外有人会问:为什么不直接查数据库的 shop 表?当然也可以,一张表存状态字段,查询也就一次主键扫描,性能并不差。区别在于:用 Redis 能让你理解缓存层存在的意义,也能在后续做菜品缓存时复用同一套思路。第 7 天这个功能更大的价值是给菜品缓存做铺垫,真正的挑战在第 4 部分。

4. 菜品与套餐查询:缓存一致性的完整方案

4.1 用户端浏览接口的查询逻辑

用户端浏览菜品,接口是GET /user/dish/list?categoryId=xx,返回该分类下所有状态为启售(status = 1)的菜品,每个菜品带上口味列表。查询逻辑本身不复杂:

SELECT * FROM dish WHERE category_id = #{categoryId} AND status = 1

但前面说过,这个接口是用户端最高频的接口之一。如果每个用户每次打开页面都执行一次 SQL,数据库压力会被放大几十倍。所以项目用 Spring Cache 做了一层查询缓存,直接加到 Service 层方法上。Spring Cache 的好处是声明式缓存,不用手动写“查缓存,没有再查库,然后回填缓存”这一大坨代码,一个注解就能搞定。

@Cacheable(cacheNames = "dish", key = "#categoryId") public List<DishVO> listWithFlavor(Long categoryId) { // 原查询逻辑 }

这个注解的含义是:查询前先看 Redis 里有没有以dish::<categoryId>为 key 的缓存,有就直接返回;没有就执行方法体,把返回结果存进 Redis。下次同样 categoryId 的请求就直接命中缓存。套餐查询一模一样,只是 cacheNames 换成setmeal。

4.2 缓存管理的三个关键配置

Spring Cache 开起来之后,有几个配置必须处理,否则会踩大坑。

第一个是 RedisCacheManager 的序列化方式。Spring Cache 默认用 JDK 序列化,存进 Redis 的是一段二进制流,肉眼完全没法排查。项目里要配置成GenericJackson2JsonRedisSerializer,让缓存以 JSON 形式存储,调试时用 Redis 客户端直接能看到内容。

第二个是 LocalDateTime 的序列化问题。DishVO 里如果有 LocalDateTime 字段,直接交给默认 ObjectMapper 会报错或者序列化成时间戳数组,反序列化时再炸一次。我当时的处理方式是自定义一个 ObjectMapper,注册JavaTimeModule,并关闭WRITE_DATES_AS_TIMESTAMPS,让时间以标准格式输出。

第三个是空值缓存。用户端传一个不存在的分类 ID,比如 -1,查库得到空 List,默认情况下 Spring Cache 并不会缓存空结果,于是每次请求都穿透到数据库。解决方式是@Cacheable(cacheNames = "dish", key = "#categoryId", unless = "#result == null")只能管 null,管不了空集合。更彻底的办法是手动把空集合包装后以短 TTL 缓存,比如 60 秒,这是防穿透的基础手段。苍穹外卖这个项目本身没有做这一步,属于可以自行扩展的优化点。

4.3 最大的坑:管理端改菜之后,缓存怎么清

只读接口加缓存很简单,难点在于管理端修改菜品后,用户端还看到旧数据。这就是缓存一致性问题的雏形。

项目的做法是 Cache Aside 模式:读的时候先读缓存,读不到再读数据库;写的时候先更新数据库,再删除相关缓存,让下一次读请求重新回源。具体落到菜品上,管理端新增、修改、启售、停售、删除菜品,都需要清掉对应分类的缓存。

这里有个细节特别容易忽略:修改菜品时,菜品可能从分类 A 移到了分类 B。如果只清当前分类 B 的缓存,分类 A 里还留着旧菜品数据。最稳妥的做法是直接清空整个dish缓存空间:

@CacheEvict(cacheNames = "dish", allEntries = true) public void update(DishDTO dishDTO) { // 更新逻辑 }

allEntries = true会把所有分类的菜品缓存一次性清掉。代价是清得范围大,但换来的是绝对不会出现数据不一致。对于学习项目来说,这个取舍完全值得。

套餐的缓存清理逻辑类似,但有一个隐形关联:套餐里包含菜品,如果修改了某个菜品的状态,套餐详情里的对应菜品状态也应变化。苍穹外卖项目里套餐详情是独立的接口,菜品状态变更不会自动联动套餐缓存,这个问题在真实系统里需要靠事件通知解决,在项目阶段可以接受,但心里要清楚。

5. 实测踩坑记录:第 7 天最容易翻车的五个地方

5.1 Nginx 反向代理路径配置不全导致小程序 404

管理端页面开发时,Nginx 已经把/api/admin/**代理到了后端 8080 端口。第 7 天开始调小程序接口,请求路径是/api/user/**。如果你用的 Nginx 配置是分路径写的,只写了 admin 没写 user,小程序所有请求都会 404。我的排查过程是先看浏览器 Network,发现请求能发出去但返回 404,然后直接在后端日志里看有没有收到请求——结果根本没进来,于是锁定是 Nginx 层的问题。

正确的 Nginx 配置应该把/api/整体代理,或者至少把location /api/user/单独加上:

location /api/ { proxy_pass http://localhost:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }

这里有一个容易钻牛角尖的地方:proxy_pass后面带不带/会影响到转发时是否剥离前缀。如果写成proxy_pass http://localhost:8080;(不带路径),原始 URI 会完整转发,也就是/api/user/user/login会按原样打到后端localhost:8080/api/user/user/login。项目后端配置了统一的 context-path,所以请求能对上。如果你改过配置发现 404,优先检查这层的路径拼接。

5.2 code2session 的 appid 和 secret 不匹配

调微信接口一定要确认你用的 appid 和 secret 是同一个微信小程序账号下的。很多人在开发阶段喜欢随意填一个测试 appid,结果换 openid 时微信返回errcode: 40125(secret 错误)或者40013(appid 无效),这时候会非常困惑。

我当时花了不少时间才确认问题是开发环境的 appid 与小程序的 AppSecret 不匹配。解决办法是登录微信公众平台,在小程序后台的“开发管理 - 开发设置”里重新获取 AppSecret,并且在代码里通过配置文件维护,不要硬编码在 Java 类里。还要注意,如果你用的是一个模拟器环境,微信登录需要真机或者授权过的开发者工具,不然 wx.login 可能拿不到合法 code。

另外,不要把session_key忽略掉。换 openid 的同时会返回 session_key,它是后续解密用户手机号等敏感信息的密钥。苍穹外卖 Day 7 只用到 openid,但代码里最好把返回的 JSON 完整解析了,留着字段备用。

5.3 拦截器放行路径顺序不对

用户端加完拦截器之后,我一开始把/user/user/login放在了拦截器配置的最后面。Spring 的拦截器注册是基于 AntPathMatcher 的规则匹配,路径顺序不影响匹配结果,真正影响的是你有没有把登录接口放进excludePathPatterns。

如果忘了排除登录接口,就会陷入一个奇怪的现象:小程序端点登录,后端拦截器先拦截,发现没有 token 直接返回 401,登录永远不可能成功。排查时看后端日志能看到JwtTokenUserInterceptor打印的“没有 token”提示。

正确的注册代码:

registry.addInterceptor(jwtTokenUserInterceptor) .addPathPatterns("/user/**") .excludePathPatterns("/user/user/login");

管理端的员工登录接口同理,/admin/employee/login必须在管理端拦截器的排除名单里。

5.4 用户端 JWT 解析出的 userId 是 null

用户端登录签发 JWT 时,我一开始往 claims 里只放了 openid,没放 userId。结果后续用户端查看菜品详情、加购物车等需要“当前用户是谁”的接口,从 ThreadLocal 里拿 userId 就一直是 null。

这个问题定位起来也不难:在拦截器里解析 token 后,打印一下 claims 内容,发现根本没有 userId。解决办法是签发时同时放入 openid 和 userId,拦截器解析后优先取 userId 存入 ThreadLocal。另外要注意,用户第一次登录时 userId 是 insert 之后 MyBatis 回填的主键,所以必须在 insert 语句里配置useGeneratedKeys="true" keyProperty="id",否则新用户第一次登录时 userId 依然是 null,这个 bug 更隐蔽。

5.5 菜品缓存 JSON 反序列化类型错乱

用GenericJackson2JsonRedisSerializer存 List 数据时,如果直接通过 RedisTemplate 手动读写,很容易出现反序列化后拿到List<LinkedHashMap>而不是List<DishVO>,然后强转直接 ClassCastException。Spring Cache 的@Cacheable因为有方法返回类型约束,一般不会出这个问题,但如果你在代码里手动用 RedisTemplate 写缓存,就要在序列化配置里保存类型信息:

GenericJackson2JsonRedisSerializer.registerNullValueSerializer(objectMapper, null); redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());

更省心的做法是第 7 天这个阶段全部用 Spring Cache 注解,不要手动操作 RedisTemplate 读写集合,把类型转换的坑留给框架处理。

6. 项目复盘:这套缓存和登录方案离生产环境还有多远

6.1 登录体系的三个薄弱环节

第一,用户端 token 没有刷新机制。JWT 过期后用户只能重新登录,这在真实小程序里体验很不好。生产上通常做双 token:短期 access token + 长期 refresh token,或者干脆用小程序官方的登录态管理方案,在 token 过期前静默续期。

第二,openid 不能跨应用。如果餐饮品牌有多个小程序,比如主品牌小程序和外卖小程序分开运营,同一个用户在两边的 openid 不同,无法直接识别为同一人。真实系统会维护用户统一账号体系,通过手机号绑定来打通。

第三,代码里没有处理微信前端解密。获取用户手机号需要结合 session_key 解密,Day 7 阶段没做,但这恰恰是外卖系统最重要的信息之一,后续做会员营销、订单通知都依赖手机号。建议学完项目后自己把这块补上。

6.2 菜品缓存值得改进的两个方向

缓存一致性上,项目用allEntries = true全量清理,简单粗暴可靠,但代价是缓存命中率突降。假设一个分类有 5000 个用户正在看,管理员改一道菜,所有用户的缓存全部失效,5000 个请求同时回源数据库——这就是缓存击穿。

更专业的做法有几种:一是精确到分类清理,修改菜品时同时清理新旧分类的缓存;二是加分布式锁,缓存失效后只让一个线程回源数据库,其他线程等待并复用结果;三是用延迟双删,先删缓存、更新数据库、隔几百毫秒再删一次缓存,避免并发场景下旧数据回填。

另外缓存预热也值得做。营业高峰前把热门分类的菜品提前加载到 Redis,而不是等第一个用户请求来了才回源填充。这些属于架构优化,项目本身不要求,但面试时能讲清楚,会明显加分。

6.3 从 Day 7 往后看:后面的模块会反复用到今天的基建

购物车要注意用户维度。加购物车时必须从 ThreadLocal 取 userId,否则购物车数据串用户,我估计不少人在下一章会踩这个坑。

用户下单要注意数据一致。订单涉及菜品快照(价格、名称)、购物车清空、库存扣减,任何一个环节失败都要回滚。Day 7 打下的缓存基础在订单模块会继续使用,但会更复杂,因为订单是写多读少的场景,缓存策略和菜品浏览完全不同。

支付回调要注意幂等。微信支付回调可能重复推送,同样的订单回调必须只处理一次。这和 JWT、缓存无关,但属于“用户的钱不能出错”的底线,提前建立这种意识比多写十个接口都重要。

6.4 我的个人体会

第 7 天真正让我从“写接口”过渡到“设计系统”的,不是微信登录,而是菜品缓存。微信登录流程再怎么绕,本质是“一次外部接口调用 + 一次数据库读写 + 一次 token 生成”,有官方文档照着写就行。缓存则完全不同,它逼着你思考数据什么时候会过期、失效时怎么兜底、并发下会不会击穿数据库。

我踩过最深的一次坑是修改菜品后忘了清缓存,结果用户端刷了半天还是旧菜品,最后发现是改菜接口被加了@CacheEvict,但新增和启停售接口没加。那次我意识到一个道理:缓存代码写起来只要一行注解,但想清楚所有写入口很难。后来我就养成了一个习惯,每写完一个写操作接口,先列一遍“哪些入口会改这批数据”,再逐一把缓存清理补全。

最后分享一个很实用的小技巧:开发阶段把 Spring Cache 的日志级别调到 DEBUG,这样每次缓存命中、缓存失效、缓存回填都能在控制台看到。调日志只是改一行配置,但排查问题时省下的时间是实打实的。

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

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

立即咨询