微信扫码注册登录
前言
- 本项目实现了微信扫码登录注册功能,用户可以通过扫描二维码,在微信公众平台上完成登录或注册操作。
- 如果你的服务号、公众号使用了
关键词回复,该项目使用到了消息推送会影响导致关键词回复冲突,需要在项目中post /api/v1/wechat/event配置关键词回复(我这里没有做这个功能,默认没有开启关键词回复)。 - 你可以看我另一个文档,使用
网站应用授权登录,这样就不用开启消息推送。
功能特性
- 微信扫码登录注册一体化
- 用户通过微信扫码即可完成登录或注册
- 支持新用户自动注册和老用户直接登录
- 无需输入用户名密码,提升用户体验
- 消息推送驱动的扫码事件处理
- 基于微信消息推送机制处理扫码事件
- 用户扫码关注后,系统自动完成登录流程
- 实时状态同步,支持轮询检查登录状态
- 会话状态管理
- 创建带参数的二维码,每个二维码有唯一会话ID
- 支持三种状态:pending(等待中)、success(成功)、expired(过期)
- 10分钟有效期,自动过期处理
- 用户身份统一
- 微信用户和账号密码用户统一管理
- 支持用户绑定微信账号
- 通过 login_type 字段区分登录方式(account/wechat)
- 架构分层设计
- 标准的三层架构: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,登录成功
准备
- 微信公众平台账号
- 服务器环境(如:Linux、Windows)
- 数据库(如:MySQL、PostgreSQL)
- 语言:Golang
- 微信扫码登录注册相关配置(如:AppID、AppSecret、Token等)
- 微信开发者平台:developers.weixin.qq.com/platform?ai…
- 微信公众平台:mp.weixin.qq.com/
- 微信开放平台:open.weixin.qq.com
申请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
请求参数:
| 参数名 | 类型 | 必填 | 描述 |
|---|---|---|---|
| evidence | string | 是 | 登录会话标识(从生成二维码接口获取) |
响应格式:
-
等待中:
{ "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)
}
}
相关链接