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

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

前言

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

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

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

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

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

功能特性

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

相关链接

相关推荐
Assby2 小时前
Java速通Go基础内容
后端
心在飞扬2 小时前
LangGraph 基础知识
前端·后端
Java编程爱好者2 小时前
MyBatis-mybatis入门与增删改查
后端
神奇小汤圆2 小时前
并发编程进阶:volatile、内存屏障与 CPU 缓存机制详解
后端
神奇小汤圆2 小时前
Redis实现 IP 维度滑动窗口限流实践
后端
程序员清风3 小时前
小红书二面:Spring Boot的单例模式是如何实现的?
java·后端·面试
树獭叔叔3 小时前
19-为什么AI工程这么喜欢"创造名词":从Prompt到Skill的造词运动
后端·aigc·openai
天朝八阿哥3 小时前
使用Docker+vscode搭建离线的go开发调试环境
后端·docker·visual studio code
心在飞扬3 小时前
工具调用出错捕获提升程序健壮性
前端·后端