后端开发日记②:微信登录、用户资料与地址归属

小程序登录不是把前端传来的用户信息直接保存,而是由服务端向微信确认身份,再把微信身份转换成项目自己的登录态。本篇记录用户从临时 code 到业务 Token,以及登录后如何安全维护资料和地址。
1. 登录链路
登录接口使用 @Anonymous 放行,但放行的只有入口,身份仍由后端验证:
wx.login code -> jscode2session -> openid / unionid -> 查询或创建 HyWxUser -> 构造 LoginUser -> TokenService.createToken前端传来的 code 是一次性凭证,后端使用 AppID 和 Secret 调用微信 jscode2session。只有微信响应中的 openid 才能作为当前小程序内的稳定用户标识。
2. 首次登录的并发问题
两个请求可能几乎同时完成 selectByOpenId,都认为用户不存在。只靠“先查再插”无法保证唯一性,所以数据库仍需要为 open_id 建唯一索引,并捕获 DuplicateKeyException:
try { insertHyWxUser(user);} catch (DuplicateKeyException e) { user = selectHyWxUserByOpenId(openId);}应用层处理让第二个请求可以继续登录,数据库约束则保证不会真正产生两条用户记录,两层缺一不可。
3. 手机号授权与 access_token 缓存
手机号授权凭证需要调用微信接口换取手机号,而这个接口又依赖全局 access_token。后端把 Token 缓存在内存中,并在微信给出的过期时间前 300 秒主动失效,减少临界点请求失败。
当微信返回 40001 或 42001 时,服务会清空缓存并重试一次。这里的重试只针对明确的 Token 失效,不对所有异常盲目重试,避免第三方服务故障时放大流量。
4. 接入项目自己的登录态
微信用户没有后台角色和部门,但可以复用现有 Token 基础设施。后端构造最小 SysUser 与 LoginUser,用户名使用 wx_用户ID,权限集合为空,再交给 TokenService 生成 Token。
这样后续业务代码仍可统一调用:
Long userId = SecurityUtils.getUserId();LoginUser loginUser = SecurityUtils.getLoginUser();退出登录时删除服务端缓存中的 Token,而不是只让前端清空本地存储。
5. 资料更新使用字段白名单
用户资料实体包含状态、删除标记、登录失败次数等后台字段。修改个人资料时不能把请求实体整体保存,而是新建更新对象,只复制允许修改的昵称:
HyWxUser update = new HyWxUser();update.setUserId(SecurityUtils.getUserId());update.setNickname(request.getNickname());update.setUpdateTime(DateUtils.getNowDate());头像接口也只返回 hasAvatar,真实存储路径保留在服务端。读取图片时先校验当前身份,再输出文件流。
6. 地址簿的资源归属
地址的新增、修改、默认设置和删除都以安全上下文中的用户 ID 为准。即使请求体包含 wxUserId,后端也会覆盖它;修改前还要查询原记录并确认归属。
设置默认地址是一个整体动作:先查出当前用户所有有效地址,再把目标地址设为 1、其余设为 0,整个过程放在事务中。删除使用逻辑删除,同时取消默认标记,避免地址消失后仍残留默认状态。
7. code2Session 调用怎么实现
后端调用微信登录服务时,需要把配置和临时 code 编码为查询参数:
private JsonNode codeToSession(String code) { String query = "appid=" + encode(miniProgramAppId) + "&secret=" + encode(miniProgramSecret) + "&js_code=" + encode(code) + "&grant_type=authorization_code";
HttpRequest request = HttpRequest.newBuilder( URI.create(CODE_TO_SESSION_URL + "?" + query)) .timeout(Duration.ofSeconds(8)) .GET() .build(); HttpResponse<String> response = httpClient.send( request, HttpResponse.BodyHandlers.ofString()); return objectMapper.readTree(response.body());}这里有四个值得注意的点:
- 所有参数都经过 URL 编码,不能直接拼接用户输入;
- 连接超时和单次请求超时分开配置,避免线程无限等待;
- HTTP 2xx 只表示请求到达微信,不代表业务一定成功;
- JSON 中还要检查
openid、errcode和errmsg。
如果 openid 为空,服务会把微信返回的业务错误转换为 ServiceException。Controller 不需要重复 try-catch,全局异常处理器会把它转为统一响应。
8. 首次登录为什么需要唯一索引
首次登录的基本代码是:
HyWxUser user = selectHyWxUserByOpenId(openId);if (user == null) { user = buildNewWxUser(openId, unionId); try { insertHyWxUser(user); } catch (DuplicateKeyException e) { user = selectHyWxUserByOpenId(openId); }}单线程下这段逻辑很直观,但并发情况下可能发生:
请求 A 查询:不存在请求 B 查询:不存在请求 A 插入:成功请求 B 插入:触发唯一键冲突捕获异常后重新查询,使请求 B 可以复用 A 已创建的用户。这里不能只依赖 synchronized,因为服务部署成多个实例后,每个实例都有自己的 JVM 锁。数据库的 open_id 唯一索引才是跨实例有效的最终约束。
9. Token 是怎样返回给小程序的
微信身份确认后,项目把小程序用户转换成统一的 LoginUser:
private LoginUser buildLoginUser(WxLoginVo loginVo) { SysUser tokenUser = new SysUser(); tokenUser.setUserName("wx_" + loginVo.getUserId()); tokenUser.setNickName(loginVo.getNickname()); tokenUser.setPhonenumber(loginVo.getMobile()); tokenUser.setStatus("0"); tokenUser.setDelFlag("0"); return new LoginUser( loginVo.getUserId(), null, tokenUser, Collections.emptySet());}小程序用户没有后台部门与菜单权限,所以部门 ID 为空、权限集合为空,但仍能复用 Token 创建、刷新和注销逻辑。WxLoginResultVo 最终同时返回 Token 和经过裁剪的用户信息。
后续请求携带 Authorization,JWT 过滤器解析 Token,把 LoginUser 放入安全上下文。业务 Service 获取用户 ID 时读取的是服务端恢复的登录态,而不是请求体中的字段。
10. access_token 缓存代码拆解
微信手机号接口需要全局 access_token。服务使用同步方法保证同一个进程内只有一个线程刷新:
private synchronized String getAccessToken() { long now = System.currentTimeMillis(); if (StringUtils.isNotEmpty(cachedAccessToken) && cachedAccessTokenExpireTime > now) { return cachedAccessToken; }
JsonNode result = requestAccessToken(); String accessToken = result.path("access_token").asText(); int expiresIn = result.path("expires_in").asInt(7200); long cacheSeconds = Math.max( 60, expiresIn - 300); cachedAccessToken = accessToken; cachedAccessTokenExpireTime = now + cacheSeconds * 1000L; return cachedAccessToken;}提前 300 秒过期是为了避开“本地判断仍有效,但请求到达微信时已经过期”的临界窗口。Math.max(60, ...) 防止第三方返回异常的短有效期后得到负数。
当手机号接口返回明确的 Token 失效码时,代码清空缓存并重试一次。只重试一次很重要:如果配置错误或微信持续异常,无限重试会让当前接口一直占用服务器线程。
11. 地址更新为什么重新构造对象
用户提交的 HyUserAddress 中可能带有 wxUserId、删除标记和状态。Service 不把它原样交给 Mapper,而是创建白名单对象:
private HyUserAddress buildUserAddress( HyUserAddress source, Long wxUserId) { HyUserAddress target = new HyUserAddress(); target.setWxUserId(wxUserId); target.setVesselName(source.getVesselName()); target.setMmsi(source.getMmsi()); target.setReceiverName( StringUtils.nvl(source.getReceiverName(), "")); target.setReceiverPhone( StringUtils.nvl(source.getReceiverPhone(), "")); target.setLongitude(source.getLongitude()); target.setLatitude(source.getLatitude()); return target;}wxUserId 使用安全上下文中的值,空姓名和电话被归一化为空字符串。未出现在复制列表中的后台字段不会被用户更新,这比依靠前端“不传那些字段”可靠得多。
12. 设置默认地址的事务过程
默认地址需要满足“同一用户最多一条”。当前实现先确认目标地址归属,再查询用户全部有效地址并逐条更新:
@Transactional(rollbackFor = Exception.class)public int setDefaultUserAddress(Long addressId) { Long wxUserId = SecurityUtils.getUserId(); HyUserAddress target = selectById(addressId); checkAddressOwner(target, wxUserId);
List<HyUserAddress> rows = selectByUserId(wxUserId); for (HyUserAddress row : rows) { HyUserAddress update = new HyUserAddress(); update.setAddressId(row.getAddressId()); update.setIsDefault( addressId.equals(row.getAddressId()) ? "1" : "0"); mapper.updateHyUserAddress(update); } return 1;}事务保证更新到一半发生异常时全部回滚。数据量增大后可以进一步改为两条条件 SQL:先清空当前用户默认值,再设置目标地址,减少循环产生的数据库往返。
13. 本篇小结
登录功能真正解决的是“后端如何确认你是谁”,资料和地址功能解决的是“确认身份后你能改什么”。第三方身份校验、项目 Token、字段白名单与资源归属组合起来,才构成完整的用户边界。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!




























