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

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

小程序登录不是把前端传来的用户信息直接保存,而是由服务端向微信确认身份,再把微信身份转换成项目自己的登录态。本篇记录用户从临时 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());
}

这里有四个值得注意的点:

  1. 所有参数都经过 URL 编码,不能直接拼接用户输入;
  2. 连接超时和单次请求超时分开配置,避免线程无限等待;
  3. HTTP 2xx 只表示请求到达微信,不代表业务一定成功;
  4. 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、字段白名单与资源归属组合起来,才构成完整的用户边界。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
后端开发日记②:微信登录、用户资料与地址归属
https://firefly.cuteleaf.cn/posts/backend-dev-diary-2/
作者
Yore
发布于
2026-07-01
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Yore
Hello, I'm Yore.
公告
欢迎来到Yore的博客!本站基于原创作者夏叶的博客网站模版创作。
分类
标签
最新动态
站点统计
文章
51
分类
2
标签
124
总字数
94,115
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
EdgeOne Pages
博客版本
Firefly v6.15.0
文章许可
CC BY-NC-SA 4.0