Skip to content

微信小程序手机号快捷登录 ​

概述 ​

微信小程序手机号快捷登录由微信小程序客户端、业务服务端和微信开放接口共同完成。用户在小程序中主动授权手机号后,客户端取得两个不同用途的一次性凭证:

  • uni.login 或 wx.login 返回的小程序登录 code,用于换取 openid 和 session_key。
  • getPhoneNumber 回调返回的手机号授权 code,用于换取经过微信验证的手机号。

两个 code 不能互换,也不能在客户端直接使用 AppSecret 调用微信接口。服务端完成凭证交换、账号关联和业务 Token 签发,客户端只接收业务登录结果。

本文介绍一套完整的手机号快捷登录流程,适用于 uni-app 微信小程序和基于 tio-boot 的 Java 服务端。

一、开通微信能力 ​

登录微信公众平台,进入目标小程序后完成以下配置:

  1. 检查小程序主体、认证状态和服务类目。
  2. 查找“手机号快速验证组件”或“获取手机号”能力,按照后台提示开通并确认可用额度。不同主体的菜单名称可能不同,通常位于“付费管理”等相关入口。
  3. 在“开发管理 -> 开发设置”取得小程序 AppID 和 AppSecret。
  4. 在微信后台配置用户隐私保护指引,按实际用途声明手机号等个人信息的处理方式。
  5. 在“开发管理 -> 开发设置 -> 服务器域名”中配置后端 HTTPS 合法域名。

手机号授权必须由用户主动点击微信提供的授权按钮触发。应用自己的用户协议和隐私政策不能替代微信授权弹窗。

二、环境配置 ​

AppID 必须与小程序客户端配置保持一致,AppSecret 只能保存在服务端私密配置中,不能放入前端代码、前端构建变量或日志。

properties
WECHAT_MINI_APP_ID=你的小程序AppID
WECHAT_MINI_APP_SECRET=你的AppSecret

如果微信后台启用了接口 IP 白名单,还需要加入服务端访问微信接口时使用的真实公网出口 IP。手机 IP、开发电脑局域网 IP 和后端服务器公网出口 IP 不是同一个概念。

前端配置示例:

properties
VITE_MP_API_BASE_URL=https://api.example.com/api/mi

源码内部业务路由以 /api/mi 开头。如果本地服务通过管理上下文暴露接口,地址也可能是:

properties
VITE_MP_API_BASE_URL=http://127.0.0.1:8100/admin/api/mi

真机不能访问开发电脑的 127.0.0.1。本地真机测试时,应使用局域网 HTTPS 地址或可被手机访问的测试服务器,并在微信开发者工具中按开发阶段需要配置合法域名校验。

三、登录流程 ​

text
用户点击手机号授权按钮
        |
        v
小程序 getPhoneNumber -> phoneCode
        |
        +-- uni.login -> loginCode
        |
        v
业务服务端接收 loginCode 和 phoneCode
        |
        +-- jscode2session -> openid、session_key
        +-- stable_token -> access_token
        +-- getuserphonenumber -> verified phone
        |
        v
服务端创建或关联业务账号
        |
        v
签发业务 token 返回客户端

服务端请求微信接口:

  1. GET https://api.weixin.qq.com/sns/jscode2session,使用 AppID、AppSecret 和 loginCode 换取 openid。
  2. POST https://api.weixin.qq.com/cgi-bin/stable_token,取得调用手机号接口所需的应用 access_token。该 token 应在服务端缓存,并在过期前刷新。
  3. POST https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=...,提交 phoneCode,取得微信验证后的手机号。
  4. 服务端根据手机号、AppID 和 OpenID 查询或创建业务账号,最后签发业务 token。

不要把 session_key、AppSecret、微信 access_token 或手机号授权凭证返回给客户端,也不要记录到日志中。

四、服务端接口 ​

业务登录接口可以定义为:

text
POST /api/mi/auth/wechat/phone-login
Content-Type: application/json

请求体:

json
{
  "loginCode": "uni.login 返回的一次性 code",
  "phoneCode": "getPhoneNumber 回调中的一次性 code",
  "inviterUserId": "可选的邀请人 ID"
}

其中 inviterUserId 只能作为邀请关系的业务参数,不能用于确定当前登录用户身份。服务端必须以微信接口返回的 OpenID 和手机号为可信身份来源。

成功响应示例:

json
{
  "code": 1,
  "msg": "ok",
  "data": {
    "token": "业务登录 token",
    "expiresAt": "1735689600",
    "userId": "100001"
  }
}

建议的错误语义:

HTTP 状态含义
400参数错误、授权码失效或已被使用
409微信身份和手机号分别属于不同业务账号,存在归属冲突
429微信接口或业务接口触发频率限制
503微信配置、微信服务、Redis 或网络不可用

五、服务端客户端实现 ​

下面的客户端只负责服务端调用微信接口。实际项目可以替换 HTTP 客户端和 JSON 工具,但不能把 AppSecret 下沉到前端。

java
package com.example.auth.integration;

import com.jfinal.kit.Kv;
import java.net.URLEncoder;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.MediaType;
import okhttp3.Response;
import java.util.concurrent.TimeUnit;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Map;
import nexus.io.model.exception.BusinessException;
import nexus.io.tio.utils.environment.EnvUtils;
import nexus.io.tio.utils.json.Json;

public class WechatMiniClient {
  private final OkHttpClient http = new OkHttpClient.Builder()
      .connectTimeout(5, TimeUnit.SECONDS).readTimeout(10, TimeUnit.SECONDS)
      .callTimeout(12, TimeUnit.SECONDS).retryOnConnectionFailure(false)
      .followRedirects(false).followSslRedirects(false).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 {
      Request.Builder builder = new Request.Builder().url("https://api.weixin.qq.com" + path)
          .header("Accept", "application/json");
      if (body == null) {
        builder.get();
      } else {
        builder.post(RequestBody.create(MediaType.parse("application/json; charset=utf-8"), Json.getJson().toJson(body)));
      }
      try (Response response = http.newCall(builder.build()).execute()) {
        if (!response.isSuccessful() || response.body() == null) { throw unavailable(); }
        Object parsed = Json.getJson().parse(response.body().string());
        if (!(parsed instanceof Map<?, ?> values)) { throw unavailable(); }
        return Kv.create().set(values);
      }
    } 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 是一次性凭证。

六、身份服务实现 ​

服务层应同时校验两个微信接口的结果,并在数据库事务中完成账号关联。下面是核心逻辑示例:

java
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.model.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 登录

数据库应为手机号和微信身份分别建立唯一约束,并在事务中处理并发创建,避免同一用户被重复注册。

七、独立 Handler 与路由 ​

每个接口使用一个 Handler;Handler、Service、请求与响应实体分别放在不同包下。JSON 正文使用 getBodyObject() 绑定请求实体,查询和表单字段使用 getRequestMap()。字段类型与格式由独立校验工具通过 ParameterValidator 检查,全局异常处理器捕获 ParameterValidationException 并返回 HTTP 400。

java
package com.example.auth.model;

public record WechatPhoneLoginRequest(String loginCode, String phoneCode) { }
java
package com.example.auth.util;

import com.example.auth.model.WechatPhoneLoginRequest;
import nexus.io.tio.http.common.HttpRequest;
import nexus.io.tio.utils.validator.ParameterValidator;

public final class LoginParameters {
  private LoginParameters() { }

  public static WechatPhoneLoginRequest read(HttpRequest request) {
    WechatPhoneLoginRequest body = request.getBodyObject(WechatPhoneLoginRequest.class);
    String loginCode = ParameterValidator.text(body.loginCode(), "loginCode", 256);
    String phoneCode = ParameterValidator.text(body.phoneCode(), "phoneCode", 256);
    return new WechatPhoneLoginRequest(loginCode, phoneCode);
  }
}
java
package com.example.auth.handler;

import com.example.auth.model.WechatPhoneLoginRequest;
import com.example.auth.service.WechatPhoneLoginService;
import com.example.auth.util.LoginParameters;
import nexus.io.jfinal.aop.Aop;
import nexus.io.model.body.RespBodyVo;
import nexus.io.tio.boot.http.TioRequestContext;
import nexus.io.tio.http.common.HttpRequest;
import nexus.io.tio.http.common.HttpResponse;

public class WechatPhoneLoginHandler {
  public HttpResponse handle(HttpRequest request) {
    WechatPhoneLoginRequest input = LoginParameters.read(request);
    WechatPhoneLoginService service = Aop.get(WechatPhoneLoginService.class);
    return TioRequestContext.getResponse().respond(
        RespBodyVo.ok(service.login(input.loginCode(), input.phoneCode())));
  }
}
java
package com.example.auth.config;

import com.example.auth.handler.WechatPhoneLoginHandler;
import nexus.io.jfinal.aop.Aop;
import nexus.io.tio.http.server.router.HttpRequestRouter;

public class LoginRoutes {
  public void register(HttpRequestRouter router) {
    router.post("/api/mi/auth/wechat/phone-login",
        Aop.get(WechatPhoneLoginHandler.class)::handle);
  }
}

在业务拦截器中精确放行手机号快捷登录路由,其他需要会话的接口仍由拦截器校验 Token。公开登录路由还需限流,微信授权凭证由 Service 验证。应用账号关联事务及其调用的登录事务应使用相同隔离级别;使用 READ COMMITTED 配合手机号、微信身份锁和唯一约束,可以让并发等待后读取到已提交的账号绑定。

八、uni-app 小程序端 ​

微信小程序页面使用 open-type="getPhoneNumber",用户点击按钮后取得手机号授权 code,再调用 uni.login 取得登录 code。

vue
<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 后端和前端准备 ​

  1. 在微信公众平台完成认证、手机号能力开通和隐私指引配置。
  2. 将 AppID、AppSecret 配置到后端 secrets.txt、.env 或部署环境变量,统一通过 EnvUtils.get 读取。
  3. 确认小程序 manifest.json 中的 AppID 与服务端配置一致。
  4. 重启后端服务。
  5. 执行前端微信小程序构建命令,并将生成目录导入微信开发者工具。

9.2 真机验证 ​

  1. 使用真实小程序 AppID 导入构建目录。
  2. 在开发者工具中添加开发者或体验者账号。
  3. 打开登录页并勾选用户协议和隐私政策。
  4. 点击“手机号快捷登录”。
  5. 在微信弹窗中确认手机号授权。
  6. 验证登录成功后是否跳转首页,并检查“我的”页面能否正常读取用户信息。
  7. 退出登录后再次登录,确认账号被复用而不是重复创建。

开发者工具的“不校验合法域名”只适合开发阶段。正式真机测试应使用 HTTPS 合法域名,并确认手机网络可以访问服务端。

9.3 接口检查 ​

客户端调用的接口为:

text
POST https://你的接口域名/api/mi/auth/wechat/phone-login

请求体中应同时出现两个不同的字符串:

json
{
  "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>

十一、安全注意事项 ​

  1. AppSecret 只保存在服务端,不能提交到前端仓库。
  2. 不信任客户端传入的手机号、OpenID、UnionID 和用户 ID。
  3. 两个微信 code 均为一次性凭证,失败后重新获取,不自动重放。
  4. 业务账号创建和身份绑定应使用事务和唯一约束。
  5. 处理账号冲突时不要自动迁移订单、余额、资源或其他业务数据。
  6. 手机号属于个人信息,数据库访问、日志和错误响应应遵守最小化原则。
  7. 生产环境必须使用 HTTPS,并限制服务端错误信息,避免回显微信接口内部细节。

十二、统一登录入口 ​

客户端手机号快捷登录短信验证码登录账号密码登录
微信小程序getPhoneNumber 授权按钮支持支持
H5、其他端不显示支持支持

uni-app 使用 MP-WEIXIN 条件编译包围快捷登录按钮与入口;H5 默认展示短信登录。不要通过浏览器 User-Agent 显示一个实际无法取得授权码的快捷登录按钮。普通网页提交的“手机号”或“OpenID”也不能替代微信服务端交换结果。

三种登录方式应复用同一业务账号和会话表。密码登录支持已绑定手机号或业务账号号;初次短信登录后,在账号安全页重新验证绑定手机号以设置密码。密码使用随机盐的慢哈希保存,修改后撤销其他会话;普通用户密码与后台管理员凭证分开。

短信验证码发送前提交限流预留,明确发送成功后才允许验证。短信 API 超时不自动重复发送,失败记录仍计入配额;成功响应不返回真实验证码。参考阿里云短信登录和注册。

参考资料 ​

MIT Licensed