下午五点,带着前一天刚接完微信支付的余温,我开始动手Day6的内容:HttpClient、微信小程序开发、微信登录、商品浏览。
说实话,到了这个阶段才是我觉得外卖项目真正“活”过来的临界点。前面几天一直在搭后端、写管理端接口,说白了都是数据往里灌、管理页面往里看的活。Day6不一样,用户端小程序一接进来,后端接口才算真正被“人”用起来。而这一切的起点,就是弄清楚一件事:Java后端怎么去调用微信官方接口。
这篇文章按我的实际推进顺序来写,先聊HttpClient为什么是必须品,再拆微信登录的完整链路,然后是商品浏览的数据流转,最后把联调期间踩过的坑都摊开说。跟着走一遍,你手头的外卖项目应该也能从“管理端能跑”推进到“用户端能下单”。
1. HttpClient:Java后端与微信服务器之间的那座桥
1.1 为什么微信登录非要后端去调接口
先明确一个基础问题:微信登录时,前端小程序拿到的是临时登录凭证code,但拿这个code换openid和session_key的请求,微信官方明确要求必须在开发者服务器完成,不能在小程序端直接发起。
原因不难理解。这个请求要带上小程序的AppId和AppSecret,AppSecret一旦嵌入小程序前端代码,就等于公开了。小程序代码包可以被反编译,明文密钥相当于把整个用户体系的数据接口交了出去。所以微信强制要求这个换取的请求放在后端做,前端只负责把code传给后端。
那后端怎么去请求微信服务器?这就轮到HttpClient出场了。
HttpClient是一个针对Java编写的HTTP客户端工具库。说白了,它就是帮后端程序发出HTTP请求去调用其他服务,并接收响应结果。微信登录接口、微信支付接口、访问其他第三方服务的接口,本质都是一趟HTTP请求。
1.2 为什么不用JDK自带的HttpURLConnection
很多初学者会问:JDK自带的HttpURLConnection不是也能发HTTP请求吗?为什么要引入这个依赖?
以我自己很早踩过的坑来说。用HttpURLConnection发一个带参数的POST请求,大概要写五六十行基础代码:设置连接超时、读取超时、请求头、编码、手动管理输入输出流、还要处理连接和响应之间的边界。而HttpClient这套封装把链表式的API调用组织成了流式写法,核心操作几行代码搞定,超时、重试、连接管理都被处理到位,错误信息也更明确。
单说两个项目里最直接的收益点:
- 超时控制更方便:小程序端用户等待登录,后端必须在合理时限内拿到微信返回。设置连接超时和读取超时只需一行,避免线程被拖死。
- 响应内容处理省心:设置好编码之后,把返回的JSON字符串转为对象,不会出现中文乱码这种玄学问题。
所以我看到项目里引入org.apache.httpcomponents的依赖时,第一反应是松了口气。拿它来处理微信登录接口的调用,是当时成熟、稳妥的选择。
提示:如果你用的Spring框架,也可以选择Spring自带的RestTemplate,或者更高层的WebClient。但刚开始学项目的话,把HttpClient的底层调用逻辑看明白,后面换任何客户端工具都很轻松。
2. 微信登录的小程序端触发与后端user表设计
2.1 前端登录按钮和wx.login的协作关系
小程序端的登录入口,一般放在“我的”页面顶部,用户点击头像区域或登录按钮触发。按钮本身不复杂,核心是调起微信的登录能力。
我先在小程序前端加了一个“微信一键登录”按钮,点击事件大概是下面这个模式:
onLoginClick() { uni.showLoading({ title: '登录中...' }); // 1. 获取临时登录凭证code uni.login({ provider: 'weixin', success: (loginRes) => { // 2. 把code交给后端,由后端换取会话信息 loginApi({ code: loginRes.code }) .then((res) => { uni.setStorageSync('token', res.token); uni.hideLoading(); }) } }); }留意到这个模式的关键点:前端不直接要session_key,也不直接拿openid做人脸识别式处理,它只需要把code交给后端,再拿到后端签发的token即可。code是一次性的,有效期几分钟,并且只能用一次,换过即失效。
2.2 user表为什么这么建
后端在接收code之前,先得有一张用户表来承接微信用户数据。我没有把表设计得很复杂,核心字段就这些:
| 字段 | 说明 |
|---|---|
| id | 主键,自增 |
| openid | 微信用户唯一标识,登录鉴权的主键 |
| nickname | 用户昵称 |
| avatar | 用户头像路径 |
| create_time | 创建时间,方便排查首登来源 |
其实可以再记一个unionid,但这个在普通小程序业务里用不到,除非你有多端应用需要打通账号体系。我建议最初就只保留openid作为唯一键来用,别过度设计。
要注意的是,openid 这个概念很多人第一次接触会混淆。微信扫码登录网页版用的是unionid和openid的一套逻辑,但小程序端每个用户在每个小程序下的openid是唯一的。同一个用户在不同小程序下,openid也是不一样的。所以拿openid作为当前小程序用户表的主键,是合理的识别逻辑。
2.3 code换session_key的详细请求封装
前端把code传过来之后,后端要做的第一件事就是组装请求。微信官方给的接口地址是:
https://api.weixin.qq.com/sns/jscode2session我设置了四个请求参数:
| 参数 | 值 |
|---|---|
| appid | 小程序的AppId |
| secret | 小程序的AppSecret |
| js_code | 前端传来的临时code |
| grant_type | authorization_code |
这里直接套了一个HttpGet,把参数拼到URL末尾发起请求。实际代码如下:
public String getSessionInfo(String code) { String url = "https://api.weixin.qq.com/sns/jscode2session" + "?appid=" + appid + "&secret=" + secret + "&js_code=" + code + "&grant_type=authorization_code"; HttpGet httpGet = new HttpGet(url); try (CloseableHttpResponse response = httpClient.execute(httpGet)) { return EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); } catch (IOException e) { log.error("调用微信登录接口失败,code:{}", code, e); throw new BusinessException("微信登录失败"); } }返回的是一段JSON,核心字段只有两个必拿的:openid 和 session_key。session_key 是微信加密数据的解密钥,在这套外卖项目里暂未用在敏感数据解密上,但属于微信登录标准化流程的一部分。
2.4 新老用户的分支处理
拿到openid之后,逻辑很简单但又很关键,两段分支:
User user = userMapper.getByOpenid(openid); if (user == null) { // 新用户:默认昵称,默认头像,插入记录 user = User.builder() .openid(openid) .nickname("微信用户") .avatar("/default-avatar.png") .build(); userMapper.insert(user); }为什么这里要默认昵称和头像?因为小程序登录首登时,微信并没有把头像昵称授权信息一次性送过来。头像昵称需要用户主动授权填写,得额外走一轮用户信息授权逻辑。先把默认值兜住,后面再做资料完善,这是几乎所有小程序的通用做法。
新用户插入记录之后,后端要给前端签发一个token来标识登录状态。这里我使用的是JWT方案,把用户id放进去,设置7天有效期。以后前端每个请求都把这个JWT放在请求头里,后端通过拦截器解析,就完成身份识别了。
3. 登录态返回与前端缓存:别把代码写散了
3.1 登录接口的统一响应结构
后端把这套登录逻辑整理成了一个完整的controller方法,前端调用后收到的响应结构大概是:
{ "code": 1, "msg": "success", "data": { "token": "eyJhbGciOiJIUzI1NiJ9..." } }我在这个项目里规定好:所有接口统一返回这个结构。code为1表示成功,0表示失败(封禁、参数错误等),msg是提示信息,data是业务数据。前端拿到这个结构之后,只需要判断code再解析data。
这种统一响应结构的好处,后面做商品浏览、购物车、订单接口时很快就体现出来了。前端封装request.js,只需要在拦截器里统一处理code,不需要每个页面各写一套错误处理逻辑。
3.2 前端Token到底是什么时候存的
我走了个小弯路。第一次我是在登录接口返回之后,直接在页面里执行uni.setStorageSync。后来看项目规范,发现这种做法不好维护,因为每个页面可能都需要重复处理。
更推荐的做法是把登录接口封装成一个独立API模块,把token存储逻辑也放进这个模块里:
// api/login.js export function loginApi(data) { return request({ url: '/user/user/login', method: 'POST', data }).then(res => { // 这里统一存token uni.setStorageSync('token', res.token); return res; }); }页面调用时不需要关心token怎么存的,只管拿到登录成功状态就行。这种模块封装意识,是项目往后做大后很宝贵的习惯。
另外,我在示例里用了uni.request。注意事项封装中,我把baseURL、请求头token、响应非2xx状态码的提示统一处理了。一个干净的基础request模块长这样:
// utils/request.js const BASE_URL = 'http://localhost:8080/api'; function request({ url, method = 'GET', data = {} }) { return new Promise((resolve, reject) => { uni.request({ url: BASE_URL + url, method, data, header: { token: uni.getStorageSync('token') || '' }, success: (res) => { if (res.data.code === 1) { resolve(res.data.data); } else { uni.showToast({ title: res.data.msg, icon: 'none' }); reject(res.data); } }, fail: (err) => reject(err) }); }); }这算是一个基础模板,以后所有业务接口都从这里走。
4. 商品浏览:用户端首页数据流的完整解剖
4.1 首页到底要展示哪些数据
商品浏览这块,Day6实际做的是三个核心接口对接:分类列表、启售商品列表、按分类Id查启售商品及口味列表。
用户打开小程序首页,最先看到的是顶部分类导航(热销、主食、小吃、饮料等),中间是每个分类下对应的商品卡片,卡片里包含图片、名称、价格、销量、加购按钮。再看点进去的详情页,知道这个菜有哪些口味可选。
所以我给控制器设计了这么几个查询方法,直接对应前端页面需要:
| 接口路径 | 功能 |
|---|---|
| GET /user/category/list | 查询所有分类(仅启用状态) |
| GET /user/dish/list | 查询所有启售商品(打包到分类下) |
| GET /user/dish/list?categoryId=xxx | 按分类查询商品列表 |
| GET /user/dish/queryById?id=xxx | 查询商品详情及口味列表 |
4.2 按分类查询的SQL实现
dish表里设计了两个查询维度:一是不带条件的查询所有启售商品,二是根据categoryId过滤。这两个都可以用一个查询封装,需要动态拼SQL条件。
我是用MyBatis的XML来写的,核心就是动态SQL:
<select id="listByCategoryId" resultType="com.sky.entity.Dish"> select * from dish <where> <if test="categoryId != null"> and category_id = #{categoryId} </if> and status = 1 </where> </select>注意这里有两个细节容易查掉:
- status = 1 一定要加。商品有“启售/停售”状态,停售的菜如果还在前端展示,用户下单时就会碰一鼻子灰,体验极差。
- categoryId为空时,查询所有启售商品。前端首页第一次加载时需要先拿到全部分类和商品,所以空值判断不能漏掉。
4.3 商品图片路径处理,容易出彩也容易暴雷
小程序端展示商品图片,后端的返回里给的是相对路径,比如:
/upload/dish/20250601120001.jpg但小程序里面的image组件src必须是一个完整可访问的URL。所以我在发布商品图片返回给前端前,统一拼上文件服务器的前缀:
{ "image": "http://localhost:8080/upload/dish/20250601120001.jpg" }这里要特别留一个心:如果你的文件服务器不在同一台机器,或者以后迁移到对象存储了,这个前缀改起来会牵一发动全身。比较好的做法是在配置里设置一个baseUrl或者imagePrefix,不要硬编码在代码里。
我在项目里的配置大概是:
sky: # 配置前缀路径 file: base-url: http://localhost:8080然后后端返回前端前统一拼好:
dish.setImage(fileBaseUrl + dish.getImage());这种做法虽然简单,但在联调阶段大大省心,前端不用在每一处去猜图片路径来源。
4.4 首页加载的商品渲染逻辑
小程序端首页加载逻辑我放在了onLoad中,一进页面就把分类和商品一起拉取:
async loadHomeData() { const categories = await getCategoryList(); if (categories.length) { this.categories = categories; this.currentCategoryId = categories[0].id; await this.loadDishes(); } }这个顺序有个目的:默认选中第一个分类,就直接把第一个分类下的商品显示出来。用户看图不看字,上来就有内容可吃,转化率会高很多。分类之间切换时再重新请求新的商品列表。
还有一个非常常见的前端优化点:加载时给个骨架屏或者loading。我用的是skyUI里的load-more和空数据占位,实际感受下来比白屏强太多。
5. 踩坑实录与排查链路:你也会遇到的几个典型问题
5.1 中文参数到后端变成乱码
第一次联调登录接口,前端传的nickname是“张三”,后端打日志显示å¼ ä¸‰。排查链路如下:
- 先看前端请求头,确认
Content-Type: application/json没问题。 - 再用postman模拟同样的中文参数,后端起日志,结果正常。
- 对比后发现,前端uni.request在GET请求拼接URL时,中文没有做encodeURIComponent编码。
- 修复方式:前端请求封装中对请求参数统一做encode处理。
// 参数序列化编码 if (method === 'GET') { const query = Object.keys(data) .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`) .join('&'); url = `${url}?${query}`; }5.2 后端调用微信接口超时
有段时间登录经常莫名其妙失败,看后端日志发现,connect timed out和read timed out轮流出现。问题指向鲜明:网络到api.weixin.qq.com的链路不稳定。
解决方案是给HttpClient设置合理的超时时间,避免线程无限等待。我在创建HttpClient的时候这样配置:
RequestConfig config = RequestConfig.custom() .setConnectTimeout(5000) // 连接建立超时5秒 .setConnectionRequestTimeout(3000) // 请求连接超时3秒 .setSocketTimeout(5000) // 读数据超时5秒 .build();这里再延伸一句:连接池模式下,很多问题其实是连接复用导致的。微信接口一般不会有连接复用问题,但自己服务间的调用建议开连接管理器,不要每次new一个client。
5.3 小程序端image组件不显示图片
这个坑我当时排查了将近半小时。后端图片路径正确,浏览器直接访问也正常,但小程序页面里的图片就是不出来。
最终定位到原因:项目启动时,后端通过配置文件定义的图片访问根路径是http://localhost:8080/upload/,但前端在小程序开发者工具里跑,走的是模拟器环境,访问localhost其实访问的是PC机的本机,按理说应该通。真正的问题出在小程序开发者工具默认不校验合法域名、不校验TLS版本,但本地路径不在白名单里。
解决办法是在开发者工具的详情设置里,把“不校验合法域名、web-view(业务域名)、TLS版本以及HTTPS证书”勾上。正式发布时,域名必须是备案过的HTTPS域名,并在微信公众平台后台加到request合法域名里。
5.4 前端请求了但后端没收到,排查请求头token
商品列表接口联调时,前端明明调用了接口,但后端拦截器报“用户未登录”。我先看前端有没有把token放进请求头,发现request封装里header组织写得不对:
header: { 'token': uni.getStorageSync('token') }这里有两个点。第一是后端接收token的header名称到底是token还是authentication,不同项目约定不同,一定要和后端校验代码对齐。第二是token如果为空,不应该继续往后端发,可以直接在前端跳转登录页。
我在后端拦截器里的校验逻辑是:
String token = request.getHeader("token"); if (StringUtils.isBlank(token)) { throw new BusinessException(ResultCode.USER_NOT_LOGIN); }所以前端定死在header键名为token,两边一照应就通了。
5.5 数据库时间字段给前端多了八小时
商品列表里有个上架时间字段,前端显示比实际时间多了8小时。这是时区问题。MySQL的create_time字段是 UTC 存储,Java查出来转成字符串时带上了东八区偏移,但前端没做任何处理,直接显示。
排查链路:
- 先查数据库原始值,确认存储本身没问题。
- 再查后端JSON序列化配置,看LocalDateTime输出格式。
- 最后在前端对时间字符串做统一格式化,通过封装时间工具函数解决。
简单粗暴的做法是后端统一返回时间戳,前端统一转换。我用的是各端点配置spring.jackson.date-format=yyyy-MM-dd HH:mm:ss加time-zone=GMT+8,后端返回规范格式,前端直接展示。比让前端去解析时区更省心。
6. 联调完成后的整体验收与判断标准
6.1 我用来判定“Day6完成”的标准
Day6做得对不对,我不能只看“接口通了没”。我给自己定了一个可量化验收清单,照着逐项检查:
- [ ] 小程序“我的”页点击登录,能拿到token并且刷新页面后登录态还在
- [ ] 退出小程序重进,不需要重新登录(token在本地缓存,且未过期)
- [ ] 首页能在3秒内完成首屏渲染(分类+商品图片)
- [ ] 切换分类,商品列表正确切换,且没有加载白屏
- [ ] 商品卡片上的价格分毫不差地对应数据库里的值
- [ ] 停售商品没有出现在任何列表里
- [ ] 图片缓存第二次加载时性能没有明显劣化
其中第二条是一个隐含的关键点:前端每次打开小程序,自动带上token去请求用户信息接口,而不是强制用户再次微信登录。这也是微信生态的标准玩法,毕竟没人愿意每次打开都重新登录一次。
6.2 前端登录态自动检查的写法
顺带把我在App.vue里做的登录态自动检查分享一下:
onLaunch() { const token = uni.getStorageSync('token'); if (token) { getUserInfo().then(res => { this.userInfo = res; }).catch(() => { uni.removeStorageSync('token'); }); } }token在每次请求时由拦截器校验,如果后端返回未登录错误,前端统一做清理跳转。这算最基础的登录态管理逻辑,够用,没上复杂的sdk。
7. 给即将进入Day7的你留几个实操延展点
Day6做完当天,我在自己的便签上顺手写了几条接下来要注意的事,现在一起分享出来:
关于图片懒加载:首页商品多的情况下,图片一次性全部加载会卡顿。小程序image组件自带lazy-load属性,直接加就行,成本极低收益直观。
关于下拉刷新:用户切后台再回到小程序,商品价格可能已变化。建议在onPullDownRefresh做一次商品重新加载。微信给的接口能力要利用起来,不然用户看的是旧数据。
关于接口异常提示:联调阶段入参错误多数是前端问题,但线上用户看到的却是“加载失败”这种大而化之的提示。建议后端在BusinessException里带上具体错误码和可读信息,前端再映射成友好文案。这样划分清晰,排查问题也不必全线拉网。
关于微信登录的会话密钥:session_key 其实还有业务价值。后续如果要解密手机号、获取微信运动等敏感信息,都需要它。所以后端虽然目前不存它,但要让代码保留获取session_key的通道,别把这段逻辑写死删除。
Day6到这里,用户端最核心的两个地基已经打好了:一个能登录的小程序壳子,一个能正常浏览商品的门面。下一节开始就要碰购物车和下单了,那才是真正考验数据和状态一致性的地方。提前把登录态和商品浏览的基础打牢,后面自然顺一点。