引言
对于部署的应用,希望有一个事件通知功能。
受到 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))
}