微信小程序手机号快捷登录
概述
微信小程序手机号快捷登录由微信小程序客户端、业务服务端和微信开放接口共同完成。用户在小程序中主动授权手机号后,客户端取得两个不同用途的一次性凭证:
uni.login或wx.login返回的小程序登录code,用于换取openid和session_key。getPhoneNumber回调返回的手机号授权code,用于换取经过微信验证的手机号。
两个 code 不能互换,也不能在客户端直接使用 AppSecret 调用微信接口。服务端完成凭证交换、账号关联和业务 Token 签发,客户端只接收业务登录结果。
本文介绍一套完整的手机号快捷登录流程,适用于 uni-app 微信小程序和基于 tio-boot 的 Java 服务端。
一、开通微信能力
登录微信公众平台,进入目标小程序后完成以下配置:
- 检查小程序主体、认证状态和服务类目。
- 查找“手机号快速验证组件”或“获取手机号”能力,按照后台提示开通并确认可用额度。不同主体的菜单名称可能不同,通常位于“付费管理”等相关入口。
- 在“开发管理 -> 开发设置”取得小程序 AppID 和 AppSecret。
- 在微信后台配置用户隐私保护指引,按实际用途声明手机号等个人信息的处理方式。
- 在“开发管理 -> 开发设置 -> 服务器域名”中配置后端 HTTPS 合法域名。
手机号授权必须由用户主动点击微信提供的授权按钮触发。应用自己的用户协议和隐私政策不能替代微信授权弹窗。
二、环境配置
AppID 必须与小程序客户端配置保持一致,AppSecret 只能保存在服务端环境变量中,不能放入前端代码、前端构建变量或日志。
WECHAT_MINI_APP_ID=你的小程序AppID
WECHAT_MINI_APP_SECRET=你的AppSecret
如果微信后台启用了接口 IP 白名单,还需要加入服务端访问微信接口时使用的真实公网出口 IP。手机 IP、开发电脑局域网 IP 和后端服务器公网出口 IP 不是同一个概念。
前端配置示例:
VITE_MP_API_BASE_URL=https://api.example.com/api/mi
源码内部业务路由以 /api/mi 开头。如果本地服务通过管理上下文暴露接口,地址也可能是:
VITE_MP_API_BASE_URL=http://127.0.0.1:8100/admin/api/mi
真机不能访问开发电脑的 127.0.0.1。本地真机测试时,应使用局域网 HTTPS 地址或可被手机访问的测试服务器,并在微信开发者工具中按开发阶段需要配置合法域名校验。
三、登录流程
用户点击手机号授权按钮
|
v
小程序 getPhoneNumber -> phoneCode
|
+-- uni.login -> loginCode
|
v
业务服务端接收 loginCode 和 phoneCode
|
+-- jscode2session -> openid、session_key
+-- stable_token -> access_token
+-- getuserphonenumber -> verified phone
|
v
服务端创建或关联业务账号
|
v
签发业务 token 返回客户端
服务端请求微信接口:
GET https://api.weixin.qq.com/sns/jscode2session,使用 AppID、AppSecret 和loginCode换取openid。POST https://api.weixin.qq.com/cgi-bin/stable_token,取得调用手机号接口所需的应用access_token。该 token 应在服务端缓存,并在过期前刷新。POST https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=...,提交phoneCode,取得微信验证后的手机号。- 服务端根据手机号、AppID 和 OpenID 查询或创建业务账号,最后签发业务 token。
不要把 session_key、AppSecret、微信 access_token 或手机号授权凭证返回给客户端,也不要记录到日志中。
四、服务端接口
业务登录接口可以定义为:
POST /api/mi/auth/wechat/phone-login
Content-Type: application/json
请求体:
{
"loginCode": "uni.login 返回的一次性 code",
"phoneCode": "getPhoneNumber 回调中的一次性 code",
"inviterUserId": "可选的邀请人 ID"
}
其中 inviterUserId 只能作为邀请关系的业务参数,不能用于确定当前登录用户身份。服务端必须以微信接口返回的 OpenID 和手机号为可信身份来源。
成功响应示例:
{
"code": 1,
"msg": "ok",
"data": {
"token": "业务登录 token",
"expiresAt": "1735689600",
"userId": "100001"
}
}
建议的错误语义:
| HTTP 状态 | 含义 |
|---|---|
400 | 参数错误、授权码失效或已被使用 |
409 | 微信身份和手机号分别属于不同业务账号,存在归属冲突 |
429 | 微信接口或业务接口触发频率限制 |
503 | 微信配置、微信服务、Redis 或网络不可用 |
五、服务端客户端实现
下面的客户端只负责服务端调用微信接口。实际项目可以替换 HTTP 客户端和 JSON 工具,但不能把 AppSecret 下沉到前端。
package com.example.auth.integration;
import com.jfinal.kit.Kv;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import nexus.io.tio.boot.exception.BusinessException;
import nexus.io.tio.utils.environment.EnvUtils;
import nexus.io.tio.utils.json.Json;
public class WechatMiniClient {
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5)).build();
private String cachedToken;
private String cachedAppId;
private long tokenExpiresAt;
public Kv session(String code) {
String path = "/sns/jscode2session?appid=" + encode(appId())
+ "&secret=" + encode(secret())
+ "&js_code=" + encode(code)
+ "&grant_type=authorization_code";
Kv result = request(path, null);
check(result);
return result;
}
public Kv phone(String code) {
String token = accessToken(null);
Kv result = request("/wxa/business/getuserphonenumber?access_token="
+ encode(token), Kv.by("code", code));
Integer error = result.getInt("errcode");
if (error != null && (error == 40001 || error == 40014 || error == 42001)) {
token = accessToken(token);
result = request("/wxa/business/getuserphonenumber?access_token="
+ encode(token), Kv.by("code", code));
}
check(result);
Object phoneInfo = result.get("phone_info");
if (!(phoneInfo instanceof Map<?, ?> values)) {
throw unavailable();
}
return Kv.create().set(values);
}
private synchronized String accessToken(String rejectedToken) {
String id = appId();
long now = Instant.now().getEpochSecond();
boolean rejected = rejectedToken != null && rejectedToken.equals(cachedToken);
if (!rejected && id.equals(cachedAppId) && cachedToken != null
&& tokenExpiresAt > now) {
return cachedToken;
}
Kv body = Kv.by("grant_type", "client_credential")
.set("appid", id).set("secret", secret())
.set("force_refresh", rejected);
Kv result = request("/cgi-bin/stable_token", body);
check(result);
String token = result.getStr("access_token");
Long expiresIn = result.getLong("expires_in");
if (token == null || token.isBlank() || expiresIn == null || expiresIn <= 60) {
throw unavailable();
}
cachedAppId = id;
cachedToken = token;
tokenExpiresAt = now + expiresIn - 60;
return token;
}
private Kv request(String path, Kv body) {
try {
HttpRequest.Builder builder = HttpRequest.newBuilder(
URI.create("https://api.weixin.qq.com" + path))
.timeout(Duration.ofSeconds(10))
.header("Accept", "application/json");
if (body == null) {
builder.GET();
} else {
builder.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(
Json.getJson().toJson(body), StandardCharsets.UTF_8));
}
HttpResponse<String> response = http.send(
builder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() != 200) throw unavailable();
Object parsed = Json.getJson().parse(response.body());
if (!(parsed instanceof Map<?, ?> values)) throw unavailable();
return Kv.create().set(values);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw unavailable();
} catch (Exception e) {
throw unavailable();
}
}
private void check(Kv result) {
Integer error = result.getInt("errcode");
if (error == null || error == 0) return;
if (error == 40029 || error == 40163) {
throw new BusinessException(400, "微信授权已失效,请重新点击登录");
}
if (error == 45011 || error == 45009) {
throw new BusinessException(429, "微信请求频繁,请稍后重试");
}
throw unavailable();
}
public String appId() {
return required("WECHAT_MINI_APP_ID");
}
private String secret() {
return required("WECHAT_MINI_APP_SECRET");
}
private String required(String name) {
String value = EnvUtils.get(name);
if (value == null || value.isBlank()) {
throw new BusinessException(503, "微信登录尚未配置");
}
return value.trim();
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
private static BusinessException unavailable() {
return new BusinessException(503, "微信服务暂不可用");
}
}
手机号接口的应用 access_token 可以缓存。只有收到明确的 token 无效错误时才刷新并重试一次;网络超时、已消费的手机号 code 和普通业务失败不能自动重试,因为手机号 code 是一次性凭证。
六、身份服务实现
服务层应同时校验两个微信接口的结果,并在数据库事务中完成账号关联。下面是核心逻辑示例:
package com.example.auth.service;
import com.jfinal.kit.Kv;
import com.example.auth.integration.WechatMiniClient;
import java.util.Map;
import nexus.io.jfinal.aop.Aop;
import nexus.io.tio.boot.exception.BusinessException;
public class WechatPhoneLoginService {
public Kv login(String loginCode, String phoneCode) {
WechatMiniClient client = Aop.get(WechatMiniClient.class);
Kv session = client.session(loginCode);
String openid = session.getStr("openid");
if (openid == null || openid.isBlank()) {
throw new BusinessException(503, "微信身份信息不可用");
}
Kv phoneInfo = client.phone(phoneCode);
String phone = phoneInfo.getStr("purePhoneNumber");
Object watermark = phoneInfo.get("watermark");
String appId = watermark instanceof Map<?, ?> values
? String.valueOf(values.get("appid")) : null;
if (phone == null || phone.isBlank() || !client.appId().equals(appId)) {
throw new BusinessException(503, "微信手机号信息不可用");
}
// 在事务中按手机号、openid 和账号唯一约束完成查询、创建或绑定。
// 手机号与微信身份分别属于不同账号时返回 409,不直接合并业务数据。
return accountService().loginOrCreate(phone, openid,
session.getStr("unionid"));
}
private AccountService accountService() {
return Aop.get(AccountService.class);
}
}
实际账号服务至少应处理以下情况:
| 情况 | 处理方式 |
|---|---|
| 新微信、新手机号 | 创建账号并绑定微信身份和手机号 |
| 新微信、已有手机号 | 复用手机号账号并绑定微信身份 |
| 已有微信、未绑定手机号 | 验证归属后补充手机号 |
| 手机号和微信分别属于不同账号 | 返回 409,不自动合并 |
unionid 缺失 | 仍可使用 AppID 和 OpenID 登录 |
数据库应为手机号和微信身份分别建立唯一约束,并在事务中处理并发创建,避免同一用户被重复注册。
七、路由配置
手机号快捷登录接口必须是公开路由,但公开只表示不需要业务 Token,不表示可以跳过微信凭证校验。
router.add(HttpMethod.POST, "/api/mi/auth/wechat/phone-login",
authHandler::wechatPhoneLogin,
Map.of(AuthInterceptor.ACCESS, AccessPolicy.PUBLIC));
Handler 示例:
public HttpResponse wechatPhoneLogin(HttpRequest request) {
Kv body = request.getBodyObject(Kv.class);
String loginCode = ParameterValidator.text(body.getStr("loginCode"),
"loginCode", 256);
String phoneCode = ParameterValidator.text(body.getStr("phoneCode"),
"phoneCode", 256);
String inviterUserId = body.getStr("inviterUserId");
RespBodyVo result = service.login(loginCode, phoneCode, inviterUserId);
return TioRequestContext.getResponse().respond(result);
}
八、uni-app 小程序端
微信小程序页面使用 open-type="getPhoneNumber",用户点击按钮后取得手机号授权 code,再调用 uni.login 取得登录 code。
<template>
<button
v-if="agreed"
open-type="getPhoneNumber"
:loading="busy"
:disabled="busy"
@getphonenumber="wechatPhone"
>
{{ busy ? '正在登录' : '手机号快捷登录' }}
</button>
<button v-else @click="showAgreement">手机号快捷登录</button>
</template>
<script setup>
import { ref } from 'vue'
import { api } from '@/services/api'
const agreed = ref(false)
const busy = ref(false)
async function wechatPhone(event) {
if (busy.value || !agreed.value) return
if (!event.detail?.code) {
throw new Error('未获取到手机号授权,请重试或使用短信登录')
}
busy.value = true
try {
const loginResult = await new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
timeout: 10000,
success: resolve,
fail: reject
})
})
if (!loginResult.code) throw new Error('未获取到微信登录凭证')
const data = await api('/auth/wechat/phone-login', {
loginCode: loginResult.code,
phoneCode: event.detail.code
}, 'POST')
saveSession(data)
} finally {
busy.value = false
}
}
</script>
用户拒绝授权时保留短信验证码登录入口。前端不应提交用户手工填写的手机号作为微信认证结果,也不应自行拼接 OpenID 或用户 ID。
九、测试步骤
9.1 后端和前端准备
- 在微信公众平台完成认证、手机号能力开通和隐私指引配置。
- 将 AppID、AppSecret 配置到后端
.env或部署环境变量。 - 确认小程序
manifest.json中的 AppID 与服务端配置一致。 - 重启后端服务。
- 执行前端微信小程序构建命令,并将生成目录导入微信开发者工具。
9.2 真机验证
- 使用真实小程序 AppID 导入构建目录。
- 在开发者工具中添加开发者或体验者账号。
- 打开登录页并勾选用户协议和隐私政策。
- 点击“手机号快捷登录”。
- 在微信弹窗中确认手机号授权。
- 验证登录成功后是否跳转首页,并检查“我的”页面能否正常读取用户信息。
- 退出登录后再次登录,确认账号被复用而不是重复创建。
开发者工具的“不校验合法域名”只适合开发阶段。正式真机测试应使用 HTTPS 合法域名,并确认手机网络可以访问服务端。
9.3 接口检查
客户端调用的接口为:
POST https://你的接口域名/api/mi/auth/wechat/phone-login
请求体中应同时出现两个不同的字符串:
{
"loginCode": "uni.login 返回的 code",
"phoneCode": "getPhoneNumber 返回的 code"
}
登录成功后检查:
- 服务端没有向日志输出 AppSecret、session_key、access_token 或完整 code。
- 返回结果只包含业务 token、用户 ID 和过期时间等业务字段。
- 数据库没有重复的手机号或微信身份绑定。
- 后续需要登录的业务接口能够使用返回的业务 token。
十、常见问题
| 现象 | 排查方向 |
|---|---|
| 点击按钮没有授权弹窗 | 检查是否使用真实 AppID、按钮是否为 getPhoneNumber、用户是否已同意应用协议 |
| 手机号授权失败 | 检查手机号能力是否开通、额度是否可用、隐私指引是否完成 |
| 授权码无效 | 重新点击登录获取两个新 code,不要重复提交旧 code |
| 真机请求失败 | 检查 HTTPS 证书、request 合法域名、前端 API 地址和网络连通性 |
返回 503 | 检查 AppID、AppSecret、微信服务、Redis 和服务端公网出口 |
返回 409 | 手机号和微信身份已属于不同账号,应走已有账号处理流程,不直接改库合并 |
| 登录成功但后续接口未授权 | 检查客户端是否保存业务 token,以及请求头是否为 Authorization: Bearer <token> |
十一、安全注意事项
- AppSecret 只保存在服务端,不能提交到前端仓库。
- 不信任客户端传入的手机号、OpenID、UnionID 和用户 ID。
- 两个微信 code 均为一次性凭证,失败后重新获取,不自动重放。
- 业务账号创建和身份绑定应使用事务和唯一约束。
- 处理账号冲突时不要自动迁移订单、余额、资源或其他业务数据。
- 手机号属于个人信息,数据库访问、日志和错误响应应遵守最小化原则。
- 生产环境必须使用 HTTPS,并限制服务端错误信息,避免回显微信接口内部细节。
