手写一个“苍穹外卖日记”系列,今天到Day6。前五天把后端架构、员工端、分类管理这些基础服务撸完了,今天开始碰真正的C端逻辑:微信小程序端的登录和商品浏览。说白了,今天的目标很明确——让小程序用户能打开首页、能微信授权登录、能浏览商品分类和菜品列表。
这一天的内容有一个绕不开的前提:后端要主动调用微信官方接口来换登录凭证,这就必须引入HTTP客户端工具。我在项目里用的是Apache HttpClient,原因很简单:Spring Boot自带的能力不适合做精细控制,项目里其他同学也在用,踩坑资料多,出问题好排查。今天我会把HttpClient的封装、微信登录完整链路、小程序端商品浏览的实现过程全部记录下来,包括联调时踩过的坑,希望能给同样在写外卖类小程序的朋友节省点时间。
1. 先说说为什么今天的主角是HttpClient
1.1 后端为什么要自己发起HTTP请求
很多刚接触小程序开发的同学会有个疑问:微信登录不是前端小程序直接调用就行了吗,为什么要绕到后端?
答案是安全。小程序端调用微信登录接口时,需要一个关键的参数appSecret,这是小程序的密钥,相当于后端服务的“口令”。如果把这个密钥写在小程序前端代码里,那随便一个人反编译小程序包就能把它扒出来,等于把服务端权限拱手让人。所以正确的做法是:小程序只拿到一个临时凭证code,把这个code发给自己的后端,由后端拿code加appId和appSecret去请求微信官方接口,换回用户身份标识,再返回自己的登录凭证给小程序。
这个“后端请求微信官方接口”的行为,就是今天引入HttpClient的核心场景。当然,实际项目中HttpClient的应用不止微信登录,后续做微信支付、对接地图服务、调用推荐系统,都会用到它。所以今天这一步不光是功能开发,更是在搭建一个“对外HTTP通信”的基础设施。
1.2 用HttpClient之前,先看清这几个候选者
Java里发HTTP请求的方式有好几种,每种的适用场景不一样,我在做苍穹外卖之前把这些都过了一遍:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| JDK自带HttpURLConnection | 无需引入依赖 | API太原始,代码量大 | 偶尔用一次、不追求维护性 |
| Apache HttpClient | 功能全、配置灵活、资料多 | 引入依赖,类库稍重 | 企业级项目标配 |
| OkHttp | 性能好、支持HTTP/2 | 回调风格需要适应 | Android/H5端偏多 |
| Spring RestTemplate | 与Spring集成好 | 5.x之后维护态度冷淡 | 轻量调用 |
| Spring WebClient | 响应式、非阻塞 | 学习成本高、调试麻烦 | 高并发异步场景 |
我的选择是Apache HttpClient 4.5.x版本。理由有三条:
- 稳定可靠。这个库在业界用了十几年,各种边界情况都有人踩过坑,遇到问题搜一下基本能解决。
- 和Spring Boot搭配顺手。我只需要封装一个工具类,把
doGet和doPost两个方法暴露出去,Service层调用起来跟本地方法没什么区别。 - 团队习惯。我们项目小组的技术选型统一用这个,后续代码review和维护成本低。
如果你自己写项目,用OkHttp也一样能搞定,关键是不要换着花样写。定下一个,封装好,别人看着也统一。
1.3 照抄能用的HttpClient工具类
HttpClient的用法其实不难,难的是把超时、编码、异常处理这些东西一次配好。我下面是项目里实际在用的封装,注释比较详细,可以直接参考:
import org.apache.http.client.config.RequestConfig; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpPost; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.apache.http.util.EntityUtils; import java.util.Map; /** * HTTP客户端工具类,统一封装GET/POST请求 */ public class HttpClientUtil { // 连接建立超时时间(毫秒) private static final int CONNECT_TIMEOUT = 5000; // 连接池获取连接超时时间(毫秒) private static final int CONNECTION_REQUEST_TIMEOUT = 5000; // 读取数据超时时间(毫秒),微信接口正常情况下2秒内能返回 private static final int SOCKET_TIMEOUT = 10000; /** * GET请求,支持参数透传 * @param url 请求地址 * @param paramMap 查询参数 * @return 响应体字符串 */ public static String doGet(String url, Map<String, String> paramMap) { // 拼接参数:把 paramMap 生成 key=value&key2=value2 if (paramMap != null && !paramMap.isEmpty()) { StringBuilder sb = new StringBuilder(url); sb.append("?"); for (Map.Entry<String, String> entry : paramMap.entrySet()) { sb.append(entry.getKey()).append("=").append(entry.getValue()).append("&"); } url = sb.substring(0, sb.length() - 1); } try (CloseableHttpClient httpClient = HttpClients.createDefault()) { HttpGet httpGet = new HttpGet(url); httpGet.setConfig(buildRequestConfig()); try (CloseableHttpResponse response = httpClient.execute(httpGet)) { return EntityUtils.toString(response.getEntity(), "UTF-8"); } } catch (Exception e) { throw new RuntimeException("GET请求失败: " + url, e); } } /** * POST请求,请求体为JSON字符串 * @param url 请求地址 * @param json JSON字符串,比如 {"code":"xxx"} * @return 响应体字符串 */ public static String doPost(String url, String json) { try (CloseableHttpClient httpClient = HttpClients.createDefault()) { HttpPost httpPost = new HttpPost(url); httpPost.setConfig(buildRequestConfig()); httpPost.setHeader("Content-Type", "application/json;charset=UTF-8"); httpPost.setEntity(new StringEntity(json, "UTF-8")); try (CloseableHttpResponse response = httpClient.execute(httpPost)) { return EntityUtils.toString(response.getEntity(), "UTF-8"); } } catch (Exception e) { throw new RuntimeException("POST请求失败: " + url, e); } } private static RequestConfig buildRequestConfig() { return RequestConfig.custom() .setConnectTimeout(CONNECT_TIMEOUT) .setConnectionRequestTimeout(CONNECTION_REQUEST_TIMEOUT) .setSocketTimeout(SOCKET_TIMEOUT) .build(); } }有几个细节值得说明:
- 连接池不在这里体现。HttpClients.createDefault()每次都是新建连接,对低并发的管理后台没问题。如果后续要支撑高并发接口,最好配置连接池管理器。
- 超时时间必须区分。连接超时和读取超时是两回事,连接超时解决“连不上”,读取超时解决“连上了但不返回”。微信接口偶尔慢,我设了10秒读取超时,经验上已经够宽松。
- 字符集强制UTF-8。很多HTTP工具默认用ISO-8859-1解析,中文会乱码。EntityUtils.toString的第二个参数一定要写清楚。
提示:实际项目里不建议把请求参数直接拼在URL里,中文和特殊字符要做URLEncoder.encode。微信的code参数是纯英文数字,所以上面的写法暂时能跑,但你要知道这只是“学习阶段够用”。
2. 微信登录:从打通官方接口到签发自己的token
2.1 微信登录的完整流程到底长什么样
微信登录的官方流程,核心是一个叫code2Session的接口。整体链路可以拆成五步:
- 小程序端调用
wx.login(),拿到一个临时凭证code,这个code有效期很短,官方文档说是5分钟,而且只能用一次。 - 小程序把code通过自己后端接口传过去,比如POST
/user/login,请求体里就是一个{ "code": "xxx" }。 - 后端收到code后,用
appid + appsecret + code这三个参数去请求微信官方接口https://api.weixin.qq.com/sns/jscode2session。 - 微信返回一个JSON,里面包含
openid(用户在小程序下的唯一标识)和session_key(会话密钥),也可能是错误码。 - 后端拿到
openid之后,查自己的用户表。如果这个用户第一次登录,就自动注册一条用户记录;如果已经存在,就直接复用。最后用自己的签名算法生成一个token返回给小程序。
这里需要理解openid和session_key的区别:openid是用户的身份标识,用于识别“你是谁”;session_key是微信加密通信用的密钥,接口里返回它主要是为了给后续解密手机号、解密用户信息用。苍穹外卖这个项目登录阶段不需要解密数据,我们只需要openid。
还有个概念是unionid。如果用户同时登录了同一个主体的公众号、小程序、App,unionid是跨平台的统一标识。项目里只要小程序端,openid就够用。如果你以后要做多端打通,再考虑unionid。
2.2 后端登录接口:不到50行代码搞定核心逻辑
先添加微信相关的配置到application.yml:
sky: wechat: appid: wx1234567890abcdef # 改成你自己的小程序appid secret: abcdef1234567890abcdef1234567890 # 改成你自己的appsecret grant-type: authorization_code然后定义一个配置类读取这些值:
@Component @ConfigurationProperties(prefix = "sky.wechat") @Data public class WeChatProperties { private String appid; private String secret; private String grantType; }接下来是登录接口的Controller,接收小程序传来的code:
@RestController @RequestMapping("/user") @Slf4j public class UserController { @Autowired private UserService userService; /** * 小程序用户登录 */ @PostMapping("/login") public Result<UserLoginVO> login(@RequestBody UserLoginDTO userLoginDTO) { log.info("微信登录:code = {}", userLoginDTO.getCode()); UserLoginVO userLoginVO = userService.wxLogin(userLoginDTO); return Result.success(userLoginVO); } }核心逻辑在Service层,看着不多,但每一行都有它的作用:
@Service @Slf4j public class UserServiceImpl implements UserService { // 微信登录请求地址 private static final String WX_LOGIN_URL = "https://api.weixin.qq.com/sns/jscode2session"; @Autowired private WeChatProperties weChatProperties; @Autowired private UserMapper userMapper; @Autowired private JwtUtil jwtUtil; @Override public UserLoginVO wxLogin(UserLoginDTO userLoginDTO) { // 1. 用code换取微信的openid Map<String, String> params = new HashMap<>(); params.put("appid", weChatProperties.getAppid()); params.put("secret", weChatProperties.getSecret()); params.put("js_code", userLoginDTO.getCode()); params.put("grant_type", weChatProperties.getGrantType()); String json = HttpClientUtil.doGet(WX_LOGIN_URL, params); JSONObject jsonObject = JSON.parseObject(json); // 2. 检查openid是否成功获取 String openid = jsonObject.getString("openid"); if (openid == null || openid.isEmpty()) { throw new BusinessException("微信登录失败: " + jsonObject.getString("errmsg")); } // 3. 根据openid查询用户,不存在则自动注册 User user = userMapper.getByOpenid(openid); if (user == null) { user = User.builder() .openid(openid) .createTime(LocalDateTime.now()) .build(); userMapper.insert(user); } // 4. 签发自己的token Map<String, Object> claims = new HashMap<>(); claims.put("userId", user.getId()); String token = jwtUtil.createJWT(claims); // 5. 返回给前端 UserLoginVO userLoginVO = UserLoginVO.builder() .id(user.getId()) .openid(openid) .token(token) .build(); return userLoginVO; } }为什么要自动注册而不是强制绑定手机号?这里有两个考虑:第一,微信登录本身已经是一个可信任的身份来源,openid具有唯一性,用它当用户主键是可行的;第二,外卖C端用户的核心痛点是“下单快”,如果第一次进来就强制绑定手机号,会流失很多用户。等用户下单支付时再诱导绑定手机号,转化率会高很多。
关于JWT签发,用的JwtUtil其实就是hutool或jjwt封装的一个工具类,核心是:
public String createJWT(Map<String, Object> claims) { return Jwts.builder() .setClaims(claims) .setExpiration(new Date(System.currentTimeMillis() + 3600 * 1000)) // 1小时过期 .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }注意token的过期时间,我自己设过一天的,后来改成2小时。太长的token泄露风险高,太短的用户体验差,外卖场景2小时够用。
2.3 小程序端登录对接
小程序端逻辑很简单:页面加载时调wx.login,把code发给自己后端。
// utils/auth.js const login = () => { return new Promise((resolve, reject) => { wx.login({ success: (res) => { if (res.code) { wx.request({ url: 'https://你的后端域名/api/user/login', method: 'POST', data: { code: res.code }, success: (response) => { if (response.data.code === 1) { wx.setStorageSync('token', response.data.data.token) resolve(response.data.data) } else { reject(response.data.msg) } }, fail: (err) => reject(err) }) } else { reject('wx.login获取code失败') } } }) }) }这里有几个容易被忽略的点:
wx.login返回的code是一次性的,同一个code不能换两次openid。联调时如果你反复用同一个code测试,第二次就会拿到40029错误码。- 拿到token一定要存起来。后续所有需要登录态的请求,都在请求头里带
Authorization: Bearer token。 - 小程序每次冷启动,建议重新走一遍登录。因为wx.login的code是新的,后端返回的token也是新签发的,这样能保证用户身份是最新的。
2.4 登录联调时我踩过的三道坎
第一道坎是appSecret配置错误。把测试号的secret填到正式环境,结果微信直接返回40125。排查方式很简单:用Postman直接请求微信接口,把返回的errmsg拉到搜索引擎里查,比对着代码猜快得多。
第二道坎是grant_type大小写。微信文档写的参数值是authorization_code,但我一开始写成了Authorization_code,接口返回40012。微信返回码对大小写非常敏感,复制粘贴都容易出问题。
第三道坎是局域网联调时的小程序“不合法域名”。在开发者工具里,可以勾选“不校验合法域名”绕过,但真机预览必须配置request合法域名。这个问题其实在商品浏览时也会遇到,后面细说。
3. 商品浏览:小程序首页从0到能看能点
3.1 首页拆解:先想清楚要哪些模块
登录搞定之后,就要让用户“看得到东西”。商品浏览功能听起来简单,但拆开来看包含三个模块:
- 分类展示:左侧竖排分类菜单,用户点一下右侧列表切换到对应分类。外卖类目一般是“热销”“主食”“小食”“饮品”。
- 商品列表:根据当前分类展示菜品卡,包含图片、名称、描述、价格、销量。每一行右边一般带一个“加号”按钮,方便用户加购。
- 购物车入口:底部TabBar或者悬浮球显示购物车。
我今天的范围是前两个:分类+商品列表。购物车是后面的内容,今天先把“能看”打通。
小程序端的页面结构没有太多花活,关键在后端接口设计。我选择的是两个查询接口:
- 查询分类列表:
GET /category/list,参数传type=1(1代表菜品分类,2代表套餐分类)。 - 查询某分类下的商品:
GET /shop/product/list,参数传categoryId,同时只返回status=1(启用状态)的商品。
为什么不一个接口把商品全查出来?因为外卖场景下商品量会越来越大,全量返回导致首屏加载时间和流量消耗都会上来。分类查询天然是“按需加载”的,符合小程序端的性能要求。
3.2 后端商品查询接口怎么做
分类接口在Day5已经做好了,今天直接复用。商品查询接口需要新写,逻辑很直接:
@RestController @RequestMapping("/shop") public class ShopController { @Autowired private ProductService productService; /** * 用户端:根据分类id查询启用状态的商品列表 */ @GetMapping("/product/list") public Result<List<ProductVO>> listByCategoryId(Long categoryId) { log.info("用户端菜品列表:categoryId = {}", categoryId); List<ProductVO> list = productService.listWithCategory(categoryId); return Result.success(list); } }Service实现:
@Override public List<ProductVO> listWithCategory(Long categoryId) { // 1. 查询该分类下状态为启用的商品 Product query = new Product(); query.setCategoryId(categoryId); query.setStatus(1); List<Product> productList = productMapper.list(query); // 2. 补充分类名称(因为前端可以直接展示分类名) List<ProductVO> voList = new ArrayList<>(); for (Product product : productList) { ProductVO vo = BeanUtils.copyProperties(product, ProductVO.class); Category category = categoryMapper.getById(product.getCategoryId()); if (category != null) { vo.setCategoryName(category.getName()); } voList.add(vo); } return voList; }你可能会问,给小程序端返回数据时,为什么不用Product实体直接返回,而要包一层VO?因为实体类里有updateTime、status这些字段,对C端用户没意义,甚至可能是敏感的内部信息。VO的作用就是“按需输出”,只暴露前端需要的内容。这个习惯建议一开始就养成,后面接口多了就懂它的价值了。
返回的统一结构是Result,包含code、msg、data三段。这是整个项目统一约定的,前端所有请求都能用同一套逻辑解析,省了很多重复代码。
3.3 小程序端请求封装与页面渲染
先做一层异步请求封装,避免每个页面都写一遍wx.request:
// utils/request.js const BASE_URL = 'https://你的后端域名/api' const request = (url, method = 'GET', data = {}) => { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + url, method: method, data: data, header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + wx.getStorageSync('token') }, success: (res) => { if (res.data.code === 1) { resolve(res.data.data) } else if (res.data.code === 0) { // 后端提示业务错误 wx.showToast({ title: res.data.msg, icon: 'none' }) reject(res.data.msg) } else { reject(res.data.msg || '请求失败') } }, fail: (err) => { wx.showToast({ title: '网络异常', icon: 'none' }) reject(err) } }) }) } module.exports = { request, BASE_URL }然后首页的代码分为两块。第一块是分类菜单,左侧滚动区:
// pages/index/index.js Page({ data: { categories: [], activeCategoryId: null, products: [], loading: false }, onLoad() { this.loadCategories() }, async loadCategories() { const categories = await request('/category/list?type=1') this.setData({ categories }) if (categories.length > 0) { this.setData({ activeCategoryId: categories[0].id }) this.loadProducts(categories[0].id) } }, async loadProducts(categoryId) { this.setData({ loading: true }) try { const products = await request(`/shop/product/list?categoryId=${categoryId}`) this.setData({ products }) } finally { this.setData({ loading: false }) } }, onSelectCategory(e) { const id = e.currentTarget.dataset.id this.setData({ activeCategoryId: id }) this.loadProducts(id) } })第二块是页面模板,只保留核心骨架:
<view class="category-wrap"> <scroll-view class="category-left" scroll-y> <view wx:for="{{categories}}" wx:key="id" class="category-item {{activeCategoryId == item.id ? 'active' : ''}}" >