实现微信扫码注册登录-基于参数二维码

4 阅读18分钟

微信扫码注册登录

前言

  • 本项目实现了微信扫码登录注册功能,用户可以通过扫描二维码,在微信公众平台上完成登录或注册操作。
  • 如果你的服务号、公众号使用了关键词回复,该项目使用到了消息推送会影响导致关键词回复冲突,需要在项目中post /api/v1/wechat/event配置关键词回复(我这里没有做这个功能,默认没有开启关键词回复)。
  • 你可以看我另一个文档,使用网站应用授权登录,这样就不用开启消息推送

功能特性

  1. 微信扫码登录注册一体化
  • 用户通过微信扫码即可完成登录或注册
  • 支持新用户自动注册和老用户直接登录
  • 无需输入用户名密码,提升用户体验
  1. 消息推送驱动的扫码事件处理
  • 基于微信消息推送机制处理扫码事件
  • 用户扫码关注后,系统自动完成登录流程
  • 实时状态同步,支持轮询检查登录状态
  1. 会话状态管理
  • 创建带参数的二维码,每个二维码有唯一会话ID
  • 支持三种状态:pending(等待中)、success(成功)、expired(过期)
  • 10分钟有效期,自动过期处理
  1. 用户身份统一
  • 微信用户和账号密码用户统一管理
  • 支持用户绑定微信账号
  • 通过 login_type 字段区分登录方式(account/wechat)
  1. 架构分层设计
  • 标准的三层架构:API层 → Service层 → Repository层
  • 独立的会话管理器处理扫码状态
  • 模块化解耦,便于维护和扩展

相关链接

sequenceDiagram
    participant 用户
    participant 服务器
    participant 微信

    用户->>服务器: 请求登录
    服务器->>服务器: 创建微信登录会话 id=1, code=1234
    服务器->>微信: 创建带参数二维码 code=1234
    微信-->>服务器: 返回二维码信息
    服务器-->>用户: 返回二维码
    
    activate 用户
    用户->>服务器: 轮询状态 id=1
    服务器-->>用户: 等待中...
    deactivate 用户
    
    Note over 用户: 扫码关注
    用户->>微信: 扫码关注
    微信->>服务器: 推送扫码关注事件
    
    服务器->>服务器: code=1234, openid=xxx<br/>创建用户,标记会话1成功,写token到会话1
    
    用户->>服务器: 轮询状态 id=1
    服务器-->>用户: 返回会话1的token,登录成功

准备

申请AppID、AppSecret

  • 登录微信开发者平台,点击“我的业务与服务”,选择服务号
  • 创建成功后,即可获取到AppID、AppSecret等相关配置信息。
  • 配置 API IP白名单
  • 开启消息推送 插入配置图片:

开启消息推送转存失败,建议直接上传图片文件

  • 记得检查服务号-绑定关系-开放平台(要保证你已经绑定了)

在config定义全局配置

// config.yaml

  wechat:
    # 微信公众平台配置
    app_id: "your_app_id"
    app_secret: "your_app_secret"
    token: "your_token"
    encoding_aes_key: "your_encoding_aes_key"
    base_url: "https://api.weixin.qq.com"
    qr_base_url: "https://mp.weixin.qq.com"

配置数据库

  • 主要在用户表中添加一个login_type字段,用于区分用户是通过账号密码登录还是微信登录。
  • 微信绑定表中添加user_id字段,用于关联用户表。
  • 微信扫码会话表,用于存储用户扫码登录的会话信息。
-- 用户表
CREATE TABLE IF NOT EXISTS `users` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '用户ID',
  `username` varchar(50) NOT NULL COMMENT '用户名',
  `email` varchar(100) NOT NULL COMMENT '邮箱',
  `password` varchar(255) NOT NULL COMMENT '密码',
  `nickname` varchar(50) DEFAULT NULL COMMENT '昵称',
  `avatar` varchar(255) DEFAULT NULL COMMENT '头像',
  `status` varchar(20) NOT NULL DEFAULT 'active' COMMENT '状态:active-正常,disabled-禁用',
  `login_type` varchar(20) NOT NULL DEFAULT 'account' COMMENT '登录类型:account-账号密码,wechat-微信登录',
  `favorite_games` varchar(255) DEFAULT NULL COMMENT '喜爱的游戏,字符串格式',
  `last_login_at` datetime DEFAULT NULL COMMENT '最后登录时间',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `deleted_at` datetime DEFAULT NULL COMMENT '删除时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_username` (`username`),
  UNIQUE KEY `idx_email` (`email`),
  KEY `idx_deleted_at` (`deleted_at`),
  KEY `idx_login_type` (`login_type`),
  KEY `idx_status_login_type` (`status`,`login_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
  • 微信绑定表
-- 创建微信绑定表
CREATE TABLE IF NOT EXISTS `wechat_bindings` (
    `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
    `user_id` bigint unsigned NOT NULL COMMENT '用户ID',
    `open_id` varchar(100) NOT NULL COMMENT '微信OpenID,用户在当前应用的唯一标识',
    `union_id` varchar(100) DEFAULT NULL COMMENT '微信UnionID,用户在开放平台的唯一标识',
    `nickname` varchar(100) DEFAULT NULL COMMENT '微信昵称',
    `avatar` varchar(500) DEFAULT NULL COMMENT '微信头像URL',
    `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `deleted_at` datetime DEFAULT NULL COMMENT '删除时间',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_open_id` (`open_id`),
    UNIQUE KEY `uk_user_id` (`user_id`),
    KEY `idx_union_id` (`union_id`),
    KEY `idx_deleted_at` (`deleted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='微信绑定表';
  • 创建微信扫码登录会话表
-- 创建微信扫码登录会话表
CREATE TABLE IF NOT EXISTS `wechat_qrcode_session` (
    `id` varchar(100) NOT NULL COMMENT '会话ID',
    `scene_code` varchar(100) NOT NULL COMMENT '场景值',
    `status` varchar(20) NOT NULL DEFAULT 'pending' COMMENT '状态:pending-待处理,success-成功,expired-过期',
    `user_id` bigint unsigned DEFAULT NULL COMMENT '用户ID',
    `token` varchar(512) DEFAULT NULL COMMENT '登录token',
    `expire_time` datetime NOT NULL COMMENT '过期时间',
    `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `deleted_at` datetime DEFAULT NULL COMMENT '删除时间',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_scene_code` (`scene_code`),
    KEY `idx_user_id` (`user_id`),
    KEY `idx_status` (`status`),
    KEY `idx_expire_time` (`expire_time`),
    KEY `idx_deleted_at` (`deleted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='微信扫码登录会话表';

接口设计

  • POST /api/v1/wechat/qr-login - 生成二维码
  • GET /api/v1/wechat/login-status - 检查登录状态
  • POST /api/v1/wechat/event - 微信事件回调 (处理用户扫描二维码事件)

架构概述

本设计采用标准的分层架构,遵循项目的架构规范:

┌──────────────────────────────────────────────────────────────────┐
│                          API 层                                 │
├──────────────────────────────────────────────────────────────────┤
│ 路由定义:api/wechat/v1/wechat.go                              │
│ 请求处理:api/wechat/v1/wechat_handler.go                      │
├──────────────────────────────────────────────────────────────────┤
│                          Service 层                             │
├──────────────────────────────────────────────────────────────────┤
│ 业务逻辑:internal/modules/wechat/service/wechat_service.go    │
│ 会话管理:internal/modules/wechat/service/session_manager.go    │
├──────────────────────────────────────────────────────────────────┤
│                         Repository 层                           │
├──────────────────────────────────────────────────────────────────┤
│ 数据访问:internal/modules/wechat/repository/wechat_repository.go │
│ 会话存储:internal/modules/wechat/repository/session_repository.go │
├──────────────────────────────────────────────────────────────────┤
│                          Model 层                               │
├──────────────────────────────────────────────────────────────────┤
│ 数据模型:internal/modules/wechat/model/wechat_model.go         │
│ 会话模型:internal/modules/wechat/model/session_model.go         │
└──────────────────────────────────────────────────────────────────┘

目录结构树

internal/modules/wechat/
├── model/
│   ├── session\_model.go        # 会话模型定义
│   └── wechat\_model.go         # 微信相关模型定义
├── repository/
│   ├── session\_repository.go   # 会话数据访问层
│   └── wechat\_repository.go    # 微信数据访问层
├── service/
│   ├── session\_manager.go      # 会话管理逻辑
│   └── wechat\_service.go       # 微信核心业务逻辑
└── provider.go                 # Wire 依赖注入配置

api/wechat/
└── v1/
├── wechat.go           # 微信接口定义(Service 接口、请求/响应结构体)
└── wechat\_handler.go   # 微信 HTTP 处理器(Handler 层)

1. 生成二维码接口

接口地址POST /api/v1/wechat/qr-login

请求方式:POST

请求参数:无

响应格式

{
  "code": 0,
  "message": "success",
  "data": {
    "qrCodeUrl": "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=gQE11111111111111111L11111111",
    "evidence": "bf11111111111111111"
  }
}

参数说明

  • qrCodeUrl:微信二维码图片URL,用于前端显示
  • evidence:登录会话标识,用于后续查询登录状态

2. 检查登录状态接口

接口地址GET /api/v1/wechat/login-status

请求方式:GET

请求参数

参数名类型必填描述
evidencestring登录会话标识(从生成二维码接口获取)

响应格式

  • 等待中

    {
      "code": 0,
      "message": "success",
      "data": {
        "status": "pending"
      }
    }
    
  • 登录成功

    {
      "code": 0,
      "message": "success",
      "data": {
        "status": "success",
        "access_token": "eyJ11111111111111111...",
        "user": {
          "id": "u_1234567890",
          "username": "微信用户_abc123",
          "nickname": "微信昵称",
          "avatar": "https://wx.qlogo.cn/mmopen/vi_32/...",
          "login_type": "wechat"
        }
      }
    }
    
  • 二维码过期

    {
      "code": 0,
      "message": "success",
      "data": {
        "status": "expired"
      }
    }
    

3. 微信事件回调接口

接口地址POST /api/v1/wechat/event

请求方式:POST

说明:此接口由微信服务器调用,用于接收扫码事件通知,前端无需调用。

4. 核心组件

组件职责文件位置
微信服务实现微信登录和扫码逻辑internal/modules/wechat/service/wechat_service.go
会话管理器管理登录会话和状态internal/modules/wechat/service/session_manager.go
微信仓库处理微信绑定数据internal/modules/wechat/repository/wechat_repository.go
会话仓库处理会话数据internal/modules/wechat/repository/session_repository.go
微信模型定义微信相关数据结构internal/modules/wechat/model/wechat_model.go
会话模型定义会话数据结构internal/modules/wechat/model/session_model.go

代码实现

config 配置

// config\config.go
package config

import (
	"github.com/spf13/viper"
)

// Config 应用配置结构
type Config struct {
	Server   ServerConfig   `mapstructure:"server"`
	Database DatabaseConfig `mapstructure:"database"`
	Log      LogConfig      `mapstructure:"log"`
	JWT      JWTConfig      `mapstructure:"jwt"`
	External ExternalConfig `mapstructure:"external"`
	View     ViewConfig
}


// ExternalConfig 外部服务配置
type ExternalConfig struct {
	Wechat      WechatConfig      `mapstructure:"wechat"`       // 扫码登录用
	WechatOAuth WechatOAuthConfig `mapstructure:"wechat_oauth"` // 网页授权登录用
}

// WechatConfig 微信配置
type WechatConfig struct {
	AppID          string `mapstructure:"app_id"`
	AppSecret      string `mapstructure:"app_secret"`
	Token          string `mapstructure:"token"`
	EncodingAESKey string `mapstructure:"encoding_aes_key"`
	BaseURL        string `mapstructure:"base_url"`    // 微信API基础地址,默认为 https://api.weixin.qq.com
	QRBaseURL      string `mapstructure:"qr_base_url"` // 二维码展示页面地址,默认为 https://mp.weixin.qq.com
}

// WechatOAuthConfig 微信OAuth配置
type WechatOAuthConfig struct {
	AppID       string `mapstructure:"app_id"`       // 微信网站应用AppID
	AppSecret   string `mapstructure:"app_secret"`   // 微信网站应用AppSecret
	RedirectURI string `mapstructure:"redirect_uri"` // OAuth回调地址
	Scope       string `mapstructure:"scope"`        // 授权作用域:snsapi_base 或 snsapi_userinfo
	BaseURL     string `mapstructure:"base_url"`     // 微信API基础地址,默认 https://api.weixin.qq.com
}

服务层

// internal\modules\wechat\service\session_manager.go
package service

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"time"

	"go-api/internal/modules/wechat/model"
	"go-api/internal/modules/wechat/repository"
)

// SessionManager 会话管理器
type SessionManager struct {
	sessionRepo repository.SessionRepository
}

// NewSessionManager 创建会话管理器
func NewSessionManager(sessionRepo repository.SessionRepository) *SessionManager {
	return &SessionManager{
		sessionRepo: sessionRepo,
	}
}

// CreateSession 创建登录会话
func (m *SessionManager) CreateSession(ctx context.Context) (*model.Session, error) {
	sceneCode := m.generateSceneCode()
	session := &model.Session{
		ID:         m.generateSessionID(),
		SceneCode:  sceneCode,
		Status:     "pending",
		ExpireTime: time.Now().Add(10 * time.Minute),
		CreatedAt:  time.Now(),
		UpdatedAt:  time.Now(),
	}

	err := m.sessionRepo.Create(ctx, session)
	if err != nil {
		return nil, err
	}

	return session, nil
}

// GetSession 获取会话
func (m *SessionManager) GetSession(ctx context.Context, sessionID string) (*model.Session, error) {
	session, err := m.sessionRepo.GetByID(ctx, sessionID)
	if err != nil {
		return nil, err
	}

	// 检查是否过期
	if session.IsExpired() && session.Status != "expired" {
		session.MarkExpired()
		m.sessionRepo.Update(ctx, session)
	}

	return session, nil
}

// GetSessionByScene 根据场景值获取会话
func (m *SessionManager) GetSessionByScene(ctx context.Context, sceneCode string) (*model.Session, error) {
	return m.sessionRepo.GetBySceneCode(ctx, sceneCode)
}

// UpdateSessionStatus 更新会话状态
func (m *SessionManager) UpdateSessionStatus(ctx context.Context, sessionID, status, token string) error {
	session, err := m.GetSession(ctx, sessionID)
	if err != nil {
		return err
	}

	session.Status = status
	session.Token = token
	session.UpdatedAt = time.Now()

	return m.sessionRepo.Update(ctx, session)
}

// generateSessionID 生成会话ID
func (m *SessionManager) generateSessionID() string {
	b := make([]byte, 16)
	rand.Read(b)
	return "sess_" + hex.EncodeToString(b)
}

// generateSceneCode 生成场景值
func (m *SessionManager) generateSceneCode() string {
	b := make([]byte, 16)
	rand.Read(b)
	return "login_" + hex.EncodeToString(b)
}


// internal\modules\wechat\service\wechat_service.go
package service

import (
	"context"
	"crypto/rand"
	"encoding/json"
	"encoding/xml"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
	"time"

	v1 "go-api/api/wechat/v1"
	"go-api/config"
	userModel "go-api/internal/modules/user/model"
	userRepo "go-api/internal/modules/user/repository"
	userService "go-api/internal/modules/user/service"
	"go-api/internal/modules/wechat/model"
	"go-api/internal/modules/wechat/repository"
	"go-api/pkg/errcode"
	"go-api/pkg/logx"
	"go-api/pkg/xid"
)

// wechatService 微信服务实现
type wechatService struct {
	config         *config.Config
	wechatRepo     repository.WechatRepository
	sessionRepo    repository.SessionRepository
	userRepo       userRepo.UserRepository
	tokenService   userService.TokenService
	sessionManager *SessionManager
}

// NewWechatService 创建微信服务
func NewWechatService(
	config *config.Config,
	wechatRepo repository.WechatRepository,
	sessionRepo repository.SessionRepository,
	userRepo userRepo.UserRepository,
	tokenService userService.TokenService,
) v1.WechatService {
	return &wechatService{
		config:         config,
		wechatRepo:     wechatRepo,
		sessionRepo:    sessionRepo,
		userRepo:       userRepo,
		tokenService:   tokenService,
		sessionManager: NewSessionManager(sessionRepo),
	}
}

// CreateQrLogin 创建微信扫码登录
func (s *wechatService) CreateQrLogin(ctx context.Context) (*v1.WechatQrLoginResp, error) {
	// 创建登录会话
	session, err := s.sessionManager.CreateSession(ctx)
	if err != nil {
		logx.G(ctx).WithField("err", err).Error("创建登录会话失败")
		return nil, errcode.ErrBiz(errcode.ErrWechatQrCreateFailed, "创建登录会话失败")
	}
	logx.G(ctx).WithField("session_id", session.ID).WithField("scene_code", session.SceneCode).Info("登录会话创建成功")

	// 获取微信access_token
	accessToken, err := s.getWechatAccessToken(ctx)
	if err != nil {
		logx.G(ctx).WithField("err", err).Error("获取微信access_token失败")
		return nil, errcode.ErrBiz(errcode.ErrWechatApiError, "获取微信access_token失败")
	}
	logx.G(ctx).WithField("access_token", accessToken[:20]+"...").Info("获取access_token成功")

	// 创建二维码
	qrCodeUrl, err := s.createWechatQrCode(ctx, accessToken, session.SceneCode)
	if err != nil {
		logx.G(ctx).WithField("err", err).Error("创建二维码失败")
		return nil, errcode.ErrBiz(errcode.ErrWechatQrCreateFailed, "创建二维码失败")
	}
	logx.G(ctx).WithField("qr_code_url", qrCodeUrl).Info("创建二维码成功")

	return &v1.WechatQrLoginResp{
		QrCodeUrl: qrCodeUrl,
		Evidence:  session.ID,
	}, nil
}

// CheckLoginStatus 检查登录状态
func (s *wechatService) CheckLoginStatus(ctx context.Context, evidence string) (*v1.WechatLoginStatusResp, error) {
	session, err := s.sessionManager.GetSession(ctx, evidence)
	if err != nil {
		return nil, errcode.ErrBiz(errcode.ErrWechatSessionNotFound, "登录会话不存在")
	}

	// 检查是否过期
	if session.IsExpired() {
		session.MarkExpired()
		s.sessionRepo.Update(ctx, session)
		return &v1.WechatLoginStatusResp{
			Status: "expired",
		}, nil
	}

	if session.Status == "success" {
		// 获取用户信息
		user, err := s.userRepo.GetByID(ctx, int64(session.UserID))
		if err != nil {
			return nil, errcode.ErrBiz(errcode.ErrBizUserNotFound, "用户不存在")
		}

		return &v1.WechatLoginStatusResp{
			Status:      "success",
			AccessToken: session.Token,
			User: &v1.UserInfo{
				ID:        xid.FormatID(user.ID),
				Username:  user.Username,
				Nickname:  user.Nickname,
				Avatar:    user.Avatar,
				LoginType: user.LoginType,
			},
		}, nil
	}

	return &v1.WechatLoginStatusResp{
		Status: "pending",
	}, nil
}

// HandleWechatEvent 处理微信事件
func (s *wechatService) HandleWechatEvent(ctx context.Context, eventData []byte) error {
	logx.G(ctx).WithField("event_data", string(eventData)).Info("收到微信事件")

	// 解析微信事件
	var event model.WechatEvent
	if err := xml.Unmarshal(eventData, &event); err != nil {
		logx.G(ctx).WithField("err", err).WithField("event_data", string(eventData)).Error("解析微信事件失败")
		return errcode.ErrBiz(errcode.ErrInvalidParam, "解析微信事件失败")
	}

	logx.G(ctx).WithField("event", event.Event).WithField("from_user", event.FromUserName).WithField("event_key", event.EventKey).Info("解析微信事件成功")

	// 处理扫码事件
	if event.Event == "SCAN" || event.Event == "subscribe" {
		sceneCode := event.EventKey
		if strings.HasPrefix(sceneCode, "qrscene_") {
			sceneCode = strings.TrimPrefix(sceneCode, "qrscene_")
		}

		logx.G(ctx).WithField("scene_code", sceneCode).Info("处理扫码事件")

		if sceneCode == "" {
			logx.G(ctx).Error("场景值为空")
			return errcode.ErrBiz(errcode.ErrInvalidParam, "场景值不能为空")
		}

		// 获取会话信息
		session, err := s.sessionManager.GetSessionByScene(ctx, sceneCode)
		if err != nil {
			logx.G(ctx).WithField("err", err).WithField("scene_code", sceneCode).Error("获取会话信息失败")
			return err
		}

		logx.G(ctx).WithField("session_id", session.ID).WithField("status", session.Status).Info("获取会话信息成功")

		// 检查会话是否已过期
		if session.IsExpired() {
			logx.G(ctx).WithField("session_id", session.ID).Warn("登录会话已过期")
			session.MarkExpired()
			s.sessionRepo.Update(ctx, session)
			return errcode.ErrBiz(errcode.ErrWechatSessionExpired, "登录会话已过期")
		}

		// 获取微信用户信息
		userInfo, err := s.getWechatUserInfo(ctx, event.FromUserName)
		if err != nil {
			logx.G(ctx).WithField("err", err).WithField("open_id", event.FromUserName).Error("获取微信用户信息失败")
			return err
		}

		logx.G(ctx).WithField("open_id", userInfo.OpenID).WithField("nickname", userInfo.Nickname).Info("获取微信用户信息成功")

		// 处理用户登录/注册
		err = s.handleWechatUserLogin(ctx, session, userInfo)
		if err != nil {
			logx.G(ctx).WithField("err", err).Error("处理用户登录失败")
			return err
		}

		logx.G(ctx).WithField("session_id", session.ID).Info("用户登录处理成功")
	} else {
		logx.G(ctx).WithField("event", event.Event).Info("非扫码事件,忽略")
	}

	return nil
}

// getWechatBaseURL 获取微信API基础地址
func (s *wechatService) getWechatBaseURL() string {
	if s.config.External.Wechat.BaseURL != "" {
		return s.config.External.Wechat.BaseURL
	}
	return "https://api.weixin.qq.com"
}

// getWechatQRBaseURL 获取微信二维码展示页面基础地址
func (s *wechatService) getWechatQRBaseURL() string {
	if s.config.External.Wechat.QRBaseURL != "" {
		return s.config.External.Wechat.QRBaseURL
	}
	return "https://mp.weixin.qq.com"
}

// getWechatAccessToken 获取微信access_token
func (s *wechatService) getWechatAccessToken(ctx context.Context) (string, error) {
	url := fmt.Sprintf(
		"%s/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
		s.getWechatBaseURL(),
		s.config.External.Wechat.AppID,
		s.config.External.Wechat.AppSecret,
	)

	resp, err := s.httpGet(ctx, url)
	if err != nil {
		return "", err
	}

	var tokenResp model.WechatAccessTokenResponse
	if err := json.Unmarshal(resp, &tokenResp); err != nil {
		return "", err
	}

	if tokenResp.ErrCode != 0 {
		return "", fmt.Errorf("微信API错误: %s", tokenResp.ErrMsg)
	}

	return tokenResp.AccessToken, nil
}

// createWechatQrCode 创建微信二维码
func (s *wechatService) createWechatQrCode(ctx context.Context, accessToken, sceneCode string) (string, error) {
	url := fmt.Sprintf("%s/cgi-bin/qrcode/create?access_token=%s", s.getWechatBaseURL(), accessToken)

	payload := map[string]interface{}{
		"expire_seconds": 600,
		"action_name":    "QR_STR_SCENE",
		"action_info": map[string]interface{}{
			"scene": map[string]string{
				"scene_str": sceneCode,
			},
		},
	}

	payloadBytes, _ := json.Marshal(payload)

	logx.G(ctx).WithField("url", url).WithField("payload", string(payloadBytes)).Info("创建微信二维码请求")

	resp, err := s.httpPost(ctx, url, payloadBytes)
	if err != nil {
		logx.G(ctx).WithField("err", err).Error("创建微信二维码HTTP请求失败")
		return "", err
	}

	logx.G(ctx).WithField("response", string(resp)).Info("创建微信二维码响应")

	var qrResp model.WechatQRCodeResponse
	if err := json.Unmarshal(resp, &qrResp); err != nil {
		logx.G(ctx).WithField("err", err).WithField("response", string(resp)).Error("解析微信二维码响应失败")
		return "", err
	}

	if qrResp.ErrCode != 0 {
		logx.G(ctx).WithField("err_code", qrResp.ErrCode).WithField("err_msg", qrResp.ErrMsg).Error("微信API返回错误")
		return "", fmt.Errorf("微信API错误[%d]: %s", qrResp.ErrCode, qrResp.ErrMsg)
	}

	return fmt.Sprintf("%s/cgi-bin/showqrcode?ticket=%s", s.getWechatQRBaseURL(), qrResp.Ticket), nil
}

// getWechatUserInfo 获取微信用户信息
func (s *wechatService) getWechatUserInfo(ctx context.Context, openID string) (*model.WechatUserInfo, error) {
	accessToken, err := s.getWechatAccessToken(ctx)
	if err != nil {
		return nil, err
	}

	url := fmt.Sprintf(
		"%s/cgi-bin/user/info?access_token=%s&openid=%s&lang=zh_CN",
		s.getWechatBaseURL(),
		accessToken,
		openID,
	)

	resp, err := s.httpGet(ctx, url)
	if err != nil {
		return nil, err
	}

	var userInfo model.WechatUserInfo
	if err := json.Unmarshal(resp, &userInfo); err != nil {
		return nil, err
	}

	return &userInfo, nil
}

// handleWechatUserLogin 处理微信用户登录
func (s *wechatService) handleWechatUserLogin(ctx context.Context, session *model.Session, wechatUser *model.WechatUserInfo) error {
	// 查询是否已存在绑定关系
	binding, err := s.wechatRepo.GetByOpenID(ctx, wechatUser.OpenID)
	if err != nil {
		// 检查是否是记录不存在的错误
		if e, ok := err.(*errcode.Error); ok && e.Code == errcode.ErrDbNotFound {
			// 记录不存在,继续处理
		} else {
			// 其他数据库错误
			return errcode.ErrDb(err)
		}
	}

	var user *userModel.User

	if binding != nil {
		// 已存在绑定关系,获取用户信息
		user, err = s.userRepo.GetByID(ctx, int64(binding.UserID))
		if err != nil {
			return errcode.ErrBiz(errcode.ErrBizUserNotFound, "用户不存在")
		}
	} else {
		// 不存在绑定关系,创建新用户和绑定关系
		user, err = s.createWechatUser(ctx, wechatUser)
		if err != nil {
			return err
		}
	}

	// 生成token
	token, err := s.tokenService.GenerateToken(ctx, user.ID)
	if err != nil {
		return errcode.ErrBiz(errcode.ErrBizTokenInvalid, "生成token失败")
	}

	// 更新最后登录时间
	now := time.Now()
	user.LastLoginAt = &now
	if err := s.userRepo.Update(ctx, user); err != nil {
		// 记录错误但不影响登录流程
		logx.G(ctx).WithError(err).Error("更新最后登录时间失败")
	}

	// 更新会话状态
	session.MarkSuccess(uint64(user.ID), token)
	return s.sessionRepo.Update(ctx, session)
}

// createWechatUser 创建微信用户
func (s *wechatService) createWechatUser(ctx context.Context, wechatUser *model.WechatUserInfo) (*userModel.User, error) {
	// 创建用户
	user := &userModel.User{
		Username:  s.generateUsername(),
		Email:     nil,
		Password:  nil,
		Nickname:  wechatUser.Nickname,
		Avatar:    wechatUser.Avatar,
		Status:    "active",
		LoginType: "wechat",
	}

	// 使用用户仓库创建用户
	if err := s.userRepo.Create(ctx, user); err != nil {
		return nil, errcode.ErrBiz(errcode.ErrWechatUserCreateFailed, "创建用户失败")
	}

	// 创建微信绑定关系
	binding := &model.WechatBinding{
		UserID:   uint64(user.ID),
		OpenID:   wechatUser.OpenID,
		UnionID:  wechatUser.UnionID,
		Nickname: wechatUser.Nickname,
		Avatar:   wechatUser.Avatar,
	}

	// 使用微信仓库创建绑定关系
	if err := s.wechatRepo.Create(ctx, binding); err != nil {
		return nil, errcode.ErrBiz(errcode.ErrWechatUserCreateFailed, "创建微信绑定关系失败")
	}

	return user, nil
}

// generateUsername 生成用户名
func (s *wechatService) generateUsername() string {
	return fmt.Sprintf("微信用户_%s", generateRandomString(8))
}

// generateRandomString 生成随机字符串
func generateRandomString(length int) string {
	const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
	b := make([]byte, length)
	for i := range b {
		randomBytes := make([]byte, 1)
		rand.Read(randomBytes)
		b[i] = charset[randomBytes[0]%byte(len(charset))]
	}
	return string(b)
}

// httpGet HTTP GET请求
func (s *wechatService) httpGet(ctx context.Context, url string) ([]byte, error) {
	client := &http.Client{
		Timeout: 10 * time.Second,
	}

	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
	if err != nil {
		return nil, err
	}

	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	return io.ReadAll(resp.Body)
}

// httpPost HTTP POST请求
func (s *wechatService) httpPost(ctx context.Context, url string, body []byte) ([]byte, error) {
	client := &http.Client{
		Timeout: 10 * time.Second,
	}

	req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(body)))
	if err != nil {
		return nil, err
	}

	req.Header.Set("Content-Type", "application/json")

	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	return io.ReadAll(resp.Body)
}

model 层

// internal\modules\wechat\model\session_model.go

package model

import (
	"time"

	"gorm.io/gorm"
)

// Session 登录会话模型
type Session struct {
	ID         string         `gorm:"primaryKey;type:varchar(100)" json:"id"`
	SceneCode  string         `gorm:"type:varchar(100);uniqueIndex;not null" json:"scene_code"`
	Status     string         `gorm:"type:varchar(20);default:'pending'" json:"status"` // pending, success, expired
	UserID     uint64         `gorm:"index" json:"user_id"`
	Token      string         `gorm:"type:varchar(512)" json:"token"`
	ExpireTime time.Time      `gorm:"index;not null" json:"expire_time"`
	CreatedAt  time.Time      `json:"created_at"`
	UpdatedAt  time.Time      `json:"updated_at"`
	DeletedAt  gorm.DeletedAt `gorm:"index" json:"-"`
}

// TableName 指定表名
func (Session) TableName() string {
	return "wechat_qrcode_session"
}

// IsExpired 检查会话是否过期
func (s *Session) IsExpired() bool {
	return time.Now().After(s.ExpireTime)
}

// MarkExpired 标记会话为过期状态
func (s *Session) MarkExpired() {
	s.Status = "expired"
	s.UpdatedAt = time.Now()
}

// MarkSuccess 标记会话为成功状态
func (s *Session) MarkSuccess(userID uint64, token string) {
	s.Status = "success"
	s.UserID = userID
	s.Token = token
	s.UpdatedAt = time.Now()
}

// internal\modules\wechat\model\wechat_model.go

package model

import (
	"time"

	"gorm.io/gorm"
)

// WechatBinding 微信绑定模型
type WechatBinding struct {
	ID        uint64         `gorm:"primaryKey" json:"id"`
	UserID    uint64         `gorm:"index;not null" json:"user_id"`
	OpenID    string         `gorm:"type:varchar(100);uniqueIndex;not null" json:"open_id"`
	UnionID   string         `gorm:"type:varchar(100);index" json:"union_id"`
	Nickname  string         `gorm:"type:varchar(100)" json:"nickname"`
	Avatar    string         `gorm:"type:varchar(500)" json:"avatar"`
	CreatedAt time.Time      `json:"created_at"`
	UpdatedAt time.Time      `json:"updated_at"`
	DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}

// TableName 指定表名
func (WechatBinding) TableName() string {
	return "wechat_bindings"
}

// WechatEvent 微信事件模型
type WechatEvent struct {
	ToUserName   string `xml:"ToUserName" json:"to_user_name"`
	FromUserName string `xml:"FromUserName" json:"from_user_name"`
	CreateTime   int64  `xml:"CreateTime" json:"create_time"`
	MsgType      string `xml:"MsgType" json:"msg_type"`
	Event        string `xml:"Event" json:"event"`
	EventKey     string `xml:"EventKey" json:"event_key"`
	Ticket       string `xml:"Ticket" json:"ticket"`
}

// WechatUserInfo 微信用户信息模型
type WechatUserInfo struct {
	OpenID   string `json:"openid"`
	UnionID  string `json:"unionid"`
	Nickname string `json:"nickname"`
	Avatar   string `json:"headimgurl"`
}

// WechatAccessTokenResponse 微信access_token响应
type WechatAccessTokenResponse struct {
	AccessToken string `json:"access_token"`
	ExpiresIn   int    `json:"expires_in"`
	ErrCode     int    `json:"errcode"`
	ErrMsg      string `json:"errmsg"`
}

// WechatQRCodeResponse 微信二维码响应
type WechatQRCodeResponse struct {
	Ticket        string `json:"ticket"`
	ExpireSeconds int    `json:"expire_seconds"`
	URL           string `json:"url"`
	ErrCode       int    `json:"errcode"`
	ErrMsg        string `json:"errmsg"`
}

repository 层

// internal\modules\wechat\repository\session_repository.go

package repository

import (
	"context"
	"time"

	"go-api/internal/modules/wechat/model"
	"go-api/pkg/errcode"

	"gorm.io/gorm"
)

// SessionRepository 会话仓库接口
type SessionRepository interface {
	Create(ctx context.Context, session *model.Session) error
	GetByID(ctx context.Context, sessionID string) (*model.Session, error)
	GetBySceneCode(ctx context.Context, sceneCode string) (*model.Session, error)
	Update(ctx context.Context, session *model.Session) error
	DeleteExpired(ctx context.Context) error
}

// sessionRepository 会话仓库实现
type sessionRepository struct {
	db *gorm.DB
}

// NewSessionRepository 创建会话仓库
func NewSessionRepository(db *gorm.DB) SessionRepository {
	return &sessionRepository{
		db: db,
	}
}

// Create 创建会话
func (r *sessionRepository) Create(ctx context.Context, session *model.Session) error {
	return r.db.WithContext(ctx).Create(session).Error
}

// GetByID 根据ID获取会话
func (r *sessionRepository) GetByID(ctx context.Context, sessionID string) (*model.Session, error) {
	var session model.Session
	err := r.db.WithContext(ctx).Where("id = ?", sessionID).First(&session).Error
	if err != nil {
		if err == gorm.ErrRecordNotFound {
			return nil, errcode.NewError(errcode.ErrDbNotFound, "记录不存在")
		}
		return nil, errcode.ErrDb(err)
	}
	return &session, nil
}

// GetBySceneCode 根据场景值获取会话
func (r *sessionRepository) GetBySceneCode(ctx context.Context, sceneCode string) (*model.Session, error) {
	var session model.Session
	err := r.db.WithContext(ctx).Where("scene_code = ?", sceneCode).First(&session).Error
	if err != nil {
		if err == gorm.ErrRecordNotFound {
			return nil, errcode.NewError(errcode.ErrDbNotFound, "记录不存在")
		}
		return nil, errcode.ErrDb(err)
	}
	return &session, nil
}

// Update 更新会话
func (r *sessionRepository) Update(ctx context.Context, session *model.Session) error {
	session.UpdatedAt = time.Now()
	return r.db.WithContext(ctx).Save(session).Error
}

// DeleteExpired 删除过期会话
func (r *sessionRepository) DeleteExpired(ctx context.Context) error {
	return r.db.WithContext(ctx).Where("expire_time < ?", time.Now()).Delete(&model.Session{}).Error
}


// internal\modules\wechat\repository\wechat_repository.go

package repository

import (
	"context"
	"time"

	"go-api/internal/modules/wechat/model"
	"go-api/pkg/errcode"

	"gorm.io/gorm"
)

// WechatRepository 微信仓库接口
type WechatRepository interface {
	Create(ctx context.Context, binding *model.WechatBinding) error
	GetByOpenID(ctx context.Context, openID string) (*model.WechatBinding, error)
	GetByUserID(ctx context.Context, userID uint64) (*model.WechatBinding, error)
	Update(ctx context.Context, binding *model.WechatBinding) error
}

// wechatRepository 微信仓库实现
type wechatRepository struct {
	db *gorm.DB
}

// NewWechatRepository 创建微信仓库
func NewWechatRepository(db *gorm.DB) WechatRepository {
	return &wechatRepository{
		db: db,
	}
}

// Create 创建微信绑定
func (r *wechatRepository) Create(ctx context.Context, binding *model.WechatBinding) error {
	return r.db.WithContext(ctx).Create(binding).Error
}

// GetByOpenID 根据OpenID获取绑定
func (r *wechatRepository) GetByOpenID(ctx context.Context, openID string) (*model.WechatBinding, error) {
	var binding model.WechatBinding
	err := r.db.WithContext(ctx).Where("open_id = ?", openID).First(&binding).Error
	if err != nil {
		if err == gorm.ErrRecordNotFound {
			return nil, errcode.NewError(errcode.ErrDbNotFound, "记录不存在")
		}
		return nil, errcode.ErrDb(err)
	}
	return &binding, nil
}

// GetByUserID 根据用户ID获取绑定
func (r *wechatRepository) GetByUserID(ctx context.Context, userID uint64) (*model.WechatBinding, error) {
	var binding model.WechatBinding
	err := r.db.WithContext(ctx).Where("user_id = ?", userID).First(&binding).Error
	if err != nil {
		if err == gorm.ErrRecordNotFound {
			return nil, errcode.NewError(errcode.ErrDbNotFound, "记录不存在")
		}
		return nil, errcode.ErrDb(err)
	}
	return &binding, nil
}

// Update 更新微信绑定
func (r *wechatRepository) Update(ctx context.Context, binding *model.WechatBinding) error {
	binding.UpdatedAt = time.Now()
	return r.db.WithContext(ctx).Save(binding).Error
}


依赖注入

// internal\modules\wechat\provider.go

package wechat

import (
	"github.com/google/wire"

	v1 "go-api/api/wechat/v1"
	"go-api/internal/modules/wechat/repository"
	"go-api/internal/modules/wechat/service"
)

// ProviderSet 微信模块依赖注入集合
var ProviderSet = wire.NewSet(
	// 仓库层
	repository.NewWechatRepository,
	repository.NewSessionRepository,
	// 服务层
	service.NewWechatService,
	// Module
	NewModule,
)

// Module 微信模块
type Module struct {
	WechatService v1.WechatService
}

// NewModule 创建微信模块
func NewModule(wechatService v1.WechatService) *Module {
	return &Module{
		WechatService: wechatService,
	}
}


路由的

// api\wechat\v1\wechat_handler.go
package v1

import (
	"io"
	"net/http"

	"go-api/config"
	"go-api/pkg/errcode"
	"go-api/pkg/respx"

	"github.com/gin-gonic/gin"
)

// WechatHandler 微信处理器
type WechatHandler struct {
	wechatService WechatService
	config        *config.Config
}

// NewWechatHandler 创建微信处理器
func NewWechatHandler(wechatService WechatService, cfg *config.Config) *WechatHandler {
	return &WechatHandler{
		wechatService: wechatService,
		config:        cfg,
	}
}

// CreateQrLogin 创建微信扫码登录
func (h *WechatHandler) CreateQrLogin(c *gin.Context) {
	resp, err := h.wechatService.CreateQrLogin(c)
	if err != nil {
		if e, ok := err.(*errcode.Error); ok {
			respx.Error(c, e.Code, e.Message)
			return
		}
		respx.Error(c, errcode.ErrUnknown, "生成二维码失败")
		return
	}

	respx.Success(c, resp)
}

// CheckLoginStatus 检查登录状态
func (h *WechatHandler) CheckLoginStatus(c *gin.Context) {
	evidence := c.Query("evidence")
	if evidence == "" {
		respx.Error(c, errcode.ErrInvalidParam, "参数错误")
		return
	}

	resp, err := h.wechatService.CheckLoginStatus(c, evidence)
	if err != nil {
		if e, ok := err.(*errcode.Error); ok {
			respx.Error(c, e.Code, e.Message)
			return
		}
		respx.Error(c, errcode.ErrUnknown, "查询登录状态失败")
		return
	}

	respx.Success(c, resp)
}

// VerifyWechatServer 验证微信服务器(GET请求)
func (h *WechatHandler) VerifyWechatServer(c *gin.Context) {
	echostr := c.Query("echostr")

	// 直接返回 echostr,不做签名验证
	if echostr != "" {
		c.String(http.StatusOK, echostr)
		return
	}

	// 如果没有 echostr,直接返回 success
	c.String(http.StatusOK, "success")
}

// HandleWechatEvent 处理微信事件(POST请求)
func (h *WechatHandler) HandleWechatEvent(c *gin.Context) {
	eventData, err := io.ReadAll(c.Request.Body)
	if err != nil {
		c.String(http.StatusBadRequest, "Invalid request")
		return
	}

	err = h.wechatService.HandleWechatEvent(c, eventData)
	if err != nil {
		c.String(http.StatusInternalServerError, "Failed to handle event")
		return
	}

	// 微信要求返回success
	c.String(http.StatusOK, "success")
}

// api\wechat\v1\wechat.go

package v1

import (
	"context"

	"go-api/config"

	"github.com/gin-gonic/gin"
)

// WechatQrLoginReq 微信扫码登录请求
type WechatQrLoginReq struct{}

// WechatQrLoginResp 微信扫码登录响应
type WechatQrLoginResp struct {
	QrCodeUrl string `json:"qrCodeUrl"`
	Evidence  string `json:"evidence"`
}

// WechatLoginStatusResp 微信登录状态响应
type WechatLoginStatusResp struct {
	Status      string    `json:"status"`
	AccessToken string    `json:"access_token,omitempty"`
	User        *UserInfo `json:"user,omitempty"`
}

// UserInfo 用户信息
type UserInfo struct {
	ID        string `json:"id"`
	Username  string `json:"username"`
	Nickname  string `json:"nickname"`
	Avatar    string `json:"avatar"`
	LoginType string `json:"login_type"`
}

// WechatService 微信服务接口
type WechatService interface {
	CreateQrLogin(ctx context.Context) (*WechatQrLoginResp, error)
	CheckLoginStatus(ctx context.Context, evidence string) (*WechatLoginStatusResp, error)
	HandleWechatEvent(ctx context.Context, eventData []byte) error
}

// RegisterWechatRoutes 注册微信路由
func RegisterWechatRoutes(v1 *gin.RouterGroup, s WechatService, cfg *config.Config) {
	h := NewWechatHandler(s, cfg)
	wechatGroup := v1.Group("/wechat")
	{
		wechatGroup.POST("/qr-login", h.CreateQrLogin)
		wechatGroup.GET("/login-status", h.CheckLoginStatus)
		// 微信服务器验证(GET)和事件推送(POST)共用同一个路径
		wechatGroup.GET("/event", h.VerifyWechatServer)
		wechatGroup.POST("/event", h.HandleWechatEvent)
	}
}



相关链接