QQ群消息监控系统 - 企业级方案
1 架构设计
1.1 整体架构图
┌─────────────────────────────────────────────────────────┐
│ QQ群消息监控系统 │
├─────────────────────────────────────────────────────────┤
│ QQ群消息 → NapCat(QQ机器人) → WebSocket → Golang服务 │
│ │ │
│ ┌─────────────────────────┼──────┐ │
│ │ 通知分发层 │ │
│ ├─────────┬─────────┬─────────┤ │
│ │ 微信通知 │ 邮件通知 │ 短信通知 │ │
│ │PushPlus │ 企业邮箱 │ 腾讯云SMS│ │
│ └─────────┴─────────┴─────────┘ │
│ │ │
│ ┌─────────────────────────┼──────┐ │
│ │ Web管理后台 │ │
│ │ 配置管理 / 人员管理 / 测试 │ │
│ └──────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
1.2 通知方式说明
| 通知方式 | 实现方案 | 单人/多人 | 配置复杂度 |
|---|---|---|---|
| 微信 | PushPlus API | 每人独立Token,循环发送 | 简单 |
| 邮件 | SMTP(支持企业邮箱) | 一封邮件群发多人 | 简单 |
| 短信 | 腾讯云SMS API | 一次API批量发送 | 中等 |
2 通知方式配置
2.1 PushPlus微信通知
2.1.1 什么是PushPlus
PushPlus是一个消息推送平台,通过微信公众号将消息推送到用户微信。
2.1.2 获取Token
步骤1: 浏览器访问 http://www.pushplus.plus
步骤2: 点击右上角「登录」
步骤3: 使用微信扫描二维码登录
步骤4: 登录后,点击左侧「发送消息」→「一对一发送」
步骤5: 在页面顶部找到Token,格式如:a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
步骤6: 复制并妥善保存Token
步骤7: 微信搜索「pushplus 推送加」公众号并关注
步骤8: 如有多个人员需要接收微信通知,每个人都执行上述步骤
2.1.3 验证Token
# 在服务器执行
curl -X POST http://www.pushplus.plus/send \
-H "Content-Type: application/json" \
-d '{
"token": "你的Token",
"title": "测试消息",
"content": "<h1>✅ 测试成功</h1><p>PushPlus配置正确</p>",
"template": "html"
}'
预期结果: 微信收到公众号推送
2.1.4 记录Token
| 人员 | Token |
|---|---|
| 张三 | a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6 |
| 李四 | b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6a1 |
| 王五 | c3d4e5f6g7h8i9j0k1l2m3n4o5p6a1b2 |
2.2 企业邮箱SMTP配置
2.2.1 确认企业邮箱SMTP信息
你的邮箱:luo.yu@opg.cn
确认的信息:
| 配置项 | 说明 | 示例值 |
|---|---|---|
| SMTP服务器 | 企业邮箱的SMTP地址 | smtp.opg.cn 或 smtp.exmail.qq.com |
| SMTP端口 | 通常是587或465 | 587 |
| 邮箱账号 | 完整邮箱地址 | luo.yu@opg.cn |
| 邮箱密码 | 邮箱密码或专用授权码 | 你的密码 |
2.2.2 查找企业邮箱SMTP信息
方法一:查看邮箱帮助文档
- 登录企业邮箱网页版
- 查看「设置」→「客户端设置」或「POP3/SMTP设置」
- 找到SMTP服务器地址和端口
方法二:常见企业邮箱SMTP配置
| 企业邮箱类型 | SMTP服务器 | 端口 |
|---|---|---|
| 腾讯企业邮箱 | smtp.exmail.qq.com | 587 |
| 网易企业邮箱 | smtp.ym.163.com | 587 |
| 阿里企业邮箱 | smtp.qiye.aliyun.com | 587 |
| 自建邮局(opg.cn) | smtp.opg.cn | 587 |
2.2.3 测试企业邮箱SMTP
# 方法1:使用telnet测试端口
telnet smtp.exmail.qq.com 587
# 出现 Connected 表示端口可达
# 方法2:使用openssl测试
openssl s_client -starttls smtp -connect smtp.exmail.qq.com:587
2.2.4 获取授权码
腾讯企业邮箱:
- 登录 https://exmail.qq.com
- 设置 → 客户端设置
- 开启SMTP服务
- 生成授权码
2.3 腾讯云短信配置
2.3.1 开通短信服务
步骤1: 访问 https://console.cloud.tencent.com/smsv2
步骤2: 点击「开通短信服务」
步骤3: 完成实名认证(如未认证)
2.3.2 创建短信签名
步骤1: 左侧菜单 →「国内短信」→「签名管理」
步骤2: 点击「创建签名」
步骤3: 填写:
- 签名类型:选择「网站」或「App」
- 签名用途:选择「自用」
- 签名内容:
QQ消息监控 - 上传证明材料
步骤4: 提交审核(一般2小时内通过)
2.3.3 创建短信模板
步骤1: 左侧菜单 →「正文模板管理」
步骤2: 点击「创建正文模板」
步骤3: 填写:
- 模板名称:
QQ群消息通知 - 短信类型:普通短信
- 短信内容:
您收到关键字为{1}的消息:{2},发送者:{3},请及时处理。
步骤4: 提交审核
2.3.4 获取配置信息
获取SDK App ID:
- 左侧菜单 →「应用管理」
- 查看「SDK App ID」,如:
1400XXXXXX
获取API密钥:
- 访问 https://console.cloud.tencent.com/cam/capi
- 点击「新建密钥」
- 记录:
SecretId: AKIDxxxxxxxxxxxxxxxxxxxxxxxxxxxx
SecretKey: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
2.3.5 汇总短信配置
SecretId: AKIDxxxxxxxxxxxxxxxxxxxxxxxxxxxx
SecretKey: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
SDK App ID: 1400XXXXXX
签名: QQ消息监控
模板ID: 1234567(审核通过后显示)
3 服务器环境搭建
3.1 系统检查
cat /etc/redhat-release
# Rocky Linux release 9.6
free -h
# 确认4G内存
df -h
# 确认磁盘空间 > 10G
3.2 安装Docker
# 添加Docker仓库
dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
# 安装Docker
dnf install -y docker-ce docker-ce-cli containerd.io
# 启动Docker
systemctl start docker
systemctl enable docker
# 验证
docker version
docker ps
3.3 安装Golang
cd /tmp
wget https://golang.google.cn/dl/go1.21.5.linux-amd64.tar.gz
rm -rf /usr/local/go
tar -C /usr/local -xzf go1.21.5.linux-amd64.tar.gz
cat >> /etc/profile << 'EOF'
export GOROOT=/usr/local/go
export GOPATH=/root/go
export PATH=$PATH:$GOROOT/bin:$GOPATH/bin
export GOPROXY=https://goproxy.cn,direct
export GO111MODULE=on
EOF
source /etc/profile
go version
# go version go1.21.5 linux/amd64
3.4 创建项目目录
mkdir -p /opt/qq-monitor/service/{model,config,notifier,handler,api}
mkdir -p /opt/qq-monitor/napcat
cd /opt/qq-monitor/service
go mod init qq-monitor
go get github.com/gorilla/websocket
4 项目代码编写
4.1 文件结构
/opt/qq-monitor/service/
├── main.go # 主入口
├── go.mod
├── go.sum
├── config.json # 运行时配置(自动生成)
├── model/
│ └── types.go # 数据模型
├── config/
│ └── config.go # 配置管理
├── notifier/
│ ├── notifier.go # 通知器接口
│ ├── pushplus.go # 微信通知
│ ├── email.go # 邮件通知(支持企业邮箱)
│ ├── sms.go # 短信通知
│ └── manager.go # 通知管理器
├── handler/
│ ├── websocket.go # WebSocket处理
│ └── message.go # 消息处理
└── api/
├── handler.go # API处理器
└── web.go # Web管理界面
4.2 编写代码
1.model/types.go
cat > /opt/qq-monitor/service/model/types.go << 'GOEOF'
package model
import "time"
// QQ消息结构
type QQMessage struct {
PostType string `json:"post_type"`
MessageType string `json:"message_type"`
GroupID int64 `json:"group_id"`
UserID int64 `json:"user_id"`
RawMessage string `json:"raw_message"`
MessageID string `json:"message_id"`
Time int64 `json:"time"`
SelfID int64 `json:"self_id"`
Sender struct {
Nickname string `json:"nickname"`
Card string `json:"card"`
UserID int64 `json:"user_id"`
} `json:"sender"`
}
// 通知人员
type NotifyPerson struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Phone string `json:"phone"`
WechatToken string `json:"wechat_token"`
Enable bool `json:"enable"`
}
// 邮件配置(支持企业邮箱)
type EmailConfig struct {
Enable bool `json:"enable"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
Username string `json:"username"`
Password string `json:"password"`
FromName string `json:"from_name"`
}
// 短信配置
type SMSConfig struct {
Enable bool `json:"enable"`
SecretID string `json:"secret_id"`
SecretKey string `json:"secret_key"`
AppID string `json:"app_id"`
SignName string `json:"sign_name"`
TemplateID string `json:"template_id"`
}
// 微信配置
type WechatConfig struct {
Enable bool `json:"enable"`
}
// 系统总配置
type AppConfig struct {
Keywords []string `json:"keywords"`
NotifyList []NotifyPerson `json:"notify_list"`
Email EmailConfig `json:"email"`
SMS SMSConfig `json:"sms"`
Wechat WechatConfig `json:"wechat"`
NotifyMode []string `json:"notify_mode"`
}
// 通知内容
type NotifyContent struct {
Title string
Keyword string
SenderName string
GroupID int64
Time time.Time
OriginalMsg string
}
// API响应
type APIResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
GOEOF
2.config/config.go
cat > /opt/qq-monitor/service/config/config.go << 'GOEOF'
package config
import (
"encoding/json"
"fmt"
"log"
"os"
"qq-monitor/model"
"sync"
)
var (
AppCfg *model.AppConfig
ConfigLock sync.RWMutex
ConfigPath = "config.json"
)
func getDefaultConfig() *model.AppConfig {
return &model.AppConfig{
Keywords: []string{"官方介质已到"},
NotifyList: []model.NotifyPerson{
{
ID: "admin",
Name: "管理员",
Enable: true,
},
},
Email: model.EmailConfig{
Enable: false,
SMTPHost: "smtp.exmail.qq.com",
SMTPPort: 587,
Username: "",
Password: "",
FromName: "QQ消息监控系统",
},
SMS: model.SMSConfig{
Enable: false,
},
Wechat: model.WechatConfig{
Enable: false,
},
NotifyMode: []string{},
}
}
func Init() error {
if _, err := os.Stat(ConfigPath); os.IsNotExist(err) {
log.Println("📝 配置文件不存在,创建默认配置...")
AppCfg = getDefaultConfig()
return Save()
}
data, err := os.ReadFile(ConfigPath)
if err != nil {
return fmt.Errorf("读取配置失败: %v", err)
}
var cfg model.AppConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("解析配置失败: %v", err)
}
AppCfg = &cfg
log.Printf("✅ 配置加载成功")
log.Printf(" 📋 关键字: %v", AppCfg.Keywords)
log.Printf(" 📡 通知方式: %v", AppCfg.NotifyMode)
log.Printf(" 👥 通知人员: %d人", len(AppCfg.NotifyList))
return nil
}
func Get() *model.AppConfig {
ConfigLock.RLock()
defer ConfigLock.RUnlock()
cfg := *AppCfg
return &cfg
}
func Save() error {
data, err := json.MarshalIndent(AppCfg, "", " ")
if err != nil {
return fmt.Errorf("序列化失败: %v", err)
}
return os.WriteFile(ConfigPath, data, 0644)
}
func Update(fn func(*model.AppConfig)) error {
ConfigLock.Lock()
defer ConfigLock.Unlock()
fn(AppCfg)
return Save()
}
func GetEnabledPersons() []model.NotifyPerson {
ConfigLock.RLock()
defer ConfigLock.RUnlock()
var persons []model.NotifyPerson
for _, p := range AppCfg.NotifyList {
if p.Enable {
persons = append(persons, p)
}
}
return persons
}
GOEOF
3.notifier/notifier.go(通知器接口)
cat > /opt/qq-monitor/service/notifier/notifier.go << 'GOEOF'
package notifier
import "qq-monitor/model"
// Notifier 通知器接口
type Notifier interface {
// Name 通知器名称
Name() string
// IsAvailable 检查通知器是否可用
IsAvailable() bool
// SendBatch 批量发送通知
SendBatch(persons []model.NotifyPerson, content model.NotifyContent) map[string]error
}
// NotifyResult 通知结果
type NotifyResult struct {
PersonID string
Success bool
Error error
Channel string
}
GOEOF
4.notifier/pushplus.go(微信通知)
cat > /opt/qq-monitor/service/notifier/pushplus.go << 'GOEOF'
package notifier
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"qq-monitor/model"
"sync"
"time"
)
type PushPlusNotifier struct {
client *http.Client
}
func NewPushPlusNotifier() *PushPlusNotifier {
return &PushPlusNotifier{
client: &http.Client{Timeout: 10 * time.Second},
}
}
func (n *PushPlusNotifier) Name() string {
return "微信通知"
}
func (n *PushPlusNotifier) IsAvailable() bool {
return true
}
// Send 发送给单人
func (n *PushPlusNotifier) Send(person model.NotifyPerson, content model.NotifyContent) error {
if person.WechatToken == "" {
return fmt.Errorf("用户 %s 未配置微信Token", person.Name)
}
htmlContent := fmt.Sprintf(`<!DOCTYPE html>
<html><head><meta charset="UTF-8"></head>
<body style="font-family:'Microsoft YaHei',sans-serif;padding:15px;background:#f5f5f5;">
<div style="max-width:500px;margin:0 auto;background:white;border-radius:10px;overflow:hidden;box-shadow:0 2px 10px rgba(0,0,0,0.1);">
<div style="background:linear-gradient(135deg,#e74c3c,#c0392b);color:white;padding:20px;text-align:center;">
<h2 style="margin:0;">🚨 QQ群消息监控提醒</h2>
</div>
<div style="padding:20px;">
<table style="width:100%%;border-collapse:collapse;">
<tr><td style="padding:10px;border-bottom:1px solid #eee;color:#555;font-weight:bold;">⏰ 通知时间</td><td style="padding:10px;border-bottom:1px solid #eee;">%s</td></tr>
<tr><td style="padding:10px;border-bottom:1px solid #eee;color:#555;font-weight:bold;">🔑 匹配关键字</td><td style="padding:10px;border-bottom:1px solid #eee;color:red;font-weight:bold;">%s</td></tr>
<tr><td style="padding:10px;border-bottom:1px solid #eee;color:#555;font-weight:bold;">👤 消息发送者</td><td style="padding:10px;border-bottom:1px solid #eee;">%s</td></tr>
<tr><td style="padding:10px;border-bottom:1px solid #eee;color:#555;font-weight:bold;">📱 消息来源群</td><td style="padding:10px;border-bottom:1px solid #eee;">群ID: %d</td></tr>
</table>
<div style="background:#ffeaea;padding:15px;border-radius:8px;margin-top:15px;border:2px solid #e74c3c;">
<p style="font-size:16px;color:#c0392b;font-weight:bold;margin:0;">💬 %s</p>
</div>
</div>
<div style="background:#f8f9fa;padding:10px;text-align:center;color:#999;font-size:12px;">
QQ消息监控系统 v3.0 · 自动推送
</div>
</div>
</body></html>`,
content.Time.Format("2006-01-02 15:04:05"),
content.Keyword,
content.SenderName,
content.GroupID,
content.OriginalMsg,
)
reqBody := map[string]interface{}{
"token": person.WechatToken,
"title": content.Title,
"content": htmlContent,
"template": "html",
"channel": "wechat",
}
jsonData, _ := json.Marshal(reqBody)
resp, err := n.client.Post(
"http://www.pushplus.plus/send",
"application/json",
bytes.NewBuffer(jsonData),
)
if err != nil {
return fmt.Errorf("PushPlus请求失败: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
log.Printf(" 微信[%s] 响应: %s", person.Name, string(body))
var result map[string]interface{}
json.Unmarshal(body, &result)
if code, ok := result["code"].(float64); ok && code == 200 {
log.Printf(" ✅ 微信通知成功: %s", person.Name)
return nil
}
return fmt.Errorf("微信通知失败: %s", string(body))
}
// SendBatch 批量发送(循环发送给每个有Token的人)
func (n *PushPlusNotifier) SendBatch(persons []model.NotifyPerson, content model.NotifyContent) map[string]error {
results := make(map[string]error)
var wg sync.WaitGroup
var mu sync.Mutex
var validPersons []model.NotifyPerson
for _, p := range persons {
if p.WechatToken != "" {
validPersons = append(validPersons, p)
}
}
if len(validPersons) == 0 {
log.Println(" ⚠️ 无有效微信Token")
return results
}
log.Printf(" 📱 发送微信通知给 %d 人", len(validPersons))
for _, p := range validPersons {
wg.Add(1)
go func(person model.NotifyPerson) {
defer wg.Done()
err := n.Send(person, content)
mu.Lock()
results[person.ID] = err
mu.Unlock()
}(p)
}
wg.Wait()
return results
}
GOEOF
5.notifier/email.go(邮件通知,支持企业邮箱)
cat > /opt/qq-monitor/service/notifier/email.go << 'GOEOF'
package notifier
import (
"crypto/tls"
"fmt"
"log"
"net"
"net/smtp"
"qq-monitor/config"
"qq-monitor/model"
"strings"
)
type EmailNotifier struct{}
func NewEmailNotifier() *EmailNotifier {
return &EmailNotifier{}
}
func (n *EmailNotifier) Name() string {
return "邮件通知"
}
func (n *EmailNotifier) IsAvailable() bool {
cfg := config.Get()
return cfg.Email.Enable && cfg.Email.Username != "" && cfg.Email.Password != ""
}
// SendBatch 批量发送(一封邮件群发给所有人)
func (n *EmailNotifier) SendBatch(persons []model.NotifyPerson, content model.NotifyContent) map[string]error {
results := make(map[string]error)
var emails []string
var validPersons []model.NotifyPerson
for _, p := range persons {
if p.Email != "" {
emails = append(emails, p.Email)
validPersons = append(validPersons, p)
}
}
if len(emails) == 0 {
log.Println(" ⚠️ 无有效邮箱")
return results
}
log.Printf(" 📧 发送邮件通知给 %d 人: %v", len(emails), emails)
err := n.sendEmail(emails, content)
for _, p := range validPersons {
results[p.ID] = err
}
return results
}
// sendEmail 发送邮件
func (n *EmailNotifier) sendEmail(toEmails []string, content model.NotifyContent) error {
cfg := config.Get()
htmlBody := n.buildEmailHTML(content, toEmails)
// 构建邮件头
fromName := cfg.Email.FromName
if fromName == "" {
fromName = "QQ消息监控系统"
}
headers := make(map[string]string)
headers["From"] = fmt.Sprintf("%s <%s>", fromName, cfg.Email.Username)
headers["To"] = strings.Join(toEmails, ",")
headers["Subject"] = content.Title
headers["MIME-Version"] = "1.0"
headers["Content-Type"] = "text/html; charset=UTF-8"
message := ""
for k, v := range headers {
message += fmt.Sprintf("%s: %s\r\n", k, v)
}
message += "\r\n" + htmlBody
addr := fmt.Sprintf("%s:%d", cfg.Email.SMTPHost, cfg.Email.SMTPPort)
// 使用TLS连接(支持企业邮箱)
tlsConfig := &tls.Config{
ServerName: cfg.Email.SMTPHost,
InsecureSkipVerify: false,
}
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
// 如果TLS失败,尝试普通连接+STARTTLS
plainConn, plainErr := net.Dial("tcp", addr)
if plainErr != nil {
return fmt.Errorf("连接SMTP服务器失败: %v", err)
}
defer plainConn.Close()
client, clientErr := smtp.NewClient(plainConn, cfg.Email.SMTPHost)
if clientErr != nil {
return fmt.Errorf("创建SMTP客户端失败: %v", clientErr)
}
defer client.Quit()
if err := client.StartTLS(tlsConfig); err != nil {
return fmt.Errorf("STARTTLS失败: %v", err)
}
return n.sendWithClient(client, toEmails, message)
}
defer conn.Close()
client, err := smtp.NewClient(conn, cfg.Email.SMTPHost)
if err != nil {
return fmt.Errorf("创建SMTP客户端失败: %v", err)
}
defer client.Quit()
return n.sendWithClient(client, toEmails, message)
}
func (n *EmailNotifier) sendWithClient(client *smtp.Client, toEmails []string, message string) error {
cfg := config.Get()
// 认证
auth := smtp.PlainAuth("", cfg.Email.Username, cfg.Email.Password, cfg.Email.SMTPHost)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("SMTP认证失败: %v (请检查邮箱账号和密码)", err)
}
// 设置发件人
if err := client.Mail(cfg.Email.Username); err != nil {
return fmt.Errorf("设置发件人失败: %v", err)
}
// 设置收件人
for _, email := range toEmails {
if err := client.Rcpt(email); err != nil {
return fmt.Errorf("设置收件人 %s 失败: %v", email, err)
}
}
// 发送邮件内容
w, err := client.Data()
if err != nil {
return fmt.Errorf("准备发送数据失败: %v", err)
}
_, err = w.Write([]byte(message))
if err != nil {
return fmt.Errorf("写入邮件内容失败: %v", err)
}
err = w.Close()
if err != nil {
return fmt.Errorf("关闭发送失败: %v", err)
}
log.Printf(" ✅ 邮件发送成功: %d 人", len(toEmails))
return nil
}
// buildEmailHTML 构建邮件HTML
func (n *EmailNotifier) buildEmailHTML(content model.NotifyContent, recipients []string) string {
recipientList := strings.Join(recipients, ", ")
return fmt.Sprintf(`<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body style="font-family:'Microsoft YaHei',Arial,sans-serif;background:#f5f5f5;margin:0;padding:20px;">
<div style="max-width:600px;margin:0 auto;background:white;border-radius:10px;overflow:hidden;box-shadow:0 2px 10px rgba(0,0,0,0.1);">
<div style="background:linear-gradient(135deg,#e74c3c,#c0392b);color:white;padding:30px;text-align:center;">
<h2 style="margin:0;">🚨 QQ群消息监控提醒</h2>
<p style="margin:10px 0 0;">检测到关键字消息,请及时处理</p>
</div>
<div style="padding:30px;">
<div style="background:#fff3cd;border:1px solid #ffc107;border-radius:8px;padding:15px;margin-bottom:20px;">
<strong>⚠️ 重要提醒:</strong>检测到关键字 <span style="color:red;">"%s"</span>
</div>
<table style="width:100%%;border-collapse:collapse;margin:20px 0;">
<tr><td style="padding:12px;border-bottom:1px solid #eee;font-weight:bold;color:#555;">⏰ 通知时间</td><td style="padding:12px;border-bottom:1px solid #eee;">%s</td></tr>
<tr><td style="padding:12px;border-bottom:1px solid #eee;font-weight:bold;color:#555;">🔑 匹配关键字</td><td style="padding:12px;border-bottom:1px solid #eee;color:red;font-weight:bold;">%s</td></tr>
<tr><td style="padding:12px;border-bottom:1px solid #eee;font-weight:bold;color:#555;">📱 消息来源群</td><td style="padding:12px;border-bottom:1px solid #eee;">群ID: %d</td></tr>
<tr><td style="padding:12px;border-bottom:1px solid #eee;font-weight:bold;color:#555;">👤 发送者</td><td style="padding:12px;border-bottom:1px solid #eee;">%s</td></tr>
</table>
<div style="background:#f8d7da;border:2px solid #dc3545;border-radius:8px;padding:20px;margin:20px 0;">
<p style="font-size:18px;color:#721c24;font-weight:bold;margin:0;">💬 %s</p>
</div>
<div style="background:#e8f5e9;border-radius:8px;padding:10px 15px;margin:15px 0;font-size:13px;color:#2e7d32;">
📧 此邮件已发送给 %d 位收件人: %s
</div>
</div>
<div style="background:#f8f9fa;padding:20px;text-align:center;color:#6c757d;font-size:13px;">
QQ消息监控系统 v3.0 · 自动发送 · %s
</div>
</div>
</body></html>`,
content.Keyword,
content.Time.Format("2006-01-02 15:04:05"),
content.Keyword,
content.GroupID,
content.SenderName,
content.OriginalMsg,
len(recipients),
recipientList,
content.Time.Format("2006-01-02"),
)
}
GOEOF
6.notifier/sms.go(短信通知)
cat > /opt/qq-monitor/service/notifier/sms.go << 'GOEOF'
package notifier
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"qq-monitor/config"
"qq-monitor/model"
"strings"
"time"
)
type SMSNotifier struct {
client *http.Client
}
func NewSMSNotifier() *SMSNotifier {
return &SMSNotifier{
client: &http.Client{Timeout: 15 * time.Second},
}
}
func (n *SMSNotifier) Name() string {
return "短信通知"
}
func (n *SMSNotifier) IsAvailable() bool {
cfg := config.Get()
return cfg.SMS.Enable && cfg.SMS.SecretID != "" && cfg.SMS.SecretKey != ""
}
// SendBatch 批量发送(一次API调用发送所有手机号)
func (n *SMSNotifier) SendBatch(persons []model.NotifyPerson, content model.NotifyContent) map[string]error {
results := make(map[string]error)
var phones []string
var validPersons []model.NotifyPerson
for _, p := range persons {
if p.Phone != "" {
phone := p.Phone
if !strings.HasPrefix(phone, "+86") {
phone = "+86" + phone
}
phones = append(phones, phone)
validPersons = append(validPersons, p)
}
}
if len(phones) == 0 {
log.Println(" ⚠️ 无有效手机号")
return results
}
log.Printf(" 📱 发送短信通知给 %d 人", len(phones))
err := n.sendSMS(phones, content)
for _, p := range validPersons {
results[p.ID] = err
}
return results
}
// sendSMS 调用腾讯云API发送短信
func (n *SMSNotifier) sendSMS(phones []string, content model.NotifyContent) error {
cfg := config.Get()
params := map[string]interface{}{
"PhoneNumberSet": phones,
"SmsSdkAppId": cfg.SMS.AppID,
"SignName": cfg.SMS.SignName,
"TemplateId": cfg.SMS.TemplateID,
"TemplateParamSet": []string{
content.Keyword,
truncateStr(content.OriginalMsg, 20),
content.SenderName,
},
}
payload, _ := json.Marshal(params)
host := "sms.tencentcloudapi.com"
algorithm := "TC3-HMAC-SHA256"
timestamp := time.Now().Unix()
date := time.Unix(timestamp, 0).UTC().Format("2006-01-02")
// 计算签名
hashedPayload := sha256Hex(string(payload))
canonicalRequest := fmt.Sprintf("POST\n/\n\ncontent-type:application/json\nhost:%s\n\ncontent-type;host\n%s",
host, hashedPayload)
hashedCanonicalRequest := sha256Hex(canonicalRequest)
credentialScope := fmt.Sprintf("%s/sms/tc3_request", date)
stringToSign := fmt.Sprintf("%s\n%d\n%s\n%s",
algorithm, timestamp, credentialScope, hashedCanonicalRequest)
secretDate := hmacSHA256([]byte("TC3"+cfg.SMS.SecretKey), []byte(date))
secretService := hmacSHA256(secretDate, []byte("sms"))
secretSigning := hmacSHA256(secretService, []byte("tc3_request"))
signature := hex.EncodeToString(hmacSHA256(secretSigning, []byte(stringToSign)))
authorization := fmt.Sprintf("%s Credential=%s/%s, SignedHeaders=content-type;host, Signature=%s",
algorithm, cfg.SMS.SecretID, credentialScope, signature)
req, _ := http.NewRequest("POST", "https://"+host, strings.NewReader(string(payload)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Host", host)
req.Header.Set("X-TC-Action", "SendSms")
req.Header.Set("X-TC-Version", "2021-01-11")
req.Header.Set("X-TC-Timestamp", fmt.Sprintf("%d", timestamp))
req.Header.Set("X-TC-Region", "ap-guangzhou")
req.Header.Set("Authorization", authorization)
resp, err := n.client.Do(req)
if err != nil {
return fmt.Errorf("短信API请求失败: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
log.Printf(" 短信API响应: %s", string(body))
var result map[string]interface{}
json.Unmarshal(body, &result)
if response, ok := result["Response"].(map[string]interface{}); ok {
if errInfo, ok := response["Error"].(map[string]interface{}); ok {
return fmt.Errorf("短信发送失败: %v", errInfo["Message"])
}
if sendStatusSet, ok := response["SendStatusSet"].([]interface{}); ok {
for _, status := range sendStatusSet {
if s, ok := status.(map[string]interface{}); ok {
if code, ok := s["Code"].(string); ok && code != "Ok" {
return fmt.Errorf("短信发送失败: %s - %s", code, s["Message"])
}
}
}
}
}
log.Printf(" ✅ 短信发送成功: %d 人", len(phones))
return nil
}
func truncateStr(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen]) + "..."
}
func sha256Hex(s string) string {
h := sha256.New()
h.Write([]byte(s))
return hex.EncodeToString(h.Sum(nil))
}
func hmacSHA256(key, data []byte) []byte {
h := hmac.New(sha256.New, key)
h.Write(data)
return h.Sum(nil)
}
GOEOF
7.notifier/manager.go(通知管理器)
cat > /opt/qq-monitor/service/notifier/manager.go << 'GOEOF'
package notifier
import (
"log"
"qq-monitor/config"
"qq-monitor/model"
"strings"
"sync"
)
type NotificationManager struct {
notifiers map[string]Notifier
}
var (
instance *NotificationManager
once sync.Once
)
// GetManager 获取通知管理器单例
func GetManager() *NotificationManager {
once.Do(func() {
instance = &NotificationManager{
notifiers: make(map[string]Notifier),
}
// 注册三种通知器
instance.Register("wechat", NewPushPlusNotifier())
instance.Register("email", NewEmailNotifier())
instance.Register("sms", NewSMSNotifier())
})
return instance
}
func (nm *NotificationManager) Register(name string, n Notifier) {
nm.notifiers[name] = n
log.Printf("📌 通知器已注册: %s", name)
}
// Notify 发送通知(根据配置的通知模式)
func (nm *NotificationManager) Notify(content model.NotifyContent) {
cfg := config.Get()
persons := config.GetEnabledPersons()
if len(persons) == 0 {
log.Println("⚠️ 没有启用的通知人员,跳过通知")
return
}
if len(cfg.NotifyMode) == 0 {
log.Println("⚠️ 未配置通知方式,跳过通知")
return
}
log.Println(strings.Repeat("-", 40))
log.Printf("📢 开始发送通知")
log.Printf(" 📡 通知方式: %v", cfg.NotifyMode)
log.Printf(" 👥 通知人员: %d人", len(persons))
for _, p := range persons {
modes := []string{}
if p.WechatToken != "" {
modes = append(modes, "微信")
}
if p.Email != "" {
modes = append(modes, "邮件")
}
if p.Phone != "" {
modes = append(modes, "短信")
}
log.Printf(" - %s (%s)", p.Name, strings.Join(modes, "/"))
}
log.Println(strings.Repeat("-", 40))
var wg sync.WaitGroup
for _, mode := range cfg.NotifyMode {
notifier, ok := nm.notifiers[mode]
if !ok {
log.Printf("⚠️ 通知方式 %s 未注册", mode)
continue
}
if !notifier.IsAvailable() {
log.Printf("⚠️ 通知方式 %s 未启用或配置不完整", mode)
continue
}
wg.Add(1)
go func(m string, n Notifier) {
defer wg.Done()
log.Printf("📤 [%s] 开始发送...", n.Name())
results := n.SendBatch(persons, content)
successCount := 0
failCount := 0
for _, err := range results {
if err == nil {
successCount++
} else {
failCount++
}
}
log.Printf("📊 [%s] 发送完成: 成功=%d 失败=%d", n.Name(), successCount, failCount)
}(mode, notifier)
}
wg.Wait()
log.Println(strings.Repeat("=", 40))
}
GOEOF
8.handler/websocket.go
cat > /opt/qq-monitor/service/handler/websocket.go << 'GOEOF'
package handler
import (
"encoding/json"
"log"
"net/http"
"qq-monitor/model"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func WebSocketHandler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("❌ WebSocket升级失败: %v", err)
return
}
defer conn.Close()
log.Printf("✅ NapCat QQ机器人已连接: %s", r.RemoteAddr)
msgHandler := NewMessageHandler()
for {
_, message, err := conn.ReadMessage()
if err != nil {
log.Printf("⚠️ WebSocket连接断开: %v", err)
break
}
var qqMsg model.QQMessage
if err := json.Unmarshal(message, &qqMsg); err != nil {
continue
}
go msgHandler.Process(qqMsg)
}
log.Println("NapCat连接已断开,等待重连...")
}
GOEOF
9.handler/message.go
cat > /opt/qq-monitor/service/handler/message.go << 'GOEOF'
package handler
import (
"log"
"qq-monitor/config"
"qq-monitor/model"
"qq-monitor/notifier"
"strings"
"time"
)
type MessageHandler struct {
notifyMgr *notifier.NotificationManager
}
func NewMessageHandler() *MessageHandler {
return &MessageHandler{
notifyMgr: notifier.GetManager(),
}
}
func (h *MessageHandler) Process(msg model.QQMessage) {
// 只处理群消息
if msg.MessageType != "group" {
return
}
// 检查关键字
cfg := config.Get()
matchedKeyword := ""
for _, kw := range cfg.Keywords {
if strings.Contains(msg.RawMessage, kw) {
matchedKeyword = kw
break
}
}
if matchedKeyword == "" {
return
}
// 获取发送者名称
senderName := msg.Sender.Card
if senderName == "" {
senderName = msg.Sender.Nickname
}
log.Println()
log.Printf("🚨 检测到关键字消息!")
log.Printf(" 🔑 关键字: %s", matchedKeyword)
log.Printf(" 👤 发送者: %s", senderName)
log.Printf(" 📱 群ID: %d", msg.GroupID)
log.Printf(" 💬 内容: %s", msg.RawMessage)
// 构建通知内容
content := model.NotifyContent{
Title: fmt.Sprintf("【QQ监控】%s - 消息提醒", matchedKeyword),
Keyword: matchedKeyword,
SenderName: senderName,
GroupID: msg.GroupID,
Time: time.Now(),
OriginalMsg: msg.RawMessage,
}
// 发送通知
h.notifyMgr.Notify(content)
}
GOEOF
10.api/handler.go
cat > /opt/qq-monitor/service/api/handler.go << 'GOEOF'
package api
import (
"encoding/json"
"net/http"
"qq-monitor/config"
"qq-monitor/model"
)
// CORSMiddleware CORS中间件
func CORSMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next(w, r)
}
}
// GetConfig 获取配置
func GetConfig(w http.ResponseWriter, r *http.Request) {
cfg := config.Get()
respondJSON(w, model.APIResponse{Code: 200, Message: "成功", Data: cfg})
}
// UpdateWechatEnable 更新微信启用状态
func UpdateWechatEnable(w http.ResponseWriter, r *http.Request) {
var req struct{ Enable bool }
json.NewDecoder(r.Body).Decode(&req)
config.Update(func(cfg *model.AppConfig) {
cfg.Wechat.Enable = req.Enable
if req.Enable && !contains(cfg.NotifyMode, "wechat") {
cfg.NotifyMode = append(cfg.NotifyMode, "wechat")
}
if !req.Enable {
cfg.NotifyMode = removeItem(cfg.NotifyMode, "wechat")
}
})
respondJSON(w, model.APIResponse{Code: 200, Message: "微信通知配置已更新"})
}
// UpdateEmailConfig 更新邮件配置
func UpdateEmailConfig(w http.ResponseWriter, r *http.Request) {
var req model.EmailConfig
json.NewDecoder(r.Body).Decode(&req)
config.Update(func(cfg *model.AppConfig) {
cfg.Email = req
if req.Enable && !contains(cfg.NotifyMode, "email") {
cfg.NotifyMode = append(cfg.NotifyMode, "email")
}
if !req.Enable {
cfg.NotifyMode = removeItem(cfg.NotifyMode, "email")
}
})
respondJSON(w, model.APIResponse{Code: 200, Message: "邮件配置已更新"})
}
// UpdateSMSConfig 更新短信配置
func UpdateSMSConfig(w http.ResponseWriter, r *http.Request) {
var req model.SMSConfig
json.NewDecoder(r.Body).Decode(&req)
config.Update(func(cfg *model.AppConfig) {
cfg.SMS = req
if req.Enable && !contains(cfg.NotifyMode, "sms") {
cfg.NotifyMode = append(cfg.NotifyMode, "sms")
}
if !req.Enable {
cfg.NotifyMode = removeItem(cfg.NotifyMode, "sms")
}
})
respondJSON(w, model.APIResponse{Code: 200, Message: "短信配置已更新"})
}
// UpdateKeywords 更新关键字
func UpdateKeywords(w http.ResponseWriter, r *http.Request) {
var req struct{ Keywords []string }
json.NewDecoder(r.Body).Decode(&req)
config.Update(func(cfg *model.AppConfig) {
cfg.Keywords = req.Keywords
})
respondJSON(w, model.APIResponse{Code: 200, Message: "关键字已更新"})
}
// UpdateNotifyMode 更新通知方式
func UpdateNotifyMode(w http.ResponseWriter, r *http.Request) {
var req struct{ Modes []string }
json.NewDecoder(r.Body).Decode(&req)
config.Update(func(cfg *model.AppConfig) {
cfg.NotifyMode = req.Modes
})
respondJSON(w, model.APIResponse{Code: 200, Message: "通知方式已更新"})
}
// AddPerson 添加人员
func AddPerson(w http.ResponseWriter, r *http.Request) {
var person model.NotifyPerson
json.NewDecoder(r.Body).Decode(&person)
if person.Name == "" {
respondJSON(w, model.APIResponse{Code: 400, Message: "姓名不能为空"})
return
}
config.Update(func(cfg *model.AppConfig) {
cfg.NotifyList = append(cfg.NotifyList, person)
})
respondJSON(w, model.APIResponse{Code: 200, Message: "人员添加成功"})
}
// DeletePerson 删除人员
func DeletePerson(w http.ResponseWriter, r *http.Request) {
var req struct{ ID string }
json.NewDecoder(r.Body).Decode(&req)
config.Update(func(cfg *model.AppConfig) {
var newList []model.NotifyPerson
for _, p := range cfg.NotifyList {
if p.ID != req.ID {
newList = append(newList, p)
}
}
cfg.NotifyList = newList
})
respondJSON(w, model.APIResponse{Code: 200, Message: "人员删除成功"})
}
func respondJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
func removeItem(slice []string, item string) []string {
var result []string
for _, s := range slice {
if s != item {
result = append(result, s)
}
}
return result
}
GOEOF
11.api/web.go(Web管理界面)
bash
cat > /opt/qq-monitor/service/api/web.go << 'GOEOF'
package api
import "net/http"
func WebIndex(w http.ResponseWriter, r *http.Request) {
html := `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>QQ消息监控系统 v3.0</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Microsoft YaHei',sans-serif;background:#f0f2f5;padding:15px;color:#333}
.container{max-width:950px;margin:0 auto}
h1{text-align:center;color:#1a1a1a;margin:15px 0;font-size:22px}
.card{background:white;border-radius:10px;padding:18px;margin-bottom:12px;box-shadow:0 2px 6px rgba(0,0,0,0.08)}
.card h2{color:#333;margin-bottom:10px;padding-bottom:8px;border-bottom:2px solid #4CAF50;font-size:15px}
.card h3{font-size:14px;color:#555;margin:12px 0 8px}
.form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px}
.form-group{flex:1;min-width:150px}
label{display:block;margin-bottom:3px;color:#666;font-weight:bold;font-size:12px}
input,select{width:100%;padding:7px;border:1px solid #ddd;border-radius:5px;font-size:12px}
input:focus,select:focus{outline:none;border-color:#4CAF50}
button{background:#4CAF50;color:white;border:none;padding:7px 14px;border-radius:5px;cursor:pointer;font-size:12px;margin:2px}
button:hover{opacity:.85}
button.danger{background:#f44336}
button.warn{background:#ff9800}
button.info{background:#2196F3}
.status{padding:10px;border-radius:5px;margin:8px 0;display:none;font-size:13px}
.status.success{background:#d4edda;color:#155724;border:1px solid #28a745;display:block}
.status.error{background:#f8d7da;color:#721c24;border:1px solid #dc3545;display:block}
.person-item{background:#f9f9f9;padding:10px;margin-bottom:5px;border-radius:5px;display:flex;justify-content:space-between;align-items:center}
.person-name{font-weight:bold;font-size:13px}
.person-detail{color:#666;font-size:11px;margin-top:2px}
.keyword-tag{display:inline-block;background:#e8f5e9;color:#2e7d32;padding:3px 8px;border-radius:12px;margin:2px;font-size:11px}
.help-text{font-size:10px;color:#999;margin-top:2px}
.toggle-container{display:flex;align-items:center;gap:10px;margin:8px 0}
.toggle-label{font-size:13px;color:#555}
.badge{display:inline-block;padding:2px 6px;border-radius:8px;font-size:10px;margin-left:4px}
.badge-on{background:#d4edda;color:#155724}
.badge-off{background:#f8d7da;color:#721c24}
.mode-info{font-size:12px;color:#555;margin:5px 0}
</style>
</head>
<body>
<div class="container">
<h1>📡 QQ消息监控系统 v3.0</h1>
<!-- 通知方式总览 -->
<div class="card">
<h2>📡 通知方式状态</h2>
<div id="notifyStatus" class="mode-info">加载中...</div>
</div>
<!-- 微信配置 -->
<div class="card">
<h2>📱 微信通知 (PushPlus)</h2>
<p class="help-text">💡 每个人员独立Token | <a href="http://www.pushplus.plus" target="_blank">获取Token</a> | 需关注"pushplus 推送加"公众号</p>
<div class="toggle-container">
<label class="toggle-label">启用微信通知:</label>
<input type="checkbox" id="wechatEnable" onchange="toggleWechat()">
<span id="wechatStatus" class="badge badge-off">未启用</span>
</div>
</div>
<!-- 邮件配置 -->
<div class="card">
<h2>📧 邮件通知 (支持企业邮箱)</h2>
<p class="help-text">💡 支持QQ邮箱、企业邮箱(如luo.yu@opg.cn) | 一封邮件群发所有人</p>
<div class="form-row">
<div class="form-group"><label>启用:</label><select id="emailEnable" onchange="toggleEmail()"><option value="false">禁用</option><option value="true">启用</option></select></div>
</div>
<div id="emailConfig" style="display:none;">
<div class="form-row">
<div class="form-group"><label>SMTP服务器:</label><input type="text" id="emailHost" placeholder="smtp.exmail.qq.com"></div>
<div class="form-group"><label>端口:</label><input type="number" id="emailPort" placeholder="587" value="587"></div>
</div>
<div class="form-row">
<div class="form-group"><label>邮箱账号:</label><input type="email" id="emailUser" placeholder="luo.yu@opg.cn"></div>
<div class="form-group"><label>邮箱密码/授权码:</label><input type="password" id="emailPass" placeholder="密码或授权码"></div>
</div>
</div>
<button onclick="saveEmail()">💾 保存邮件配置</button>
</div>
<!-- 短信配置 -->
<div class="card">
<h2>📱 短信通知 (腾讯云SMS)</h2>
<p class="help-text">💡 一次API批量发送 | 需在腾讯云短信控制台配置签名和模板</p>
<div class="form-row">
<div class="form-group"><label>启用:</label><select id="smsEnable" onchange="toggleSMS()"><option value="false">禁用</option><option value="true">启用</option></select></div>
</div>
<div id="smsConfig" style="display:none;">
<div class="form-row">
<div class="form-group"><label>SecretId:</label><input type="text" id="smsSecretId"></div>
<div class="form-group"><label>SecretKey:</label><input type="password" id="smsSecretKey"></div>
</div>
<div class="form-row">
<div class="form-group"><label>SDK App ID:</label><input type="text" id="smsAppId"></div>
<div class="form-group"><label>签名:</label><input type="text" id="smsSignName" placeholder="QQ消息监控"></div>
</div>
<div class="form-row">
<div class="form-group"><label>模板ID:</label><input type="text" id="smsTemplateId"></div>
</div>
</div>
<button onclick="saveSMS()">💾 保存短信配置</button>
</div>
<!-- 关键字管理 -->
<div class="card">
<h2>🔑 监控关键字</h2>
<div id="keywordsList" style="margin-bottom:8px;"></div>
<div class="form-row">
<div class="form-group"><input type="text" id="newKeyword" placeholder="输入关键字,如: 官方介质已到"></div>
</div>
<button onclick="addKeyword()">➕ 添加</button>
</div>
<!-- 人员管理 -->
<div class="card">
<h2>👥 通知人员管理</h2>
<p class="help-text">💡 每个人员可配置: 邮箱(邮件通知) | 手机号(短信通知) | 微信Token(微信通知)</p>
<h3>添加人员</h3>
<div class="form-row">
<div class="form-group"><label>姓名 *:</label><input type="text" id="personName" placeholder="必填"></div>
<div class="form-group"><label>邮箱:</label><input type="email" id="personEmail" placeholder="邮箱(邮件通知用)"></div>
</div>
<div class="form-row">
<div class="form-group"><label>手机号:</label><input type="tel" id="personPhone" placeholder="手机号(短信通知用)"></div>
<div class="form-group"><label>微信Token:</label><input type="text" id="personWechatToken" placeholder="PushPlus Token"></div>
</div>
<button onclick="addPerson()">➕ 添加人员</button>
<h3>人员列表</h3>
<div id="personList"></div>
</div>
<div id="status" class="status"></div>
</div>
<script>
const API='/api';
function showStatus(msg,ok){const s=document.getElementById('status');s.textContent=msg;s.className='status '+(ok?'success':'error');setTimeout(()=>s.className='status',4000);}
async function loadConfig(){
try{
const r=await fetch(API+'/config');
const cfg=(await r.json()).data;
// 微信状态
const weOn=cfg.wechat?.enable||false;
document.getElementById('wechatEnable').checked=weOn;
document.getElementById('wechatStatus').textContent=weOn?'已启用':'未启用';
document.getElementById('wechatStatus').className='badge '+(weOn?'badge-on':'badge-off');
// 邮件配置
const eOn=cfg.email?.enable||false;
document.getElementById('emailEnable').value=eOn?'true':'false';
if(eOn){document.getElementById('emailConfig').style.display='block';
document.getElementById('emailHost').value=cfg.email?.smtp_host||'smtp.exmail.qq.com';
document.getElementById('emailPort').value=cfg.email?.smtp_port||587;
document.getElementById('emailUser').value=cfg.email?.username||'';
document.getElementById('emailPass').value=cfg.email?.password||'';}
// 短信配置
const sOn=cfg.sms?.enable||false;
document.getElementById('smsEnable').value=sOn?'true':'false';
if(sOn){document.getElementById('smsConfig').style.display='block';
document.getElementById('smsSecretId').value=cfg.sms?.secret_id||'';
document.getElementById('smsSecretKey').value=cfg.sms?.secret_key||'';
document.getElementById('smsAppId').value=cfg.sms?.app_id||'';
document.getElementById('smsSignName').value=cfg.sms?.sign_name||'';
document.getElementById('smsTemplateId').value=cfg.sms?.template_id||'';}
// 通知状态
const modes=cfg.notify_mode||[];
document.getElementById('notifyStatus').innerHTML=
'当前启用通知方式: '+(modes.length>0?modes.map(m=>({wechat:'📱微信',email:'📧邮件',sms:'📱短信'}[m]||m)).join(' + '):'❌ 未启用任何通知方式');
// 关键字
const kw=cfg.keywords||[];
document.getElementById('keywordsList').innerHTML=kw.length>0?kw.map(k=>'<span class="keyword-tag">'+k+' <button class="danger" style="padding:1px 5px;font-size:10px;" onclick="removeKeyword(\''+k+'\')">✕</button></span>').join(' '):'<span style="color:#999;">暂无关键字</span>';
// 人员列表
const pl=cfg.notify_list||[];
document.getElementById('personList').innerHTML=pl.length>0?pl.map(p=>'<div class="person-item"><div><div class="person-name">'+p.name+(p.enable?'':' <span style="color:#999;">(已禁用)</span>')+'</div><div class="person-detail">'+(p.email?'📧'+p.email+' ':'')+(p.phone?'📱'+p.phone+' ':'')+(p.wechat_token?'💬微信Token已配置':'💬微信未配置')+'</div></div><button class="danger" onclick="deletePerson(\''+p.id+'\')">删除</button></div>').join(''):'<p style="color:#999;">暂无通知人员</p>';
}catch(e){showStatus('加载配置失败: '+e.message,false);}
}
async function toggleWechat(){const e=document.getElementById('wechatEnable').checked;await fetch(API+'/wechat/enable',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({enable:e})});loadConfig();showStatus('微信通知已'+(e?'启用':'禁用'),true);}
function toggleEmail(){document.getElementById('emailConfig').style.display=document.getElementById('emailEnable').value==='true'?'block':'none';}
function toggleSMS(){document.getElementById('smsConfig').style.display=document.getElementById('smsEnable').value==='true'?'block':'none';}
async function saveEmail(){
const c={enable:document.getElementById('emailEnable').value==='true',smtp_host:document.getElementById('emailHost').value,smtp_port:parseInt(document.getElementById('emailPort').value)||587,username:document.getElementById('emailUser').value,password:document.getElementById('emailPass').value,from_name:'QQ监控系统'};
await fetch(API+'/email/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(c)});
loadConfig();showStatus('邮件配置已保存',true);}
async function saveSMS(){
const c={enable:document.getElementById('smsEnable').value==='true',provider:'tencent',secret_id:document.getElementById('smsSecretId').value,secret_key:document.getElementById('smsSecretKey').value,app_id:document.getElementById('smsAppId').value,sign_name:document.getElementById('smsSignName').value,template_id:document.getElementById('smsTemplateId').value};
await fetch(API+'/sms/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(c)});
loadConfig();showStatus('短信配置已保存',true);}
async function addKeyword(){const kw=document.getElementById('newKeyword').value.trim();if(!kw)return;const r=await fetch(API+'/config');const cfg=(await r.json()).data;const keywords=cfg.keywords||[];if(keywords.includes(kw))return alert('已存在');keywords.push(kw);await fetch(API+'/keywords',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({keywords})});document.getElementById('newKeyword').value='';loadConfig();showStatus('关键字已添加',true);}
async function removeKeyword(kw){if(!confirm('删除"'+kw+'"?'))return;const r=await fetch(API+'/config');const cfg=(await r.json()).data;const keywords=(cfg.keywords||[]).filter(k=>k!==kw);await fetch(API+'/keywords',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({keywords})});loadConfig();showStatus('关键字已删除',true);}
async function addPerson(){const name=document.getElementById('personName').value.trim();if(!name)return alert('请输入姓名');const person={id:'p_'+Date.now(),name:name,email:document.getElementById('personEmail').value.trim(),phone:document.getElementById('personPhone').value.trim(),wechat_token:document.getElementById('personWechatToken').value.trim(),enable:true};await fetch(API+'/person/add',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(person)});['personName','personEmail','personPhone','personWechatToken'].forEach(id=>document.getElementById(id).value='');loadConfig();showStatus('人员添加成功',true);}
async function deletePerson(id){if(!confirm('确定删除?'))return;await fetch(API+'/person/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})});loadConfig();showStatus('人员已删除',true);}
loadConfig();
</script>
</body></html>`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
}
GOEOF
12.main.go
cat > /opt/qq-monitor/service/main.go << 'GOEOF'
package main
import (
"log"
"net/http"
"os"
"os/signal"
"qq-monitor/api"
"qq-monitor/config"
"qq-monitor/handler"
"strings"
"syscall"
)
func main() {
log.Println(strings.Repeat("=", 55))
log.Println("🚀 QQ消息监控系统 v3.0 (NapCat) 启动中...")
log.Println(strings.Repeat("=", 55))
// 初始化配置
if err := config.Init(); err != nil {
log.Fatalf("❌ 配置初始化失败: %v", err)
}
// 注册路由
mux := http.NewServeMux()
// WebSocket路由(NapCat连接)
mux.HandleFunc("/ws", handler.WebSocketHandler)
// API路由
mux.HandleFunc("/api/config", api.CORSMiddleware(api.GetConfig))
mux.HandleFunc("/api/wechat/enable", api.CORSMiddleware(api.UpdateWechatEnable))
mux.HandleFunc("/api/email/config", api.CORSMiddleware(api.UpdateEmailConfig))
mux.HandleFunc("/api/sms/config", api.CORSMiddleware(api.UpdateSMSConfig))
mux.HandleFunc("/api/keywords", api.CORSMiddleware(api.UpdateKeywords))
mux.HandleFunc("/api/notify-mode", api.CORSMiddleware(api.UpdateNotifyMode))
mux.HandleFunc("/api/person/add", api.CORSMiddleware(api.AddPerson))
mux.HandleFunc("/api/person/delete", api.CORSMiddleware(api.DeletePerson))
// Web管理界面
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
api.WebIndex(w, r)
} else {
http.NotFound(w, r)
}
})
// 启动服务
go func() {
log.Println("🌐 Web管理界面: http://0.0.0.0:19090")
log.Println("📡 WebSocket服务: ws://0.0.0.0:18080/ws")
log.Println(strings.Repeat("=", 55))
log.Println("⏳ 等待NapCat连接...")
if err := http.ListenAndServe(":19090", mux); err != nil {
log.Fatalf("❌ HTTP服务启动失败: %v", err)
}
}()
// 优雅退出
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("🛑 系统正在关闭...")
}
GOEOF
5 编译部署
5.1 编译项目
cd /opt/qq-monitor/service
# 下载依赖
go mod tidy
# 编译
go build -o monitor .
# 验证
ls -la monitor
5.2 首次运行生成配置文件
cd /opt/qq-monitor/service
./monitor
# 看到日志后按 Ctrl+C 停止
# 确认配置文件已生成
cat config.json
5.3 创建systemd服务
cat > /etc/systemd/system/qq-monitor.service << 'EOF'
[Unit]
Description=QQ Message Monitor Service v3.0
After=network.target docker.service
Requires=docker.service
[Service]
Type=simple
User=root
WorkingDirectory=/opt/qq-monitor/service
ExecStart=/opt/qq-monitor/service/monitor
Restart=always
RestartSec=10
StandardOutput=append:/var/log/qq-monitor.log
StandardError=append:/var/log/qq-monitor-error.log
Environment=TZ=Asia/Shanghai
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable qq-monitor
5.4 配置防火墙
firewall-cmd --zone=public --add-port=19090/tcp --permanent
firewall-cmd --zone=public --add-port=18080/tcp --permanent
firewall-cmd --zone=public --add-port=6099/tcp --permanent
firewall-cmd --reload
firewall-cmd --list-ports
6 NapCat部署
6.1 部署NapCat
# 拉取镜像
docker pull mlikiowa/napcat-docker:latest
# 创建容器(使用host网络模式)
docker run -d \
--name napcat \
--restart always \
--network host \
-v /opt/qq-monitor/napcat:/root/.config/QQ \
-v /opt/qq-monitor/napcat/config:/usr/local/napcat/config \
-e NAPCAT_GID=0 \
-e NAPCAT_UIN=0 \
mlikiowa/napcat-docker:latest
# 查看容器状态
docker ps | grep napcat
6.2 配置NapCat WebSocket
# 等待容器启动
sleep 30
# 创建WebSocket配置
mkdir -p /opt/qq-monitor/napcat/config
cat > /opt/qq-monitor/napcat/config/napcat.json << 'EOF'
{
"wsReverse": {
"enable": true,
"url": "ws://127.0.0.1:18080/ws",
"reconnectInterval": 3000
}
}
EOF
# 重启NapCat
docker restart napcat
6.2 VNC登录QQ
# 查看NapCat日志获取VNC信息
docker logs napcat 2>&1 | grep -i "vnc\|http"
# 浏览器打开
# http://你的服务器IP:6099
VNC默认密码: admin
操作步骤:
- 浏览器打开
http://服务器IP:6099 - 输入密码
admin - 看到QQ登录界面
- 用手机QQ扫码登录
- 等待登录完成
6.3 验证连接
# 启动Golang服务
systemctl start qq-monitor
# 查看日志
tail -f /var/log/qq-monitor.log
7 系统配置与测试
7.1 访问管理界面
http://你的服务器公网IP:19090
7.2 仅启用微信通知
操作步骤:
- 在管理界面勾选「启用微信通知」
- 添加人员时填写微信Token
- 发送测试消息验证
7.3 仅启用邮件通知
操作步骤:
- 邮件配置:
- 启用:是
- SMTP服务器:
smtp.exmail.qq.com(或你的企业邮箱SMTP) - 端口:587
- 邮箱账号:
luo.yu@opg.cn - 密码:你的邮箱密码或授权码
- 保存配置
- 添加人员时填写邮箱
- 发送测试消息验证
7.4 仅启用短信通知
操作步骤:
- 短信配置:
- 启用:是
- 填写SecretId、SecretKey、AppID、签名、模板ID
- 保存配置
- 添加人员时填写手机号
- 发送测试消息验证
7.5 微信+邮件通知
操作步骤:
- 启用微信通知
- 配置并保存邮件
- 两种方式都会自动启用
7.6 三种方式全部启用
操作步骤:
- 依次启用微信、配置邮件、配置短信
- 添加人员时填写三种联系方式
- 发送测试消息
7.7 发送测试消息
在QQ群中发送:
举起手来2官方介质已到,请查收!
7.8 查看通知日志
tail -f /var/log/qq-monitor.log
8 API管理命令
# 查看完整配置
curl http://localhost:19090/api/config | python3 -m json.tool
# 设置仅微信通知
curl -X POST http://localhost:19090/api/notify-mode \
-H "Content-Type: application/json" \
-d '{"modes":["wechat"]}'
# 设置微信+邮件通知
curl -X POST http://localhost:19090/api/notify-mode \
-H "Content-Type: application/json" \
-d '{"modes":["wechat","email"]}'
# 设置三种全部
curl -X POST http://localhost:19090/api/notify-mode \
-H "Content-Type: application/json" \
-d '{"modes":["wechat","email","sms"]}'
# 更新关键字
curl -X POST http://localhost:19090/api/keywords \
-H "Content-Type: application/json" \
-d '{"keywords":["官方介质已到","紧急通知"]}'
# 添加人员
curl -X POST http://localhost:19090/api/person/add \
-H "Content-Type: application/json" \
-d '{
"id":"user1",
"name":"张三",
"email":"zhangsan@opg.cn",
"phone":"13800138000",
"wechat_token":"token_xxx",
"enable":true
}'
# 删除人员
curl -X POST http://localhost:19090/api/person/delete \
-H "Content-Type: application/json" \
-d '{"id":"user1"}'
9 日常运维
# 服务状态
systemctl status qq-monitor
docker ps | grep napcat
# 重启服务
systemctl restart qq-monitor
docker restart napcat
# 查看日志
tail -f /var/log/qq-monitor.log
tail -f /var/log/qq-monitor-error.log
# QQ掉线重登
# 浏览器打开 http://服务器IP:6099 重新扫码