wechat-auth-微信服务号网页授权登录开发.md 16 KB


description: "微信服务号网页授权登录开发指令"

本指令指导如何实现微信服务号网页授权登录功能,包括后端API开发、前端组件集成和移动端适配。

前置要求

  1. 已注册微信开放平台服务号
  2. 已配置网页授权域名
  3. 获取AppID和AppSecret

开发步骤

1. 用户实体字段添加

在用户实体中添加微信相关字段:

// src/server/modules/users/user.entity.ts
@Column({ name: 'wechat_openid', type: 'varchar', length: 255, nullable: true, comment: '微信开放平台openid' })
wechatOpenid!: string | null;

@Column({ name: 'wechat_unionid', type: 'varchar', length: 255, nullable: true, comment: '微信开放平台unionid' })
wechatUnionid!: string | null;

@Column({ name: 'wechat_nickname', type: 'varchar', length: 255, nullable: true, comment: '微信昵称' })
wechatNickname!: string | null;

@Column({ name: 'wechat_avatar', type: 'varchar', length: 500, nullable: true, comment: '微信头像URL' })
wechatAvatar!: string | null;

@Column({ name: 'wechat_sex', type: 'tinyint', nullable: true, comment: '微信性别(1:男,2:女,0:未知)' })
wechatSex!: number | null;

@Column({ name: 'wechat_province', type: 'varchar', length: 100, nullable: true, comment: '微信省份' })
wechatProvince!: string | null;

@Column({ name: 'wechat_city', type: 'varchar', length: 100, nullable: true, comment: '微信城市' })
wechatCity!: string | null;

@Column({ name: 'wechat_country', type: 'varchar', length: 100, nullable: true, comment: '微信国家' })
wechatCountry!: string | null;

2. 用户Schema更新

在用户Schema中添加微信字段定义:

// src/server/modules/users/user.schema.ts
wechatOpenid: z.string().max(255, '微信openid最多255个字符').nullable().openapi({
  example: 'o6_bmjrPTlm6_2sgVt7hMZOPfL2M',
  description: '微信开放平台openid'
}),
wechatUnionid: z.string().max(255, '微信unionid最多255个字符').nullable().openapi({
  example: 'o6_bmasdasdsad6_2sgVt7hMZOPfL2M',
  description: '微信开放平台unionid'
}),
wechatNickname: z.string().max(255, '微信昵称最多255个字符').nullable().openapi({
  example: '微信用户',
  description: '微信昵称'
}),
wechatAvatar: z.string().max(500, '微信头像URL最多500个字符').nullable().openapi({
  example: 'http://thirdwx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/46',
  description: '微信头像URL'
}),
wechatSex: z.number().int('微信性别必须是整数').min(0, '微信性别最小值为0').max(2, '微信性别最大值为2').nullable().openapi({
  example: 1,
  description: '微信性别(1:男,2:女,0:未知)'
}),
wechatProvince: z.string().max(100, '微信省份最多100个字符').nullable().openapi({
  example: '广东省',
  description: '微信省份'
}),
wechatCity: z.string().max(100, '微信城市最多100个字符').nullable().openapi({
  example: '深圳市',
  description: '微信城市'
}),
wechatCountry: z.string().max(100, '微信国家最多100个字符').nullable().openapi({
  example: '中国',
  description: '微信国家'
})

3. 微信认证服务类

创建微信认证服务:

// src/server/modules/wechat/wechat-auth.service.ts
import axios from 'axios';
import { UserService } from '../users/user.service';
import { AuthService } from '../auth/auth.service';

export class WechatAuthService {
  private readonly appId: string = process.env.WECHAT_MP_APP_ID || '';
  private readonly appSecret: string = process.env.WECHAT_MP_APP_SECRET || '';

  constructor(
    private readonly userService: UserService,
    private readonly authService: AuthService
  ) {}

  // 获取授权URL
  getAuthorizationUrl(redirectUri: string, scope: 'snsapi_base' | 'snsapi_userinfo' = 'snsapi_userinfo'): string {
    const state = Math.random().toString(36).substring(2);
    return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${this.appId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${scope}&state=${state}#wechat_redirect`;
  }

  // 通过code获取access_token
  async getAccessToken(code: string): Promise<any> {
    const url = `https://api.weixin.qq.com/sns/oauth2/access_token?appid=${this.appId}&secret=${this.appSecret}&code=${code}&grant_type=authorization_code`;
    const response = await axios.get(url);
    return response.data;
  }

  // 获取用户信息
  async getUserInfo(accessToken: string, openid: string): Promise<any> {
    const url = `https://api.weixin.qq.com/sns/userinfo?access_token=${accessToken}&openid=${openid}&lang=zh_CN`;
    const response = await axios.get(url);
    return response.data;
  }

  // 微信登录/注册
  async wechatLogin(code: string): Promise<{ token: string; user: any }> {
    // 1. 获取access_token
    const tokenData = await this.getAccessToken(code);
    if (tokenData.errcode) {
      throw new Error(`微信认证失败: ${tokenData.errmsg}`);
    }

    const { access_token, openid, unionid } = tokenData;

    // 2. 检查用户是否存在
    let user = await this.userService.findByWechatOpenid(openid);

    if (!user) {
      // 3. 获取用户信息(首次登录)
      const userInfo = await this.getUserInfo(access_token, openid);
      
      // 4. 创建新用户
      user = await this.userService.createUser({
        username: `wx_${openid.substring(0, 8)}`,
        password: Math.random().toString(36).substring(2),
        wechatOpenid: openid,
        wechatUnionid: unionid,
        wechatNickname: userInfo.nickname,
        wechatAvatar: userInfo.headimgurl,
        wechatSex: userInfo.sex,
        wechatProvince: userInfo.province,
        wechatCity: userInfo.city,
        wechatCountry: userInfo.country,
        nickname: userInfo.nickname
      });
    }

    // 5. 生成JWT token
    const token = this.authService.generateToken(user);
    
    return { token, user };
  }
}

4. 微信认证路由

创建微信认证API路由:

// src/server/api/auth/wechat/
// wechat-authorize.ts - 微信授权URL获取路由
import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
import { z } from '@hono/zod-openapi';
import { WechatAuthService } from '@/server/modules/wechat/wechat-auth.service';
import { ErrorSchema } from '@/server/utils/errorHandler';
import { AppDataSource } from '@/server/data-source';
import { UserService } from '@/server/modules/users/user.service';
import { AuthService } from '@/server/modules/auth/auth.service';

const AuthorizeSchema = z.object({
  redirectUri: z.string().url().openapi({
    example: 'https://example.com/auth/callback',
    description: '回调地址'
  }),
  scope: z.enum(['snsapi_base', 'snsapi_userinfo']).default('snsapi_userinfo').openapi({
    example: 'snsapi_userinfo',
    description: '授权范围'
  })
});

const AuthorizeResponseSchema = z.object({
  url: z.string().url().openapi({
    example: 'https://open.weixin.qq.com/connect/oauth2/authorize?appid=xxx',
    description: '微信授权URL'
  })
});

const userService = new UserService(AppDataSource);
const authService = new AuthService(userService);
const wechatAuthService = new WechatAuthService(userService, authService);

const authorizeRoute = createRoute({
  method: 'post',
  path: '/authorize',
  request: {
    body: {
      content: {
        'application/json': {
          schema: AuthorizeSchema
        }
      }
    }
  },
  responses: {
    200: {
      description: '获取微信授权URL成功',
      content: {
        'application/json': {
          schema: AuthorizeResponseSchema
        }
      }
    },
    400: {
      description: '请求参数错误',
      content: {
        'application/json': {
          schema: ErrorSchema
        }
      }
    }
  }
});

const app = new OpenAPIHono().openapi(authorizeRoute, async (c) => {
  try {
    const { redirectUri, scope } = await c.req.json();
    const url = wechatAuthService.getAuthorizationUrl(redirectUri, scope);
    return c.json({ url }, 200);
  } catch (error) {
    return c.json({ 
      code: 400, 
      message: error instanceof Error ? error.message : '获取授权URL失败' 
    }, 400);
  }
});

export default app;
// src/server/api/auth/wechat/wechat-login.ts - 微信登录路由
import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
import { z } from '@hono/zod-openapi';
import { WechatAuthService } from '@/server/modules/wechat/wechat-auth.service';
import { ErrorSchema } from '@/server/utils/errorHandler';
import { AppDataSource } from '@/server/data-source';
import { UserService } from '@/server/modules/users/user.service';
import { AuthService } from '@/server/modules/auth/auth.service';
import { TokenResponseSchema } from '../login/password';
import { parseWithAwait } from '@/server/utils/parseWithAwait';

const WechatLoginSchema = z.object({
  code: z.string().min(1).openapi({
    example: '0816TxlL1Qe3QY0qgDlL1pQxlL16TxlO',
    description: '微信授权code'
  })
});

const userService = new UserService(AppDataSource);
const authService = new AuthService(userService);
const wechatAuthService = new WechatAuthService(userService, authService);

const wechatLoginRoute = createRoute({
  method: 'post',
  path: '/login',
  request: {
    body: {
      content: {
        'application/json': {
          schema: WechatLoginSchema
        }
      }
    }
  },
  responses: {
    200: {
      description: '微信登录成功',
      content: {
        'application/json': {
          schema: TokenResponseSchema
        }
      }
    },
    400: {
      description: '请求参数错误',
      content: {
        'application/json': {
          schema: ErrorSchema
        }
      }
    },
    401: {
      description: '微信认证失败',
      content: {
        'application/json': {
          schema: ErrorSchema
        }
      }
    }
  }
});

const app = new OpenAPIHono().openapi(wechatLoginRoute, async (c) => {
  try {
    const { code } = await c.req.json();
    const result = await wechatAuthService.wechatLogin(code);
    
    // 使用 parseWithAwait 确保返回数据符合Schema定义
    const validatedResult = await parseWithAwait(TokenResponseSchema, result);
    
    return c.json(validatedResult, 200);
  } catch (error) {
    return c.json({ 
      code: 401, 
      message: error instanceof Error ? error.message : '微信登录失败' 
    }, 401);
  }
});

export default app;
// src/server/api/auth/wechat/index.ts - 路由聚合
import { OpenAPIHono } from '@hono/zod-openapi';
import authorizeRoute from './wechat-authorize';
import loginRoute from './wechat-login';

const app = new OpenAPIHono()
  .route('/', authorizeRoute)
  .route('/', loginRoute);

export default app;

5. parseWithAwait 使用规范

概述

parseWithAwait 是通用CRUD模块提供的数据验证工具,用于确保返回数据的类型安全,支持异步验证和转换。

使用场景

所有涉及数据查询和返回的扩展路由都应使用 parseWithAwait 处理响应数据。

基本用法

import { parseWithAwait } from '@/server/utils/parseWithAwait';

// 验证单个实体
const validatedEntity = await parseWithAwait(YourEntitySchema, entityData);

// 验证实体数组
const validatedEntities = await parseWithAwait(z.array(YourEntitySchema), entitiesData);

集成示例

// 在微信登录路由中使用 parseWithAwait
const app = new OpenAPIHono().openapi(wechatLoginRoute, async (c) => {
  try {
    const { code } = await c.req.json();
    const result = await wechatAuthService.wechatLogin(code);
    
    // 使用 parseWithAwait 确保返回数据符合Schema定义
    const validatedResult = await parseWithAwait(TokenResponseSchema, result);
    
    return c.json(validatedResult, 200);
  } catch (error) {
    return c.json({
      code: 401,
      message: error instanceof Error ? error.message : '微信登录失败'
    }, 401);
  }
});

优势

  • 类型安全:确保返回数据完全符合Zod schema定义
  • 异步支持:支持异步验证和转换操作
  • 错误处理:提供详细的验证错误信息
  • 性能优化:避免运行时类型错误
  • 向后兼容:与现有代码完全兼容

最佳实践

  1. 所有查询路由:GET请求返回数据前必须使用 parseWithAwait
  2. 列表查询:使用 z.array(EntitySchema) 格式验证数组
  3. 单条查询:直接使用实体Schema验证单个对象
  4. 错误处理:捕获并适当处理验证错误

6. 注册微信路由

在API入口文件中注册微信路由:

// src/server/api.ts
import wechatRoutes from '@/server/api/auth/wechat/index';

// 注册路由
api.route('/api/v1/auth/wechat', wechatRoutes);

6. 前端微信登录组件

创建React微信登录组件:

// src/client/components/WechatLoginButton.tsx
import React from 'react';
import { Button } from '@/client/components/ui/button';
import { Wechat } from 'lucide-react';
import { authClient } from '@/client/api';

interface WechatLoginButtonProps {
  redirectUri: string;
  scope?: 'snsapi_base' | 'snsapi_userinfo';
  onSuccess?: (data: any) => void;
  onError?: (error: Error) => void;
}

const WechatLoginButton: React.FC<WechatLoginButtonProps> = ({
  redirectUri,
  scope = 'snsapi_userinfo',
  onSuccess,
  onError,
  children = '微信登录'
}) => {
  const handleWechatLogin = async () => {
    try {
      // 使用RPC客户端获取微信授权URL
      const response = await authClient.wechat.authorize.$post({
        json: { redirectUri, scope }
      });

      if (response.status !== 200) {
        throw new Error('获取微信授权URL失败');
      }

      const { url } = await response.json();
      
      // 重定向到微信授权页面
      window.location.href = url;
    } catch (error) {
      onError?.(error as Error);
    }
  };

  return (
    <Button
      variant="default"
      onClick={handleWechatLogin}
      className="bg-[#07C160] text-white hover:bg-[#06B456] border-none"
    >
      <Wechat className="w-4 h-4 mr-2" />
      {children}
    </Button>
  );
};

export default WechatLoginButton;

7. 移动端微信登录集成

更新移动端认证页面:

// src/client/mobile/pages/AuthPage.tsx - 添加微信登录选项
import WechatLoginButton from '@/client/components/WechatLoginButton';
import { toast } from 'sonner'

// 在表单后添加微信登录按钮
<div className="mt-6">
  <div className="relative">
    <div className="absolute inset-0 flex items-center">
      <div className="w-full border-t border-gray-300"></div>
    </div>
    <div className="relative flex justify-center text-sm">
      <span className="px-2 bg-white text-gray-500">或使用以下方式登录</span>
    </div>
  </div>
  
  <div className="mt-4">
    <WechatLoginButton
      redirectUri={`${window.location.origin}/mobile/auth/callback`}
      onSuccess={(data) => {
        localStorage.setItem('token', data.token);
        navigate(getReturnUrl(), { replace: true });
      }}
      onError={(error) => {
        toast(error.message);
      }}
    >
      微信一键登录
    </WechatLoginButton>
  </div>
</div>

8. 环境变量配置

在.env文件中添加微信配置:

# 微信服务号配置(公众号)
WECHAT_MP_APP_ID=your_wechat_mp_app_id
WECHAT_MP_APP_SECRET=your_wechat_mp_app_secret

# 微信开放平台配置(可选,用于多端统一)
WECHAT_OPEN_APP_ID=your_wechat_open_app_id
WECHAT_OPEN_APP_SECRET=your_wechat_open_app_secret

故障排除

常见问题

  1. redirect_uri参数错误

    • 检查网页授权域名配置
    • 确保回调地址与配置一致
  2. invalid code

    • code只能使用一次
    • code有效期5分钟
  3. access_token过期

    • access_token有效期为2小时
    • refresh_token有效期为30天
  4. API调用频率限制

    • 微信API有调用频率限制
    • 建议添加缓存机制