微信扫码注册登录-基于网站应用

微信扫码注册登录-网站应用授权登录

前言

  • 不想看介绍直接跳转目录看"代码实现"

  • 本项目实现了微信扫码登录注册功能,用户可以通过扫描二维码,完成登录或注册操作。

  • 本项目使用了网站应用授权登录

  • 里面有两个接口都是可以使用的,一个是生成授权链接(完整页面跳转),一个是预授权接口(内嵌二维码)

  • 如果你喜欢管理消息推送,可以去看我另一文章"实现微信扫码注册登录-基于参数二维码"

功能特性

  1. 微信扫码登录注册一体化

  2. 无需消息推送

  • 基于OAuth2.0协议,不依赖微信消息推送
  • 不与公众号关键词回复冲突
  1. 开放平台集成
  • 使用微信开放平台网站应用
  • 支持企业级应用审核流程
  1. 复用现有接口
  • 复用 /login-status 查询登录状态
  • 统一的用户体系和会话管理
  1. 与消息推送版对比
特性 网站应用版 消息推送版
依赖 开放平台网站应用 公众号消息推送
跳转 可选跳转/内嵌 不跳转
冲突 无冲突 与关键词回复冲突
审核 需企业资质审核 个人/企业均可

相关链接

准备

申请AppID、AppSecret

1. 处理公众平台账号

微信开放平台:open.weixin.qq.com

  • 登录开放平台账号(需要企业账号,需要审核1-7个工作日)
  • 进入"网站应用"管理页面
  • 点击"创建网站应用"
  • 填写应用名称、网站域名等信息
  • 提交审核(大概1-7个工作日)
  • 审核通过后,即可获取AppID和AppSecret

2. 绑定网站应用

微信开发者平台:developers.weixin.qq.com/platform?ai...

  • 登录开发者平台账号
  • 首页"我的业务"有一个"网站应用"选项
  • 进入"网站应用"管理页面
  • 点击"绑定网站应用"
  • 填写AppID、AppSecret等信息
  • 提交绑定
  • 绑定成功后,即可使用AppID和AppSecret调用微信接口

在config定义全局配置

yaml 复制代码
// config.yaml
external:
  wechat_oauth:
    app_id: "your_app_id"                              # 微信开放平台网站应用AppID
    app_secret: "your_app_secret"                      # 微信开放平台网站应用AppSecret
    redirect_uri: "https://your-domain.com/api/v1/wechat/oauth/callback"  # OAuth回调地址
    scope: "snsapi_login"                              # 网站应用固定使用 snsapi_login
    base_url: "https://api.weixin.qq.com"             # 微信API基础地址
配置项 说明
app_id 微信开放平台网站应用的AppID
app_secret 微信开放平台网站应用的AppSecret
redirect_uri 授权成功后微信回调的地址,需与微信开放平台配置一致
scope 网站应用固定使用 snsapi_login
base_url 微信API基础地址,一般使用默认值 https://api.weixin.qq.com

配置数据库

  • 主要在用户表中添加一个login_type字段,用于区分用户是通过账号密码登录还是微信登录。
  • 微信绑定表中添加user_id字段,用于关联用户表。
  • 微信扫码会话表,用于存储用户扫码登录的会话信息。
sql 复制代码
-- 用户表
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='用户表';
  • 微信绑定表
sql 复制代码
-- 创建微信绑定表
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='微信绑定表';
  • 创建微信扫码登录会话表
sql 复制代码
-- 创建微信扫码登录会话表
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='微信扫码登录会话表';

接口设计

接口 方法 说明
/api/v1/wechat/oauth/auth GET 生成微信OAuth授权链接(完整页面跳转)
/api/v1/wechat/oauth/callback GET 微信OAuth回调处理(微信调用)
/api/v1/wechat/oauth/preauth POST 获取微信授权参数(内嵌二维码)
/api/v1/wechat/login-status GET 查询登录状态(复用现有接口)

架构概述

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

bash 复制代码
┌──────────────────────────────────────────────────────────────────┐
│                          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         │
└──────────────────────────────────────────────────────────────────┘

目录结构树

bash 复制代码
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. 生成授权链接(完整页面跳转)

适用于需要完整页面跳转的场景,后端生成授权链接后直接302跳转至微信授权页面。

使用场景:

  • 用户点击"微信登录"按钮后,页面完全跳转到微信授权页
  • 类似一号店的实现方式:用户点击登录 → 跳转到微信域 → 扫码授权 → 跳转回原网站

请求

bash 复制代码
GET /api/v1/wechat/oauth/auth?redirect_url={登录成功后跳转地址}

请求参数

参数 类型 必填 说明
redirect_url string 登录成功后跳转的页面地址

响应

  • 302 跳转至微信授权页面

流程说明

  1. 用户点击"微信登录"按钮
  2. 前端调用此接口
  3. 后端生成授权链接并302跳转
  4. 用户在微信中完成扫码授权
  5. 微信自动跳转回回调地址

2. 授权回调

注意:此接口由微信服务器调用,前端无需直接调用

请求

bash 复制代码
GET /api/v1/wechat/oauth/callback?code={授权码}&state={状态码}&redirect_url={前端跳转地址}

请求参数

参数 类型 必填 说明
code string 微信返回的授权码
state string 会话状态码,用于校验
redirect_url string 前端传入的跳转地址(从查询参数中获取)

响应

  • 成功 :302 跳转至 redirect_url?evidence={会话ID}
  • 失败 :302 跳转至 redirect_url?error={错误信息}

错误码

错误 说明
invalid_state 会话不存在或已过期
access_denied 用户拒绝授权
token_failed 获取授权信息失败
userinfo_failed 获取用户信息失败

3. 预授权接口(内嵌二维码)

适用于需要在网站内完成登录的场景,无需跳转到微信域,提升登录的流畅性与成功率。

使用场景:

  • 网站希望用户在网站内就能完成登录,无需跳转到微信域下登录后再返回
  • 将微信登录二维码内嵌到自己页面中,用户使用微信扫码授权后通过JS将code返回给网站
  • 类似微信开放平台提供的JS内嵌二维码登录方式

请求

bash 复制代码
POST /api/v1/wechat/oauth/preauth
Content-Type: application/json

请求体

json 复制代码
{
  "redirect_url": "https://your_redirect_url.com"
}

请求参数

参数 类型 必填 说明
redirect_url string 登录成功后跳转的页面地址

响应示例

json 复制代码
{
  "code": "success",
  "message": "success",
  "data": {
    "app_id": "your_app_id",
    "state": "sess_19b18bd6f04dded5a1ab7032da887a4e",
    "redirect_uri": "https://your_redirect_url.com/api/v1/wechat/oauth/callback?redirect_url=https%3AFbaidu.com",
    "scope": "snsapi_login",
    "auth_url": "https://open.weixin.qq.com/connect/qrconnect?appid=your_app_id&redirect_uri=...&response_type=code&scope=snsapi_login&state=sess_19b18bd6f04dded5a1ab7032da887a4e#wechat_redirect"
  }
}

响应字段说明

字段 类型 说明
app_id string 微信应用ID
state string 会话状态码,用于校验和跟踪登录状态
redirect_uri string 微信回调地址(已包含redirect_url参数)
scope string 授权作用域(snsapi_login 或 snsapi_userinfo)
auth_url string 完整的微信授权URL,可直接打开或提取参数使用

使用方式

前端获取参数后,可以使用微信JS内嵌二维码方式:

方式一:使用微信官方JS-SDK内嵌二维码

javascript 复制代码
// 引入微信JS-SDK
// https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js

var obj = new WxLogin({
  self_redirect: false,  // true:在当前页面跳转;false:在新页面跳转
  id: "login_container", // 二维码容器ID
  appid: response.data.app_id,
  scope: response.data.scope,
  redirect_uri: response.data.redirect_uri,
  state: response.data.state,
  style: "black",        // 二维码样式:black/white
  href: ""               // 自定义CSS链接
});

方式二:直接跳转 auth_url(完整页面跳转)

javascript 复制代码
window.location.href = response.data.auth_url;

完整登录流程

方式一:完整页面跳转(使用 /oauth/auth)

ini 复制代码
┌─────────┐     ┌─────────┐     ┌─────────┐     ┌─────────┐
│  前端   │     │  后端   │     │  微信   │     │  用户   │
└────┬────┘     └────┬────┘     └────┬────┘     └────┬────┘
     │               │               │               │
     │ 点击微信登录   │               │               │
     │──────────────>│               │               │
     │               │               │               │
     │ 302 跳转      │               │               │
     │<──────────────│               │               │
     │               │               │               │
     │ 跳转微信授权页  │               │               │
     │──────────────────────────────>│               │
     │               │               │               │
     │               │               │ 用户扫码授权   │
     │               │               │<──────────────│
     │               │               │               │
     │               │ 回调 /oauth/callback           │
     │               │<──────────────────────────────│
     │               │               │               │
     │ 302 redirect_url?evidence=xxx │               │
     │<──────────────────────────────│               │
     │               │               │               │

方式二:内嵌二维码(使用 /oauth/preauth)

bash 复制代码
┌─────────┐     ┌─────────┐     ┌─────────┐     ┌─────────┐
│  前端   │     │  后端   │     │  微信   │     │  用户   │
└────┬────┘     └────┬────┘     └────┬────┘     └────┬────┘
     │               │               │               │
     │ POST /preauth │               │               │
     │──────────────>│               │               │
     │               │               │               │
     │ return params │               │               │
     │<──────────────│               │               │
     │               │               │               │
     │ 内嵌微信二维码  │               │               │
     │(WxLogin JS)  │               │               │
     │               │               │               │
     │               │               │ 用户扫码授权   │
     │               │               │<──────────────│
     │               │               │               │
     │               │ 回调 /oauth/callback           │
     │               │<──────────────────────────────│
     │               │               │               │
     │ 302 redirect_url?evidence=xxx │               │
     │<──────────────────────────────│               │
     │               │               │               │

4. 查询登录状态

请求

bash 复制代码
GET /api/v1/wechat/login-status?evidence={会话ID}

请求参数

参数 类型 必填 说明
evidence string 回调返回的会话ID

响应示例

json 复制代码
{
  "code": "success",
  "message": "success",
  "data": {
    "status": "success",
    "access_token": "eyJhbGciOiJIUzI1NiIs...",
    "user": {
      "id": "1",
      "username": "微信用户_a1b2c3d4",
      "nickname": "张三",
      "avatar": "https://example.com/avatar.jpg",
      "login_type": "wechat"
    }
  }
}

状态说明

状态 说明
pending 等待用户授权
success 登录成功,返回token
expired 会话已过期

使用流程

  1. 用户完成微信授权后,微信会回调到 redirect_url?evidence=xxx
  2. 前端从URL中获取 evidence 参数
  3. 前端调用 /login-status?evidence=xxx 查询登录结果
  4. 如果 statussuccess,获取 access_token 完成登录

代码实现

config 配置

go 复制代码
// 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
}

服务层

go 复制代码
// 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)
}
go 复制代码
// 如果有缺少函数,建议看一下我上一篇文档微信扫码登录-消息推送版本,大概是我漏写了一些函数
// 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),
	}
}


// OAuthAuth 生成OAuth授权链接
func (s *wechatService) OAuthAuth(ctx context.Context, redirectURL string) (*v1.WechatOAuthResult, error) {
	// 创建登录会话
	session, err := s.sessionManager.CreateSession(ctx)
	if err != nil {
		logx.G(ctx).WithError(err).Error("创建OAuth登录会话失败")
		return nil, errcode.ErrBiz(errcode.ErrWechatQrCreateFailed, "创建登录会话失败")
	}
	logx.G(ctx).WithField("session_id", session.ID).Info("OAuth登录会话创建成功")

	// 使用session.ID作为state
	state := session.ID

	// 构建授权URL
	scope := s.config.External.WechatOAuth.Scope
	if scope == "" {
		scope = "snsapi_userinfo"
	}

	// 构建回调URL,将前端redirect_url作为查询参数
	callbackURL := s.config.External.WechatOAuth.RedirectURI
	if callbackURL == "" {
		callbackURL = fmt.Sprintf("%s/api/v1/wechat/oauth/callback", s.config.Server.Host)
	}

	// 如果前端传了redirect_url,将其编码后附加到callbackURL
	if redirectURL != "" {
		callbackURI, err := url.Parse(callbackURL)
		if err != nil {
			logx.G(ctx).WithError(err).Error("解析回调URL失败")
			return nil, errcode.ErrBiz(errcode.ErrWechatQrCreateFailed, "配置错误")
		}
		query := callbackURI.Query()
		query.Set("redirect_url", redirectURL)
		callbackURI.RawQuery = query.Encode()
		callbackURL = callbackURI.String()
	}

	// 网站应用使用 qrconnect,公众号使用 oauth2/authorize
	authURL := fmt.Sprintf(
		"https://open.weixin.qq.com/connect/qrconnect?appid=%s&redirect_uri=%s&response_type=code&scope=%s&state=%s#wechat_redirect",
		s.config.External.WechatOAuth.AppID,
		url.QueryEscape(callbackURL),
		scope,
		state,
	)

	logx.G(ctx).WithField("auth_url", authURL).Info("生成OAuth授权链接")
	return &v1.WechatOAuthResult{
		AppID:       s.config.External.WechatOAuth.AppID,
		State:       state,
		RedirectURI: callbackURL,
		Scope:       scope,
		AuthURL:     authURL,
	}, nil
}

// OAuthCallback 处理OAuth回调
func (s *wechatService) OAuthCallback(ctx context.Context, code, state, redirectURL string) (string, error) {
	// 验证state(即sessionID)
	session, err := s.sessionManager.GetSession(ctx, state)
	if err != nil {
		logx.G(ctx).WithError(err).WithField("state", state).Error("无效的state")
		return "", errcode.ErrBiz(errcode.ErrWechatSessionNotFound, "登录会话不存在或已过期")
	}

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

	// 用code换取access_token
	oauthToken, err := s.getOAuthAccessToken(ctx, code)
	if err != nil {
		logx.G(ctx).WithError(err).Error("获取OAuth access_token失败")
		return "", errcode.ErrBiz(errcode.ErrWechatApiError, "获取授权信息失败")
	}
	logx.G(ctx).WithField("openid", oauthToken.OpenID).Info("获取OAuth access_token成功")

	// 用access_token获取用户信息
	wechatUser, err := s.getOAuthUserInfo(ctx, oauthToken.AccessToken, oauthToken.OpenID)
	if err != nil {
		logx.G(ctx).WithError(err).Error("获取OAuth用户信息失败")
		return "", errcode.ErrBiz(errcode.ErrWechatApiError, "获取用户信息失败")
	}
	logx.G(ctx).WithField("nickname", wechatUser.Nickname).Info("获取OAuth用户信息成功")

	// 处理用户登录(复用现有逻辑)
	if err := s.handleWechatUserLogin(ctx, session, wechatUser); err != nil {
		logx.G(ctx).WithError(err).Error("处理OAuth用户登录失败")
		return "", err
	}

	logx.G(ctx).WithField("session_id", session.ID).Info("OAuth登录成功")

	// 构建跳转URL(带evidence)
	// 优先使用前端传来的redirect_url,否则使用配置中的RedirectURI
	targetURL := redirectURL
	if targetURL == "" {
		targetURL = s.config.External.WechatOAuth.RedirectURI
	}

	// 使用url parse处理跳转URL,添加evidence参数
	targetURI, err := url.Parse(targetURL)
	if err != nil {
		logx.G(ctx).WithError(err).Error("解析跳转URL失败")
		return "", errcode.ErrBiz(errcode.ErrWechatQrCreateFailed, "配置错误")
	}
	query := targetURI.Query()
	query.Set("evidence", session.ID)
	targetURI.RawQuery = query.Encode()

	return targetURI.String(), nil
}

// getOAuthAccessToken 用code换取OAuth access_token
func (s *wechatService) getOAuthAccessToken(ctx context.Context, code string) (*model.OAuthAccessTokenResponse, error) {
	baseURL := s.config.External.WechatOAuth.BaseURL
	if baseURL == "" {
		baseURL = "https://api.weixin.qq.com"
	}

	url := fmt.Sprintf(
		"%s/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code",
		baseURL,
		s.config.External.WechatOAuth.AppID,
		s.config.External.WechatOAuth.AppSecret,
		code,
	)

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

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

	if tokenResp.ErrCode != 0 {
		return nil, fmt.Errorf("wechat oauth error: %d - %s", tokenResp.ErrCode, tokenResp.ErrMsg)
	}

	return &tokenResp, nil
}

// getOAuthUserInfo 用OAuth access_token获取用户信息
func (s *wechatService) getOAuthUserInfo(ctx context.Context, accessToken, openID string) (*model.WechatUserInfo, error) {
	baseURL := s.config.External.WechatOAuth.BaseURL
	if baseURL == "" {
		baseURL = "https://api.weixin.qq.com"
	}

	url := fmt.Sprintf(
		"%s/sns/userinfo?access_token=%s&openid=%s&lang=zh_CN",
		baseURL,
		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
}

model 层

go 复制代码
// 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()
}
go 复制代码
// 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"`
}

// OAuthAccessTokenResponse OAuth access_token响应
type OAuthAccessTokenResponse struct {
	AccessToken  string `json:"access_token"`
	ExpiresIn    int    `json:"expires_in"`
	RefreshToken string `json:"refresh_token"`
	OpenID       string `json:"openid"`
	Scope        string `json:"scope"`
	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 层

go 复制代码
// 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
}
go 复制代码
// 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
}

依赖注入

go 复制代码
// 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,
	}
}

路由的

go 复制代码
// api\wechat\v1\wechat_handler.go

package v1

import (
	"io"
	"net/http"

	"go-api/config"
	"go-api/pkg/errcode"
	"go-api/pkg/logx"
	"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,
	}
}


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


// OAuthAuth 微信OAuth授权入口
func (h *WechatHandler) OAuthAuth(c *gin.Context) {
	redirectURL := c.Query("redirect_url")

	result, err := h.wechatService.OAuthAuth(c, redirectURL)
	if err != nil {
		logx.G(c).WithError(err).Error("生成OAuth授权链接失败")
		c.String(http.StatusInternalServerError, "生成授权链接失败")
		return
	}

	c.Redirect(http.StatusFound, result.AuthURL)
}

// OAuthCallback 微信OAuth回调处理
func (h *WechatHandler) OAuthCallback(c *gin.Context) {
	code := c.Query("code")
	state := c.Query("state")
	redirectURL := c.Query("redirect_url")

	redirectTarget, err := h.wechatService.OAuthCallback(c, code, state, redirectURL)
	if err != nil {
		logx.G(c).WithError(err).Error("处理OAuth回调失败")
		// 失败时跳转回前端,带错误参数
		fallbackURL := redirectURL
		if fallbackURL == "" {
			fallbackURL = h.config.External.WechatOAuth.RedirectURI
		}
		c.Redirect(http.StatusFound, fallbackURL+"?error="+err.Error())
		return
	}

	c.Redirect(http.StatusFound, redirectTarget)
}

// PreAuth 微信预授权(获取wx.login参数)
func (h *WechatHandler) PreAuth(c *gin.Context) {
	var req WechatPreAuthReq
	if err := c.ShouldBindJSON(&req); err != nil {
		logx.G(c).WithError(err).Error("预授权参数绑定失败")
		respx.Error(c, errcode.ErrInvalidParam, "参数错误: "+err.Error())
		return
	}

	resp, err := h.wechatService.OAuthAuth(c, req.RedirectURL)
	if err != nil {
		logx.G(c).WithError(err).Error("获取微信预授权参数失败")
		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)
}
go 复制代码
// 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"`
}

// WechatPreAuthReq 微信预授权请求
type WechatPreAuthReq struct {
	RedirectURL string `json:"redirect_url"`
}

// WechatOAuthResult OAuth授权结果
type WechatOAuthResult struct {
	AppID       string `json:"app_id"`
	State       string `json:"state"`
	RedirectURI string `json:"redirect_uri"`
	Scope       string `json:"scope"`
	AuthURL     string `json:"auth_url"`
}

// 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 {
	HandleWechatEvent(ctx context.Context, eventData []byte) error
	// OAuthAuth 生成OAuth授权链接
	OAuthAuth(ctx context.Context, redirectURL string) (*WechatOAuthResult, error)
	// OAuthCallback 处理OAuth回调
	OAuthCallback(ctx context.Context, code, state, redirectURL string) (string, error)
}

// RegisterWechatRoutes 注册微信路由
func RegisterWechatRoutes(v1 *gin.RouterGroup, s WechatService, cfg *config.Config) {
	h := NewWechatHandler(s, cfg)
	wechatGroup := v1.Group("/wechat")
	{
		// OAuth网页授权登录
		wechatGroup.GET("/oauth/auth", h.OAuthAuth)
		wechatGroup.GET("/oauth/callback", h.OAuthCallback)
		// 微信预授权(获取wx.login参数)
		wechatGroup.POST("/oauth/preauth", h.PreAuth)
	}
}

相关链接

相关推荐
王码码20358 小时前
Go语言的测试:从单元测试到集成测试
后端·golang·go·接口
王码码20358 小时前
Go语言中的测试:从单元测试到集成测试
后端·golang·go·接口
嵌入式×边缘AI:打怪升级日志9 小时前
使用JsonRPC实现前后台
前端·后端
小码哥_常10 小时前
从0到1:Spring Boot 中WebSocket实战揭秘,开启实时通信新时代
后端
lolo大魔王10 小时前
Go语言的异常处理
开发语言·后端·golang
IT_陈寒12 小时前
Python多进程共享变量那个坑,我差点没爬出来
前端·人工智能·后端
码事漫谈12 小时前
2026软考高级·系统架构设计师备考指南
后端
AI茶水间管理员13 小时前
如何让LLM稳定输出 JSON 格式结果?
前端·人工智能·后端
其实是白羊14 小时前
我用 Vibe Coding 搓了一个 IDEA 插件,复制URI 再也不用手动拼了
后端·intellij idea
用户83562907805114 小时前
Python 操作 Word 文档节与页面设置
后端·python