文档

基于 Better Auth Next.js 实现微信双端登录:踩坑指南与完整实现

前言

微信登录作为国内最主流的第三方登录方式,几乎是每个 Web 应用的必备功能。然而,微信登录的复杂性远超想象——不同场景需要不同的 AppID,不同的授权流程,还有各种令人头痛的配置要求。

最近,我在基于 Better AuthNext.js 的项目中实现了微信双端登录功能,支持 PC 端扫码登录和微信内 H5 直接授权,踩了不少坑,也总结了一套相对完善的解决方案。本文将从背景介绍、技术难点、实现流程到具体代码,为你提供一个完整的微信登录实现指南。

背景:微信登录的复杂生态

微信登录的多种场景

在开始实现之前,我们需要理解微信登录的使用场景:

  1. PC 端浏览器:用户扫描二维码登录
  2. 微信内置浏览器:直接跳转授权,无需扫码
  3. 手机端浏览器:通常跳转到二维码页面
  4. 原生 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_tokenopenid

# 核心错误
Error: Missing accessToken or openid before calling WeChat API.

之前的解决方案,如内存缓存 (wechatUserInfoCache),都治标不治本,且在生产环境中不可靠。最终的解决方案是通过修改 Token 代理,将 openid "藏" 在标准的 scope 字段中传递下去。

整体技术方案

架构设计

我们的方案基于以下技术栈:

  • Better Auth:现代化的认证框架
  • Next.js 15:React 全栈框架
  • Hono:高性能的 API 框架
  • Prisma:数据库 ORM

核心设计思想

  1. 双 Provider 架构:同时支持 PC 端和移动端。
  2. 智能设备检测:自动选择合适的登录方式。
  3. Token 代理传递 openid:创建代理端点,处理微信特殊的 OAuth 格式,并将 openid 注入 scope 字段。
  4. 自定义 getUserInfo:在插件中实现 getUserInfo 函数,从 scope 字段中解析出 openid

前置准备工作

1. 申请微信开放平台(PC 端扫码)

  1. 访问 微信开放平台
  2. 注册开发者账号并完成企业认证(300元)
  3. 创建网站应用,获取 AppID 和 AppSecret
  4. 配置授权回调域名(最多可设置 3 个), 例如: dev.your-domain.com

2. 申请微信服务号(移动端授权)

  1. 访问 微信公众平台
  2. 注册服务号并完成微信认证(300元/年)
  3. 获取服务号的 AppID 和 AppSecret
  4. 配置关键设置:

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-authgenericOAuth 插件在从 tokenUrl 获得响应后,并不会将 openid 这样的非标准字段传递给 getUserInfo 函数。

我们的解决方案分为两步:

  1. 在 Token 代理端点 (/api/auth/wechat/token):当从微信服务器获取到 access_tokenopenid 后,我们不直接返回。而是将 openid 附加到标准的 scope 字段上,形成一个新的 scope 字符串,例如 "snsapi_userinfo openid:o-xxxxxxxxxxxx"
  2. wechat-oauth-plugin.tsgetUserInfo 函数中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. 客户端插件配置

为了让前端能调用 signInWithWeChatPCsignInWithWeChatMobile,我们需要一个客户端插件。这部分也无需修改。

// 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);
    }
  }
}

总结

微信登录的实现虽然复杂,但通过理解其核心概念和遵循最佳实践,可以构建一个稳定可靠的认证系统。关键要点包括:

  1. 理解 OpenID 和 UnionID 的区别,选择合适的用户标识策略
  2. 正确配置微信应用,包括域名、IP 白名单等
  3. 处理好 better-auth 的兼容性问题,特别是 openid 的传递
  4. 做好安全防护,保护 AppSecret 和用户数据
  5. 充分测试,覆盖不同设备和场景
  6. 监控和优化,确保系统稳定运行

希望这份指南能帮助你顺利实现微信登录功能,避免常见的坑点。如果遇到问题,建议先检查微信后台配置,然后查看服务端日志,最后参考本文的错误排查部分。

On this page

No Headings