基于 Better Auth Next.js 实现微信双端登录:踩坑指南与完整实现
前言
微信登录作为国内最主流的第三方登录方式,几乎是每个 Web 应用的必备功能。然而,微信登录的复杂性远超想象——不同场景需要不同的 AppID,不同的授权流程,还有各种令人头痛的配置要求。
最近,我在基于 Better Auth 和 Next.js 的项目中实现了微信双端登录功能,支持 PC 端扫码登录和微信内 H5 直接授权,踩了不少坑,也总结了一套相对完善的解决方案。本文将从背景介绍、技术难点、实现流程到具体代码,为你提供一个完整的微信登录实现指南。
背景:微信登录的复杂生态
微信登录的多种场景
在开始实现之前,我们需要理解微信登录的使用场景:
- PC 端浏览器:用户扫描二维码登录
- 微信内置浏览器:直接跳转授权,无需扫码
- 手机端浏览器:通常跳转到二维码页面
- 原生 App:调用微信 SDK
每种场景对应不同的技术方案和认证要求,这也是微信登录复杂性的根源。
微信开放平台 vs 微信公众平台
- 微信开放平台:面向网站应用和移动应用,提供扫码登录能力
- 微信公众平台:管理公众号、服务号,提供网页授权能力
对于 Web 应用来说:
- PC 端扫码登录:需要微信开放平台的网站应用
- 微信内 H5 授权:需要微信公众平台的服务号
替代方案:小程序登录
除了本文介绍的双端方案,还有一个值得考虑的替代方案是通过小程序实现登录:
优势:
- 统一的技术栈:PC 端和移动端都跳转到同一个小程序
- 可获取用户手机号:小程序有获取手机号的能力
- 开发成本较低:只需要开发一个小程序
劣势:
- 用户体验略复杂:需要跳转到小程序进行授权,增加了操作步骤
- 依赖小程序生态:用户必须安装微信才能使用
- 跳转流程相对繁琐:Web → 小程序 → 授权 → 返回 Web
由于小程序方案的用户体验相对复杂,且跳转流程较为繁琐,本文重点介绍了更为直接的双端登录方案。如果你的业务场景特别需要获取用户手机号,可以考虑小程序登录作为补充方案。
微信登录基础概念
在深入实现之前,我们需要理解微信生态中的一些核心概念,这些概念对于正确设计用户身份识别系统至关重要。
OpenID vs UnionID
微信提供了两种用户标识符,理解它们的区别是实现微信登录的基础:
OpenID
- 定义:用户在单个微信应用(公众号、小程序、网站应用)中的唯一标识
- 特点:同一用户在不同应用中的 OpenID 是不同的
- 用途:适用于单一应用的用户识别
- 获取方式:通过任何微信 OAuth 授权流程都能获取
UnionID
- 定义:同一微信开放平台账号下,同一用户的唯一标识
- 特点:同一用户在同一开放平台账号下的所有应用中,UnionID 都相同
- 用途:适用于多应用间的用户身份统一
- 获取条件:需要将应用绑定到微信开放平台,且用户需要关注了该开放平台账号下的公众号
此外,虽然微信登录的时候,同一用户可以选择授权不同的头像和昵称,但 unionid 是不变的。
Scope 权限范围
微信 OAuth 提供了不同的权限范围,对应不同的使用场景:
snsapi_login
- 使用场景:PC 端网站扫码登录
- 权限范围:获取用户基本信息(昵称、头像、性别、地区)
- 特点:需要用户主动扫码确认
- 应用类型:微信开放平台的网站应用
snsapi_userinfo
- 使用场景:移动端网页授权
- 权限范围:获取用户基本信息
- 特点:需要用户点击确认授权
- 应用类型:微信公众平台的服务号
snsapi_base
- 使用场景:静默授权,仅获取 OpenID
- 权限范围:只能获取用户的 OpenID
- 特点:无需用户确认,静默授权
- 应用类型:微信公众平台的服务号
应用类型与使用场景
微信开放平台 - 网站应用
- 申请条件:需要企业资质和 ICP 备案域名
- 认证费用:300元/年
- 主要功能:PC 端扫码登录
- 获取信息:OpenID、UnionID(如果绑定了开放平台)、用户基本信息
- 技术特点:使用
snsapi_login
scope
微信公众平台 - 服务号
- 申请条件:需要企业资质
- 认证费用:300元/年
- 主要功能:移动端网页授权、消息推送
- 获取信息:OpenID、用户基本信息(需要
snsapi_userinfo
) - 技术特点:支持静默授权和用户信息授权
常见误区与注意事项
误区1:认为 OpenID 可以跨应用使用
错误理解:用户在公众号 A 的 OpenID 可以在公众号 B 中使用 正确理解:每个应用的 OpenID 都是独立的,需要使用 UnionID 进行跨应用识别
误区2:UnionID 总是可以获取
错误理解:所有微信 OAuth 流程都能获取 UnionID 正确理解:只有满足特定条件(绑定开放平台且用户关注了相关公众号)才能获取 UnionID
误区3:用户信息永远不变
错误理解:用户的昵称、头像等信息是固定的 正确理解:用户可以随时修改昵称、头像,应用应该定期更新用户信息
前提条件
- ICP 备案域名
- 企业资质:必须有企业营业执照
- 准备 600元/年 的认证费
技术难点与踩坑点
1. 认证成本高昂
微信的各种认证都需要付费:
- 微信开放平台认证:300元/年 (用于支持 PC 端微信扫码登录)
- 服务号认证:300元/年 (用于支持移动端微信内 H5 授权)
此外中间还有不少需要盖章授权、承诺书的步骤,比较繁琐。
2. 配置复杂且容易出错
IP白名单限制:
错误信息:errcode: 40164, errmsg: invalid ip
解决方案:在服务号后台添加服务器IP到白名单
授权域名配置:
错误信息:redirect_uri 参数错误
解决方案:正确配置网页授权域名(不要加协议和路径)
权限范围错误:
错误信息:scope 参数错误
解决方案:PC端使用 snsapi_login,移动端使用 snsapi_userinfo
3. 微信 OAuth 流程的特殊性
微信的 OAuth 流程与标准 OAuth2 有差异:
- 使用
appid
而不是client_id
- Token 接口使用 GET 请求而不是 POST
- 返回格式不完全符合 OAuth2 标准
4. 设备检测的挑战
需要准确识别用户的环境来选择合适的登录方式:
// 检测微信内置浏览器
const isWeChat = /MicroMessenger/i.test(navigator.userAgent);
// 检测移动设备
const isMobile = /Android|webOS|iPhone|iPad|iPod/i.test(navigator.userAgent);
5. better-auth
插件的 openid
传递问题
这是本次实现中最核心的坑。better-auth
的通用 OAuth 插件 (genericOAuth
) 在设计上只遵循标准的 OAuth2 字段。微信在 Token 响应中返回的 openid
是一个非标准字段,better-auth
在处理后会将其丢弃,导致后续的 getUserInfo
函数无法获取到 openid
,而调用微信的 userinfo
接口又必须同时提供 access_token
和 openid
。
# 核心错误
Error: Missing accessToken or openid before calling WeChat API.
之前的解决方案,如内存缓存 (wechatUserInfoCache
),都治标不治本,且在生产环境中不可靠。最终的解决方案是通过修改 Token 代理,将 openid
"藏" 在标准的 scope
字段中传递下去。
整体技术方案
架构设计
我们的方案基于以下技术栈:
- Better Auth:现代化的认证框架
- Next.js 15:React 全栈框架
- Hono:高性能的 API 框架
- Prisma:数据库 ORM
核心设计思想
- 双 Provider 架构:同时支持 PC 端和移动端。
- 智能设备检测:自动选择合适的登录方式。
- Token 代理传递
openid
:创建代理端点,处理微信特殊的 OAuth 格式,并将openid
注入scope
字段。 - 自定义
getUserInfo
:在插件中实现getUserInfo
函数,从scope
字段中解析出openid
。
前置准备工作
1. 申请微信开放平台(PC 端扫码)
- 访问 微信开放平台
- 注册开发者账号并完成企业认证(300元)
- 创建网站应用,获取 AppID 和 AppSecret
- 配置授权回调域名(最多可设置 3 个), 例如: dev.your-domain.com
2. 申请微信服务号(移动端授权)
- 访问 微信公众平台
- 注册服务号并完成微信认证(300元/年)
- 获取服务号的 AppID 和 AppSecret
- 配置关键设置:
IP白名单设置:
设置路径:开发 → 基本配置 → IP白名单
添加内容:你的云服务器IP地址
网页授权域名:
设置路径:设置与开发 → 账号设置 → 功能设置 → 网页授权域名
配置格式:your-domain.com(不要加协议和路径)(可以设置多个)
3. 环境变量配置
# PC端扫码登录 - 网站应用
WECHAT_WEBSITE_APP_ID=your_website_app_id
WECHAT_WEBSITE_APP_SECRET=your_website_app_secret
# 手机端授权登录 - 服务号
WECHAT_SERVICE_ACCOUNT_APP_ID=your_service_account_app_id
WECHAT_SERVICE_ACCOUNT_APP_SECRET=your_service_account_secret
完整实现方案
1. 解决 missing openid
的核心思路
问题的根源在于 better-auth
的 genericOAuth
插件在从 tokenUrl
获得响应后,并不会将 openid
这样的非标准字段传递给 getUserInfo
函数。
我们的解决方案分为两步:
- 在 Token 代理端点 (
/api/auth/wechat/token
):当从微信服务器获取到access_token
和openid
后,我们不直接返回。而是将openid
附加到标准的scope
字段上,形成一个新的scope
字符串,例如"snsapi_userinfo openid:o-xxxxxxxxxxxx"
。 - 在
wechat-oauth-plugin.ts
的getUserInfo
函数中:better-auth
会将scope
字符串解析成一个scopes
数组并传递给getUserInfo
。我们在这个函数里,从scopes
数组中找到openid:...
这个值,并从中解析出openid
。
这样,我们就通过 scope
字段,成功地将 openid
从 Token 端点 "走私" 到了 getUserInfo
函数中,解决了 missing openid
的问题。
2. 微信 OAuth 插件实现 (wechat-oauth-plugin.ts
)
首先,我们创建自定义的微信 OAuth 插件。关键在于 getUserInfo
函数的实现,它必须能从 scopes
数组中解析出 openid
。
// src/lib/auth/plugins/wechat-oauth-plugin.ts
import { getBaseUrl } from "@/lib/utils";
import { genericOAuth } from "better-auth/plugins/generic-oauth";
interface WeChatOAuthOptions {
website: { appId: string; appSecret: string };
serviceAccount: { appId: string; appSecret: string };
}
export function createDualWeChatOAuthPlugin(options: WeChatOAuthOptions) {
const baseUrl = getBaseUrl();
const redirectURI = `${baseUrl}/api/auth/oauth2/callback`;
// 关键的 getUserInfo 函数,用于从 scope 解析 openid
const customGetUserInfo = async (tokens: any) => {
console.log("Entering getUserInfo with tokens:", tokens);
let openid: string | undefined;
// better-auth 将 scope 字符串解析为 scopes 数组
if (Array.isArray(tokens.scopes)) {
const openidScope = tokens.scopes.find(s => s.startsWith('openid:'));
if (openidScope) {
openid = openidScope.split(':')[1];
}
console.log("Parsed openid from scopes array:", openid);
}
if (!tokens.accessToken || !openid) {
console.error("Missing accessToken or openid before calling WeChat API.", { tokens });
throw new Error("Missing accessToken or openid before calling WeChat API.");
}
const userInfoUrl = new URL("https://api.weixin.qq.com/sns/userinfo");
userInfoUrl.searchParams.set("access_token", tokens.accessToken);
userInfoUrl.searchParams.set("openid", openid);
userInfoUrl.searchParams.set("lang", "zh_CN");
console.log("Calling WeChat userinfo API:", userInfoUrl.toString());
const response = await fetch(userInfoUrl.toString());
const userInfo = await response.json();
if (userInfo.errcode) {
throw new Error(`WeChat user info error: ${userInfo.errmsg}`);
}
return userInfo;
};
const mapProfileToUser = (profile: any) => {
if (!profile.unionid && !profile.openid) {
throw new Error("WeChat user info error: missing openid and unionid");
}
const userId = profile.unionid || profile.openid;
return {
id: userId,
name: profile.nickname,
email: `${userId}@wechat.local`, // 微信没有邮箱,使用虚拟邮箱
image: profile.headimgurl,
emailVerified: true,
wechatOpenId: profile.openid,
wechatUnionId: profile.unionid,
};
};
return genericOAuth({
config: [
// PC端配置(使用网站应用)
{
providerId: "wechat-pc",
authorizationUrl: "https://open.weixin.qq.com/connect/qrconnect",
tokenUrl: `${baseUrl}/api/auth/wechat/token`,
userInfoUrl: "https://api.weixin.qq.com/sns/userinfo", // 此URL不会被直接调用,因为我们覆盖了getUserInfo
clientId: options.website.appId,
clientSecret: options.website.appSecret,
authorizationUrlParams: {
appid: options.website.appId,
response_type: "code",
scope: "snsapi_login",
redirect_uri: `${redirectURI}/wechat-pc`,
},
getUserInfo: customGetUserInfo,
mapProfileToUser: mapProfileToUser,
authentication: "post",
disableImplicitSignUp: false,
},
// 手机端配置(使用服务号)
{
providerId: "wechat-mobile",
authorizationUrl: "https://open.weixin.qq.com/connect/oauth2/authorize",
tokenUrl: `${baseUrl}/api/auth/wechat/token`,
userInfoUrl: "https://api.weixin.qq.com/sns/userinfo", // 此URL不会被直接调用
clientId: options.serviceAccount.appId,
clientSecret: options.serviceAccount.appSecret,
authorizationUrlParams: {
appid: options.serviceAccount.appId,
response_type: "code",
scope: "snsapi_userinfo",
redirect_uri: `${redirectURI}/wechat-mobile`,
},
getUserInfo: customGetUserInfo, // 复用同一个 getUserInfo 函数
mapProfileToUser: mapProfileToUser, // 复用同一个 mapProfileToUser 函数
authentication: "post",
disableImplicitSignUp: false,
},
],
});
}
// 便捷函数:根据环境变量创建微信OAuth插件
export function wechatOAuth() {
const websiteAppId = process.env.WECHAT_WEBSITE_APP_ID;
const websiteAppSecret = process.env.WECHAT_WEBSITE_APP_SECRET;
const serviceAccountAppId = process.env.WECHAT_SERVICE_ACCOUNT_APP_ID;
const serviceAccountAppSecret = process.env.WECHAT_SERVICE_ACCOUNT_APP_SECRET;
if (websiteAppId && websiteAppSecret && serviceAccountAppId && serviceAccountAppSecret) {
return createDualWeChatOAuthPlugin({
website: { appId: websiteAppId, appSecret: websiteAppSecret },
serviceAccount: { appId: serviceAccountAppId, appSecret: serviceAccountAppSecret }
});
}
// 可以添加对单一配置的向后兼容支持
// ...
throw new Error(
"WeChat OAuth configuration missing. Please set all four environment variables."
);
}
3. Token 代理端点 (auth.ts
)
这个代理端点是解决方案的另一半。它负责调用微信的 Token 接口,然后将 openid
注入到 scope
字段中返回给 better-auth
。
// src/server/routes/auth.ts
import { Hono } from "hono";
import { auth } from "@/lib/auth";
export const authRouter = new Hono()
.post("/auth/wechat/token", async (c) => {
try {
const body = await c.req.text();
const params = new URLSearchParams(body);
const code = params.get("code");
const clientId = params.get("client_id");
const clientSecret = params.get("client_secret");
if (!code || !clientId || !clientSecret) {
return c.json({ error: "Missing required parameters" }, 400);
}
const wechatTokenUrl = new URL(
"https://api.weixin.qq.com/sns/oauth2/access_token"
);
wechatTokenUrl.searchParams.set("appid", clientId);
wechatTokenUrl.searchParams.set("secret", clientSecret);
wechatTokenUrl.searchParams.set("code", code);
wechatTokenUrl.searchParams.set("grant_type", "authorization_code");
const wechatResponse = await fetch(wechatTokenUrl.toString(), {
method: "GET",
});
const wechatTokenData = await wechatResponse.json();
if (wechatTokenData.errcode) {
return c.json({
error: "invalid_grant",
error_description: `WeChat error: ${wechatTokenData.errmsg}`,
}, 400);
}
// **核心解决方案**:
// 将 openid 和 unionid 都嵌入到 scope 字段中,以便传递给 getUserInfo 函数。
const scopeParts = [wechatTokenData.scope || ''];
if (wechatTokenData.openid) {
scopeParts.push(`openid:${wechatTokenData.openid}`);
}
if (wechatTokenData.unionid) {
scopeParts.push(`unionid:${wechatTokenData.unionid}`);
}
const standardTokenResponse = {
access_token: wechatTokenData.access_token,
token_type: "Bearer",
expires_in: wechatTokenData.expires_in || 7200,
refresh_token: wechatTokenData.refresh_token,
scope: scopeParts.join(' ').trim(), // 返回包含 openid 和 unionid 的 scope
};
return c.json(standardTokenResponse);
} catch (error) {
console.error("Error in /api/auth/wechat/token:", error);
return c.json({
error: "server_error",
error_description: "Internal server error during token exchange",
}, 500);
}
})
// 所有其他认证路由由 Better Auth 处理
.all("/auth/*", (c) => {
return auth.handler(c.req.raw);
});
4. Better Auth 配置
在 Better Auth 中集成我们的微信插件。这里的配置与旧方案基本一致,关键是确保 wechatOAuth()
插件被正确加载。
// src/lib/auth/auth.ts
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth-prisma-adapter";
import { db } from "@/lib/db";
import { getBaseUrl } from "@/lib/utils";
import { wechatOAuth } from "./plugins/wechat-oauth-plugin";
export const auth = betterAuth({
baseURL: getBaseUrl(),
database: prismaAdapter(db, { provider: "postgresql" }),
// 账户链接配置(关键:支持多provider自动链接)
account: {
accountLinking: {
enabled: true,
trustedProviders: ["wechat-pc", "wechat-mobile"],
},
},
// 在用户表中添加微信相关的字段
user: {
additionalFields: {
wechatOpenId: { type: "string", required: false },
wechatUnionId: { type: "string", required: false },
},
},
plugins: [
// 加载微信插件(如果环境变量已配置)
...(process.env.WECHAT_WEBSITE_APP_ID && process.env.WECHAT_WEBSITE_APP_SECRET
? [wechatOAuth()]
: []),
// ... 其他插件
],
});
5. 前端登录组件
前端组件通过设备检测,决定调用 signInWithWeChatPC
还是 signInWithWeChatMobile
。这部分无需修改。
// src/components/auth/SocialSigninButton.tsx
"use client";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth/client";
import { WeChatIcon } from "@/components/icons";
export function SocialSigninButton({ provider }: { provider: string }) {
const onSignin = async () => {
if (provider === "wechat") {
try {
// 检查设备类型和浏览器环境
const userAgent = window.navigator.userAgent;
const isWeChat = /MicroMessenger/i.test(userAgent);
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent);
let result;
if (isWeChat && isMobile) {
// 微信内置浏览器 -> 使用服务号直接授权
result = await (authClient as any).signInWithWeChatMobile({
callbackURL: callbackURL.toString(),
});
} else {
// 其他环境 -> 使用PC端扫码
result = await (authClient as any).signInWithWeChatPC({
callbackURL: callbackURL.toString(),
});
}
// 重定向到微信授权页面
if (result.redirect && result.url) {
window.location.href = result.url;
}
} catch (error) {
console.error("WeChat login failed:", error);
}
}
};
return (
<Button onClick={onSignin} variant="outline" type="button">
<WeChatIcon className="mr-2 size-4" />
微信登录
</Button>
);
}
6. 客户端插件配置
为了让前端能调用 signInWithWeChatPC
和 signInWithWeChatMobile
,我们需要一个客户端插件。这部分也无需修改。
// src/lib/auth/plugins/wechat-oauth-client.ts
import type { BetterAuthClientPlugin } from "better-auth/client";
export function wechatOAuthClient(): BetterAuthClientPlugin {
return {
id: "wechat-oauth-client",
getActions: ($fetch) => ({
signInWithWeChat: async (options = {}) => {
const userAgent = typeof window !== "undefined" ? window.navigator.userAgent : "";
const providerId = options.forceProvider || getWeChatProviderId(userAgent);
return $fetch("/sign-in/oauth2", {
method: "POST",
body: {
providerId,
callbackURL: options.callbackURL || `${window?.location.origin}/app`,
errorCallbackURL: options.errorCallbackURL,
newUserCallbackURL: options.newUserCallbackURL,
disableRedirect: options.disableRedirect,
},
});
},
signInWithWeChatPC: async (options = {}) => {
return $fetch("/sign-in/oauth2", {
method: "POST",
body: {
providerId: "wechat-pc",
callbackURL: options.callbackURL || `${window?.location.origin}/app`,
errorCallbackURL: options.errorCallbackURL,
newUserCallbackURL: options.newUserCallbackURL,
disableRedirect: options.disableRedirect,
},
});
},
signInWithWeChatMobile: async (options = {}) => {
return $fetch("/sign-in/oauth2", {
method: "POST",
body: {
providerId: "wechat-mobile",
callbackURL: options.callbackURL || `${window?.location.origin}/app`,
errorCallbackURL: options.errorCallbackURL,
newUserCallbackURL: options.newUserCallbackURL,
disableRedirect: options.disableRedirect,
},
});
},
}),
};
}
function getWeChatProviderId(userAgent: string): string {
const isWeChat = /MicroMessenger/i.test(userAgent);
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent);
return (isWeChat && isMobile) ? "wechat-mobile" : "wechat-pc";
}
新手常见问题与注意事项
开发环境配置
本地开发域名配置
微信要求使用真实域名进行授权,本地开发时需要特殊配置:
方案1:使用 ngrok 等内网穿透工具
# 安装 ngrok
npm install -g ngrok
# 启动本地服务
npm run dev
# 在另一个终端启动 ngrok
ngrok http 3000
# 使用 ngrok 提供的域名配置微信授权域名
# 例如:abc123.ngrok.io
方案2:配置测试域名
# 修改 /etc/hosts 文件
127.0.0.1 dev.your-domain.com
# 使用 dev.your-domain.com:3000 进行本地开发
# 在微信后台配置 dev.your-domain.com 为授权域名
环境变量管理
建议使用不同的环境变量文件:
# .env.local (本地开发)
WECHAT_WEBSITE_APP_ID=your_dev_website_app_id
WECHAT_WEBSITE_APP_SECRET=your_dev_website_app_secret
WECHAT_SERVICE_ACCOUNT_APP_ID=your_dev_service_account_app_id
WECHAT_SERVICE_ACCOUNT_APP_SECRET=your_dev_service_account_secret
# .env.production (生产环境)
WECHAT_WEBSITE_APP_ID=your_prod_website_app_id
WECHAT_WEBSITE_APP_SECRET=your_prod_website_app_secret
WECHAT_SERVICE_ACCOUNT_APP_ID=your_prod_service_account_app_id
WECHAT_SERVICE_ACCOUNT_APP_SECRET=your_prod_service_account_secret
安全注意事项
1. AppSecret 保护
错误做法:
// ❌ 永远不要在前端暴露 AppSecret
const appSecret = "your_app_secret";
正确做法:
// ✅ AppSecret 只能在服务端使用
// 通过环境变量或安全的配置管理系统获取
const appSecret = process.env.WECHAT_WEBSITE_APP_SECRET;
2. 回调 URL 验证
// 验证回调 URL 是否为允许的域名
function validateCallbackURL(url: string): boolean {
const allowedDomains = [
'your-domain.com',
'dev.your-domain.com',
'staging.your-domain.com'
];
try {
const urlObj = new URL(url);
return allowedDomains.includes(urlObj.hostname);
} catch {
return false;
}
}
3. 用户数据处理
// 处理用户敏感信息
function sanitizeUserData(wechatUser: any) {
return {
id: wechatUser.unionid || wechatUser.openid,
name: wechatUser.nickname?.replace(/[<>"'&]/g, ''), // 防止 XSS
image: wechatUser.headimgurl,
// 不要存储不必要的敏感信息
};
}
常见错误排查
错误1:redirect_uri 参数错误
错误信息:redirect_uri参数错误
可能原因:
1. 授权域名配置错误
2. 回调 URL 格式不正确
3. 协议不匹配(http vs https)
解决方案:
1. 检查微信后台的授权域名配置
2. 确保回调 URL 格式:https://your-domain.com/api/auth/oauth2/callback/wechat-pc
3. 生产环境必须使用 HTTPS
错误2:invalid ip
错误信息:errcode: 40164, errmsg: invalid ip
原因:服务器 IP 不在微信后台的白名单中
解决方案:
1. 获取服务器真实 IP:curl ifconfig.me
2. 在微信公众平台后台添加 IP 到白名单
3. 如果使用 CDN,需要添加 CDN 的出口 IP
错误3:scope 参数错误
错误信息:scope参数错误
原因:使用了错误的 scope 值
解决方案:
- PC 端扫码:使用 snsapi_login
- 移动端授权:使用 snsapi_userinfo 或 snsapi_base
- 不要混用不同平台的 scope
错误4:Missing openid
错误信息:Missing accessToken or openid before calling WeChat API
原因:better-auth 插件无法获取 openid
解决方案:
1. 确保使用了本文提供的 Token 代理端点
2. 检查 getUserInfo 函数是否正确解析 scope 中的 openid
3. 查看服务端日志确认 Token 响应格式
测试建议
1. 多设备测试
// 测试不同设备的登录流程
const testCases = [
{ device: 'PC Chrome', expected: 'wechat-pc' },
{ device: 'iPhone Safari', expected: 'wechat-pc' },
{ device: 'iPhone WeChat', expected: 'wechat-mobile' },
{ device: 'Android WeChat', expected: 'wechat-mobile' },
];
2. 用户状态测试
// 测试不同用户状态
const userStates = [
'新用户首次登录',
'已有用户重复登录',
'用户修改昵称后登录',
'用户在不同设备登录',
];
3. 错误场景测试
// 测试错误处理
const errorScenarios = [
'用户拒绝授权',
'网络连接失败',
'微信服务器错误',
'无效的授权码',
];
性能优化建议
1. 缓存用户信息
// 使用 Redis 缓存用户基本信息
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
async function getCachedUserInfo(openid: string) {
const cached = await redis.get(`wechat:user:${openid}`);
if (cached) {
return JSON.parse(cached);
}
return null;
}
async function setCachedUserInfo(openid: string, userInfo: any) {
// 缓存 1 小时
await redis.setex(`wechat:user:${openid}`, 3600, JSON.stringify(userInfo));
}
2. 批量处理用户信息更新
// 定期批量更新用户信息
async function batchUpdateUserInfo() {
const users = await db.user.findMany({
where: {
wechatOpenId: { not: null },
updatedAt: { lt: new Date(Date.now() - 24 * 60 * 60 * 1000) } // 24小时前
},
take: 100
});
for (const user of users) {
try {
const freshUserInfo = await fetchWeChatUserInfo(user.wechatOpenId);
await db.user.update({
where: { id: user.id },
data: {
name: freshUserInfo.nickname,
image: freshUserInfo.headimgurl,
updatedAt: new Date()
}
});
} catch (error) {
console.error(`Failed to update user ${user.id}:`, error);
}
}
}
总结
微信登录的实现虽然复杂,但通过理解其核心概念和遵循最佳实践,可以构建一个稳定可靠的认证系统。关键要点包括:
- 理解 OpenID 和 UnionID 的区别,选择合适的用户标识策略
- 正确配置微信应用,包括域名、IP 白名单等
- 处理好 better-auth 的兼容性问题,特别是 openid 的传递
- 做好安全防护,保护 AppSecret 和用户数据
- 充分测试,覆盖不同设备和场景
- 监控和优化,确保系统稳定运行
希望这份指南能帮助你顺利实现微信登录功能,避免常见的坑点。如果遇到问题,建议先检查微信后台配置,然后查看服务端日志,最后参考本文的错误排查部分。