1
0
Fork 0

Compare commits

...

No commits in common. "main" and "v2" have entirely different histories.
main ... v2

164 changed files with 4850 additions and 3830 deletions

21
.editorconfig Normal file
View File

@ -0,0 +1,21 @@
root = true
[*]
insert_final_newline = true
charset = utf-8
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[{Makefile,go.mod,go.sum,*.go,.gitmodules}]
indent_style = tab
indent_size = 4
[*.md]
indent_size = 4
trim_trailing_whitespace = false
eclint_indent_style = unset
[Dockerfile]
indent_size = 4

View File

@ -1,54 +0,0 @@
name: BuildImage
on:
push:
# branches:
# - main
tags:
- '*'
jobs:
build-image:
runs-on: ubuntu-latest
container:
# 使用这个镜像不然Docker无法打包镜像
image: catthehacker/ubuntu:act-latest
steps:
- name: Setup Golang
uses: actions/setup-go@v3
with:
go-version: '>=1.21.0'
cache: false
- name: Checkout Code
uses: actions/checkout@v3
- name: Gen Tags
id: gen_tags
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
with:
images: gitee.ltd/lxh/go-wxhelper
tags: |
type=ref,event=branch
type=ref,event=tag
- name: Print Tags
run: |
echo "${{ steps.gen_tags.outputs.tags }}"
echo "----------------- labels -----------------"
echo "${{ steps.meta.outputs.labels }}"
- name: Login to Repository
uses: docker/login-action@v2
with:
registry: gitee.ltd
username: ${{ secrets.USERNAME }}
password: ${{ secrets.PASSWORD }}
- name: Build image
uses: docker/build-push-action@v2
with:
push: true
tags: ${{ steps.gen_tags.outputs.tags }}
labels: ${{ steps.gen_tags.outputs.labels }}

20
.gitignore vendored
View File

@ -1,10 +1,10 @@
.idea
vendor
logs
*.exe
*.pprof
cache
log
dist
*.log
blacklist.txt
.idea
vendor
logs
*build*
debug
dist
*_deleted*
config.yaml

View File

@ -1,21 +1,18 @@
FROM golang:alpine as builder
WORKDIR /builder
COPY . .
#ENV GO111MODULE=on
#ENV GOPROXY=https://goproxy.cn,direct
RUN go version
RUN go mod download && go build -o wxhelper
RUN ls -lh && chmod -R +x ./*
FROM code.hyxc1.com/open/alpine:3.16.0 as runner
LABEL org.opencontainers.image.authors="lxh@cxh.cn"
EXPOSE 19099
EXPOSE 8080
WORKDIR /app
COPY --from=builder /builder/wxhelper ./wxhelper
COPY --from=builder /builder/views ./views
CMD ./wxhelper
FROM golang:alpine as builder
WORKDIR /builder
COPY . .
RUN go mod download
RUN go build -o app
RUN ls -lh && chmod +x ./app
FROM code.hyxc1.com/open/alpine:3.16.0 as runner
LABEL org.opencontainers.image.authors="lxh@cxh.cn"
# 定义一下版本号
ARG APP_VER
ENV APP_VER=${APP_VER}
WORKDIR /app
COPY --from=builder /builder/app ./app
CMD ./app

View File

@ -0,0 +1,25 @@
package aiassistant
import (
"github.com/gin-gonic/gin"
"unicode/utf8"
"wechat-robot/pkg/response"
aiAssistantService "wechat-robot/service/aiassistant"
)
// DeleteById
// @description: 删除AI助手
// @param ctx
func DeleteById(ctx *gin.Context) {
var id = ctx.Param("id")
if utf8.RuneCountInString(id) != 32 {
response.New(ctx).SetMsg("参数错误").Fail()
return
}
// 删除数据
if err := aiAssistantService.DeleteById(id); err != nil {
response.New(ctx).SetMsg("删除失败").SetError(err).Fail()
} else {
response.New(ctx).SetMsg("删除成功").Success()
}
}

View File

@ -0,0 +1,25 @@
package aiassistant
import (
"github.com/gin-gonic/gin"
"wechat-robot/model/param/aiassistant"
"wechat-robot/pkg/response"
aiAssistantService "wechat-robot/service/aiassistant"
)
// Save
// @description: 保存AI助手
// @param ctx
func Save(ctx *gin.Context) {
var p aiassistant.Save
if err := ctx.ShouldBind(&p); err != nil {
response.New(ctx).SetMsg("参数错误").SetError(err).Fail()
return
}
// 保存数据
if err := aiAssistantService.Save(p); err != nil {
response.New(ctx).SetMsg("保存失败").SetError(err).Fail()
return
}
response.New(ctx).SetMsg("保存成功").Success()
}

View File

@ -0,0 +1,26 @@
package aiassistant
import (
"github.com/gin-gonic/gin"
"wechat-robot/model/param/aiassistant"
"wechat-robot/pkg/response"
aiAssistantService "wechat-robot/service/aiassistant"
)
// GetAll
// @description: 获取所有AI助手
// @param ctx
func GetAll(ctx *gin.Context) {
var p aiassistant.GetAll
if err := ctx.ShouldBind(&p); err != nil {
response.New(ctx).SetMsg("参数错误").SetError(err).Fail()
return
}
records, err := aiAssistantService.GetAll(p)
if err != nil {
response.New(ctx).SetMsg("获取失败").SetError(err).Fail()
return
}
response.New(ctx).SetData(records).Success()
}

View File

@ -0,0 +1,34 @@
package login
import (
"github.com/gin-gonic/gin"
"github.com/mojocn/base64Captcha"
"wechat-robot/model/vo/login"
"wechat-robot/pkg/captcha"
"wechat-robot/pkg/response"
)
// GetImgCaptcha
// @description: 获取图片验证码
// @param ctx
func GetImgCaptcha(ctx *gin.Context) {
store := new(captcha.RedisStore)
math := base64Captcha.DriverMath{
Height: 60,
Width: 240,
Fonts: []string{"ApothecaryFont.ttf"},
}
driver := math.ConvertFonts()
// 验证码生成器
c := base64Captcha.NewCaptcha(driver, store)
id, imgStr, _, err := c.Generate()
if err != nil {
response.New(ctx).SetMsg("验证码生成失败").Fail()
return
}
// 数据返回
result := login.CaptchaCode{Id: id, Img: imgStr}
response.New(ctx).SetData(result).Success()
}

120
api/admin/login/login.go Normal file
View File

@ -0,0 +1,120 @@
package login
import (
"context"
"encoding/json"
"gitee.ltd/lxh/logger/log"
"github.com/gin-gonic/gin"
"net/http"
"net/url"
"wechat-robot/internal/redis"
"wechat-robot/model/param/login"
"wechat-robot/pkg/auth"
"wechat-robot/pkg/captcha"
"wechat-robot/pkg/response"
userService "wechat-robot/service/adminuser"
)
// Login
// @description: 登录
// @param ctx
// @return err
func Login(ctx *gin.Context) {
log.Debugf("收到登录请求")
var p login.GetTokenWithPassword
if err := ctx.ShouldBind(&p); err != nil {
response.New(ctx).SetMsg("参数错误").SetError(err).Fail()
return
}
// 验证验证码是否正确
if !new(captcha.RedisStore).Verify(p.VerifyId, p.VerifyCode, true) {
response.New(ctx).SetMsg("验证码错误").Fail()
return
}
// 重写参数
ctx.Request.Form = url.Values{
"username": {p.Username},
"password": {p.Password},
"scope": {"ALL"},
"grant_type": {"password"},
}
// 参数解析成功,进行登录
if err := auth.OAuthServer.HandleTokenRequest(ctx.Writer, ctx.Request); err != nil {
log.Errorf("登录失败:%s", err.Error())
response.New(ctx).SetMsg("系统错误").SetError(err).Fail()
return
}
if ctx.Writer.Status() == http.StatusOK {
go userService.UpdateLastLoginInfo(p.Username, ctx.ClientIP())
}
}
// Refresh
// @description: 刷新Token
// @param ctx
func Refresh(ctx *gin.Context) {
var p login.RefreshToken
if err := ctx.ShouldBind(&p); err != nil {
response.New(ctx).SetMsg("参数错误").SetError(err).Fail()
return
}
// 取出用户Id
userId := auth.GetUserIdWithRefreshToken(p.RefreshToken)
// 重写参数
ctx.Request.Form = url.Values{
"refresh_token": {p.RefreshToken},
"grant_type": {"refresh_token"},
}
// 刷新Token
if err := auth.OAuthServer.HandleTokenRequest(ctx.Writer, ctx.Request); err != nil {
log.Errorf("Token数据返回失败: %v", err.Error())
response.New(ctx).SetMsg("系统错误").Fail()
}
// 登录成功才更新登录时间
if ctx.Writer.Status() == http.StatusOK {
// 登录成功更新登录时间和IP
go userService.UpdateLastLoginInfo(userId, ctx.ClientIP())
}
}
// Logout
// @description: 退出登录
// @param ctx
func Logout(ctx *gin.Context) {
log.Debug("退出登录啦")
// Token字符串前缀
const bearerSchema string = "Bearer "
// 取出Token
tokenHeader := ctx.GetHeader("Authorization")
tokenStr := tokenHeader[len(bearerSchema):]
// 取出原始RedisKey
baseDataId, err := redis.Client.Get(context.Background(), "oauth:token:"+tokenStr).Result()
if err != nil {
response.New(ctx).SetMsg("Token信息获取失败").Fail()
return
}
baseDataStr, err := redis.Client.Get(context.Background(), "oauth:token:"+baseDataId).Result()
if err != nil {
response.New(ctx).SetMsg("Token信息获取失败").Fail()
return
}
// 转换数据为Map
tokenData := make(map[string]interface{})
if err = json.Unmarshal([]byte(baseDataStr), &tokenData); err != nil {
response.New(ctx).SetMsg("系统错误").SetError(err).Fail()
return
}
// 删除Redis缓存的数据
redis.Client.Del(context.Background(), "oauth:token:"+baseDataId)
redis.Client.Del(context.Background(), "oauth:token:"+tokenData["Access"].(string))
redis.Client.Del(context.Background(), "oauth:token:"+tokenData["Refresh"].(string))
response.New(ctx).Success()
}

1
api/admin/menu/menu.go Normal file
View File

@ -0,0 +1 @@
package menu

24
api/admin/menu/save.go Normal file
View File

@ -0,0 +1,24 @@
package menu
import (
"github.com/gin-gonic/gin"
menuParam "wechat-robot/model/param/menu"
"wechat-robot/pkg/response"
"wechat-robot/service/menu"
)
// Save
// @description: 保存菜单
// @param ctx
func Save(ctx *gin.Context) {
var p menuParam.Save
if err := ctx.ShouldBind(&p); err != nil {
response.New(ctx).SetError(err).Fail()
return
}
if err := menu.Save(p); err != nil {
response.New(ctx).SetError(err).Fail()
return
}
response.New(ctx).Success()
}

23
api/admin/menu/user.go Normal file
View File

@ -0,0 +1,23 @@
package menu
import (
"github.com/gin-gonic/gin"
"wechat-robot/pkg/response"
"wechat-robot/service/menu"
)
// GetUserMenuTree
// @description: 获取用户权限内的菜单树
// @param ctx
func GetUserMenuTree(ctx *gin.Context) {
// 获取用户Id
userId := ctx.GetString("userId")
// 获取菜单树
tree, err := menu.GetUserMenus(userId)
if err != nil {
response.New(ctx).SetMsg("系统错误").SetError(err).Fail()
return
}
// 返回数据
response.New(ctx).SetData(tree).Success()
}

25
api/admin/robot/delete.go Normal file
View File

@ -0,0 +1,25 @@
package robot
import (
"github.com/gin-gonic/gin"
"unicode/utf8"
"wechat-robot/pkg/response"
robotService "wechat-robot/service/robot"
)
// DeleteById
// @description: 删除机器人
// @param ctx
func DeleteById(ctx *gin.Context) {
var id = ctx.Param("id")
if utf8.RuneCountInString(id) != 32 {
response.New(ctx).SetMsg("参数错误").Fail()
return
}
// 删除数据
if err := robotService.DeleteById(id); err != nil {
response.New(ctx).SetMsg("删除失败").SetError(err).Fail()
} else {
response.New(ctx).SetMsg("删除成功").Success()
}
}

31
api/admin/robot/save.go Normal file
View File

@ -0,0 +1,31 @@
package robot
import (
"github.com/gin-gonic/gin"
robotParam "wechat-robot/model/param/robot"
"wechat-robot/pkg/response"
robotService "wechat-robot/service/robot"
)
// Save
// @description: 保存机器人
// @param ctx
func Save(ctx *gin.Context) {
var p robotParam.Save
if err := ctx.ShouldBind(&p); err != nil {
response.New(ctx).SetMsg("参数错误").SetError(err).Fail()
return
}
// 参数校验
if p.Id == "" && (p.HookApi == "" || p.Version == 0) {
response.New(ctx).SetMsg("参数错误").Fail()
return
}
if err := robotService.Save(p); err != nil {
response.New(ctx).SetMsg("保存失败").SetError(err).Fail()
return
}
response.New(ctx).SetMsg("保存成功").Success()
}

26
api/admin/robot/select.go Normal file
View File

@ -0,0 +1,26 @@
package robot
import (
"github.com/gin-gonic/gin"
robotParam "wechat-robot/model/param/robot"
"wechat-robot/pkg/response"
robotService "wechat-robot/service/robot"
)
// GetAll
// @description: 获取所有机器人
// @param ctx
func GetAll(ctx *gin.Context) {
var p robotParam.GetAll
if err := ctx.ShouldBind(&p); err != nil {
response.New(ctx).SetMsg("参数错误").SetError(err).Fail()
return
}
records, err := robotService.GetAll(p)
if err != nil {
response.New(ctx).SetMsg("数据获取失败").SetError(err).Fail()
return
}
response.New(ctx).SetData(records).Success()
}

27
api/admin/role/role.go Normal file
View File

@ -0,0 +1,27 @@
package role
import (
"github.com/gin-gonic/gin"
roleParam "wechat-robot/model/param/role"
"wechat-robot/pkg/response"
roleService "wechat-robot/service/role"
)
// GetAll
// @description: 获取所有角色
// @param ctx
func GetAll(ctx *gin.Context) {
var p roleParam.GetAll
if err := ctx.ShouldBind(&p); err != nil {
response.New(ctx).SetMsg("参数错误").SetError(err).Fail()
return
}
records, err := roleService.GetAll(p)
if err != nil {
response.New(ctx).SetMsg("获取所有角色失败").SetError(err).Fail()
return
}
response.New(ctx).SetData(records).Success()
}

21
api/callback/hook.go Normal file
View File

@ -0,0 +1,21 @@
package callback
import (
"gitee.ltd/lxh/logger/log"
"github.com/gin-gonic/gin"
"wechat-robot/model/param/callback"
"wechat-robot/pkg/response"
)
// RobotHookNotify
// @description: 机器人HOOK后回调
// @param ctx
func RobotHookNotify(ctx *gin.Context) {
var param callback.RobotHook
if err := ctx.ShouldBind(&param); err != nil {
response.New(ctx).SetError(err).Fail()
return
}
log.Debugf("机器人已启动Id: %s", param.Robot)
}

View File

@ -1,161 +0,0 @@
package app
import (
"github.com/gin-gonic/gin"
"go-wechat/client"
"go-wechat/entity"
"gorm.io/gorm"
"log"
"net/http"
)
// changeStatusParam
// @description: 修改状态用的参数集
type changeStatusParam struct {
WxId string `json:"wxId" binding:"required"`
UserId string `json:"userId"`
}
// changeUseAiModelParam
// @description: 修改使用的AI模型用的参数集
type changeUseAiModelParam struct {
WxId string `json:"wxid" binding:"required"` // 群Id或微信Id
Model string `json:"model" binding:"required"` // 模型代码
}
// ChangeEnableAiStatus
// @description: 修改是否开启AI
// @param ctx
func ChangeEnableAiStatus(ctx *gin.Context) {
var p changeStatusParam
if err := ctx.ShouldBindJSON(&p); err != nil {
ctx.String(http.StatusBadRequest, "参数错误")
return
}
log.Printf("待修改的微信Id%s", p.WxId)
err := client.MySQL.Model(&entity.Friend{}).
Where("wxid = ?", p.WxId).
Update("`enable_ai`", gorm.Expr(" !`enable_ai`")).Error
if err != nil {
log.Printf("修改是否开启AI失败%s", err)
ctx.String(http.StatusInternalServerError, "操作失败: %s", err)
return
}
ctx.String(http.StatusOK, "操作成功")
}
// ChangeUseAiModel
// @description: 修改使用的AI模型
// @param ctx
func ChangeUseAiModel(ctx *gin.Context) {
var p changeUseAiModelParam
if err := ctx.ShouldBind(&p); err != nil {
ctx.String(http.StatusBadRequest, "参数错误")
return
}
err := client.MySQL.Model(&entity.Friend{}).
Where("wxid = ?", p.WxId).
Update("`ai_model`", p.Model).Error
if err != nil {
log.Printf("修改【%s】的AI模型失败%s", p.WxId, err)
ctx.String(http.StatusInternalServerError, "操作失败: %s", err)
return
}
ctx.String(http.StatusOK, "操作成功")
}
// ChangeEnableGroupRankStatus
// @description: 修改是否开启水群排行榜
// @param ctx
func ChangeEnableGroupRankStatus(ctx *gin.Context) {
var p changeStatusParam
if err := ctx.ShouldBindJSON(&p); err != nil {
ctx.String(http.StatusBadRequest, "参数错误")
return
}
log.Printf("待修改的群Id%s", p.WxId)
err := client.MySQL.Model(&entity.Friend{}).
Where("wxid = ?", p.WxId).
Update("`enable_chat_rank`", gorm.Expr(" !`enable_chat_rank`")).Error
if err != nil {
log.Printf("修改开启水群排行榜失败:%s", err)
ctx.String(http.StatusInternalServerError, "操作失败: %s", err)
return
}
ctx.String(http.StatusOK, "操作成功")
}
// ChangeEnableWelcomeStatus
// @description: 修改是否开启迎新
// @param ctx
func ChangeEnableWelcomeStatus(ctx *gin.Context) {
var p changeStatusParam
if err := ctx.ShouldBindJSON(&p); err != nil {
ctx.String(http.StatusBadRequest, "参数错误")
return
}
log.Printf("待修改的群Id%s", p.WxId)
err := client.MySQL.Model(&entity.Friend{}).
Where("wxid = ?", p.WxId).
Update("`enable_welcome`", gorm.Expr(" !`enable_welcome`")).Error
if err != nil {
log.Printf("修改开启迎新失败:%s", err)
ctx.String(http.StatusInternalServerError, "操作失败: %s", err)
return
}
ctx.String(http.StatusOK, "操作成功")
}
// ChangeEnableCommandStatus
// @description: 修改是否开启指令
// @param ctx
func ChangeEnableCommandStatus(ctx *gin.Context) {
var p changeStatusParam
if err := ctx.ShouldBindJSON(&p); err != nil {
ctx.String(http.StatusBadRequest, "参数错误")
return
}
log.Printf("待修改的群Id%s", p.WxId)
err := client.MySQL.Model(&entity.Friend{}).
Where("wxid = ?", p.WxId).
Update("`enable_command`", gorm.Expr(" !`enable_command`")).Error
if err != nil {
log.Printf("修改指令启用状态失败:%s", err)
ctx.String(http.StatusInternalServerError, "操作失败: %s", err)
return
}
ctx.String(http.StatusOK, "操作成功")
}
// ChangeSkipGroupRankStatus
// @description: 修改是否跳过水群排行榜
// @param ctx
func ChangeSkipGroupRankStatus(ctx *gin.Context) {
var p changeStatusParam
if err := ctx.ShouldBindJSON(&p); err != nil {
ctx.String(http.StatusBadRequest, "参数错误")
return
}
log.Printf("待修改的群Id%s -> %s", p.WxId, p.UserId)
err := client.MySQL.Model(&entity.GroupUser{}).
Where("group_id = ?", p.WxId).
Where("wxid = ?", p.UserId).
Update("`skip_chat_rank`", gorm.Expr(" !`skip_chat_rank`")).Error
if err != nil {
log.Printf("修改跳过水群排行榜失败:%s", err)
ctx.String(http.StatusInternalServerError, "操作失败: %s", err)
return
}
ctx.String(http.StatusOK, "操作成功")
}

View File

@ -1,30 +0,0 @@
package app
import (
"github.com/gin-gonic/gin"
"go-wechat/service"
"net/http"
)
type getGroupUser struct {
GroupId string `json:"groupId" form:"groupId" binding:"required"` // 群Id
}
// GetGroupUsers
// @description: 获取群成员列表
// @param ctx
func GetGroupUsers(ctx *gin.Context) {
var p getGroupUser
if err := ctx.ShouldBind(&p); err != nil {
ctx.String(http.StatusBadRequest, "参数错误")
return
}
// 查询数据
records, err := service.GetGroupUsersByGroupId(p.GroupId)
if err != nil {
ctx.String(http.StatusInternalServerError, "查询失败: %s", err.Error())
return
}
// 暂时先就这样写着,跑通了再改
ctx.JSON(http.StatusOK, records)
}

View File

@ -1,29 +0,0 @@
package app
import (
"fmt"
"github.com/gin-gonic/gin"
"go-wechat/config"
"go-wechat/service"
"net/http"
)
// Index
// @description: 首页
// @param ctx
func Index(ctx *gin.Context) {
var result = gin.H{
"msg": "success",
}
// 取出所有好友列表
friends, groups, err := service.GetAllFriend()
if err != nil {
result["msg"] = fmt.Sprintf("数据获取失败: %s", err.Error())
}
result["friends"] = friends
result["groups"] = groups
result["vnc"] = config.Conf.Wechat.VncUrl
result["aiModels"] = config.Conf.Ai.Models
// 渲染页面
ctx.HTML(http.StatusOK, "index.html", result)
}

View File

@ -1,38 +0,0 @@
package client
import (
"go-wechat/config"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"log"
"os"
"strconv"
)
// MySQL MySQL客户端
var MySQL *gorm.DB
func InitMySQLClient() {
// 创建连接对象
mysqlConfig := mysql.Config{
DSN: config.Conf.MySQL.GetDSN(),
DontSupportRenameIndex: true, // 重命名索引时采用删除并新建的方式
DontSupportRenameColumn: true, // 用 `change` 重命名列
}
// gorm 配置
gormConfig := gorm.Config{}
// 是否开启调试模式
if flag, _ := strconv.ParseBool(os.Getenv("GORM_DEBUG")); flag {
gormConfig.Logger = logger.Default.LogMode(logger.Info)
}
conn, err := gorm.Open(mysql.New(mysqlConfig), &gormConfig)
if err != nil {
log.Panicf("初始化MySQL连接失败, 错误信息: %v", err)
} else {
log.Println("MySQL连接成功")
}
MySQL = conn
}

View File

@ -1,6 +0,0 @@
package constant
var SpecialId = []string{"filehelper", "newsapp", "fmessage", "weibo", "qqmail", "tmessage", "qmessage", "qqsync",
"floatbottle", "lbsapp", "shakeapp", "medianote", "qqfriend", "readerapp", "blogapp", "facebookapp", "masssendapp",
"meishiapp", "feedsapp", "voip", "blogappweixin", "weixin", "brandsessionholder", "weixinreminder", "officialaccounts",
"notification_messages", "wxitil", "userexperience_alarm", "notification_messages", "exmail_tool"}

View File

@ -1,44 +0,0 @@
package current
import (
"go-wechat/model"
plugin "go-wechat/plugin"
)
// robotInfo
// @description: 机器人信息
type robotInfo struct {
info model.RobotUserInfo
MessageHandler plugin.MessageHandler // 启用的插件
}
// 当前接入的机器人信息
var ri robotInfo
// SetRobotInfo
// @description: 设置机器人信息
// @param info
func SetRobotInfo(info model.RobotUserInfo) {
ri.info = info
}
// GetRobotInfo
// @description: 获取机器人信息
// @return model.RobotUserInfo
func GetRobotInfo() model.RobotUserInfo {
return ri.info
}
// GetRobotMessageHandler
// @description: 获取机器人插件信息
// @return robotInfo
func GetRobotMessageHandler() plugin.MessageHandler {
return ri.MessageHandler
}
// SetRobotMessageHandler
// @description: 设置机器人插件信息
// @param handler
func SetRobotMessageHandler(handler plugin.MessageHandler) {
ri.MessageHandler = handler
}

View File

@ -1,84 +0,0 @@
# 微信HOOK配置
wechat:
# 微信HOOK接口地址
host: 10.0.0.73:19088
# 微信容器映射出来的vnc页面地址没有就不填
# vncUrl: http://192.168.1.175:19087/vnc_lite.html
# 是否在启动的时候自动设置hook服务的回调
autoSetCallback: false
# 回调IP如果是Docker运行本参数必填(填auto表示自动不适用于 docker 环境)如果Docker修改了映射格式为 ip:port
callback: 10.0.0.51
# 转发到其他地址
forward:
# - 10.0.0.247:4299
# 数据库
mysql:
drive: mysql # 使用的数据库驱动,支持 mysql、postgres
host: 10.0.0.31
port: 3307
user: wechat
password: wechat123
db: wechat
schema: public # postgres 专用
task:
enable: false
syncFriends:
enable: false
cron: '*/5 * * * *' # 五分钟一次
waterGroup:
enable: true
cron:
yesterday: '30 9 * * *' # 每天9:30
week: '30 9 * * 1' # 每周一9:30
month: '30 9 1 * *' # 每月1号9:30
year: '0 9 1 1 *' # 每年1月1号9:30
# MQ配置
mq:
# RabbitMQ配置
rabbitmq:
host: 10.0.0.247
port: 5672
user: wechat
password: wechat123
vhost: wechat
# AI回复
ai:
# 是否启用
enable: false
# 模型不填默认gpt-3.5-turbo-0613
model: gpt-3.5-turbo-0613
# OpenAI Api key
apiKey: sk-xxxx
# 接口代理域名不填默认ChatGPT官方地址
baseUrl: https://sxxx
# 人设
personality: 你的名字叫张三,你是一个百科机器人,你的爱好是看电影,你的性格是开朗的,你的专长是讲故事,你的梦想是当一名童话故事作家。你对政治没有一点儿兴趣,也不会讨论任何与政治相关的话题,你甚至可以拒绝回答这一类话题。
models:
- name: ChatGPT-4
model: gpt-4-0613
- name: 讯飞星火v3
model: SparkDesk3
- name: 讯飞星火随机
model: SparkDesk
- name: 月之暗面-8k
model: moonshot-v1-8k
- name: 月之暗面-32k
model: moonshot-v1-32k
- name: 月之暗面-128k
model: moonshot-v1-128k
# 资源配置
# map[k]v结构k 会变成全小写,所以这儿不能用大写字母
resource:
# 欢迎新成员表情包
welcome-new:
type: emotion
path: 58e4150be2bba8f7b71974b10391f9e9
# 水群排行榜词云,只能是图片,末尾的`\%s`也是必须的
wordcloud:
type: image
path: D:\Share\wordcloud\%s

15
config.yaml.example Normal file
View File

@ -0,0 +1,15 @@
# 数据库配置
database:
type: mysql # 使用的数据库类型,可选 mysql | postgresql
host: mysql # 数据库地址(使用docker-compose启动可以不用改)
port: 3306 # 数据库端口
username: pixiu # 数据库用户名
password: pixiu # 数据库密码
database: pixiu # 数据库名
# Redis配置
redis:
host: redis # Redis地址(使用docker-compose启动可以不用改)
port: 6379 # Redis端口
password: mNhgeSk32fUf69C6
db: 0

View File

@ -1,19 +0,0 @@
package config
// ai
// @description: AI配置
type ai struct {
Enable bool `json:"enable" yaml:"enable"` // 是否启用AI
Model string `json:"model" yaml:"model"` // 模型
ApiKey string `json:"apiKey" yaml:"apiKey"` // API Key
BaseUrl string `json:"baseUrl" yaml:"baseUrl"` // API地址
Personality string `json:"personality" yaml:"personality"` // 人设
Models []aiModel `json:"models" yaml:"models"` // 模型列表
}
// aiModel
// @description: AI模型
type aiModel struct {
Name string `json:"name" yaml:"name"` // 模型名称
Model string `json:"model" yaml:"model"` // 模型代码
}

View File

@ -6,10 +6,7 @@ var Conf conf
// Config
// @description: 配置
type conf struct {
Task task `json:"task" yaml:"task"` // 定时任务配置
MySQL mysql `json:"mysql" yaml:"mysql"` // MySQL 配置
Wechat wechat `json:"wechat" yaml:"wechat"` // 微信助手
Mq mq `json:"mq" yaml:"mq"` // MQ 配置
Ai ai `json:"ai" yaml:"ai"` // AI配置
Resource map[string]resourceItem `json:"resource" yaml:"resource"` // 资源配置
Database db `json:"database" yaml:"database"` // 数据库 配置
Redis redis `json:"redis" yaml:"redis"` // Redis 配置
Mq mq `json:"mq" yaml:"mq"` // MQ 配置
}

32
config/db.go Normal file
View File

@ -0,0 +1,32 @@
package config
import "fmt"
// db
// @description: 数据库配置
type db struct {
Type string `json:"type" yaml:"type" mapstructure:"type"` // 数据库类型
Host string `json:"host" yaml:"host" mapstructure:"host"` // 数据库地址
Port int `json:"port" yaml:"port" mapstructure:"port"` // 数据库端口
Username string `json:"username" yaml:"username" mapstructure:"username"` // 数据库用户名
Password string `json:"password" yaml:"password" mapstructure:"password"` // 数据库密码
Database string `json:"database" yaml:"database" mapstructure:"database"` // 数据库名称
}
// GetMysqlDSN
// @description: 获取MySQL连接DSN
// @receiver d
// @return string
func (d db) GetMysqlDSN() string {
return fmt.Sprintf("%s:%s@tcp(%s:%v)/%s?charset=utf8mb4&parseTime=True&loc=Local",
d.Username, d.Password, d.Host, d.Port, d.Database)
}
// GetPostgreSQLDSN
// @description: 获取PostgreSQL连接DSN
// @receiver d
// @return string
func (d db) GetPostgreSQLDSN() string {
return fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%d sslmode=disable TimeZone=Asia/Shanghai",
d.Host, d.Username, d.Password, d.Database, d.Port)
}

View File

@ -1,24 +0,0 @@
package config
import (
"fmt"
)
// mysql
// @description: MySQL配置
type mysql struct {
Host string `mapstructure:"host" yaml:"host"` // 主机
Port int `mapstructure:"port" yaml:"port"` // 端口
User string `mapstructure:"user" yaml:"user"` // 用户名
Password string `mapstructure:"password" yaml:"password"` // 密码
Db string `mapstructure:"db" yaml:"db"` // 数据库名称
}
// GetDSN
// @description: 返回 MySQL 连接字符串
// @receiver c
// @return string
func (c mysql) GetDSN() string {
return fmt.Sprintf("%s:%s@tcp(%s:%v)/%s?charset=utf8mb4&parseTime=True&loc=Local",
c.User, c.Password, c.Host, c.Port, c.Db)
}

15
config/redis.go Normal file
View File

@ -0,0 +1,15 @@
package config
import "fmt"
// Redis配置
type redis struct {
Host string `mapstructure:"host" yaml:"host"` // 主机
Port int `mapstructure:"port" yaml:"port"` // 端口
Password string `mapstructure:"password" yaml:"password"` // 密码
Db int `mapstructure:"db" yaml:"db"` // 数据库名称
}
func (r redis) GetDSN() string {
return fmt.Sprintf("%s:%v", r.Host, r.Port)
}

View File

@ -1,8 +0,0 @@
package config
// resourceItem
// @description: 资源项
type resourceItem struct {
Type string `json:"type" yaml:"type"` // 类型
Path string `json:"path" yaml:"path"` // 路径
}

View File

@ -1,32 +0,0 @@
package config
// task
// @description: 定时任务
type task struct {
Enable bool `json:"enable" yaml:"enable"` // 是否启用
SyncFriends syncFriends `json:"syncFriends" yaml:"syncFriends"` // 同步好友
WaterGroup waterGroup `json:"waterGroup" yaml:"waterGroup"` // 水群排行榜
}
// syncFriends
// @description: 同步好友
type syncFriends struct {
Enable bool `json:"enable" yaml:"enable"` // 是否启用
Cron string `json:"cron" yaml:"cron"` // 定时任务表达式
}
// waterGroup
// @description: 水群排行榜
type waterGroup struct {
Enable bool `json:"enable" yaml:"enable"` // 是否启用
Cron waterGroupCron `json:"cron" yaml:"cron"` // 定时任务表达式
}
// waterGroupCron
// @description: 水群排行榜定时任务
type waterGroupCron struct {
Yesterday string `json:"yesterday" yaml:"yesterday"` // 昨日排行榜
Week string `json:"week" yaml:"week"` // 周排行榜
Month string `json:"month" yaml:"month"` // 月排行榜
Year string `json:"year" yaml:"year"` // 年排行榜
}

View File

@ -1,35 +0,0 @@
package config
import "strings"
// wxHelper
// @description: 微信助手
type wechat struct {
Host string `json:"host" yaml:"host"` // 接口地址
VncUrl string `json:"vncUrl" yaml:"vncUrl"` // vnc页面地址
AutoSetCallback bool `json:"autoSetCallback" yaml:"autoSetCallback"` // 是否自动设置回调地址
Callback string `json:"callback" yaml:"callback"` // 回调地址
Forward []string `json:"forward" yaml:"forward"` // 转发地址
}
// Check
// @description: 检查配置是否可用
// @receiver w
// @return bool
func (w wechat) Check() bool {
if w.Host == "" {
return false
}
if w.AutoSetCallback && w.Callback == "" {
return false
}
return true
}
func (w wechat) GetURL(uri string) string {
host := w.Host
if !strings.HasPrefix(w.Host, "http://") {
host = "http://" + w.Host
}
return host + uri
}

View File

@ -1,48 +0,0 @@
version: '3.9'
services:
wechat:
image: lxh01/wxhelper-docker:3.9.5.81-v11
container_name: gw-wechat
restart: unless-stopped
environment:
- WINEDEBUG=fixme-all
volumes:
- ./data/wechat:/home/app/.wine/drive_c/users/app/Documents/WeChat\ Files
ports:
- "19087:8080"
- "19088:19088"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:19088/api/checkLogin"]
interval: 60s
timeout: 10s
retries: 5
mysql:
image: mysql:8
container_name: gw-db
restart: unless-stopped
depends_on:
wechat:
condition: service_healthy
environment:
- MYSQL_ROOT_PASSWORD=wechat
- MYSQL_USER=wechat
- MYSQL_PASSWORD=wechat
- MYSQL_DATABASE=wechat
volumes:
- ./data/db:/var/lib/mysql
wxhelper:
image: gitee.ltd/lxh/go-wxhelper:latest
container_name: gw-service
restart: unless-stopped
depends_on:
- mysql
volumes:
# 配置文件请参阅项目根目录的config.yaml文件
- ./config/config.yaml:/app/config.yaml
ports:
- "19099:19099"

View File

@ -1,45 +0,0 @@
package entity
import (
"time"
)
// Friend
// @description: 好友列表
type Friend struct {
Wxid string `json:"wxid"` // 微信原始Id
CustomAccount string `json:"customAccount"` // 微信号
Nickname string `json:"nickname"` // 昵称
Pinyin string `json:"pinyin"` // 昵称拼音大写首字母
PinyinAll string `json:"pinyinAll"` // 昵称全拼
LastActive time.Time `json:"lastActive"` // 最后活跃时间
EnableAi bool `json:"enableAI" gorm:"type:tinyint(1) default 0 not null"` // 是否使用AI
AiModel string `json:"aiModel"` // AI模型
EnableChatRank bool `json:"enableChatRank" gorm:"type:tinyint(1) default 0 not null"` // 是否使用聊天排行
EnableWelcome bool `json:"enableWelcome" gorm:"type:tinyint(1) default 0 not null"` // 是否启用迎新
IsOk bool `json:"isOk" gorm:"type:tinyint(1) default 0 not null"` // 是否正常
}
func (Friend) TableName() string {
return "t_friend"
}
// GroupUser
// @description: 群成员
type GroupUser struct {
GroupId string `json:"groupId"` // 群Id
Wxid string `json:"wxid"` // 微信Id
Account string `json:"account"` // 账号
HeadImage string `json:"headImage"` // 头像
Nickname string `json:"nickname"` // 昵称
IsMember bool `json:"isMember" gorm:"type:tinyint(1) default 0 not null"` // 是否群成员
IsAdmin bool `json:"isAdmin" gorm:"type:tinyint(1) default 0 not null"` // 是否群主
JoinTime time.Time `json:"joinTime"` // 加入时间
LastActive time.Time `json:"lastActive"` // 最后活跃时间
LeaveTime *time.Time `json:"leaveTime"` // 离开时间
SkipChatRank bool `json:"skipChatRank" gorm:"type:tinyint(1) default 0 not null"` // 是否跳过聊天排行
}
func (GroupUser) TableName() string {
return "t_group_user"
}

View File

@ -1,25 +0,0 @@
package entity
import (
"go-wechat/types"
"time"
)
// Message
// @description: 消息数据库结构体
type Message struct {
MsgId int64 `gorm:"primaryKey"` // 消息Id
CreateTime int // 发送时间戳
CreateAt time.Time // 发送时间
Type types.MessageType // 消息类型
Content string // 内容
DisplayFullContent string // 显示的完整内容
FromUser string // 发送者
GroupUser string // 群成员
ToUser string // 接收者
Raw string // 原始通知字符串
}
func (Message) TableName() string {
return "t_message"
}

View File

@ -1,13 +0,0 @@
package entity
// PluginData
// @description: 插件数据
type PluginData struct {
UserId string `json:"userId"` // 用户Id
PluginCode string `json:"pluginCode"` // 插件编码
Data string `json:"data"` // 数据
}
func (PluginData) TableName() string {
return "t_plugin_data"
}

71
go.mod
View File

@ -1,44 +1,76 @@
module go-wechat
module wechat-robot
go 1.21
require (
github.com/duke-git/lancet/v2 v2.2.8
gitee.ltd/lxh/logger v1.0.15
github.com/fsnotify/fsnotify v1.7.0
github.com/gin-gonic/gin v1.9.1
github.com/go-co-op/gocron v1.37.0
github.com/go-oauth2/oauth2/v4 v4.5.2
github.com/go-oauth2/redis/v4 v4.1.1
github.com/go-playground/locales v0.14.1
github.com/go-playground/universal-translator v0.18.1
github.com/go-playground/validator/v10 v10.17.0
github.com/go-redis/redis/v8 v8.11.5
github.com/go-resty/resty/v2 v2.11.0
github.com/rabbitmq/amqp091-go v1.9.0
github.com/sashabaranov/go-openai v1.17.11
github.com/google/uuid v1.6.0
github.com/mojocn/base64Captcha v1.3.6
github.com/rabbitmq/amqp091-go v1.2.0
github.com/spf13/viper v1.18.2
golang.org/x/crypto v0.18.0
gorm.io/driver/mysql v1.5.2
gorm.io/gorm v1.25.5
gorm.io/driver/postgres v1.5.4
gorm.io/gorm v1.25.6
gorm.io/plugin/soft_delete v1.2.1
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.10.2 // indirect
github.com/caarlos0/env/v6 v6.10.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.16.0 // indirect
github.com/go-kit/kit v0.13.0 // indirect
github.com/go-kit/log v0.2.1 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-sql-driver/mysql v1.7.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.5.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect
github.com/jackc/pgx/v5 v5.5.2 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jpillora/backoff v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/leodido/go-urn v1.3.0 // indirect
github.com/lixh00/loki-client-go v1.0.1 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect
github.com/natefinch/lumberjack v2.0.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_golang v1.18.0 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.46.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/prometheus/prometheus v1.8.2-0.20201028100903-3245b3267b24 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
@ -47,17 +79,32 @@ require (
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tidwall/btree v1.7.0 // indirect
github.com/tidwall/buntdb v1.3.0 // indirect
github.com/tidwall/gjson v1.17.0 // indirect
github.com/tidwall/grect v0.1.4 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/rtred v0.1.2 // indirect
github.com/tidwall/tinyqueue v0.1.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
golang.org/x/arch v0.7.0 // indirect
golang.org/x/crypto v0.18.0 // indirect
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 // indirect
golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect
golang.org/x/image v0.15.0 // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/oauth2 v0.16.0 // indirect
golang.org/x/sync v0.6.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240125205218-1f4bbc51befe // indirect
google.golang.org/grpc v1.61.0 // indirect
google.golang.org/protobuf v1.32.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

1298
go.sum

File diff suppressed because it is too large Load Diff

View File

@ -1,46 +0,0 @@
package initialization
import (
"go-wechat/common/current"
"go-wechat/model"
plugin "go-wechat/plugin"
"go-wechat/plugin/plugins"
"go-wechat/service"
)
// Plugin
// @description: 初始化插件
func Plugin() {
// 定义一个处理器
dispatcher := plugin.NewMessageMatchDispatcher()
// 设置为异步处理
dispatcher.SetAsync(true)
// 注册插件
// 保存消息进数据库
dispatcher.RegisterHandler(func(*model.Message) bool {
return true
}, plugins.SaveToDb)
// 私聊指令消息
dispatcher.RegisterHandler(func(m *model.Message) bool {
// 私聊消息 或 群聊艾特机器人并且以/开头的消息
isGroupAt := m.IsAt() && !m.IsAtAll()
return (m.IsPrivateText() || isGroupAt) && m.CleanContentStartWith("/") && service.CheckIsEnableCommand(m.FromUser)
}, plugins.Command)
// AI消息插件
dispatcher.RegisterHandler(func(m *model.Message) bool {
// 群内@或者私聊文字消息
return (m.IsAt() && !m.IsAtAll()) || m.IsPrivateText()
}, plugins.AI)
// 欢迎新成员
dispatcher.RegisterHandler(func(m *model.Message) bool {
return m.IsNewUserJoin()
}, plugins.WelcomeNew)
// 注册消息处理器
current.SetRobotMessageHandler(plugin.DispatchMessage(dispatcher))
}

View File

@ -1,31 +0,0 @@
package initialization
import (
"github.com/go-resty/resty/v2"
"go-wechat/common/current"
"go-wechat/config"
"go-wechat/model"
"log"
)
// InitWechatRobotInfo
// @description: 初始化微信机器人信息
func InitWechatRobotInfo() {
// 获取数据
var base model.Response[model.RobotUserInfo]
_, err := resty.New().R().
SetHeader("Content-Type", "application/json;chartset=utf-8").
SetResult(&base).
Post(config.Conf.Wechat.GetURL("/api/userInfo"))
if err != nil {
log.Printf("获取机器人信息失败: %s", err.Error())
return
}
log.Printf("机器人Id: %s", base.Data.WxId)
log.Printf("机器人微信号: %s", base.Data.Account)
log.Printf("机器人名称: %s", base.Data.Name)
// 设置为单例
current.SetRobotInfo(base.Data)
}

View File

@ -0,0 +1,49 @@
package database
import (
"gitee.ltd/lxh/logger"
"gitee.ltd/lxh/logger/log"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"os"
"strconv"
"wechat-robot/config"
)
// Client 客户端
var Client *gorm.DB
// Init
// @description: 初始化数据库连接
func Init() {
var dialector gorm.Dialector
switch config.Conf.Database.Type {
case "mysql":
// MySQL
dialector = mysql.Open(config.Conf.Database.GetMysqlDSN())
case "postgresql":
// PostgreSQL
dialector = postgres.Open(config.Conf.Database.GetPostgreSQLDSN())
default:
log.Panic("未配置数据库或数据库类型不支持")
}
// gorm 配置
gormConfig := gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
}
// 是否开启调试模式
if flag, _ := strconv.ParseBool(os.Getenv("GORM_DEBUG")); flag {
gormConfig.Logger = logger.DefaultGormLogger()
}
// 初始化连接
conn, err := gorm.Open(dialector, &gormConfig)
if err != nil {
log.Panicf("数据库连接初始化失败: %v", err)
} else {
log.Debug("数据库连接初始化成功")
}
Client = conn
}

View File

@ -0,0 +1,72 @@
package initialize
import (
"errors"
"gitee.ltd/lxh/logger/log"
"gorm.io/gorm"
"wechat-robot/internal/database"
"wechat-robot/model/entity"
"wechat-robot/utils"
)
// initDefaultAdminUser
// @description: 初始化默认后台用户
func initDefaultAdminUser() {
// 如果数据库没有有效用户,初始化一个默认账号
var count int64
if err := database.Client.Model(entity.AdminUser{}).Count(&count).Error; err != nil {
log.Panicf("初始化默认账号失败: %s", err.Error())
}
if count > 0 {
return
}
// 开启事务
var err error
tx := database.Client.Begin()
defer func() {
if recover() != nil {
tx.Rollback()
} else {
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}
}()
// 判断角色是否存在
var role entity.Role
if err = tx.Where("code = ?", "admin").First(&role).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
log.Panicf("初始化默认账号失败: %s", err.Error())
return
}
// 如果角色不存在,创建角色
if role.Id == "" {
role.Name = "超级管理员"
role.Code = "admin"
role.Describe = "系统默认超级管理员"
if err = tx.Create(&role).Error; err != nil {
log.Panicf("初始化默认账号失败: %s", err.Error())
return
}
}
// 创建用户
var adminUser entity.AdminUser
adminUser.Username = "admin"
adminUser.Password = "admin123"
utils.PasswordUtils().HashPassword(&adminUser.Password)
if err = tx.Create(&adminUser).Error; err != nil {
log.Panicf("初始化默认账号失败: %s", err.Error())
return
}
// 绑定角色
var userRole entity.AdminUserRole
userRole.RoleId = role.Id
userRole.UserId = adminUser.Id
if err = tx.Create(&userRole).Error; err != nil {
log.Panicf("初始化默认账号失败: %s", err.Error())
return
}
}

View File

@ -1,19 +1,20 @@
package initialization
package initialize
import (
"gitee.ltd/lxh/logger/log"
"github.com/fsnotify/fsnotify"
"github.com/spf13/viper"
"go-wechat/client"
"go-wechat/config"
"log"
"wechat-robot/config"
"wechat-robot/internal/database"
"wechat-robot/internal/redis"
)
// 配置管理工具
var vp *viper.Viper
// InitConfig
// Config
// @description: 初始化配置
func InitConfig() {
func initConfig() {
vp = viper.New()
vp.AddConfigPath(".") // 设置配置文件路径
vp.SetConfigName("config") // 设置配置文件名
@ -26,27 +27,21 @@ func InitConfig() {
if err := vp.Unmarshal(&config.Conf); err != nil {
log.Panicf("配置文件解析失败: %v", err)
}
log.Printf("配置文件解析完成: %+v", config.Conf)
if !config.Conf.Wechat.Check() {
log.Panicf("微信HOOK配置缺失")
}
log.Debugf("配置文件解析完成: %+v", config.Conf)
// 初始化数据库连接
client.InitMySQLClient()
//redis.Init()
database.Init()
redis.Init()
// 下面的代码是配置变动之后自动刷新的
vp.WatchConfig()
vp.OnConfigChange(func(e fsnotify.Event) {
// 绑定配置文件
if err := vp.Unmarshal(&config.Conf); err != nil {
log.Printf("配置文件更新失败: %v", err)
log.Errorf("配置文件更新失败: %v", err)
} else {
if !config.Conf.Wechat.Check() {
log.Panicf("微信HOOK配置缺失")
}
// 初始化数据库连接
client.InitMySQLClient()
//redis.Init()
database.Init()
redis.Init()
}
})
}

View File

@ -0,0 +1,28 @@
package initialize
import (
"gitee.ltd/lxh/logger/log"
"wechat-robot/internal/database"
"wechat-robot/model/entity"
)
// databaseTable
// @description: 初始化数据库表
func databaseTable() {
tables := []any{
new(entity.AdminUser), // 用户表
new(entity.Menu), // 菜单表
new(entity.Role), // 角色表
new(entity.RoleMenu), // 角色菜单表
new(entity.AdminUserRole), // 用户角色表
new(entity.SystemConfig), // 系统配置表
new(entity.Robot), // 机器人表
new(entity.AiAssistant), // AI助手表
new(entity.Message), // 微信消息表
}
// 同步表结构
if err := database.Client.AutoMigrate(tables...); err != nil {
log.Panicf("初始化数据库表失败: %v", err)
}
}

View File

@ -0,0 +1,18 @@
package initialize
import (
"wechat-robot/internal/tasks"
"wechat-robot/mq"
"wechat-robot/pkg/auth"
)
// InitSystem
// @description: 初始化系统
func InitSystem() {
initConfig() // 初始化配置
databaseTable() // 初始化数据库表
initDefaultAdminUser() // 初始化默认管理员用户
auth.InitOAuth2Server() // 初始化OAuth2服务
tasks.StartScheduled() // 启动定时任务
mq.Init() // 初始化MQ
}

View File

@ -0,0 +1,56 @@
package message
import (
"encoding/json"
"log"
"strings"
"time"
"wechat-robot/model/entity"
"wechat-robot/model/robot"
"wechat-robot/pkg/types"
"wechat-robot/service/message"
)
// Message
// @description: 处理消息
// @param msg
// @return err
func Message(msg []byte) (err error) {
var m robot.Message
if err = json.Unmarshal(msg, &m); err != nil {
log.Printf("消息解析失败: %v", err)
log.Printf("消息内容: %d -> %v", len(msg), string(msg))
return
}
// 记录原始数据
m.Raw = string(msg)
// 提取出群成员信息
// Sys类型的消息正文不包含微信 Id所以不需要处理
if m.IsGroup() && m.Type != types.MsgTypeSys {
// 群消息,处理一下消息和发信人
groupUser := strings.Split(m.Content, "\n")[0]
groupUser = strings.ReplaceAll(groupUser, ":", "")
// 如果两个id一致说明是系统发的
if m.FromUser != groupUser {
m.GroupUser = groupUser
}
// 用户的操作单独提出来处理一下
m.Content = strings.Join(strings.Split(m.Content, "\n")[1:], "\n")
}
log.Printf("收到微信消息\n机器人Id: %s\n消息来源: %s\n群成员: %s\n消息类型: %v\n消息内容: %s", m.ToUser, m.FromUser, m.GroupUser, m.Type, m.Content)
// 消息入库
var ent entity.Message
ent.MsgId = m.MsgId
ent.Timestamp = m.CreateTime
ent.MessageTime = time.Unix(int64(m.CreateTime), 0)
ent.Content = m.Content
ent.FromUser = m.FromUser
ent.GroupUser = m.GroupUser
ent.ToUser = m.ToUser
ent.Type = m.Type
ent.DisplayFullContent = m.DisplayFullContent
ent.Raw = m.Raw
err = message.Save(ent)
return
}

30
internal/orm/page.go Normal file
View File

@ -0,0 +1,30 @@
package orm
import "gorm.io/gorm"
// Page
// @description: 分页组件
// @param current
// @param size
// @return func(db *gorm.DB) *gorm.DB
func Page(current, size int) func(db *gorm.DB) *gorm.DB {
// 如果页码是-1就不分页
if current == -1 {
return func(db *gorm.DB) *gorm.DB {
return db
}
}
// 分页
return func(db *gorm.DB) *gorm.DB {
if current == 0 {
current = 1
}
if size < 1 {
size = 10
}
// 计算偏移量
offset := (current - 1) * size
// 返回组装结果
return db.Offset(offset).Limit(size)
}
}

148
internal/orm/sort.go Normal file
View File

@ -0,0 +1,148 @@
package orm
import (
"fmt"
"gitee.ltd/lxh/logger/log"
"gorm.io/gorm"
"slices"
)
// @title checkHasDeletedAt
// @description 检查指定表是否有id_del字段
// @param tx *gorm.DB "已开启的事务对象"
// @param tableName string "表名"
// @return bool "是否包含"
func checkHasDeletedAt(tx *gorm.DB, tableName string) bool {
var columns []string
// SQL语句
sql := fmt.Sprintf("select COLUMN_NAME from information_schema.COLUMNS where table_name = '%s'", tableName)
err := tx.Raw(sql).Scan(&columns).Error
if err != nil {
log.Errorf("查询表字段失败: %v", err.Error())
return false
}
return slices.Contains(columns, "id_del")
}
// @title checkHasUpdatedAt
// @description 检查指定表是否有updated_at字段
// @param tx *gorm.DB "已开启的事务对象"
// @param tableName string "表名"
// @return bool "是否包含"
func checkHasUpdatedAt(tx *gorm.DB, tableName string) bool {
var columns []string
// SQL语句
sql := fmt.Sprintf("select COLUMN_NAME from information_schema.COLUMNS where table_name = '%s'", tableName)
err := tx.Raw(sql).Scan(&columns).Error
if err != nil {
log.Errorf("查询表字段失败: %v", err.Error())
return false
}
return slices.Contains(columns, "updated_at")
}
// UpdateSortBefore
// @description 更新之前处理序号
// @param tx *gorm.DB "已开启的事务对象"
// @param model any "模型对象"
// @return error "错误信息"
func UpdateSortBefore(tx *gorm.DB, tableName, id string, sort int, param string) (err error) {
// 查出原来的排序号
var oldSort int
err = tx.Table(tableName).Select("`sort`").Where("id = ?", id).Scan(&oldSort).Error
if err != nil {
log.Errorf("查询老数据失败: %v", err.Error())
return
}
// 如果相等,啥都不干
if oldSort == sort {
return nil
}
// 查询是否包含 id_del 字段
hasDeletedAt := checkHasDeletedAt(tx, tableName)
// 处理排序
// 如果老的排序号小于新的,(老, 新]之间的排序号都要-1
// 如果老的大于新的,[老, 新)排序号-1
if oldSort < sort {
// 老的小于新的,[老, 新) + 1
sel := tx.Table(tableName).
Where("sort <= ? AND sort > ?", sort, oldSort)
if hasDeletedAt {
sel.Where("id_del = 0")
}
if param != "" {
sel.Where(param) // 自定义条件
}
err = sel.Update("sort", gorm.Expr("sort - 1")).Error
} else {
// 老的大于新的,[新, 老) + 1
sel := tx.Table(tableName).
Where("sort >= ? AND sort < ?", sort, oldSort)
if hasDeletedAt {
sel.Where("id_del = 0")
}
if param != "" {
sel.Where(param) // 自定义条件
}
err = sel.Update("sort", gorm.Expr("sort + 1")).Error
}
return
}
// CreateSortBefore
// @description 新建之前处理序号
// @param tx *gorm.DB "已开启的事务对象"
// @param model any "模型对象"
// @return error "错误信息"
func CreateSortBefore(tx *gorm.DB, tableName string, sort int, param string) (err error) {
// 查询是否包含 id_del 字段
hasDeletedAt := checkHasDeletedAt(tx, tableName)
// 处理排序,如果没有传,就会是在最前面
sel := tx.Table(tableName).Where("sort >= ?", sort)
if hasDeletedAt {
sel.Where("id_del = 0")
}
if param != "" {
sel.Where(param)
}
err = sel.Update("sort", gorm.Expr("sort + 1")).Error
if err != nil {
log.Errorf("处理前置排序失败:%v", err)
}
return
}
// DealSortAfter
// @description 处理序号之后
// @param tx *gorm.DB "已开启的事务对象"
// @param modelName string "表名"
// @return error "错误信息"
func DealSortAfter(tx *gorm.DB, modelName, param string) (err error) {
// 保存成功,刷新排序
if param != "" {
param = " AND " + param
}
// 查询是否包含 id_del 字段
hasDeletedAt := checkHasDeletedAt(tx, modelName)
if hasDeletedAt {
param += " AND id_del = 0"
}
// 如果有更新时间字段,也更新一下值
updateParam := ""
if checkHasUpdatedAt(tx, modelName) {
updateParam = ",updated_at = NOW()"
}
sql := fmt.Sprintf("UPDATE %s a, (SELECT (@i := @i + 1) i, id FROM %s WHERE 1=1 %s order by sort ASC) i, "+
"(SELECT @i := 0) ir SET a.sort = i.i %s WHERE a.id = i.id", modelName, modelName, param, updateParam)
err = tx.Exec(sql).Error
if err != nil {
log.Errorf("刷新排序失败: %v", err.Error())
}
return
}

28
internal/redis/redis.go Normal file
View File

@ -0,0 +1,28 @@
package redis
import (
"context"
"gitee.ltd/lxh/logger/log"
"github.com/go-redis/redis/v8"
"wechat-robot/config"
)
var Client *redis.Client
// Init
// @description: 初始化redis客户端
func Init() {
conf := config.Conf.Redis
// 初始化连接
conn := redis.NewClient(&redis.Options{
Addr: conf.GetDSN(),
Password: conf.Password,
DB: conf.Db,
})
if err := conn.Ping(context.Background()).Err(); err != nil {
log.Panicf("Redis连接初始化失败: %v", err)
} else {
log.Debug("Redis连接初始化成功")
}
Client = conn
}

20
internal/tasks/tasks.go Normal file
View File

@ -0,0 +1,20 @@
package tasks
import (
"github.com/go-co-op/gocron"
"log"
"time"
)
var Scheduler *gocron.Scheduler
// StartScheduled
// @description: 启动定时任务
func StartScheduled() {
// 定时任务发送消息
Scheduler = gocron.NewScheduler(time.Local)
// 开启定时任务
Scheduler.StartAsync()
log.Println("定时任务初始化成功")
}

84
main.go
View File

@ -1,71 +1,49 @@
package main
import (
"gitee.ltd/lxh/logger"
"gitee.ltd/lxh/logger/log"
"github.com/gin-gonic/gin"
"go-wechat/config"
"go-wechat/initialization"
"go-wechat/mq"
"go-wechat/router"
"go-wechat/tasks"
"go-wechat/tcpserver"
"go-wechat/utils"
"html/template"
"log"
"net/http"
"strings"
"time"
"wechat-robot/internal/initialize"
"wechat-robot/pkg/validator"
"wechat-robot/router/admin"
"wechat-robot/router/callback"
"wechat-robot/router/middleware"
)
// init
// @description: 初始系统
func init() {
initialization.InitConfig() // 初始化配置
initialization.InitWechatRobotInfo() // 初始化机器人信息
initialization.Plugin() // 注册插件
tasks.InitTasks() // 初始化定时任务
mq.Init() // 初始化MQ
// 初始化日志工具
logger.InitLogger(logger.LogConfig{Mode: logger.Dev, LokiEnable: false, FileEnable: true})
// 初始化系统
initialize.InitSystem()
}
// main
// @description: 启动入口
func main() {
// 如果启用了自动配置回调,就设置一下
if config.Conf.Wechat.AutoSetCallback {
utils.ClearCallback()
time.Sleep(500 * time.Millisecond) // 休眠五百毫秒再设置
utils.SetCallback(config.Conf.Wechat.Callback)
}
// 启动TCP服务
go tcpserver.Start()
//go tcpserver.Start()
// 启动HTTP服务
// 注册参数绑定错误信息翻译器
validator.Init()
app := gin.Default()
// 自定义模板引擎函数
app.SetFuncMap(template.FuncMap{
"checkSwap": func(flag bool) string {
if flag {
return "swap-active"
}
return ""
},
})
app.LoadHTMLGlob("views/*.html")
app.Static("/assets", "./views/static")
app.StaticFile("/favicon.ico", "./views/wechat.ico")
// 开启自定义请求方式不允许处理函数
app.HandleMethodNotAllowed = true
// 处理请求方式不对
app.NoMethod(middleware.NoMethodHandler())
// 404返回数据
app.NoRoute(func(ctx *gin.Context) {
if strings.HasPrefix(ctx.Request.URL.Path, "/api") {
ctx.String(404, "接口不存在")
return
}
// 404直接跳转到首页
ctx.Redirect(302, "/index.html")
})
app.NoMethod(func(ctx *gin.Context) {
ctx.String(http.StatusMethodNotAllowed, "不支持的请求方式")
})
// 初始化路由
router.Init(app)
app.NoRoute(middleware.NoRouteHandler())
// 初始化接口路由
admin.InitRoute(app.Group("/admin/v1")) // 后台接口
callback.InitRoute(app.Group("/callback")) // 回调接口
// 启动服务
if err := app.Run(":8080"); err != nil {
log.Panicf("服务启动失败:%v", err)
log.Errorf("服务启动失败:%v", err)
return
}
}

23
model/entity/adminuser.go Normal file
View File

@ -0,0 +1,23 @@
package entity
import "wechat-robot/pkg/types"
// AdminUser
// @description: 用户表
type AdminUser struct {
types.BaseDbModel
Username string `json:"username" gorm:"index:deleted,unique;type:varchar(255); not null; comment:'登录账号'"`
Password string `json:"password" gorm:"type:varchar(255); comment:'密码'"`
Email *string `json:"email" gorm:"type:varchar(255); comment:'邮箱'"`
IsVerified bool `json:"isVerified" gorm:"type:tinyint(1); not null; default:0; comment:'是否验证邮箱'"`
LastLoginAt *types.DateTime `json:"lastLoginAt" gorm:"comment:'最后登录时间'"`
LastLoginIp *string `json:"lastLoginIp" gorm:"type:varchar(255); comment:'最后登录ip'"`
}
// TableName
// @description: 表名
// @receiver User
// @return string
func (AdminUser) TableName() string {
return "t_admin_user"
}

View File

@ -0,0 +1,21 @@
package entity
import "wechat-robot/pkg/types"
// AiAssistant
// @description: AI助手表
type AiAssistant struct {
types.BaseDbModel
Name string `json:"name" gorm:"type:varchar(10);not null;comment:'名称'"`
Personality string `json:"personality" gorm:"type:varchar(999);not null;comment:'人设'"`
Model string `json:"model" gorm:"type:varchar(50);not null;comment:'使用的模型'"`
Enable bool `json:"enable" gorm:"type:tinyint(1);not null;default:1;comment:'是否启用'"`
}
// TableName
// @description: 表名
// @receiver AiAssistant
// @return string
func (AiAssistant) TableName() string {
return "t_ai_assistant"
}

24
model/entity/menu.go Normal file
View File

@ -0,0 +1,24 @@
package entity
import "wechat-robot/pkg/types"
// Menu
// @description: 菜单表
type Menu struct {
types.BaseDbModel
Type types.MenuType `json:"type" gorm:"type:enum('MENU','BUTTON'); default:'MENU'; not null; comment:'菜单类型(菜单或按钮)'"`
Name string `json:"name" gorm:"type:varchar(255);not null;comment:'页面组件名称'"`
Path string `json:"path" gorm:"type:varchar(255);comment:'路径'"`
Title string `json:"title" gorm:"type:varchar(255);not null;comment:'菜单标题'"`
Icon string `json:"icon" gorm:"type:varchar(255);comment:'菜单图标'"`
Sort int `json:"sort" gorm:"type:int(3);not null;comment:'排序值(数字越小约靠前)'"`
ParentId *string `json:"parentId" gorm:"type:varchar(32);comment:'父级菜单ID(0表示顶级菜单)'"`
}
// TableName
// @description: 表名
// @receiver Menu
// @return string
func (Menu) TableName() string {
return "t_menu"
}

26
model/entity/message.go Normal file
View File

@ -0,0 +1,26 @@
package entity
import (
"time"
"wechat-robot/pkg/types"
)
// Message
// @description: 消息数据库结构体
type Message struct {
types.BaseDbModelWithReal
MsgId int64 `gorm:"index:idx_msg_id,unique;type:bigint;not null;comment:'微信Id'"` // 消息Id
Timestamp int `gorm:"type:bigint;not null;comment:'消息时间戳'"` // 发送时间戳
MessageTime time.Time `gorm:"type:datetime;not null;comment:'消息时间'"` // 发送时间
Type types.MessageType `gorm:"type:int;not null;comment:'消息类型'"` // 消息类型
Content string `gorm:"type:longtext;not null;comment:'消息内容'"` // 内容
DisplayFullContent string `gorm:"type:longtext;not null;comment:'显示的完整内容'"` // 显示的完整内容
FromUser string `gorm:"type:varchar(100);not null;comment:'发送者'"` // 发送者
GroupUser string `gorm:"type:varchar(100);comment:'群成员'"` // 群成员
ToUser string `gorm:"index:idx_msg_id,unique;type:varchar(100);not null;comment:'接收者'"` // 接收者
Raw string `gorm:"type:longtext;not null;comment:'原始通知字符串'"` // 原始通知字符串
}
func (Message) TableName() string {
return "t_message"
}

30
model/entity/robot.go Normal file
View File

@ -0,0 +1,30 @@
package entity
import "wechat-robot/pkg/types"
// Robot
// @description: 机器人信息
type Robot struct {
types.BaseDbModel
WxId string `json:"wxid" gorm:"index:deleted,unique;column:wxid;type:varchar(255);not null;comment:'微信Id'"` // 微信Id
Account string `json:"account" gorm:"type:varchar(255);comment:'微信号'"` // 微信号
Nickname string `json:"name" gorm:"type:varchar(255);comment:'昵称'"` // 昵称
Avatar string `json:"avatar" gorm:"type:varchar(255);comment:'头像'"` // 头像
Mobile string `json:"mobile" gorm:"type:varchar(255);comment:'手机号'"` // 手机
CurrentDataPath string `json:"currentDataPath" gorm:"type:varchar(255);comment:'当前数据目录'"` // 当前数据目录,登录的账号目录
DataSavePath string `json:"dataSavePath" gorm:"type:varchar(255);comment:'微信保存目录'"` // 微信保存目录
DbKey string `json:"dbKey" gorm:"type:varchar(255);comment:'数据库的SQLCipher的加密key'"` // 数据库的SQLCipher的加密key可以使用该key配合decrypt.py解密数据库
HookApi string `json:"hookApi" gorm:"type:varchar(255);comment:'hook接口地址'"` // hook接口地址
Remark string `json:"remark" gorm:"type:varchar(255);comment:'备注'"` // 备注
Version int `json:"version" gorm:"type:int;comment:'版本号'"` // 版本号
VncUrl string `json:"vncUrl" gorm:"type:varchar(255);comment:'vnc地址'"` // vnc地址
Tag string `json:"tag" gorm:"type:varchar(255);comment:'标签'"` // 标签
}
// TableName
// @description: 表名
// @receiver Robot
// @return string
func (Robot) TableName() string {
return "t_robot"
}

56
model/entity/role.go Normal file
View File

@ -0,0 +1,56 @@
package entity
import "wechat-robot/pkg/types"
// Role
// @description: 角色表
type Role struct {
types.BaseDbModel
Name string `json:"name" gorm:"type:varchar(20) not null comment '角色名称'"`
Code string `json:"code" gorm:"type:varchar(20) not null comment '角色代码'"`
Describe string `json:"describe" gorm:"type:varchar(20) not null comment '描述'"`
}
// TableName
// @description: 表名
// @receiver Role
// @return string
func (Role) TableName() string {
return "t_role"
}
// =====================================================================================================================
// RoleMenu
// @description: 角色菜单表
type RoleMenu struct {
types.BaseDbModelWithReal
RoleId string `json:"roleId" gorm:"index:idx_rm_only,unique;type:varchar(32);not null;comment:'角色id'"`
MenuId string `json:"menuId" gorm:"index:idx_rm_only,unique;type:varchar(32);not null;comment:'菜单id'"`
}
// TableName
// @description: 表名
// @receiver RoleMenu
// @return string
func (RoleMenu) TableName() string {
return "t_role_menu"
}
// =====================================================================================================================
// AdminUserRole
// @description: 管理员角色表
type AdminUserRole struct {
types.BaseDbModelWithReal
RoleId string `json:"roleId" gorm:"index:idx_aur_only,unique;type:varchar(32);not null;comment:'角色id'"`
UserId string `json:"menuId" gorm:"index:idx_aur_only,unique;type:varchar(32);not null;comment:'管理员id'"`
}
// TableName
// @description: 表名
// @receiver AdminUserRole
// @return string
func (AdminUserRole) TableName() string {
return "t_admin_user_role"
}

View File

@ -0,0 +1,19 @@
package entity
import "wechat-robot/pkg/types"
// SystemConfig
// @description: 系统配置
type SystemConfig struct {
types.BaseDbModelWithReal
Code string `json:"code" gorm:"type:varchar(255);not null;comment:'配置编码'"`
Content string `json:"content" gorm:"type:text;comment:'配置内容'"`
}
// TableName
// @description: 表名
// @receiver SystemConfig
// @return string
func (SystemConfig) TableName() string {
return "t_system_config"
}

View File

@ -1,73 +0,0 @@
package model
// LeiGodLoginResp
// @description: 雷神登录返回
type LeiGodLoginResp struct {
LoginInfo struct {
AccountToken string `json:"account_token"` // Token
ExpiryTime string `json:"expiry_time"` // 有效期
NnToken string `json:"nn_token"`
} `json:"login_info"`
UserInfo struct {
Nickname string `json:"nickname"`
Email string `json:"email"`
Mobile string `json:"mobile"`
Avatar string `json:"avatar"`
RegionCode int `json:"region_code"`
} `json:"user_info"`
}
// LeiGodUserInfoResp
// @description: 雷神用户信息返回
type LeiGodUserInfoResp struct {
UserPauseTime int `json:"user_pause_time"`
Nickname string `json:"nickname"`
Email string `json:"email"`
CountryCode string `json:"country_code"`
Mobile string `json:"mobile"`
UserName string `json:"user_name"`
MasterAccount string `json:"master_account"`
Birthday string `json:"birthday"`
PublicIp string `json:"public_ip"`
Sex string `json:"sex"`
LastLoginTime string `json:"last_login_time"`
LastLoginIp string `json:"last_login_ip"`
PauseStatus string `json:"pause_status"` // 暂停状态
PauseStatusId int `json:"pause_status_id"` // 暂停状态 0未暂停1已暂停
LastPauseTime string `json:"last_pause_time"` // 最后一次暂停时间
VipLevel string `json:"vip_level"`
Avatar string `json:"avatar"`
AvatarNew string `json:"avatar_new"`
PackageId string `json:"package_id"`
IsSwitchPackage int `json:"is_switch_package"`
PackageTitle string `json:"package_title"`
PackageLevel string `json:"package_level"`
BillingType string `json:"billing_type"`
Lang string `json:"lang"`
StopedRemaining string `json:"stoped_remaining"`
ExpiryTime string `json:"expiry_time"` // 剩余时长
ExpiryTimeSamp int `json:"expiry_time_samp"` // 剩余时长秒数
Address string `json:"address"`
MobileContactType string `json:"mobile_contact_type"`
MobileContactNumber string `json:"mobile_contact_number"`
MobileContactTitle string `json:"mobile_contact_title"`
RegionCode int `json:"region_code"`
IsPayUser string `json:"is_pay_user"`
WallLogSwitch string `json:"wall_log_switch"`
IsSetAdminPass int `json:"is_set_admin_pass"`
ExpiredExperienceTime string `json:"expired_experience_time"`
ExperienceExpiryTime string `json:"experience_expiry_time"`
ExperienceTime int `json:"experience_time"`
FirstInvoiceDiscount int `json:"first_invoice_discount"`
NnNumber string `json:"nn_number"`
UserSignature string `json:"user_signature"`
MobileExpiryTime string `json:"mobile_expiry_time"`
MobileExpiryTimeSamp int `json:"mobile_expiry_time_samp"`
MobilePauseStatus int `json:"mobile_pause_status"`
BlackExpiredTime string `json:"black_expired_time"`
MobileExperienceTime string `json:"mobile_experience_time"`
SuperTime string `json:"super_time"`
NowDate string `json:"now_date"`
NowTimeSamp int `json:"now_time_samp"`
UserEarnMinutes string `json:"user_earn_minutes"`
}

View File

@ -0,0 +1,11 @@
package aiassistant
// Save
// @description: 保存AI助手入参
type Save struct {
Id string `json:"id" form:"id"` // Id
Name string `json:"name" form:"name" binding:"required"` // 名称
Personality string `json:"personality" form:"personality" binding:"required"` // 人设
Model string `json:"model" form:"model" binding:"required"` // 使用的模型
Enable bool `json:"enable" form:"enable"` // 是否启用
}

View File

@ -0,0 +1,7 @@
package aiassistant
// GetAll
// @description: 获取所有AI助手
type GetAll struct {
Keyword string `json:"keyword" form:"keyword"` // 关键字
}

View File

@ -0,0 +1,8 @@
package callback
// RobotHook
// @description: 机器人HOOK后回调
type RobotHook struct {
Robot string `json:"robot" form:"robot"` // 机器人
Status string `json:"status" form:"status"` // 状态
}

View File

@ -0,0 +1,24 @@
package login
// GetTokenWithPassword
// @description: 账号密码登录
type GetTokenWithPassword struct {
VerifyId string `json:"verifyId" form:"verifyId" binding:"required"` // 验证Id
VerifyCode string `json:"verifyCode" form:"verifyCode" binding:"required"` // 验证码
Username string `json:"username" form:"username" binding:"required"` // 用户名
Password string `json:"password" form:"password" binding:"required"` // 密码
}
// RefreshToken
// @description: 刷新Token入参
type RefreshToken struct {
RefreshToken string `json:"refresh_token" form:"refresh_token" binding:"required"` // 刷新Token
}
// Register
// @description: 注册账号
type Register struct {
Username string `json:"username" form:"username" binding:"required"` // 用户名
Password string `json:"password" form:"password" binding:"required"` // 密码
InvitationCode string `json:"invitationCode" form:"invitationCode"` // 邀请码
}

16
model/param/menu/save.go Normal file
View File

@ -0,0 +1,16 @@
package menu
import "wechat-robot/pkg/types"
// Save
// @description: 保存菜单入参
type Save struct {
Id string `json:"id" form:"id" comment:"菜单Id"`
Type types.MenuType `json:"type" form:"type" comment:"菜单类型(菜单或按钮)" validate:"oneof=MENU BUTTON"`
Name string `json:"name" form:"name" comment:"页面组件名称"`
Path string `json:"path" form:"path" comment:"访问路径"`
Title string `json:"title" form:"title" comment:"菜单标题"`
Icon string `json:"icon" form:"icon" comment:"菜单图标"`
Sort int `json:"sort" form:"sort" comment:"排序值(数字越小越靠前)"`
ParentId string `json:"parentId" form:"parentId" comment:"父级菜单ID"`
}

View File

@ -0,0 +1,13 @@
package robot
import "wechat-robot/pkg/wxhelper"
// Save
// @description: 保存机器人
type Save struct {
Id string `json:"id" form:"id"` // id
HookApi string `json:"hookApi" form:"hookApi"` // hook接口地址
Version wxhelper.WeChatVersion `json:"version" form:"version"` // 版本
VncUrl string `json:"vncUrl" form:"vncUrl"` // vnc地址
Remark string `json:"remark" form:"remark"` // 备注
}

View File

@ -0,0 +1,8 @@
package robot
// GetAll
// @description: 查询所有机器人入参
type GetAll struct {
Keyword string `json:"keyword" form:"keyword"` // 关键字
Tag string `json:"tag" form:"tag"` // 标签
}

View File

@ -0,0 +1,8 @@
package role
// GetAll
// @description: 获取所有角色
type GetAll struct {
Keyword string `json:"keyword" form:"keyword"` // 关键词
Code string `json:"code" form:"code"` // 角色代码
}

20
model/robot/chatroom.go Normal file
View File

@ -0,0 +1,20 @@
package robot
// ChatRoomDetailInfo
// @description: 群聊详情
type ChatRoomDetailInfo struct {
Admin string `json:"admin"` // 群主微信
ChatRoomId string `json:"chatRoomId"` // 群Id
Notice string `json:"notice"` // 群公告
Xml string `json:"xml"` // 不知道是啥玩意儿
}
// GroupUser
// @description: 群成员返回结果
type GroupUser struct {
Admin string `json:"admin"` // 群主微信
AdminNickname string `json:"adminNickname"` // 群主昵称
ChatRoomId string `json:"chatRoomId"` // 群Id
MemberNickname string `json:"memberNickname"` // 成员昵称 `^G`切割
Members string `json:"members"` // 成员Id `^G`切割
}

View File

@ -1,4 +1,4 @@
package model
package robot
// FriendItem
// @description: 好友列表数据
@ -12,17 +12,7 @@ type FriendItem struct {
Reserved2 int `json:"reserved2"` // 未知
Type int `json:"type"` // 类型
VerifyFlag int `json:"verifyFlag"` // 未知
Wxid string `json:"wxid"` // 微信原始Id
}
// GroupUser
// @description: 群成员返回结果
type GroupUser struct {
Admin string `json:"admin"` // 群主微信
AdminNickname string `json:"adminNickname"` // 群主昵称
ChatRoomId string `json:"chatRoomId"` // 群Id
MemberNickname string `json:"memberNickname"` // 成员昵称 `^G`切割
Members string `json:"members"` // 成员Id `^G`切割
WxId string `json:"wxid"` // 微信原始Id
}
// ContactProfile
@ -32,5 +22,5 @@ type ContactProfile struct {
HeadImage string `json:"headImage"` // 头像
Nickname string `json:"nickname"` // 昵称
V3 string `json:"v3"` // v3
Wxid string `json:"wxid"` // 微信Id
WxId string `json:"wxid"` // 微信Id
}

View File

@ -1,11 +1,11 @@
package model
package robot
import (
"encoding/xml"
"github.com/duke-git/lancet/v2/slice"
"go-wechat/types"
"regexp"
"slices"
"strings"
"wechat-robot/pkg/types"
)
// Message
@ -88,13 +88,7 @@ func (m Message) IsRevokeMsg() bool {
// @receiver m
// @return bool
func (m Message) IsNewUserJoin() bool {
if m.Type != types.MsgTypeSys {
return false
}
isInvitation := strings.Contains(m.Content, "\"邀请\"") && strings.Contains(m.Content, "\"加入了群聊")
isScanQrCode := strings.Contains(m.Content, "通过扫描") && strings.Contains(m.Content, "加入群聊")
sysFlag := isInvitation || isScanQrCode
sysFlag := m.Type == types.MsgTypeSys && strings.Contains(m.Content, "\"邀请\"") && strings.Contains(m.Content, "\"加入了群聊")
if sysFlag {
return true
}
@ -103,7 +97,7 @@ func (m Message) IsNewUserJoin() bool {
if err := xml.Unmarshal([]byte(m.Content), &d); err != nil {
return false
}
return d.Type == "delchatroommember"
return m.Type == types.MsgTypeSys && d.Type == "delchatroommember"
}
// IsAt
@ -127,7 +121,7 @@ func (m Message) IsAtAll() bool {
// 转换@用户列表为数组
atUserList := strings.Split(d.AtUserList, ",")
// 判断是否包含@所有人
return slice.Contain(atUserList, "notify@all")
return slices.Contains(atUserList, "notify@all")
}
// IsPrivateText

View File

@ -1,4 +1,4 @@
package model
package robot
// Response
// @description: 基础返回结构体

View File

@ -1,8 +1,8 @@
package model
package robot
// RobotUserInfo
// UserInfo
// @description: 机器人用户信息
type RobotUserInfo struct {
type UserInfo struct {
WxId string `json:"wxid"` // 微信Id
Account string `json:"account"` // 微信号
Name string `json:"name"` // 昵称

View File

@ -0,0 +1,8 @@
package login
// CaptchaCode
// @description: 获取图形验证码返回结果
type CaptchaCode struct {
Id string `json:"id"` // 验证ID
Img string `json:"img"` // 图片Base64字符串
}

21
model/vo/menu/menu.go Normal file
View File

@ -0,0 +1,21 @@
package menu
// Item
// @description:
type Item struct {
Id string `json:"id"` // 菜单Id
Path string `json:"path"` // 访问路径
Name string `json:"name"` // 路由名字对应不要重复和页面组件的name保持一致必填
Meta ItemMeta `json:"meta"` // 元数据
Children []Item `json:"children,omitempty"` // 子菜单
}
// ItemMeta
// @description: 菜单元数据
type ItemMeta struct {
Title string `json:"title"` // 标题
Icon string `json:"icon"` // 图标
Rank int `json:"rank,omitempty"` // 排序
Roles []string `json:"roles"` // 当前菜单所属的角色代码
Auths []string `json:"auths"` // 当前菜单包含的按钮如果传入了用户Id返回的将会是权限内所有的按钮
}

View File

@ -1,46 +0,0 @@
package mq
import (
"encoding/json"
"go-wechat/common/current"
"go-wechat/model"
"go-wechat/types"
"log"
"strings"
)
// parse
// @description: 解析消息
// @param msg
func parse(msg []byte) {
var m model.Message
if err := json.Unmarshal(msg, &m); err != nil {
log.Printf("消息解析失败: %v", err)
log.Printf("消息内容: %d -> %v", len(msg), string(msg))
return
}
// 记录原始数据
m.Raw = string(msg)
// 提取出群成员信息
// Sys类型的消息正文不包含微信 Id所以不需要处理
if m.IsGroup() && m.Type != types.MsgTypeSys {
// 群消息,处理一下消息和发信人
groupUser := strings.Split(m.Content, "\n")[0]
groupUser = strings.ReplaceAll(groupUser, ":", "")
// 如果两个id一致说明是系统发的
if m.FromUser != groupUser {
m.GroupUser = groupUser
}
// 用户的操作单独提出来处理一下
m.Content = strings.Join(strings.Split(m.Content, "\n")[1:], "\n")
}
log.Printf("收到新微信消息\n消息来源: %s\n群成员: %s\n消息类型: %v\n消息内容: %s", m.FromUser, m.GroupUser, m.Type, m.Content)
// 插件不为空,开始执行
if p := current.GetRobotMessageHandler(); p != nil {
p(&m)
}
return
}

View File

@ -1,9 +1,10 @@
package mq
import (
"gitee.ltd/lxh/logger/log"
amqp "github.com/rabbitmq/amqp091-go"
"go-wechat/config"
"log"
"wechat-robot/config"
"wechat-robot/internal/message"
)
// MQ连接对象
@ -32,14 +33,23 @@ func Init() {
if channel, err = conn.Channel(); err != nil {
log.Panicf("打开Channel失败: %s", err)
}
log.Println("RabbitMQ连接成功")
log.Debug("RabbitMQ连接成功")
go Receive()
log.Println("开始监听消息")
log.Debug("开始监听消息")
}
// Receive
// @description: 接收消息
func Receive() (err error) {
func Receive(retry ...int) (err error) {
var retryCount int
if len(retry) > 0 {
retryCount = retry[0]
}
// 重试次数超过100次退出
if retryCount > 100 {
log.Panicf("获取消息失败次数过多")
}
// 创建交换机
if err = channel.ExchangeDeclare(
exchangeName,
@ -51,7 +61,7 @@ func Receive() (err error) {
false,
nil,
); err != nil {
log.Printf("声明Exchange失败: %s", err)
log.Errorf("声明Exchange失败: %s", err)
return
}
// 创建队列
@ -65,7 +75,7 @@ func Receive() (err error) {
nil,
)
if err != nil {
log.Printf("无法声明Queue: %s", err)
log.Errorf("无法声明Queue: %s", err)
return
}
// 绑定队列到 exchange 中
@ -77,7 +87,7 @@ func Receive() (err error) {
false,
nil)
if err != nil {
log.Printf("绑定队列失败: %s", err)
log.Errorf("绑定队列失败: %s", err)
return
}
@ -93,20 +103,23 @@ func Receive() (err error) {
nil,
)
if err != nil {
log.Printf("无法使用队列: %s", err)
log.Errorf("无法使用队列: %s", err)
return
}
for {
msg, ok := <-messages
if !ok {
log.Printf("获取消息失败")
return Receive()
log.Errorf("获取消息失败")
return Receive(retryCount + 1)
}
log.Debugf("收到消息: %s", msg.Body)
if err = message.Message(msg.Body); err != nil {
log.Errorf("处理消息失败: %s", err)
continue
}
log.Printf("收到消息: %s", msg.Body)
parse(msg.Body)
// ACK消息
if err = msg.Ack(true); err != nil {
log.Printf("ACK消息失败: %s", err)
log.Errorf("ACK消息失败: %s", err)
continue
}
}

60
pkg/auth/auth.go Normal file
View File

@ -0,0 +1,60 @@
package auth
import (
"github.com/go-oauth2/oauth2/v4"
"github.com/go-oauth2/oauth2/v4/manage"
"github.com/go-oauth2/oauth2/v4/models"
"github.com/go-oauth2/oauth2/v4/server"
"github.com/go-oauth2/oauth2/v4/store"
od "github.com/go-oauth2/redis/v4"
"time"
"wechat-robot/internal/redis"
"wechat-robot/pkg/auth/handle"
)
var (
OAuthServer *server.Server // 后台OAuth2服务
)
// InitOAuth2Server
// @description: 初始化后台OAuth2服务
func InitOAuth2Server() {
manager := manage.NewDefaultManager()
// 配置信息
cfg := &manage.Config{
AccessTokenExp: time.Hour * 24, // 访问令牌过期时间
RefreshTokenExp: time.Hour * 24 * 30, // 更新令牌过期时间
IsGenerateRefresh: true, // 是否生成新的更新令牌
}
// 设置密码模式的配置参数
manager.SetPasswordTokenCfg(cfg)
manager.MapTokenStorage(od.NewRedisStoreWithCli(redis.Client, "oauth:token:"))
// 生成Token方式
manager.MapAccessGenerate(handle.NewAccessGenerate())
// 配置客户端
// 配置客户端
clientStore := store.NewClientStore()
_ = clientStore.Set("admin", &models.Client{
ID: "admin",
Secret: "kycgfS4sBjx2rPVD",
})
manager.MapClientStorage(clientStore)
srv := server.NewServer(server.NewConfig(), manager)
// 设置密码登录模式处理逻辑
srv.SetPasswordAuthorizationHandler(handle.LoginWithPassword)
// 允许密码模式、刷新Token
srv.SetAllowedGrantType(oauth2.PasswordCredentials, oauth2.Refreshing)
// 客户端ID和授权模式检查
//srv.SetClientAuthorizedHandler(handle.CheckClient)
// 自定义响应Token的扩展字段
srv.SetExtensionFieldsHandler(handle.ExtensionFields)
// 自定义返回数据接口
srv.SetResponseTokenHandler(handle.ResponseToken)
// 自定义内部错误处理
srv.SetInternalErrorHandler(handle.InternalErrorHandler)
OAuthServer = srv
}

139
pkg/auth/handle/oauth2.go Normal file
View File

@ -0,0 +1,139 @@
package handle
import (
"context"
"encoding/json"
"fmt"
"gitee.ltd/lxh/logger/log"
"github.com/go-oauth2/oauth2/v4"
"github.com/go-oauth2/oauth2/v4/errors"
"net/http"
"time"
"wechat-robot/internal/database"
"wechat-robot/model/entity"
userService "wechat-robot/service/adminuser"
"wechat-robot/service/role"
"wechat-robot/utils"
)
// LoginWithPassword
// @description: 账号密码登录模式
// @param _
// @param clientId
// @param userId
// @param password
// @return userID
// @return err
func LoginWithPassword(_ context.Context, clientId, username, password string) (userId string, err error) {
log.Debugf("[%v]处理登录请求,账号:%s --> %s", clientId, username, password)
// 取出用户信息
var userInfo entity.AdminUser
userInfo, err = userService.GetUserWithLogin(username)
userId = userInfo.Id
if err != nil {
log.Errorf("获取用户信息失败: %v", err.Error())
err = errors.New("账号不存在")
return
}
// 校验密码
if !utils.PasswordUtils().ComparePassword(userInfo.Password, password) {
err = errors.New("密码错误")
return
}
return
}
// ExtensionFields
// @description: 自定义响应Token的扩展字段
// @param _
// @return fieldsValue
func ExtensionFields(ti oauth2.TokenInfo) (fieldsValue map[string]any) {
fieldsValue = map[string]any{}
fieldsValue["license"] = "Made By Lixunhuan"
fieldsValue["developer"] = "https://lxh.io"
fieldsValue["expires"] = time.Now().Local().Add(ti.GetAccessExpiresIn()).Format("2006/01/02 15:04:05")
// 缓存用户的accessToken为后续删除做准备
//key := fmt.Sprintf("oauth:token:token:%s", ti.GetUserID())
//if err := redis.Client.Set(context.Background(), key, ti.GetAccess(), time.Hour*24*30).Err(); err != nil {
// log.Errorf("缓存用户的accessToken失败: %v", err.Error())
//}
// 填充用户信息
var ui entity.AdminUser
_ = database.Client.Take(&ui, "id = ?", ti.GetUserID()).Error
fieldsValue["username"] = ui.Username
fieldsValue["id"] = ui.Id
// 获取用户的角色代码
fieldsValue["roles"] = role.GetCodesByUserId(ti.GetUserID())
return
}
// ResponseToken
// @description: 返回Token生成结果
// @param w http.ResponseWriter 写入响应
// @param data map[string]any 响应数据
// @param header http.Header 响应头
// @param statusCode ...int 响应状态
// @return error 错误信息
func ResponseToken(w http.ResponseWriter, data map[string]any, header http.Header, statusCode ...int) error {
log.Debugf("返回Token原始数据: %+v", data)
type response struct {
Code int `json:"code"`
Data map[string]any `json:"data"`
Msg string `json:"message"`
}
status := http.StatusOK
msg := "login success"
if len(statusCode) > 0 && statusCode[0] > 0 {
status = statusCode[0]
msg = fmt.Sprintf("%v", data["error_description"])
// 处理特殊返回 - 刷新Token到期了
switch data["error"] {
case "invalid_grant":
msg = "登录已过期,请重新授权登录"
case "invalid_request":
msg = "登录参数错误"
case "invalid_client":
msg = "客户端验证失败"
default:
log.Errorf("收到未定义的登录错误: %v", data["error_description"])
}
data = nil
}
res := response{
Code: status,
Msg: msg,
Data: data,
}
jsonBytes, err := json.Marshal(res)
if err != nil {
return err
}
w.Header().Set("Content-Type", "application/json;charset=UTF-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
for key := range header {
w.Header().Set(key, header.Get(key))
}
w.WriteHeader(status)
_, err = w.Write(jsonBytes)
if err != nil {
log.Errorf("返回Token失败: %v", err.Error())
return err
}
return err
}
// InternalErrorHandler 自定义内部错误处理
func InternalErrorHandler(err error) (re *errors.Response) {
re = errors.NewResponse(err, http.StatusUnauthorized)
re.Description = err.Error()
return
}

View File

@ -0,0 +1,78 @@
package handle
import (
"context"
"encoding/json"
"fmt"
"github.com/go-oauth2/oauth2/v4"
"github.com/google/uuid"
"strings"
"wechat-robot/internal/redis"
)
func NewAccessGenerate() *AccessGenerate {
return &AccessGenerate{}
}
type AccessGenerate struct {
}
// Token
// @description: 手动实现Token生成直接生成UUID替换掉自带的那个憨得一批的长长的字符串
// @receiver ag
// @param ctx context.Context 上下文
// @param data *oauth2.GenerateBasic 生成基础数据
// @param isGenRefresh bool 是否生成RefreshToken
// @return string AccessToken 令牌
// @return string RefreshToken 刷新令牌
// @return error 错误信息
func (ag *AccessGenerate) Token(ctx context.Context, data *oauth2.GenerateBasic, isGenRefresh bool) (string, string, error) {
u, _ := uuid.NewUUID()
access := strings.ReplaceAll(u.String(), "-", "")
refresh := ""
if isGenRefresh {
u, _ = uuid.NewUUID()
refresh = strings.ReplaceAll(u.String(), "-", "")
}
// 生成新的,清理掉旧的
ag.clearOldToken(ctx, data.UserID)
// 返回结果
return access, refresh, nil
}
// clearOldToken
// @description: 清理掉旧的Token和RefreshToken
// @receiver ag
// @param ctx context.Context 上下文
// @param userId string 用户ID
func (ag *AccessGenerate) clearOldToken(ctx context.Context, userId string) {
key := fmt.Sprintf("oauth:token:token:%s", userId)
accessToken, err := redis.Client.Get(context.Background(), key).Result()
if err != nil {
//log.Errorf("获取缓存用户的accessToken失败: %v", err.Error())
return
}
if accessToken != "" {
// 老的Token
var baseKey string
baseKey, err = redis.Client.Get(ctx, "oauth:token:"+accessToken).Result()
if err != nil {
return
}
// 老Token详细数据
var dataStr string
dataStr, err = redis.Client.Get(ctx, "oauth:token:"+baseKey).Result()
if err != nil {
return
}
var m map[string]interface{}
if err = json.Unmarshal([]byte(dataStr), &m); err != nil {
return
}
// 删除AccessToken等信息
redis.Client.Del(ctx, fmt.Sprintf("oauth:token:%v", m["Access"]))
redis.Client.Del(ctx, fmt.Sprintf("oauth:token:%v", m["Refresh"]))
redis.Client.Del(ctx, "oauth:token:"+baseKey)
}
}

39
pkg/auth/utils.go Normal file
View File

@ -0,0 +1,39 @@
package auth
import (
"context"
"encoding/json"
"gitee.ltd/lxh/logger/log"
"github.com/go-oauth2/oauth2/v4/models"
"wechat-robot/internal/redis"
)
// GetUserIdWithRefreshToken
// @description: 根据refreshToken获取userId
// @param refreshToken string refreshToken
// @return userId string userId
func GetUserIdWithRefreshToken(refreshToken string) (userId string) {
// 取出真实保存token信息的key
realKey, err := redis.Client.Get(context.Background(), "oauth:token:"+refreshToken).Result()
if err != nil {
log.Errorf("根据refreshToken获取realKey失败: %v", err)
return
}
// 取出缓存信息
var (
tiStr string // 保存的原始字符串
tokenInfo models.Token // Token结构体
)
tiStr, err = redis.Client.Get(context.Background(), "oauth:token:"+realKey).Result()
if err != nil {
log.Errorf("获取tokenInfo失败: %v", err)
return
}
// 反序列化
if err = json.Unmarshal([]byte(tiStr), &tokenInfo); err != nil {
log.Errorf("tokenInfo反序列化失败: %v", err)
return
}
// 返回userId
return tokenInfo.GetUserID()
}

66
pkg/captcha/captcha.go Normal file
View File

@ -0,0 +1,66 @@
package captcha
import (
"context"
"gitee.ltd/lxh/logger/log"
"time"
"wechat-robot/internal/redis"
)
var ctx = context.Background()
type RedisStore struct{}
// 验证码缓存前缀
const cachePrefix = "captcha:img:"
// Set
// @description: 缓存验证码信息
// @receiver r
// @param id string 验证码ID
// @param value string 验证码答案
// @return error 错误信息
func (r RedisStore) Set(id string, value string) error {
key := cachePrefix + id
err := redis.Client.Set(ctx, key, value, time.Minute).Err()
if err != nil {
log.Errorf("写入缓存验证码失败: %v", err.Error())
return err
}
return nil
}
// Get
// @description: 获取验证码信息
// @receiver r
// @param id string 验证码ID
// @param clear
// @return string
func (r RedisStore) Get(id string, clear bool) string {
key := cachePrefix + id
val, err := redis.Client.Get(ctx, key).Result()
if err != nil {
log.Errorf("读取缓存验证码失败: %v", err.Error())
return ""
}
if clear {
err = redis.Client.Del(ctx, key).Err()
if err != nil {
log.Errorf("读取缓存验证码失败: %v", err.Error())
return ""
}
}
return val
}
// Verify
// @description: 验证验证码
// @receiver r
// @param id string 验证码ID
// @param answer string 验证码答案
// @param clear bool 是否清除验证码
// @return bool 是否验证成功
func (r RedisStore) Verify(id, answer string, clear bool) bool {
v := r.Get(id, clear)
return v == answer
}

16
pkg/response/fail.go Normal file
View File

@ -0,0 +1,16 @@
package response
// Fail
// @description: 失败响应
// @receiver r
// @param data
// @return err
func (r *Response) Fail() {
if r.msg == "" {
r.msg = "系统错误"
}
if r.code == 0 {
r.code = fail
}
r.Result()
}

43
pkg/response/page.go Normal file
View File

@ -0,0 +1,43 @@
package response
// PageData
// @description: 分页数据通用结构体
type PageData[T any] struct {
Current int `json:"current"` // 当前页码
Size int `json:"size"` // 每页数量
Total int64 `json:"total"` // 总数
TotalPage int `json:"totalPage"` // 总页数
Records T `json:"records"` // 返回数据
}
// NewPageData
// @description: 创建分页数据
// @param records any 数据列表
// @param total int64 总数
// @param current int 页码
// @param size int 页数量
// @return data PageData[any] 分页数据
func NewPageData(records any, total int64, current, size int) (data PageData[any]) {
// 处理一下页码、页数量
if current == -1 {
current = 1
size = int(total)
}
// 计算总页码
totalPage := 0
if total > 0 {
upPage := 0
if int(total)%size > 0 {
upPage = 1
}
totalPage = (int(total) / size) + upPage
}
data = PageData[any]{
Current: current,
Size: size,
Total: total,
TotalPage: totalPage,
Records: records,
}
return
}

77
pkg/response/response.go Normal file
View File

@ -0,0 +1,77 @@
package response
import (
"github.com/gin-gonic/gin"
"net/http"
"wechat-robot/pkg/validator"
)
// 定义状态码
const (
fail = http.StatusInternalServerError
success = http.StatusOK
)
// Response
// @description: 返回结果
type Response struct {
ctx *gin.Context
code int
data any
msg string
errMsg string
}
// New
// @description: 返回结果实现
// @param ctx
// @return Response
func New(ctx *gin.Context) *Response {
var r Response
r.ctx = ctx
return &r
}
// SetCode
// @description: 设置状态码
// @receiver r
// @param code
// @return *Response
func (r *Response) SetCode(code int) *Response {
r.code = code
return r
}
// SetData
// @description: 设置返回数据
// @receiver r
// @param data
// @return *Response
func (r *Response) SetData(data any) *Response {
r.data = data
return r
}
// SetMsg
// @description: 设置返回消息
// @receiver r
// @param msg
// @return *Response
func (r *Response) SetMsg(msg string) *Response {
r.msg = msg
return r
}
// SetError
// @description: 设置错误信息
// @receiver r
// @param err
// @return *Response
func (r *Response) SetError(err error) *Response {
if err != nil {
err = validator.Translate(err)
r.errMsg = err.Error()
}
return r
}

27
pkg/response/result.go Normal file
View File

@ -0,0 +1,27 @@
package response
// Result
// @description: 响应
// @receiver r
// @param code int 状态码
// @param data any 数据
// @param msg string 消息
// @param err string 错误信息
// @return err error 返回数据错误
func (r *Response) Result() {
type resp struct {
Code int `json:"code"`
Data any `json:"data"`
Msg string `json:"message"`
ErrMsg string `json:"errMsg,omitempty"`
}
rd := resp{
r.code,
r.data,
r.msg,
r.errMsg,
}
// 返回数据
r.ctx.JSON(r.code, rd)
}

16
pkg/response/success.go Normal file
View File

@ -0,0 +1,16 @@
package response
// Success
// @description: 成功响应
// @receiver r
// @param data
// @return err
func (r *Response) Success() {
if r.msg == "" {
r.msg = "success"
}
if r.code == 0 {
r.code = success
}
r.Result()
}

111
pkg/types/date.go Normal file
View File

@ -0,0 +1,111 @@
package types
import (
"database/sql/driver"
"fmt"
"strings"
"time"
)
// 默认时间格式
const dateFormat = "2006-01-02"
// Date 自定义时间类型
type Date time.Time
// Scan implements the Scanner interface.
func (dt *Date) Scan(value any) error {
// mysql 内部日期的格式可能是 2006-01-02 15:04:05 +0800 CST 格式,所以检出的时候还需要进行一次格式化
tTime, _ := time.Parse("2006-01-02 15:04:05 +0800 CST", value.(time.Time).String())
*dt = Date(tTime)
return nil
}
// Value implements the driver Valuer interface.
func (dt Date) Value() (driver.Value, error) {
// 0001-01-01 00:00:00 属于空值,遇到空值解析成 null 即可
if dt.String() == "0001-01-01" {
return nil, nil
}
return []byte(dt.Format(dateFormat)), nil
}
// 用于 fmt.Println 和后续验证场景
func (dt Date) String() string {
return dt.Format(dateFormat)
}
// Format 格式化
func (dt Date) Format(fm string) string {
return time.Time(dt).Format(fm)
}
// After 时间比较
func (dt *Date) After(now time.Time) bool {
return time.Time(*dt).After(now)
}
// Before 时间比较
func (dt *Date) Before(now time.Time) bool {
return time.Time(*dt).Before(now)
}
// IBefore 时间比较
func (dt *Date) IBefore(now Date) bool {
return dt.Before(time.Time(now))
}
// SubTime 对比
func (dt Date) SubTime(t time.Time) time.Duration {
return dt.ToTime().Sub(t)
}
// Sub 对比
func (dt Date) Sub(t Date) time.Duration {
return dt.ToTime().Sub(t.ToTime())
}
// ToTime 转换为golang的时间类型
func (dt Date) ToTime() time.Time {
return time.Time(dt)
}
// IsNil 是否为空值
func (dt Date) IsNil() bool {
return dt.Format(dateFormat) == "0001-01-01"
}
// Unix 实现Unix函数
func (dt Date) Unix() int64 {
return dt.ToTime().Unix()
}
// ======== 序列化 JSON ========
// MarshalJSON 时间到字符串
func (dt Date) MarshalJSON() ([]byte, error) {
// 过滤掉空数据
if dt.IsNil() {
return []byte("\"\""), nil
}
output := fmt.Sprintf(`"%s"`, dt.Format(dateFormat))
return []byte(output), nil
}
// UnmarshalJSON 字符串到时间
func (dt *Date) UnmarshalJSON(b []byte) error {
if len(b) == 2 {
*dt = Date{}
return nil
}
// 解析指定的格式
var now time.Time
var err error
if strings.HasPrefix(string(b), "\"") {
now, err = time.ParseInLocation(`"`+dateFormat+`"`, string(b), time.Local)
} else {
now, err = time.ParseInLocation(dateFormat, string(b), time.Local)
}
*dt = Date(now)
return err
}

View File

@ -55,7 +55,7 @@ func (dt DateTime) Value() (dv driver.Value, err error) {
// 用于 fmt.Println 和后续验证场景
func (dt DateTime) String() string {
return dt.Format("2006-01-02 15:04:05")
return dt.Format(dateTimeFormat)
}
// Format 格式化

22
pkg/types/menu.go Normal file
View File

@ -0,0 +1,22 @@
package types
type MenuType string
const (
MenuTypeMenu = MenuType("MENU") // 菜单
MenuTypeButton = MenuType("BUTTON") // 按钮
)
// 状态对应的描述
var menuTypeMap = map[MenuType]string{
MenuTypeMenu: "菜单",
MenuTypeButton: "按钮",
}
// 处理为看得懂的状态
func (s MenuType) String() string {
if str, ok := menuTypeMap[s]; ok {
return str
}
return string(s)
}

42
pkg/types/model.go Normal file
View File

@ -0,0 +1,42 @@
package types
import (
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/plugin/soft_delete"
"strings"
)
// BaseDbModel
// @description: 带逻辑删除的数据库通用字段
type BaseDbModel struct {
Id string `json:"id" gorm:"type:varchar(32);primarykey"`
CreatedAt DateTime `json:"createdAt"`
UpdatedAt DateTime `json:"updatedAt"`
DeletedAt int64 `json:"-" gorm:"index:deleted; default:0"`
IsDel soft_delete.DeletedAt `json:"-" gorm:"softDelete:flag,DeletedAtField:DeletedAt; index:deleted; default:0; type:tinyint(1)"`
}
// BeforeCreate 创建数据库对象之前生成UUID
func (m *BaseDbModel) BeforeCreate(*gorm.DB) (err error) {
if m.Id == "" {
m.Id = strings.ReplaceAll(uuid.New().String(), "-", "")
}
return
}
// =====================================================================================================================
// BaseDbModelWithReal
// @description: 不带逻辑删除的数据库通用字段
type BaseDbModelWithReal struct {
Id string `json:"id" gorm:"type:varchar(32);primarykey"`
CreatedAt DateTime `json:"createdAt"`
UpdatedAt DateTime `json:"updatedAt"`
}
// BeforeCreate 创建数据库对象之前生成UUID
func (m *BaseDbModelWithReal) BeforeCreate(*gorm.DB) (err error) {
m.Id = strings.ReplaceAll(uuid.New().String(), "-", "")
return
}

25
pkg/types/userinfo.go Normal file
View File

@ -0,0 +1,25 @@
package types
import "fmt"
// UserSex 用户性别
type UserSex int
const (
UserSexMale UserSex = iota + 1 // 男
UserSexFemale // 女
UserSexUnknown // 未知
)
var userSexMap = map[UserSex]string{
UserSexMale: "男",
UserSexFemale: "女",
UserSexUnknown: "未知",
}
func (u UserSex) String() string {
if v, ok := userSexMap[u]; ok {
return v
}
return fmt.Sprintf("性别<%d>", u)
}

View File

@ -0,0 +1,55 @@
package validator
import (
"errors"
"gitee.ltd/lxh/logger/log"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/locales/zh"
ut "github.com/go-playground/universal-translator"
"github.com/go-playground/validator/v10"
zhTranslations "github.com/go-playground/validator/v10/translations/zh"
"strings"
)
var (
uni *ut.UniversalTranslator
validate *validator.Validate
trans ut.Translator
)
// Init
// @description: 初始化验证器
func Init() {
//注册翻译器
zhTranslator := zh.New()
uni = ut.New(zhTranslator, zhTranslator)
trans, _ = uni.GetTranslator("zh")
//获取gin的校验器
validate = binding.Validator.Engine().(*validator.Validate)
//注册翻译器
err := zhTranslations.RegisterDefaultTranslations(validate, trans)
if err != nil {
log.Panicf("注册翻译器失败:%v", err)
}
}
// Translate
// @description: 翻译错误信息
// @param err
// @return error
func Translate(err error) error {
errorMsg := make([]string, 0)
ves, ok := err.(validator.ValidationErrors)
if !ok {
return err
}
for _, e := range ves {
errorMsg = append(errorMsg, e.Translate(trans))
}
return errors.New(strings.Join(errorMsg, ""))
}

44
pkg/wxhelper/base.go Normal file
View File

@ -0,0 +1,44 @@
package wxhelper
import (
"errors"
robotModel "wechat-robot/model/robot"
"wechat-robot/utils"
)
// CheckLogin
// @description: 检查是否登录
// @receiver wx
// @return flag
func (wx wxHelper) CheckLogin() (flag bool) {
var api string
if api, flag = wx.version.GetApi("CheckLogin"); !flag {
return
}
// 调用接口
var resp robotModel.Response[any]
_ = utils.HttpClientUtils().Post(wx.host+api, nil, &resp, 0)
return resp.Code == 1
}
// UserInfo
// @description: 获取机器人信息
// @receiver wx
// @return data
// @return err
func (wx wxHelper) UserInfo() (data robotModel.UserInfo, err error) {
var api string
var flag bool
if api, flag = wx.version.GetApi("UserInfo"); !flag {
err = errors.New("不支持的接口")
return
}
// 调用接口
var resp robotModel.Response[robotModel.UserInfo]
if err = utils.HttpClientUtils().Post(wx.host+api, nil, &resp, 0); err != nil {
return
}
data = resp.Data
return
}

61
pkg/wxhelper/chatroom.go Normal file
View File

@ -0,0 +1,61 @@
package wxhelper
import robotModel "wechat-robot/model/robot"
// GetChatRoomDetailInfo
// @description: 获取群聊详情
// @receiver wx
// @param chatRoomId
// @return data
func (wx wxHelper) GetChatRoomDetailInfo(chatRoomId string) (data robotModel.ChatRoomDetailInfo) {
return
}
// AddMemberToChatRoom
// @description: 添加群成员
// @receiver wx
// @param chatRoomId
// @param memberWxIds
// @return err
func (wx wxHelper) AddMemberToChatRoom(chatRoomId string, memberWxIds []string) (err error) {
return
}
// DelMemberFromChatRoom
// @description: 删除群成员
// @receiver wx
// @param chatRoomId
// @param memberWxIds
// @return err
func (wx wxHelper) DelMemberFromChatRoom(chatRoomId string, memberWxIds []string) (err error) {
return
}
// GetMemberFromChatRoom
// @description: 获取群成员
// @receiver wx
// @param chatRoomId
// @return data
func (wx wxHelper) GetMemberFromChatRoom(chatRoomId string) (data robotModel.GroupUser) {
return
}
// InviteMemberToChatRoom
// @description: 邀请入群
// @receiver wx
// @param chatRoomId
// @param memberWxIds
// @return err
func (wx wxHelper) InviteMemberToChatRoom(chatRoomId string, memberWxIds []string) (err error) {
return
}
// ModifyChatRoomNickname
// @description: 修改群内昵称
// @receiver wx
// @param wxId
// @param nickname
// @return err
func (wx wxHelper) ModifyChatRoomNickname(wxId, nickname string) (err error) {
return
}

79
pkg/wxhelper/constant.go Normal file
View File

@ -0,0 +1,79 @@
package wxhelper
// WeChatVersion 微信版本
type WeChatVersion int
// 微信版本
const (
WechatVersion39223 WeChatVersion = 39223
WechatVersion39581 WeChatVersion = 39581
WechatVersion39825 WeChatVersion = 39825
)
// 接口映射
var apiMap = map[WeChatVersion]map[string]string{
WechatVersion39223: v39223ApiMap,
WechatVersion39581: v39581ApiMap,
WechatVersion39825: v39581ApiMap,
}
var v39223ApiMap = map[string]string{
"CheckLogin": "/api/?type=0",
"UserInfo": "/api/?type=1",
"SendTextMsg": "/api/?type=2",
"SendAtTest": "/api/?type=3",
"SendFileMsg": "/api/?type=6",
"SendImagesMsg": "/api/?type=5",
"SendCustomEmotionMsg": "",
"GetContactList": "/api/?type=46",
"GetChatRoomDetailInfo": "/api/?type=47",
"AddMemberToChatRoom": "/api/?type=28",
"DelMemberFromChatRoom": "/api/?type=27",
"GetMemberFromChatRoom": "/api/?type=25",
"GetContactProfile": "/api/?type=25",
"InviteMemberToChatRoom": "",
"ModifyChatRoomNickname": "/api/?type=31",
"StartHook": "/api/?type=9",
"StopHook": "/api/?type=10",
"DownloadAttach": "/api/?type=56",
"DecodeImage": "/api/?type=48",
}
var v39581ApiMap = map[string]string{
"CheckLogin": "/api/checkLogin",
"UserInfo": "/api/userInfo",
"SendTextMsg": "/api/sendTextMsg",
"SendAtTest": "/api/sendAtText",
"SendFileMsg": "/api/sendFileMsg",
"SendImagesMsg": "/api/sendImagesMsg",
"SendCustomEmotionMsg": "/api/sendCustomEmotion",
"GetContactList": "/api/getContactList",
"GetChatRoomDetailInfo": "/api/getChatRoomDetailInfo",
"AddMemberToChatRoom": "/api/addMemberToChatRoom",
"DelMemberFromChatRoom": "/api/delMemberFromChatRoom",
"GetMemberFromChatRoom": "/api/getMemberFromChatRoom",
"GetContactProfile": "/api/getContactProfile",
"InviteMemberToChatRoom": "/api/InviteMemberToChatRoom",
"ModifyChatRoomNickname": "/api/modifyNickname",
"QuitChatRoom": "/api/quitChatRoom",
"StartHook": "/api/hookSyncMsg",
"StopHook": "/api/unhookSyncMsg",
"DownloadAttach": "/api/downloadAttach",
"DecodeImage": "/api/decodeImage",
}
// GetApi
// @description: 获取接口地址
// @receiver v
// @return string
func (v WeChatVersion) GetApi(code string) (api string, ok bool) {
var am map[string]string
// 判断版本是否支持
if am, ok = apiMap[v]; !ok {
return
}
// 获取接口
api, ok = am[code]
return
}

21
pkg/wxhelper/contact.go Normal file
View File

@ -0,0 +1,21 @@
package wxhelper
import robotModel "wechat-robot/model/robot"
// GetContactList
// @description: 获取联系人列表
// @receiver wx
// @return []robotModel.FriendItem
func (wx wxHelper) GetContactList() (friends []robotModel.FriendItem) {
return
}
// GetContactProfile
// @description: 获取好友资料
// @receiver wx
// @param wxId
// @return robotModel.ContactProfile
func (wx wxHelper) GetContactProfile(wxId string) (profile robotModel.ContactProfile) {
return
}

20
pkg/wxhelper/file.go Normal file
View File

@ -0,0 +1,20 @@
package wxhelper
// DownloadAttach
// @description: 下载附件
// @receiver wx
// @param msgId
// @return err
func (wx wxHelper) DownloadAttach(msgId int) (err error) {
return
}
// DecodeImage
// @description: 解码图片
// @receiver wx
// @param filePath
// @param storeDir
// @return err
func (wx wxHelper) DecodeImage(filePath, storeDir string) (err error) {
return
}

19
pkg/wxhelper/hook.go Normal file
View File

@ -0,0 +1,19 @@
package wxhelper
// StartHook
// @description: 启动hook
// @receiver wx
// @param tcpIp
// @param tcpPort
// @return err
func (wx wxHelper) StartHook(tcpIp string, tcpPort int) (err error) {
return
}
// StopHook
// @description: 停止hook
// @receiver wx
// @return err
func (wx wxHelper) StopHook() (err error) {
return
}

Some files were not shown because too many files have changed in this diff Show More