实现QQ消息推送服务

线上365bet注册 admin 2026-02-23 16:12:52

引言

对于部署的应用,希望有一个事件通知功能。

受到 Server酱 微信推送服务的启发,准备着手实现一个QQ消息推送服务,我给他命名为 Cool Push 亦或称为 酷推 。实现后: https://cp.xuthus.cc

准备

QQ推送服务依赖两个基本组件,酷Q以及CQHTTP,酷Q是一个QQ机器人,CQHTTP实现了基于HTTP请求调用酷Q功能的接口,基于此,可以十分顺利的实现QQ消息推送服务。

为了实现可供选择机器人的QQ推送,通过运行多个酷Q实例并开放不同的HTTP请求端口。

整体的设计思路是

用户通过GitHub登录

系统记录用户GitHub的id

分配用户唯一Skey

给Skey绑定推送QQ地址与推送机器人地址

通过HTTP请求调用接口地址,实现消息推送

当然,技术栈为:Go+vue+MySQL

设计

初始化工作

先设计好用户表以及JWT鉴权结构体

type User struct {

Gid int64 `json:"gid" xorm:"pk autoincr"` //github_id

Count int64 `json:"count" xorm:"default(0)"` //用户使用统计

LastSend int64 `json:"lastSend" xorm:"default(0)"` //上次发送时间

Skey string `json:"skey" xorm:"varchar(32) notnull unique"` //发送关键钥 send_key

SendTo string `json:"sendTo" xorm:"varchar(10) default('')"` //用户QQ

SendFrom string `json:"sendFrom" xorm:"varchar(10) default('')"` //发送QQ

Status bool `json:"status" xorm:"default(true)"` //账户状态

}

// GUser github返回的数据 只取两项 id+name

type GUser struct {

ID int64 `json:"id"`

Name string `json:"name"`

}

// CustomClaims 是JWT在生成令牌时的某些声明

type CustomClaims struct {

Gid int64 `json:"gid"` //用户GID

jwt.StandardClaims

}

用户第三方登录

由于是通过GitHub进行第三方授权登录,所以需要前往GitHub申请一个OAuth App应用,填写好回调地址并获得Client ID与Client Secret

用户登录时,先检测数据表中是否存在 gid(github id) 存在则直接返回用户数据,否则进行注册操作

// AuthGithub 授权github登录

func AuthGithub(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {

//通过前端传递的code 进行登录操作

var code = r.URL.Query().Get("code")

var target = fmt.Sprintf("https://github.com/login/oauth/access_token?code=%s&client_id=%s&client_secret=%s", code, conf.ClientID, conf.ClientSecret)

resp, _ := http.Get(target)

defer resp.Body.Close()

_c, _ := ioutil.ReadAll(resp.Body)

content := string(_c)

v, _ := url.ParseQuery(content)

if v["access_token"] != nil && v["access_token"][0] != "" {

//获取成功

var gu = new(GUser)

var token = "Bearer " + v["access_token"][0]

err := gout.GET("https://api.github.com/user").SetHeader(gout.H{

"Authorization": token,

}).BindJSON(gu).Do()

if err != nil {

// 返回错误 连接API 获取用户信息失败

ret, _ := json.Marshal(&Response{

RetCode: 500,

Status: err.Error(),

})

Write(w, ret)

return

} else {

//判断 是否存在 gid 存在则不变 不存在则创建用户

u, exist, err := SearchByGid(gu.ID)

if !exist {

//没找到 注册用户 下发 jwt token

err = NewUser(gu.ID)

if err != nil {

ret, _ := json.Marshal(&Response{

RetCode: 500,

Status: err.Error(),

})

Write(w, ret)

return

}

u, _, _ = SearchByGid(gu.ID)

}

//找到了 下发jwt token

token, err := DistributeToken(gu.ID)

if err != nil {

ret, _ := json.Marshal(&Response{

RetCode: 500,

Status: "下发token失败:" + err.Error(),

Data: err,

})

Write(w, ret)

return

}

ret, _ := json.Marshal(&Response{

RetCode: 200,

Status: token,

Data: u,

})

Write(w, ret)

return

}

} else if strings.Contains(content,"error") {

ret, _ := json.Marshal(&Response{

RetCode: 500,

Status: "授权失败",

Data: content,

})

Write(w, ret)

return

} else {

//获取access_token失败

ret, _ := json.Marshal(&Response{

RetCode: 500,

Status: "登录失败",

Data: content,

})

Write(w, ret)

return

}

}

返回结果函数

Wirte 是一个结果返回函数,结构如下

// Write 输出返回结果

func Write(w http.ResponseWriter, response []byte) {

//公共的响应头设置

w.Header().Set("Access-Control-Allow-Origin", "*")

w.Header().Set("Access-Control-Allow-Headers", "*")

w.Header().Set("Access-Control-Allow-Methods", "GET, POST")

w.Header().Set("Content-Type", "application/json;charset=utf-8")

w.Header().Set("Content-Length", strconv.Itoa(len(string(response))))

_, _ = w.Write(response)

return

}

绑定过程

登录过后的用户没有绑定推送者与接收者,所以需要绑定一次

// Bind 用户绑定QQ

func Bind(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {

//先检验token

tokenString := r.Header.Get("token")

if _, err := CheckToken(tokenString); err != nil {

ret, _ := json.Marshal(&Response{

RetCode: 500,

Status: err.Error(),

})

Write(w, ret)

return

}

//绑定qq

gidString := r.URL.Query().Get("gid")

gid, err := strconv.ParseInt(gidString, 10, 64)

if err != nil {

//转换失败 说明gid有问题

ret, _ := json.Marshal(&Response{

RetCode: 500,

Status: "用户身份无法确定",

})

Write(w, ret)

return

}

to := r.URL.Query().Get("sendTo")

from := r.URL.Query().Get("sendFrom")

var user = &User{

Gid: gid,

SendTo: to,

SendFrom: from,

}

//检测用户是否存在

if _, exist, _ := SearchByGid(gid); !exist {

ret, _ := json.Marshal(&Response{

RetCode: 500,

Status: "用户不存在,非法操作",

})

Write(w, ret)

return

} else {

_, err = engine.Where("gid = ?", gid).Update(user)

if err != nil {

//转换失败 说明gid有问题

ret, _ := json.Marshal(&Response{

RetCode: 500,

Status: "绑定失败",

})

Write(w, ret)

return

}

}

ret, _ := json.Marshal(&Response{

RetCode: 200,

Status: "绑定成功",

})

Write(w, ret)

return

}

身份认证

由于整体项目使用JWT进行身份验证,所以需要一个生成token与验证token有效性的函数

// DistributeToken 分发token

func DistributeToken(gid int64) (string, error) {

claims := CustomClaims{

Gid: gid,

StandardClaims: jwt.StandardClaims{

ExpiresAt: time.Now().Unix() + conf.Jwt.Expires,

NotBefore: time.Now().Unix(),

IssuedAt: time.Now().Unix(),

Issuer: conf.Jwt.Issuer,

Subject: conf.Jwt.Subject,

},

}

ss, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(conf.Jwt.Skey))

if err != nil {

return "", err

}

return ss, nil

}

// CheckToken 检验token的函数 返回token中的 gid 以及错误信息

func CheckToken(tokenString string) (int64, error) {

if tokenString == "" {

return 0, errors.New("请求非法")

}

//开始解析

token, err := jwt.ParseWithClaims(tokenString, &CustomClaims{},

func(token *jwt.Token) (interface{}, error) {

return []byte(conf.Jwt.Skey), nil

})

//数据校验

if token.Valid {

if c, ok := token.Claims.(*CustomClaims); ok {

//检测

if _, exist, _ := SearchByGid(c.Gid); !exist {

return 0, errors.New("用户与token不匹配")

}

return c.Gid, nil

}

}

if ve, ok := err.(*jwt.ValidationError); ok {

if ve.Errors&jwt.ValidationErrorMalformed != 0 {

return 0, errors.New("token格式错误")

} else if ve.Errors&(jwt.ValidationErrorExpired|jwt.ValidationErrorNotValidYet) != 0 {

return 0, errors.New("token已过期")

}

} else {

return 0, errors.New("无法处理该token:" + err.Error())

}

return 0, errors.New("未知问题")

}

// AuthToken 验证token路由

func AuthToken(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {

tokenString := r.Header.Get("token")

gid, err := CheckToken(tokenString)

if err != nil {

ret, _ := json.Marshal(&Response{

RetCode: 500,

Status: err.Error(),

})

Write(w, ret)

return

}

u, exist, _ := SearchByGid(gid)

if !exist {

ret, _ := json.Marshal(&Response{

RetCode: 500,

Status: "token已失效",

})

Write(w, ret)

return

}

ret, _ := json.Marshal(&Response{

RetCode: 200,

Status: "ok",

Data: u,

})

Write(w, ret)

}

敏感词过滤

防止用户推送敏感消息,需要过滤用户消息敏感词

// MessageFilterAll 检测消息的所有敏感词 有则返回词汇数组 否则返回真

func MessageFilterAll(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {

var message string

//优先GET

if r.URL.Query().Get("c") != "" {

message = r.URL.Query().Get("c")

} else if r.Method == "POST" {

message = r.PostFormValue("c")

}

//检测长度

if len(message) > 1500 || len(message) == 0 {

body, _ := json.Marshal(&Response{

RetCode: 400,

Status: "文本超限或不能为空 推送失败",

})

Write(w, body)

return

}

//开始检测

list := filter.FindAll(message)

if len(list) == 0 {

body, _ := json.Marshal(&Response{

RetCode: 0,

Status: "文本没有敏感词",

})

Write(w, body)

return

}

body, _ := json.Marshal(&Response{

RetCode: 400,

Status: "服务存在敏感词",

Data: list,

})

Write(w, body)

}

filter是一个敏感词过滤器

import (

"github.com/importcjj/sensitive"

"sync"

)

var filter *sensitive.Filter

var filter_once sync.Once

func GetFilter() *sensitive.Filter {

filter_once.Do(func() {

filter = sensitive.New()

if err := filter.LoadWordDict("dict.txt");err != nil {

panic("载入敏感词库出错:"+err.Error())

}

})

return filter

}

消息推送

最后是消息推送服务,该服务依赖本地运行的CQHTTP。由于发送链接依赖skey,所以路由设计使用/send/Skey ,这在取参时十分方便。

// Send 发起推送

func Send(w http.ResponseWriter, r *http.Request, p httprouter.Params) {

var message string

//优先GET 其次获取POST 再次获取POST-body

if r.URL.Query().Get("c") != "" {

message = r.URL.Query().Get("c")

} else if r.Method == "POST" {

message = r.PostFormValue("c")

}

//都为空? 尝试获取raw

if message == "" {

buf := make([]byte, 2048)

n,_ := r.Body.Read(buf)

message = string(buf[:n])

}

//检测长度

if len(message) > 1500 || len(message) == 0 {

body, _ := json.Marshal(&Response{

RetCode: 400,

Status: "文本超限或不能为空 推送失败",

})

Write(w, body)

return

}

u, err := SearchByKey(p.ByName("skey"))

if err != nil {

//失败 返回错误

body, _ := json.Marshal(&Response{

RetCode: 500,

Status: err.Error(),

})

Write(w, body)

return

}

//检测是否绑定

if u.SendFrom == "" {

body, _ := json.Marshal(&Response{

RetCode: 400,

Status: "用户未绑定推送QQ",

})

Write(w, body)

return

}

//内容 --> 字符编码

message = url.QueryEscape(message)

//内容 --> 敏感词过滤

message = filter.Replace(message,'*')

//发送地址

var port = GetPort(u.SendFrom)

var sendUrl = conf.CQHttp+port+"/send_private_msg"

//推送

resp, _ := http.Post(sendUrl, "application/x-www-form-urlencoded", strings.NewReader("user_id="+u.SendTo+"&message="+message))

defer resp.Body.Close()

content, _ := ioutil.ReadAll(resp.Body)

ret := new(Response)

_ = json.Unmarshal(content, ret)

body, _ := json.Marshal(ret)

Write(w, body)

}

整体路由

func Run() {

fmt.Println("程序启动:"+conf.ProjectName)

router := httprouter.New()

router.GlobalOPTIONS = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

// Set CORS headers

header := w.Header()

header.Set("Access-Control-Allow-Origin", "*")

header.Set("Access-Control-Allow-Headers", "*")

header.Set("Access-Control-Allow-Methods", "GET, POST")

w.WriteHeader(http.StatusNoContent)

})

//连通性测试

router.GET("/ping", Ping)

// 登录注册授权

router.GET("/auth", AuthGithub)

// token检测

router.GET("/check", AuthToken)

// qq绑定

router.GET("/bind", Bind)

//检测敏感词

router.GET("/filter",MessageFilterAll)

router.POST("/filter",MessageFilterAll)

// 发送信息

router.GET("/send/:skey", Send)

router.POST("/send/:skey", Send)

// 主页

//router.NotFound = http.FileServer(http.Dir("dist"))

// 首页重定向

router.NotFound = http.RedirectHandler("https://cp.xuthus.cc",http.StatusFound)

log.Fatal(http.ListenAndServeTLS(conf.Server, "cert.crt", "key.key", router))

}