准备一个微信公众号订阅号, 没有的需要先申请一个或者选用测试号. 准备一个服务器, 一个域名.

参考接入指南,开发者需要按照如下步骤完成:

  1. 填写服务器配置
  2. 验证服务器地址的有效性
  3. 依据接口文档实现业务逻辑 目前先完成前两步, 后续再进行接口业务开发.

准备工作:

  1. 如果使用自己的公众号: 首先登录微信公众号后台, 左栏依次打开 设置与开发-> 基本配置. 在右栏中生成 开发者密码(AppSecret), 并配置服务器IP或调试用机器的IP为 IP白名单. 记下此页面的 开发者ID(AppID), 开发者密码(AppSecret)
  2. 使用测试号, 登录测试公众号,记下此页面的 开发者ID(AppID), 开发者密码(AppSecret), 在接口配置信息处填入准备的域名和32位token(通过在线生成工具或其他方法生成)

1 搭建服务器配置消息验证服务

在前文准备工作中, 接口配置信息基本配置中准备的参考域名为http://xiaoyangge.inith271.top/verify, token为2zeCr3Tco8J8q8s30GjfphpZwBHMFiGz. 现在编写一个web服务用于接收微信的URL验证请求, 请求方式为GET.

这里使用golang 编写一个用于验证服务器URL有效性的web服务, 参数验证逻辑来自微信消息推送-开发者服务器接收消息推送.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package main

import (
	"crypto/sha1"
	"encoding/hex"
	"log"
	"net/http"
	"sort"
	"strings"

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

// token由其他工具生成
var token = "AAAAA"

func main() {
	router := gin.Default()

	router.GET("/verify", func(ctx *gin.Context) {
		signature := ctx.Query("signature")
		timestamp := ctx.Query("timestamp")
		nonce := ctx.Query("nonce")
		echostr := ctx.Query("echostr")
		// 验证签名
		tmpArr := []string{token, timestamp, nonce}
		sort.Strings(tmpArr)
		tmpStr := strings.Join(tmpArr, "")
		hasher := sha1.New()
		hasher.Write([]byte(tmpStr))
		hexStr := hex.EncodeToString(hasher.Sum(nil))
		
		if hexStr == signature {
			log.Println("[/]:  the signature is valid")
			// 验证成功返回传入的echoStr字符串
			ctx.String(http.StatusOK, echostr)
			return
		}
		ctx.String(http.StatusOK, "Bad signature")
		log.Println("[/]: Bad signature")
	})

	log.Fatal(router.Run(":8080"))
}

在服务上运行上述服务, 这里运行在8080端口.

接下来在服务器中将域名反向代理给上述web服务, 这里使用nginx, 配置参考:

server {
    listen 80;
    server_name xiaoyangge.inith271.top;

    location / {
        proxy_pass http://0.0.0.0:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

最后使用微信提供的URL配置验证调试工具, 对验证服务进行调试.这里一个参数 AccessToken需要通过接口获取接口调用凭据 获取.

获取accessToken

使用前面记下的开发者ID(AppID), 开发者密码(AppSecret) 填入下面位置, 运行命令获取ACCESS_TOKEN值, 有效期为2小时, 2小时内未验证成功需重新获取.

1
2
3
4
5
6
7
$ curl "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=<AppID>&secret=<AppSecret>"

# 输出
#{
#	"access_token":"ACCESS_TOKEN",
#	"expires_in":7200
#} 

URL配置验证

使用URL配置验证调试工具, 填入前面获取的AccessToken, 配置好的服务器域名填入前文准备好的**URL(服务器地址), Token ** . 检查参数并发起请求按钮 测试服务是否okURL配置验证 验证结果会在右侧显示, 成功显示如上.

2 启动服务器配置

  1. 对于个人公众号, 回到公众号后台的服务器配置项, 点击修改配置按钮. 将前文配置的域名填入 URL字段中, 生成的32位token填入Token字段中, 并点击 随机生成生成一个 EncodingAESKey, 消息加解密方式选择 明文模式. 点击提交完成服务器配置编辑, 点击 启用按钮启用服务器配置.
  2. 对于测试公众号, 回到测试公众号页面的 接口配置信息项目, 填入前文准备号的域名和token.

扫码微信公众号二维码登录

用户扫码过程中登录状态: 待扫码 - 已登录 - 已失效

  • 已关注用户扫码会进入公众号页面, 触发SCAN事件推送, 状态变化为 待扫码 -> 已登录
  • 未关注用户扫码需要点击关注公众号, 触发subscribe事件推送, 状态变化为 待扫码 -> 已登录
  • 用户超时未完成前两种行为, 状态变化为 待扫码 -> 已失效
  1. client请求server生成微信登录二维码, server响应登录URL和凭证ticket.
    1. server缓存ticket用于标识用户扫码登录状态.
  2. client根据ticket向微信服务器换取二维码图片, 或者直接通过登录URL生成二维码
    1. client使用ticket去轮询登录状态接口.
  3. 用户扫码二维码, 进行关注或进入公众号, 触发微信服务器回调.
  4. server收到微信回调, 判断是SCAN或者subscribe事件, 则通过参数Ticket去缓存中更新登录状态, 获取用户信息响应token
    1. client轮询登录状态为已登录, 并获得用户token, 完成登录.
  5. Ticket已过期, client轮询登录状态为已失效, 提示刷新页面重新生成或进行其他操作.

详细项目代码实例GitHub, 项目效果如下

生成临时二维码

这里使用微信公众号的推广能力实现, 使用到接口生成带参数的二维码获取临时二维码. 临时二维码请求说明

请求方式: POST 
URL: https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=<AccessToken> 
Content-Type:application/json 

{
    "expire_seconds": 120,
    "action_name": "QR_STR_SCENE",
    "action_info": {
        "scene": {
            "scene_str": "UUID"
        }
    }
}

响应内容:
{
    "ticket": "gQH47joAAAAAAAAAASxodHRwOi8vd2VpeGluLnFxLmNvbS9xL2taZ2Z3TVRtNzJXV1Brb3ZhYmJJAAIEZ23sUwMEmm\n3sUw==",
    "expire_seconds": 60,
    "url": "http://weixin.qq.com/q/kZgfwMTm72WWPkovabbI"
}

关键请求参数:

  • AccessToken: 获取接口调用凭据
  • action_name: QR_STR_SCENE为临时的字符串参数值,QR_LIMIT_STR_SCENE为永久的字符串参数值
  • scene_str: 场景值ID(字符串形式的ID),字符串类型,长度限制为1到64 响应参数:
  • ticket: 获取的二维码ticket,凭借此ticket可以在有效时间内换取二维码。
  • expire_seconds: 该二维码有效时间,以秒为单位。 最大不超过2592000(即30天)。
  • url: 二维码图片解析后的地址,开发者可根据该地址自行生成需要的二维码图片

代码如下:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
package main

var AccessToken = "AAAAA"

func main() {
	router := gin.Default()

	// 获取临时二维码
	router.GET("/qrCode/create", func(ctx *gin.Context) {
		ticket, err := GetTicket()
		if err != nil {
			ctx.JSON(http.StatusInternalServerError, gin.H{
				"err": "获取二维码",
			})
			return
		}
		// 将url生成二维码
		qr, err := qrcode.New(ticket.Url, qrcode.Medium)
		if err != nil {
			log.Println("qrcode.New: ", err)
			ctx.JSON(http.StatusInternalServerError, gin.H{"error": "生成二维码失败"})
			return
		}
		// 将二维码图片写入响应
		ctx.Header("Content-Type", "image/png")
		b, _ := qr.PNG(256)
		ctx.Writer.Write(b)

	})

	log.Fatal(router.Run(":8080"))
}

// Value: 获取的二维码ticket,凭借此ticket可以在有效时间内换取二维码。
// ExpireSeconds: 该二维码有效时间,以秒为单位。 最大不超过2592000(即30天)。
// Url: 二维码图片解析后的地址,开发者可根据该地址自行生成需要的二维码图片
type Ticket struct {
	Value         string `json:"ticket"`
	ExpireSeconds int    `json:"expire_seconds"`
	Url           string `json:"url"`
	ErrCode       int    `json:"errcode"`
	ErrMsg        string `json:"errmsg"`
}

// action_name	二维码类型,QR_SCENE为临时的整型参数值,QR_STR_SCENE为临时的字符串参数值,QR_LIMIT_SCENE为永久的整型参数值,QR_LIMIT_STR_SCENE为永久的字符串参数值
// expire_seconds	该二维码有效时间,以秒为单位。 最大不超过2592000(即30天),此字段如果不填,则默认有效期为60秒。
// action_info	二维码详细信息
//
//	|-- scene
//		|-- scene_str	场景值ID(字符串形式的ID),字符串类型,长度限制为1到64
type ticketRequest struct {
	ActionName    string     `json:"action_name"`
	ActionInfo    actionInfo `json:"action_info"`
	ExpireSeconds int        `json:"expire_seconds"`
}

type actionInfo struct {
	Scene scene `json:"scene"`
}

type scene struct {
	SceneStr string `json:"scene_str"`
}


// 请求微信服务器获取临时二维码接口
func GetTicket() (ticket Ticket, err error) {
	uuidV1, err := uuid.NewUUID()
	if err != nil {
		log.Println("Error generating UUID v1:", err)
		return
	}
	api := "https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=" + AccessToken
	tReq := &ticketRequest{
		ActionName: "QR_STR_SCENE", // 生成临时二维码
		ActionInfo: actionInfo{
			Scene: scene{
				SceneStr: uuidV1.String(),
			},
		},
		ExpireSeconds: 120,
	}
	data, err := json.Marshal(tReq)
	if err != nil {
		log.Println("Error encoding the JSON:", err)
		return
	}

	resp, err := http.Post(api, "application/json", bytes.NewBuffer(data))

	if err != nil {
		log.Println("Error http.Post:", err)
		return
	}
	defer resp.Body.Close()

	// 解析JSON响应
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Println("Error reading the response body:", err)
		return
	}
	err = json.Unmarshal(body, &ticket)
	if err != nil {
		log.Println("Error decoding the JSON:", err)
		return
	}
	if ticket.ErrCode != 0 {
		log.Println("Error get ticket:", ticket.ErrMsg)
		return
	}
	return

}

对于前端同学, 微信提供了将上述获取的参数ticket换取二维码图片的能力, 传入的Ticket参数需要UrlEncode处理. 返回一张图片可以直接展示. 因此可以考虑在上述接口中响应ticket等参数, 而不是一张图片, 便于前端通过ticket, sence_str等值轮询请求服务确认用户登录情况.

1
2
3
4
5
6
请求方式: GET
URL: https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=<Ticket>

响应内容:
Accept-Ranges: bytes
Content-Type: image/jpg

接收回调确认登录

用户扫描上述生产的带参数的二维码时,可能推送以下两种事件:

  • 如果用户还未关注公众号,则用户可以关注公众号,关注后微信会将带场景值关注事件推送给开发者。
  • 如果用户已经关注公众号,在用户扫描后会自动进入会话,微信也会将带场景值扫描事件推送给开发者。 对应微信提供的接口能力接收事件推送

用户扫码二维码后的上述两种事件使微信服务器请求前文的配置的域名地址, 请求方式为POST, 路径带签名验证参数, 数据包为xml格式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
POST /verify?signature=71af94c33e55d3d839f146f13f5144b2a8580b77&timestamp=1720975753&nonce=1682795898&openid=oM4UQ66q997a6p_lDfy2Rtqj_ffw"
Content-Type: application/xml


<xml>
    <ToUserName><![CDATA[gh_43f9a6d80160]]></ToUserName>
    <FromUserName><![CDATA[oM4UQ6-syhv-hpTtCXsS-slp-Zys]]></FromUserName>
    <CreateTime>1720967951</CreateTime>
    <MsgType><![CDATA[event]]></MsgType>
    <Event><![CDATA[subscribe]]></Event>
    <EventKey><![CDATA[qrscene_bb9393c3-41ee-11ef-8c4a-00155d7e017a]]></EventKey>
    <Ticket><![CDATA[gQHl7zwAAAAAAAAAAS5odHRwOi8vd2VpeGluLnFxLmNvbS9xLzAybV9PWndDUDVjZ0gxSnN6anhDY1cAAgTk4pNmAwR4AAAA]]></Ticket>
</xml>

关键请求参数:

  • FromUserName: 用户openId
  • MsgType: 消息类型,event
  • Event: 事件类型,
    • subscribe值表示用户未关注时,进行关注后的事件推送
    • SCAN值表示用户已关注时的事件推送
    • unsubscribe值表示用户取消关注的事件推送
  • EventKey: 事件KEY值
    • 事件类型为subscribe时, KEY值由qrscene_为前缀,后面为生成二维码使用的场景值sence_str
    • 事件类型为SCAN时, KEY值为生成二维码使用的场景值sence_str
  • Ticket: 二维码的ticket,可用来换取二维码图片

代码如下, 需要用到xml包进行处理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
package main

var AccessToken = "AAAAA"

// 微信接口回调推送XML数据包
// ToUserName	开发者 微信号
// FromUserName	发送方账号(一个OpenID,此时发送方是系统账号)
// CreateTime	消息创建时间 (整型),时间戳
// MsgType	消息类型,event
// Event	事件类型 qualification_verify_success
// ExpiredTime	有效期 (整型),指的是时间戳,将于该时间戳认证过期
type WxEvent struct {
	ToUserName   string `xml:"ToUserName"`
	FromUserName string `xml:"FromUserName"`
	CreateTime   string `xml:"CreateTime"`
	MsgType      string `xml:"MsgType"`
	Event        string `xml:"Event"`
	EventKey     string `xml:"EventKey"`
	Ticket       string `xml:"Ticket"`
}


func main() {
	router := gin.Default()

	// 微信事件推送回调
	router.POST("/verify", func(ctx *gin.Context) {
		body := ctx.Request.Body
		b, _ := io.ReadAll(body)
		var wxEvent WxEvent
		err := xml.Unmarshal(b, &wxEvent)
		if err != nil {
			log.Println("Error on xml.Unmarshal: ", err)
			ctx.JSON(http.StatusOK, nil)
			return
		}
		log.Printf("POST /verify [event]: %+v", wxEvent)
		switch wxEvent.Event {
		case "SCAN":
			// 扫码事件, 已关注用户触发
			log.Printf("[Event] user %s 扫码成功.", wxEvent.FromUserName)
			SendLoginTemplateMsg("1W-NG2s_7Ka42FnzCzEQaug5cPDaCEebNdjJYAc9sxU", wxEvent.FromUserName, AccessToken)
		case "subscribe":
			// 用户点击关注
			log.Printf("[Event] user %s 关注公众号.", wxEvent.FromUserName)
			SendLoginTemplateMsg("ss8JStEE8H1TOe-uQAi-YKfJAH6FxgTlAeoynZuYa0s", wxEvent.FromUserName, AccessToken)
		case "unsubscribe":
			// 用户取消关注
			log.Printf("[Event] user %s 取消了关注.", wxEvent.FromUserName)
		case "TEMPLATESENDJOBFINISH":
			// 模板消息发送成功
		default:
			// 其他事件触发
			log.Printf("[Event] user %s 触发了其他事件: %+v", wxEvent.FromUserName, wxEvent)
		}
		ctx.JSON(http.StatusOK, nil)
	})

	log.Fatal(router.Run(":8080"))
}

// 发送模板消息
func SendLoginTemplateMsg(templateId, openId, acToken string) {
	api := fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=%s", acToken)
	jsonStr := fmt.Sprintf(`{
    "touser": "%s",
    "template_id": "%s",
    "url": "https://inith271.top",
    "topcolor": "#FF0000",
    "data": {}
}`, openId, templateId)

	_, err := http.Post(api, "application/json", bytes.NewBuffer([]byte(jsonStr)))
	if err != nil {
		log.Println("模板消息发送失败.")
	}

}

在监听到关注或取消关注事件后, 上述代码只完成了简单的模板消息发送, 使用到接口模板消息. 效果如下: 后端同学可以做进一步处理, 比如在Redis中更新用户登录状态, 更新用户关注状态等. 方便前端及时获得轮询结果.

此外微信的事件推送能力还有很多, 如认证事件推送, 订阅通知事件推送等, 在文档中可以进一步学习.