“苍穹外卖”这个系列走到第六天,终于从管理端切换到了用户端。今天的内容是微信小程序开发,核心就两块:微信登录、商品浏览。前面几天我们一直在搞后台管理端,搭环境、建表、写菜品分类、对接Redis和JWT,说实话都是从运营视角看系统。但从今天开始,小程序端一接进来,整个项目的使用者就从店长变成了顾客,技术侧的核心也变成了“怎么通过微信官方能力快速识别用户身份,然后再把商品数据安全地吐给小程序”。
这篇文章适合正在做这类项目的Java后端同学,尤其是那些之前没碰过微信小程序联调、对code换openid和前后端交互比较迷糊的人。我会把今天做的微信登录链路、商品分类浏览的接口设计、小程序端页面对接方式,以及联调过程中的坑,全部摊开来讲。包括代码、表结构、页面结构,基本照着就能复现。
1. 先把第六天的目标拆清楚:从管理端切到C端小程序
1.1 前面五天我们在做什么
苍穹外卖这个项目从第一天开始,主线就非常清楚。前五天我做的事情基本围绕管理端:搭Spring Boot工程、配置MyBatis、设计数据库表、做员工登录、管理菜品分类、菜品增删改查、文件上传下载,中间还穿插了Redis缓存店铺营业状态、JWT做登录校验、拦截器做统一鉴权。
这些功能有个共同点:它们服务的都是“餐厅自己人”。菜品管理、分类管理、营业状态开关,都是后台操作,页面是电脑端,接口路径大多以/admin开头。做这些功能时,我的主要精力都放在“数据怎么维护”“权限怎么校验”上,整个技术栈是传统的后端管理系统套路。
但第六天不一样。这天要把用户端做出来,也就是顾客在微信里打开的小程序。小程序端的技术栈、交互方式、安全模型跟网页后台完全不一样,很多东西不能照搬。比如用户身份,后台管理端是“账密登录”,前台用户端就不能让顾客注册账号输密码,而是直接依托微信的登录能力。商品浏览也不能简单把后台的列表接口丢出来,必须考虑状态过滤、图片域名、分类维度这些细节。
所以第六天虽然是“第一天做小程序”,但信息密度其实很高。
1.2 今天要完成的三个关键点
今天的大目标有两个:微信登录,商品浏览。拆细一点,其实有三个关键点必须理清。
第一点是微信小程序登录的完整链路。从前端wx.login()拿到一个临时code,到后端拿这个code去微信服务端换openid,再到后端用openid查用户表、没查到就自动注册,然后签JWT返回给前端。这条链路每一步都有讲究,后面我会把代码直接贴出来。
第二点是商品浏览的接口设计。小程序首页和分类页需要三块数据:分类列表、某个分类下的菜品列表、某个分类下的套餐列表。这三个接口路径、入参、返回体长什么样,以及为什么不能直接查表返回,都需要说清楚。
第三点是前后端联调。前端小程序页面怎么写、请求怎么封装、token怎么存、接口跨域和域名白名单怎么处理、图片为什么能显示出来。这些属于实操环节,也是初学者最容易卡住的地方。
1.3 方案选型:为什么不能直接把openid返回给前端
先说一个很多人忽略的点。微信登录时,传统做法是后端拿code换openid,然后给前端返回一个token,而不是把openid原样返回。为什么不直接返回openid?因为openid是用户的唯一身份标识,相当于这个用户在小程序里的身份证号。如果前端存的是openid,别人一旦拿到这个id,就能伪造请求直接冒充用户身份。
我见过不少初学者写接口时,直接把openid塞在返回体里交给前端,前端每次请求把openid传回后端,后端一比对就放行。这个做法能跑通,但非常不安全,openid一旦泄露,等于用户身份泄露。而且微信登录的核心思路是“后端与微信服务端对话”,前端只负责提供一次性code,code用完即废,真正的身份交换必须发生在服务端。所以我这边最终用JWT token来做自定义登录态,前端存token,后端解析token里的userId,认token不认openid。
2. 微信小程序登录:从code到token的完整后端链路
2.1 微信登录的核心流程
微信登录流程最关键的认知是:小程序前端永远拿不到用户真正的身份标识。前端只能通过wx.login()拿到一个临时凭证code,这个code有效期只有五分钟,而且只能用一次。然后前端把这个code发给自己的后端,后端再拿着code和appid、secret去请求微信官方接口jscode2session,微信服务端才会返回这个用户在小程序下的openid。
换句话讲,openid会经过两个步骤才能落到我们手里:前端拿临时code,后端用code换openid。这个设计的目的就是防止前端直接存储敏感身份信息。
后端拿到openid之后做两件事:先去user表查这个openid是否存在,如果存在,说明是老用户,直接登录;如果不存在,说明是第一次打开小程序的新用户,自动创建一条用户记录,然后再登录。这个“查不到就插入”的流程实际上完成了登录与注册的合并。
用户确认之后,后端生成一个JWT,JWT的payload里放userId,返回给前端。此后前端每次请求都带着这个token,后端通过拦截器解析token,就知道当前操作者是谁了。
2.2 用户表和实体类的设计
登录链路要跑通,第一件事是保证数据库里有对应的用户表。我在项目里用的表结构大致是:
create table user ( id bigint primary key auto_increment comment '主键', openid varchar(45) not null unique comment '微信用户openid', name varchar(32) comment '用户姓名', phone varchar(11) comment '手机号', sex varchar(2) comment '性别', id_number varchar(18) comment '身份证号', avatar varchar(500) comment '头像', create_time datetime comment '注册时间' );注意openid字段我加了unique约束,并在代码里用openid作为查询键。这样即使同一个用户删了小程序再重新打开,也能通过openid精确匹配到同一条记录,不会产生脏数据。
对应的实体类是常用的MyBatis风格:
@Data @Builder public class User implements Serializable { private static final long serialVersionUID = 1L; private Long id; private String openid; private String name; private String phone; private String sex; private String idNumber; private String avatar; private LocalDateTime createTime; }实体类字段跟表字段一一对应。这里有人可能会问,name、phone、avatar这些用户信息现在也没地方获取啊,为什么一开始就建好?因为微信登录之后,后续还有授权手机号、获取用户头像昵称这些能力,今天先把基础字段铺好,后面功能扩展时不用改表。
2.3 后端代码实战:Controller、Service、Mapper一锅端
微信登录后端代码不长,但每一层都要写清楚。先看Controller。接口路径我定的是POST /user/user/login,入参是前端传上来的code,出参是用户ID、openid和token。
@RestController @RequestMapping("/user/user") @Slf4j public class UserController { @Autowired private UserService userService; @Autowired private JwtProperties jwtProperties; @PostMapping("/login") public Result<UserLoginVO> login(@RequestBody UserLoginDTO userLoginDTO) { log.info("微信用户登录,code: {}", userLoginDTO.getCode()); User user = userService.wxLogin(userLoginDTO.getCode()); Map<String, Object> claims = new HashMap<>(); claims.put("userId", user.getId()); String token = JwtUtil.createJWT( jwtProperties.getUserSecretKey(), jwtProperties.getUserTtl(), claims); UserLoginVO userLoginVO = UserLoginVO.builder() .id(user.getId()) .openid(user.getOpenid()) .token(token) .build(); return Result.success(userLoginVO); } }这段代码最核心的是JWT的创建。jwtProperties.getUserSecretKey()和getUserTtl()是配置在application.yml里的用户端密钥和有效期,它跟管理端的密钥分开管理。这样一个项目里管理端和用户端各自持有一套JWT配置,互不干扰,也不会出现管理端的token能访问用户端接口这种越权情况。
再看Service层:
@Service @Slf4j public class UserServiceImpl implements UserService { @Autowired private UserMapper userMapper; @Override public User wxLogin(String code) { String openid = getOpenidByCode(code); if (openid == null) { throw new LoginFailedException("微信登录失败,无法获取openid"); } User user = userMapper.getByOpenid(openid); if (user == null) { user = User.builder() .openid(openid) .createTime(LocalDateTime.now()) .build(); userMapper.insert(user); } return user; } private String getOpenidByCode(String code) { String url = "https://api.weixin.qq.com/sns/jscode2session?appid=" + appid + "&secret=" + secret + "&js_code=" + code + "&grant_type=authorization_code"; // 发送HTTP请求,解析响应中的openid字段 } }这段代码里有两点值得展开。第一,getOpenidByCode里拼的是appid和secret,它们是后端配置信息,绝不能写在前端代码里。secret相当于服务端调用微信接口的密码,一旦泄露到小程序代码包,任何人都能冒充后端与微信服务端交互。
第二,为什么先查openid不存在再插入?因为微信登录天然具备“首次进入即注册”的属性。用户第一次打开小程序时,微信端就会生成一个openid,这时候我们后端只需要把这个openid落到自己的user表里,就完成了用户的注册。不需要用户再填写手机号、设置用户名密码,这跟传统网站登录有很大区别。
最后是Mapper层。我用的是注解式SQL,比较简洁:
@Mapper public interface UserMapper { @Select("select * from user where openid = #{openid}") User getByOpenid(String openid); void insert(User user); }insert语句写在XML里,因为要拿到自增主键。MyBatis的useGeneratedKeys="true"配合keyProperty="id",插入后user.getId()就能拿到新用户的主键,再把id放进JWT的claims里,整个链路就通了。
3. 商品浏览:分类、菜品、套餐三个接口的设计与实现
3.1 商品浏览背后的数据组织
商品浏览看起来只是“顾客看到列表”,但背后的数据组织不简单。苍穹外卖里商品相关数据主要落在三张表上:category分类表、dish菜品表、setmeal套餐表。
category表区分了类型,type字段为1表示菜品分类,为2表示套餐分类。比如“热菜”就是菜品分类,“超值套餐”就是套餐分类。dish表属于type=1分类下,setmeal表属于type=2分类下。套餐和菜品之间还有一组关联表,因为一个套餐里可能包含多个菜品。这些在后台管里端都已经维护好了。
小程序端商品浏览本质上就是把这三类数据按用户视角重新组合输出。用户进入小程序首页,看到的第一个维度就是分类,点击某个分类之后再看该分类下的商品。所以商品浏览的几个接口基本都是按分类维度的查询。
3.2 商品浏览的核心接口设计
我设计的是三个接口,分别对应分类列表、菜品列表、套餐列表:
第一个是查询分类列表:
GET /user/category/list 入参:type(1为菜品分类,2为套餐分类,可选) 返回:分类对象数组这个接口会返回所有可展示的分类。小程序首页的分类导航条就靠它。
第二个是查询某个分类下的菜品:
GET /user/dish/list?categoryId=101 返回:菜品对象数组,且仅返回status=1(启售)的菜品第三个是查询某个分类下的套餐:
GET /user/setmeal/list?categoryId=102 返回:套餐对象数组,同样只返回启售状态查询菜品接口的Service实现大概是:
@Override public List<DishVO> listWithFlavor(Dish dish) { List<Dish> dishList = dishMapper.list(dish); List<DishVO> dishVOList = new ArrayList<>(); for (Dish d : dishList) { DishVO dishVO = new DishVO(); BeanUtils.copyProperties(d, dishVO); // 查询菜品对应的口味 List<DishFlavor> flavors = dishFlavorMapper.getByDishId(d.getId()); dishVO.setFlavors(flavors); dishVOList.add(dishVO); } return dishVOList; }注意几个细节。第一,dishMapper.list(dish)里的dish对象携带了categoryId和status=1,SQL里对应的是:
select * from dish where category_id = #{categoryId} and status = 1 order by sort desc这样就能在SQL层直接过滤掉停售商品,不让“已停售”的菜品出现在顾客面前。这是商品浏览跟后台管理端列表最大的区别,管理端能看到所有状态的数据,用户端只能看到启售的数据。
第二,为什么DishVO要额外组装flavors口味列表?因为小程序菜品卡片上通常会显示“中辣、微辣、不辣”或者“加冰、去冰”这类规格选项。这个信息存在dish_flavor表里,一个菜品对应多个口味,普通的Dish实体装不下,所以单独封装一个DishVO,把口味塞进去。前端拿到flavors之后,可以判断菜品能不能选规格、有没有“必选”选项。
3.3 图片地址与静态资源映射
商品浏览必然会遇到图片。后台管理端上传菜品图片时,文件保存在本地的upload目录,数据库存的是一段相对路径,比如/upload/abc.jpg。小程序端要显示这张图片,就必须能通过一个完整的URL访问到它。
这里就涉及Spring Boot的静态资源映射。我加了一个配置类:
@Configuration public class WebMvcConfiguration implements WebMvcConfigurer { @Value("${sky.upload-path}") private String uploadPath; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath); } }配置之后,访问http://localhost:8080/upload/abc.jpg时,Spring会直接到uploadPath目录下找abc.jpg这个文件并返回。小程序的image组件src填全路径http://localhost:8080/upload/abc.jpg,图片就能正常渲染。
这一步很基础,但第一次做的时候容易忘。我见过不少人图片显示不出来,排查来排查去,最后发现就是没加静态资源映射,或者映射路径跟数据库里存的路径对不上。上传目录路径的值我放在配置文件里,这样部署到服务器时只要改配置,不用改代码。
4. 小程序前端:登录调用、token管理、商品列表渲染
4.1 小程序页面结构与基础布局
小程序端我采用的是原生小程序框架,没引入第三方UI库。页面文件主要分两块:首页和分类商品页。
首页结构大概是这样:
- 顶部:搜索框(占位,今天没做搜索逻辑)
- 中间:轮播图、分类宫格(横向排列的分类入口)
- 底部:推荐菜品列表
分类商品页结构是经典的双列布局:
- 左侧:纵向分类列表,可滚动
- 右侧:当前分类下的商品列表,可滚动
- 每个商品卡片:商品图片、名称、描述、价格
页面文件目录大概长这样:
pages/ index/ index.wxml index.js index.wxss category/ category.wxml category.js category.wxss login/ login.wxml login.js login.wxss其中login页其实只是一个载体。微信登录本质上不一定需要独立的“登录按钮”页面,很多小程序进入后静默登录。但为了演示登录交互逻辑,我保留了login页作为登录入口。
分类页的核心WXML结构是:
<view class="category-container"> <scroll-view scroll-y="true" class="left-menu"> <view wx:for="{{categories}}" wx:key="id" class="menu-item {{currentIndex === index ? 'active' : ''}}" bindtap="onSelectCategory" >const request = (url, method, data) => { return new Promise((resolve, reject) => { wx.request({ url: baseUrl + url, method: method || 'GET', data: data || {}, header: { 'content-type': 'application/json', 'token': wx.getStorageSync('token') }, success: (res) => { if (res.data.code === 1) { resolve(res.data); } else if (res.data.code === 0) { // 用户登录过期,尝试重新登录 wx.showToast({ title: '登录已过期', icon: 'none' }); reLogin(); reject(res.data); } else { reject(res.data); } }, fail: (err) => { reject(err); } }); }); };这里有两个设计点很关键。第一个是header里的token字段。后端拦截器规定要从header里取一个叫token的字段,如果叫authorization或者别的名字,前后端对不上,接口就永远报401。我建议在项目最开始就约定好header字段名,别写两个地方两套名字。
第二个是401自动重新登录。登录过期之后直接跳登录页会很烦,更好的做法是捕获到登录态失效的信号后,重新调用wx.login()换新code、再换取新token,然后重新发起原来的业务请求。小程序端的登录本身是静默无感知的,用户根本不需要手动介入。不过今天我只放了重登逻辑的入口,具体的“重新排队请求”机制在后续功能完善时可以继续补。
4.3 登录调用与商品列表数据加载
小程序首页加载时,可以先静默登录再拉数据。这里的顺序很关键,如果登录都还没成功就去拉商品数据,大概率会因为没带token被后端拦截,或者虽然能访问但拿不到用户相关数据。
我在index.js的onLoad里这样处理:
onLoad() { this.loginAndLoadData(); }, async loginAndLoadData() { const token = wx.getStorageSync('token'); if (!token) { await this.silentLogin(); } this.loadCategories(); this.loadDishList(); }, silentLogin() { return new Promise((resolve, reject) => { wx.login({ success: async (res) => { const resp = await request('/user/user/login', 'POST', { code: res.code }); wx.setStorageSync('token', resp.data.token); wx.setStorageSync('userId', resp.data.id); resolve(); }, fail: (err) => reject(err) }); }); }, async loadCategories() { const res = await request('/user/category/list', 'GET', { type: 1 }); this.setData({ categories: res.data }); if (res.data.length > 0) { this.loadDishList(res.data[0].id); } }, async loadDishList(categoryId) { const res = await request('/user/dish/list', 'GET', { categoryId }); this.setData({ dishList: res.data }); }这段逻辑很直观:先看本地有没有token,没有就静默登录拿token,然后请求分类,拿到分类后默认选中第一个分类并加载这个分类下的菜品列表。
注意wx.login()必须在用户授权之前就能调用,它不需要用户手动点击,也不需要用户同意任何授权弹窗。它只是返回一个临时code,真正拿到用户信息的主权在服务端。
5. 联调现场:高频问题实录与登录态设计复盘
5.1 联调高频问题速查表
第六天联调过程中遇到的问题,我可以提前给你打个预防针。下面这些场景基本覆盖了大多数初学者会卡住的点。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 登录接口报错,微信返回40029 | code无效或已过期 | 确认wx.login的code没有被重复使用,每次登录都重新调用wx.login获取新code |
| 登录接口报错,微信返回40013 | appid不合法 | 检查后端配置的appid是否是小程序自己的,别填成公众号或其他应用的 |
| 请求接口返回401 | token没传或失效 | 检查请求头字段名是否为后端拦截器要求的token,检查JWT密钥是否一致 |
| 商品接口能访问但拿不到数据 | 分类id错误或该分类下没有启售商品 | 先确认数据库里该categoryId下有没有status=1的记录 |
| 图片显示404 | 静态资源映射没配置或路径不对 | 检查addResourceHandlers,确认上传目录绝对路径正确 |
| 小程序开发者工具提示域名不合法 | 本地调试时请求了http://localhost | 开发阶段在开发者工具里勾选“不校验合法域名” |
| 新增用户后无法获取userId | insert语句没配置自增主键回填 | 在insert的XML中配置useGeneratedKeys="true"和keyProperty="id" |
| 接口返回的price是整数没有小数点 | 数据库字段类型或JSON序列化问题 | 检查price字段类型是否为decimal,统一在VO中保留两位小数 |
里面最有迷惑性的是第一个。code的过期问题发生在你“手动重复执行”的时候。我调试时习惯用postman保存上一次的请求体,第二次再跑发现报错40029,就会以为是接口问题。其实微信的code是一次性的,postman重放上一次的code当然失效。解决方法是每次都在小程序里重新触发wx.login,再从network面板里复制最新请求。
5.2 登录态和token的几个设计心得
做完微信登录之后,有几个点我想单独拎出来说,它们不是代码层面必须写死的东西,但设计得好不好直接影响后续功能的开发成本。
第一个是用户端和管理端的JWT最好分开配置。我在application.yml里配了两套sky.jwt配置,一套是adminSecretKey和管理端过期时间,另一套是userSecretKey和用户端过期时间。这样管理端接口和用户端接口可以使用不同的校验拦截器,不会出现串token的问题。如果共用一套密钥,理论上存在管理端token误用被用户端接口接收的可能,虽然业务上不太会发生,但分开了更安心。
第二个是token有效期不要设置成永久。永久token看着方便,但一旦泄露,用户身份就一直处于风险中。我给用户端token设置的过期时间是24小时,对普通外卖小程序来说,一天登录一次属于正常体验。如果用户第二天打开小程序发现登录态还在,可以直接通过静默登录再拿一个新token,过程无感。
第三个是session_key这个返回值。微信的jscode2session接口除了返回openid还会返回session_key。session_key不是用来登录的,它主要用于后续解密手机号、解密用户敏感信息。很多初学者第一次看到这个字段会以为要把它存进数据库,实际上它不需要入库,可以在需要解密时再次通过code换取,或者临时缓存起来配合解密。今天没有用到解密手机号的场景,所以我没有持久化它。
5.3 后续扩展:购物车与订单的入场准备
商品浏览做完,小程序端的骨架基本搭起来了。下一步加入购物车、下单、支付功能时,有两样东西今天已经顺带铺好了。
一个是用户表。今天新增用户时已经生成了userId并存进了JWT payload。后续创建购物车记录、创建订单时,我们都可以通过解析token拿到那个userId,从而把订单数据落到具体用户头上。
另一个是商品浏览接口的状态过滤规范。用户端能看到的商品都是启售状态,那么后续写“加购物车”“提交订单”接口时,也必须保持同样的校验逻辑:不仅入参要合法,商品本身要处于启售状态,才能继续走下单流程。今天把用户端“只能看到启售商品”的规则立下来,后面所有C端接口都要跟这个规则保持一致。
再说一个小技巧。商品浏览接口返回的数据结构里,菜品对象包含一个description描述字段,小程序页面这一步可以先不展示,但最好提前准备好样式位置。外卖场景下顾客很看重“这个菜是什么口味、有没有辣”,未来运营人员一旦补充了描述文案,前端不需要重新发版就能显示出来,这种小设计能让后续迭代省很多事。
第六天做到这里,小程序端从无到有已经有了第一个完整业务闭环:用户静默登录、身份识别、分类浏览、菜品列表展示。整个过程回头看,核心其实不是一个复杂算法或高性能架构,而是把微信登录规范和用户端商品查询规则理清楚。只要这两个链路稳了,购物车、订单、支付这些后续功能基本都是在这个骨架上继续堆肉。