微信小程序登录与商品浏览实战:HttpClient封装与后端联调
发布时间:2026/10/10 6:33:27 锦皓数字建站

手写一个“苍穹外卖日记”系列今天到Day6。前五天把后端架构、员工端、分类管理这些基础服务撸完了今天开始碰真正的C端逻辑微信小程序端的登录和商品浏览。说白了今天的目标很明确——让小程序用户能打开首页、能微信授权登录、能浏览商品分类和菜品列表。这一天的内容有一个绕不开的前提后端要主动调用微信官方接口来换登录凭证这就必须引入HTTP客户端工具。我在项目里用的是Apache HttpClient原因很简单Spring Boot自带的能力不适合做精细控制项目里其他同学也在用踩坑资料多出问题好排查。今天我会把HttpClient的封装、微信登录完整链路、小程序端商品浏览的实现过程全部记录下来包括联调时踩过的坑希望能给同样在写外卖类小程序的朋友节省点时间。1. 先说说为什么今天的主角是HttpClient1.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, MapString, String paramMap) { // 拼接参数把 paramMap 生成 keyvaluekey2value2 if (paramMap ! null !paramMap.isEmpty()) { StringBuilder sb new StringBuilder(url); sb.append(?); for (Map.EntryString, 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;charsetUTF-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. 微信登录从打通官方接口到签发自己的token2.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。如果用户同时登录了同一个主体的公众号、小程序、Appunionid是跨平台的统一标识。项目里只要小程序端openid就够用。如果你以后要做多端打通再考虑unionid。2.2 后端登录接口不到50行代码搞定核心逻辑先添加微信相关的配置到application.ymlsky: 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接收小程序传来的codeRestController RequestMapping(/user) Slf4j public class UserController { Autowired private UserService userService; /** * 小程序用户登录 */ PostMapping(/login) public ResultUserLoginVO 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 MapString, 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 MapString, 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(MapString, 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参数传type11代表菜品分类2代表套餐分类。查询某分类下的商品GET /shop/product/list参数传categoryId同时只返回status1启用状态的商品。为什么不一个接口把商品全查出来因为外卖场景下商品量会越来越大全量返回导致首屏加载时间和流量消耗都会上来。分类查询天然是“按需加载”的符合小程序端的性能要求。3.2 后端商品查询接口怎么做分类接口在Day5已经做好了今天直接复用。商品查询接口需要新写逻辑很直接RestController RequestMapping(/shop) public class ShopController { Autowired private ProductService productService; /** * 用户端根据分类id查询启用状态的商品列表 */ GetMapping(/product/list) public ResultListProductVO listByCategoryId(Long categoryId) { log.info(用户端菜品列表categoryId {}, categoryId); ListProductVO list productService.listWithCategory(categoryId); return Result.success(list); } }Service实现Override public ListProductVO listWithCategory(Long categoryId) { // 1. 查询该分类下状态为启用的商品 Product query new Product(); query.setCategoryId(categoryId); query.setStatus(1); ListProduct productList productMapper.list(query); // 2. 补充分类名称因为前端可以直接展示分类名 ListProductVO 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?type1) 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 classcategory-wrap scroll-view classcategory-left scroll-y view wx:for{{categories}} wx:keyid classcategory-item {{activeCategoryId item.id ? active : }} >
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。