微信扫码注册登录-网站应用授权登录
前言
-
不想看介绍直接跳转目录看
代码实现 -
本项目实现了微信扫码登录注册功能,用户可以通过扫描二维码,完成登录或注册操作。
-
本项目使用了
网站应用授权登录。 -
里面有两个接口都是可以使用的,一个是
生成授权链接(完整页面跳转),一个是预授权接口(内嵌二维码)。 -
如果你喜欢管理
消息推送,可以去看我另一文章“实现微信扫码注册登录-基于参数二维码”
功能特性
-
微信扫码登录注册一体化
-
无需消息推送
- 基于OAuth2.0协议,不依赖微信消息推送
- 不与公众号关键词回复冲突
- 开放平台集成
- 使用微信开放平台网站应用
- 支持企业级应用审核流程
- 复用现有接口
- 复用
/login-status查询登录状态 - 统一的用户体系和会话管理
- 与消息推送版对比
| 特性 | 网站应用版 | 消息推送版 |
|---|---|---|
| 依赖 | 开放平台网站应用 | 公众号消息推送 |
| 跳转 | 可选跳转/内嵌 | 不跳转 |
| 冲突 | 无冲突 | 与关键词回复冲突 |
| 审核 | 需企业资质审核 | 个人/企业均可 |
相关链接
准备
- 微信公众平台账号
- 服务器环境(如:Linux、Windows)
- 数据库(如:MySQL、PostgreSQL)
- 语言:Golang
- 微信扫码登录注册相关配置(如:AppID、AppSecret、Token等)
- 微信开发者平台:developers.weixin.qq.com/platform?ai…
- 微信公众平台:mp.weixin.qq.com/
- 微信开放平台:open.weixin.qq.com
申请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定义全局配置
// 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字段,用于关联用户表。
- 微信扫码会话表,用于存储用户扫码登录的会话信息。
-- 用户表
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='微信扫码登录会话表';
接口设计
| 接口 | 方法 | 说明 |
|---|---|---|
/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 | 查询登录状态(复用现有接口) |
架构概述
本设计采用标准的分层架构,遵循项目的架构规范:
┌──────────────────────────────────────────────────────────────────┐
│ 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. 生成授权链接(完整页面跳转)
适用于需要完整页面跳转的场景,后端生成授权链接后直接302跳转至微信授权页面。
使用场景:
- 用户点击"微信登录"按钮后,页面完全跳转到微信授权页
- 类似一号店的实现方式:用户点击登录 → 跳转到微信域 → 扫码授权 → 跳转回原网站
请求
GET /api/v1/wechat/oauth/auth?redirect_url={登录成功后跳转地址}
请求参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
redirect_url | string | 否 | 登录成功后跳转的页面地址 |
响应
- 302 跳转至微信授权页面
流程说明
- 用户点击"微信登录"按钮
- 前端调用此接口
- 后端生成授权链接并302跳转
- 用户在微信中完成扫码授权
- 微信自动跳转回回调地址
2. 授权回调
注意:此接口由微信服务器调用,前端无需直接调用
请求
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内嵌二维码登录方式
请求
POST /api/v1/wechat/oauth/preauth
Content-Type: application/json
请求体
{
"redirect_url": "https://your_redirect_url.com"
}
请求参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
redirect_url | string | 否 | 登录成功后跳转的页面地址 |
响应示例
{
"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内嵌二维码
// 引入微信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(完整页面跳转)
window.location.href = response.data.auth_url;
完整登录流程
方式一:完整页面跳转(使用 /oauth/auth)
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ 前端 │ │ 后端 │ │ 微信 │ │ 用户 │
└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘
│ │ │ │
│ 点击微信登录 │ │ │
│──────────────>│ │ │
│ │ │ │
│ 302 跳转 │ │ │
│<──────────────│ │ │
│ │ │ │
│ 跳转微信授权页 │ │ │
│──────────────────────────────>│ │
│ │ │ │
│ │ │ 用户扫码授权 │
│ │ │<──────────────│
│ │ │ │
│ │ 回调 /oauth/callback │
│ │<──────────────────────────────│
│ │ │ │
│ 302 redirect_url?evidence=xxx │ │
│<──────────────────────────────│ │
│ │ │ │
方式二:内嵌二维码(使用 /oauth/preauth)
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ 前端 │ │ 后端 │ │ 微信 │ │ 用户 │
└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘
│ │ │ │
│ POST /preauth │ │ │
│──────────────>│ │ │
│ │ │ │
│ return params │ │ │
│<──────────────│ │ │
│ │ │ │
│ 内嵌微信二维码 │ │ │
│(WxLogin JS) │ │ │
│ │ │ │
│ │ │ 用户扫码授权 │
│ │ │<──────────────│
│ │ │ │
│ │ 回调 /oauth/callback │
│ │<──────────────────────────────│
│ │ │ │
│ 302 redirect_url?evidence=xxx │ │
│<──────────────────────────────│ │
│ │ │ │
4. 查询登录状态
请求
GET /api/v1/wechat/login-status?evidence={会话ID}
请求参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
evidence | string | 是 | 回调返回的会话ID |
响应示例
{
"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 | 会话已过期 |
使用流程
- 用户完成微信授权后,微信会回调到
redirect_url?evidence=xxx - 前端从URL中获取
evidence参数 - 前端调用
/login-status?evidence=xxx查询登录结果 - 如果
status为success,获取access_token完成登录
代码实现
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),
}
}
// 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 层
// 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"`
}
// 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 层
// 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/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)
}
// 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)
}
}
相关链接