|
@@ -0,0 +1,643 @@
|
|
|
|
|
+# 微信服务号网页授权登录开发指令
|
|
|
|
|
+
|
|
|
|
|
+## 描述
|
|
|
|
|
+
|
|
|
|
|
+本指令指导如何实现微信服务号网页授权登录功能,包括后端API开发、前端组件集成和移动端适配。
|
|
|
|
|
+
|
|
|
|
|
+## 前置要求
|
|
|
|
|
+
|
|
|
|
|
+1. 已注册微信开放平台服务号
|
|
|
|
|
+2. 已配置网页授权域名
|
|
|
|
|
+3. 获取AppID和AppSecret
|
|
|
|
|
+
|
|
|
|
|
+## 开发步骤
|
|
|
|
|
+
|
|
|
|
|
+### 1. 用户实体字段添加
|
|
|
|
|
+
|
|
|
|
|
+在用户实体中添加微信相关字段:
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// 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中添加微信字段定义:
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// src/server/modules/users/user.schema.ts
|
|
|
|
|
+wechatOpenid: z.string().max(255).nullable().openapi({
|
|
|
|
|
+ example: 'o6_bmjrPTlm6_2sgVt7hMZOPfL2M',
|
|
|
|
|
+ description: '微信开放平台openid'
|
|
|
|
|
+}),
|
|
|
|
|
+wechatUnionid: z.string().max(255).nullable().openapi({
|
|
|
|
|
+ example: 'o6_bmasdasdsad6_2sgVt7hMZOPfL2M',
|
|
|
|
|
+ description: '微信开放平台unionid'
|
|
|
|
|
+}),
|
|
|
|
|
+wechatNickname: z.string().max(255).nullable().openapi({
|
|
|
|
|
+ example: '微信用户',
|
|
|
|
|
+ description: '微信昵称'
|
|
|
|
|
+}),
|
|
|
|
|
+wechatAvatar: z.string().max(500).nullable().openapi({
|
|
|
|
|
+ example: 'http://thirdwx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/46',
|
|
|
|
|
+ description: '微信头像URL'
|
|
|
|
|
+}),
|
|
|
|
|
+wechatSex: z.number().int().min(0).max(2).nullable().openapi({
|
|
|
|
|
+ example: 1,
|
|
|
|
|
+ description: '微信性别(1:男,2:女,0:未知)'
|
|
|
|
|
+}),
|
|
|
|
|
+wechatProvince: z.string().max(100).nullable().openapi({
|
|
|
|
|
+ example: '广东省',
|
|
|
|
|
+ description: '微信省份'
|
|
|
|
|
+}),
|
|
|
|
|
+wechatCity: z.string().max(100).nullable().openapi({
|
|
|
|
|
+ example: '深圳市',
|
|
|
|
|
+ description: '微信城市'
|
|
|
|
|
+}),
|
|
|
|
|
+wechatCountry: z.string().max(100).nullable().openapi({
|
|
|
|
|
+ example: '中国',
|
|
|
|
|
+ description: '微信国家'
|
|
|
|
|
+})
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 3. 微信认证服务类
|
|
|
|
|
+
|
|
|
|
|
+创建微信认证服务:
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// 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_APP_ID || '';
|
|
|
|
|
+ private readonly appSecret: string = process.env.WECHAT_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路由:
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// 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;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// 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';
|
|
|
|
|
+
|
|
|
|
|
+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);
|
|
|
|
|
+ return c.json(result, 200);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ return c.json({
|
|
|
|
|
+ code: 401,
|
|
|
|
|
+ message: error instanceof Error ? error.message : '微信登录失败'
|
|
|
|
|
+ }, 401);
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+export default app;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// 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. 注册微信路由
|
|
|
|
|
+
|
|
|
|
|
+在API入口文件中注册微信路由:
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// src/server/api.ts
|
|
|
|
|
+import wechatRoutes from '@/server/api/auth/wechat/index';
|
|
|
|
|
+
|
|
|
|
|
+// 注册路由
|
|
|
|
|
+api.route('/api/v1/auth/wechat', wechatRoutes);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 6. 前端微信登录组件
|
|
|
|
|
+
|
|
|
|
|
+创建React微信登录组件:
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// 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. 移动端微信登录集成
|
|
|
|
|
+
|
|
|
|
|
+更新移动端认证页面:
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// src/client/mobile/pages/AuthPage.tsx - 添加微信登录选项
|
|
|
|
|
+import WechatLoginButton from '@/client/components/WechatLoginButton';
|
|
|
|
|
+
|
|
|
|
|
+// 在表单后添加微信登录按钮
|
|
|
|
|
+<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) => {
|
|
|
|
|
+ alert(error.message);
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ 微信一键登录
|
|
|
|
|
+ </WechatLoginButton>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</div>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 8. 环境变量配置
|
|
|
|
|
+
|
|
|
|
|
+在.env文件中添加微信配置:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+# 微信服务号配置
|
|
|
|
|
+WECHAT_APP_ID=your_wechat_app_id
|
|
|
|
|
+WECHAT_APP_SECRET=your_wechat_app_secret
|
|
|
|
|
+WECHAT_REDIRECT_URI=https://yourdomain.com/auth/wechat/callback
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 9. 数据库迁移
|
|
|
|
|
+
|
|
|
|
|
+创建数据库迁移脚本添加微信字段:
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// src/server/migrations/AddWechatFields.ts
|
|
|
|
|
+import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
|
|
|
+
|
|
|
|
|
+export class AddWechatFields1712345678901 implements MigrationInterface {
|
|
|
|
|
+ public async up(queryRunner: QueryRunner): Promise<void> {
|
|
|
|
|
+ await queryRunner.query(`
|
|
|
|
|
+ ALTER TABLE users
|
|
|
|
|
+ ADD COLUMN wechat_openid VARCHAR(255) NULL COMMENT '微信开放平台openid',
|
|
|
|
|
+ ADD COLUMN wechat_unionid VARCHAR(255) NULL COMMENT '微信开放平台unionid',
|
|
|
|
|
+ ADD COLUMN wechat_nickname VARCHAR(255) NULL COMMENT '微信昵称',
|
|
|
|
|
+ ADD COLUMN wechat_avatar VARCHAR(500) NULL COMMENT '微信头像URL',
|
|
|
|
|
+ ADD COLUMN wechat_sex TINYINT NULL COMMENT '微信性别(1:男,2:女,0:未知)',
|
|
|
|
|
+ ADD COLUMN wechat_province VARCHAR(100) NULL COMMENT '微信省份',
|
|
|
|
|
+ ADD COLUMN wechat_city VARCHAR(100) NULL COMMENT '微信城市',
|
|
|
|
|
+ ADD COLUMN wechat_country VARCHAR(100) NULL COMMENT '微信国家',
|
|
|
|
|
+ ADD UNIQUE INDEX idx_wechat_openid (wechat_openid),
|
|
|
|
|
+ ADD INDEX idx_wechat_unionid (wechat_unionid)
|
|
|
|
|
+ `);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public async down(queryRunner: QueryRunner): Promise<void> {
|
|
|
|
|
+ await queryRunner.query(`
|
|
|
|
|
+ ALTER TABLE users
|
|
|
|
|
+ DROP COLUMN wechat_openid,
|
|
|
|
|
+ DROP COLUMN wechat_unionid,
|
|
|
|
|
+ DROP COLUMN wechat_nickname,
|
|
|
|
|
+ DROP COLUMN wechat_avatar,
|
|
|
|
|
+ DROP COLUMN wechat_sex,
|
|
|
|
|
+ DROP COLUMN wechat_province,
|
|
|
|
|
+ DROP COLUMN wechat_city,
|
|
|
|
|
+ DROP COLUMN wechat_country
|
|
|
|
|
+ `);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## 测试验证
|
|
|
|
|
+
|
|
|
|
|
+### 1. 单元测试
|
|
|
|
|
+
|
|
|
|
|
+创建微信认证服务的单元测试:
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// src/server/modules/wechat/__tests__/wechat-auth.service.spec.ts
|
|
|
|
|
+import { WechatAuthService } from '../wechat-auth.service';
|
|
|
|
|
+import { UserService } from '../../users/user.service';
|
|
|
|
|
+import { AuthService } from '../../auth/auth.service';
|
|
|
|
|
+import axios from 'axios';
|
|
|
|
|
+
|
|
|
|
|
+jest.mock('axios');
|
|
|
|
|
+
|
|
|
|
|
+describe('WechatAuthService', () => {
|
|
|
|
|
+ let service: WechatAuthService;
|
|
|
|
|
+ let userService: UserService;
|
|
|
|
|
+ let authService: AuthService;
|
|
|
|
|
+
|
|
|
|
|
+ beforeEach(() => {
|
|
|
|
|
+ userService = {
|
|
|
|
|
+ findByWechatOpenid: jest.fn(),
|
|
|
|
|
+ createUser: jest.fn()
|
|
|
|
|
+ } as any;
|
|
|
|
|
+
|
|
|
|
|
+ authService = {
|
|
|
|
|
+ generateToken: jest.fn()
|
|
|
|
|
+ } as any;
|
|
|
|
|
+
|
|
|
|
|
+ service = new WechatAuthService(userService, authService);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('should be defined', () => {
|
|
|
|
|
+ expect(service).toBeDefined();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 添加更多测试用例...
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 2. 集成测试
|
|
|
|
|
+
|
|
|
|
|
+测试微信认证API端点:
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+// src/server/api/auth/wechat/__tests__/wechat-auth.e2e-spec.ts
|
|
|
|
|
+import { createServer } from 'http';
|
|
|
|
|
+import { api } from '@/server/api';
|
|
|
|
|
+import * as request from 'supertest';
|
|
|
|
|
+
|
|
|
|
|
+describe('Wechat Auth (e2e)', () => {
|
|
|
|
|
+ let server: any;
|
|
|
|
|
+
|
|
|
|
|
+ beforeAll(async () => {
|
|
|
|
|
+ server = createServer(api.fetch);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ afterAll(() => {
|
|
|
|
|
+ server.close();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('/auth/wechat (POST) - should return 400 for invalid code', () => {
|
|
|
|
|
+ return request(server)
|
|
|
|
|
+ .post('/auth/wechat')
|
|
|
|
|
+ .send({ code: '' })
|
|
|
|
|
+ .expect(400);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 添加更多集成测试...
|
|
|
|
|
+});
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## 部署说明
|
|
|
|
|
+
|
|
|
|
|
+### 1. 微信服务号配置
|
|
|
|
|
+
|
|
|
|
|
+1. 登录微信公众平台
|
|
|
|
|
+2. 进入「开发」->「基本配置」
|
|
|
|
|
+3. 获取AppID和AppSecret
|
|
|
|
|
+4. 进入「设置」->「公众号设置」->「功能设置」
|
|
|
|
|
+5. 配置「网页授权域名」
|
|
|
|
|
+
|
|
|
|
|
+### 2. 服务器配置
|
|
|
|
|
+
|
|
|
|
|
+确保服务器能够访问微信API:
|
|
|
|
|
+- 开放对外网络访问
|
|
|
|
|
+- 配置正确的回调域名
|
|
|
|
|
+- 设置HTTPS(微信要求)
|
|
|
|
|
+
|
|
|
|
|
+### 3. 安全考虑
|
|
|
|
|
+
|
|
|
|
|
+1. **Token安全**: JWT token设置合理的过期时间
|
|
|
|
|
+2. **防CSRF**: 使用state参数防止CSRF攻击
|
|
|
|
|
+3. **错误处理**: 妥善处理微信API的错误响应
|
|
|
|
|
+4. **日志记录**: 记录微信认证过程中的关键操作
|
|
|
|
|
+
|
|
|
|
|
+## 故障排除
|
|
|
|
|
+
|
|
|
|
|
+### 常见问题
|
|
|
|
|
+
|
|
|
|
|
+1. **redirect_uri参数错误**
|
|
|
|
|
+ - 检查网页授权域名配置
|
|
|
|
|
+ - 确保回调地址与配置一致
|
|
|
|
|
+
|
|
|
|
|
+2. **invalid code**
|
|
|
|
|
+ - code只能使用一次
|
|
|
|
|
+ - code有效期5分钟
|
|
|
|
|
+
|
|
|
|
|
+3. **access_token过期**
|
|
|
|
|
+ - access_token有效期为2小时
|
|
|
|
|
+ - refresh_token有效期为30天
|
|
|
|
|
+
|
|
|
|
|
+4. **API调用频率限制**
|
|
|
|
|
+ - 微信API有调用频率限制
|
|
|
|
|
+ - 建议添加缓存机制
|
|
|
|
|
+
|
|
|
|
|
+## 性能优化
|
|
|
|
|
+
|
|
|
|
|
+1. **缓存access_token**: 使用Redis缓存微信access_token
|
|
|
|
|
+2. **数据库索引**: 为wechat_openid字段添加唯一索引
|
|
|
|
|
+3. **异步处理**: 用户信息获取可异步处理
|
|
|
|
|
+4. **CDN加速**: 微信头像使用CDN加速
|
|
|
|
|
+
|
|
|
|
|
+## 监控告警
|
|
|
|
|
+
|
|
|
|
|
+1. **微信API调用监控**: 监控微信API调用成功率
|
|
|
|
|
+2. **登录成功率监控**: 监控微信登录成功率
|
|
|
|
|
+3. **错误告警**: 设置微信认证错误告警
|
|
|
|
|
+4. **性能监控**: 监控微信认证接口响应时间
|
|
|
|
|
+
|
|
|
|
|
+## 版本历史
|
|
|
|
|
+
|
|
|
|
|
+- v1.0.0 (2024-01-01): 初始版本,支持微信网页授权登录
|
|
|
|
|
+- v1.1.0 (2024-02-01): 添加UnionID支持,优化错误处理
|
|
|
|
|
+- v1.2.0 (2024-03-01): 添加移动端适配,性能优化
|
|
|
|
|
+
|
|
|
|
|
+## 相关文档
|
|
|
|
|
+
|
|
|
|
|
+- [微信开放平台文档](https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/Wechat_webpage_authorization.html)
|
|
|
|
|
+- [网页授权获取用户信息](https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/Wechat_webpage_authorization.html)
|
|
|
|
|
+- [微信JS-SDK说明文档](https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html)
|