实现QQ消息推送服务

引言

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

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

准备

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

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

整体的设计思路是

  1. 用户通过GitHub登录
  2. 系统记录用户GitHub的id
  3. 分配用户唯一Skey
  4. 给Skey绑定推送QQ地址与推送机器人地址
  5. 通过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 IDClient 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))
}