QQ群消息监控


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.cnsmtp.exmail.qq.com
SMTP端口 通常是587或465 587
邮箱账号 完整邮箱地址 luo.yu@opg.cn
邮箱密码 邮箱密码或专用授权码 你的密码

2.2.2 查找企业邮箱SMTP信息

方法一:查看邮箱帮助文档

  1. 登录企业邮箱网页版
  2. 查看「设置」→「客户端设置」或「POP3/SMTP设置」
  3. 找到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 获取授权码

腾讯企业邮箱:

  1. 登录 https://exmail.qq.com
  2. 设置 → 客户端设置
  3. 开启SMTP服务
  4. 生成授权码

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:

  1. 左侧菜单 →「应用管理」
  2. 查看「SDK App ID」,如:1400XXXXXX

获取API密钥:

  1. 访问 https://console.cloud.tencent.com/cam/capi
  2. 点击「新建密钥」
  3. 记录:
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

操作步骤:

  1. 浏览器打开 http://服务器IP:6099
  2. 输入密码 admin
  3. 看到QQ登录界面
  4. 用手机QQ扫码登录
  5. 等待登录完成

6.3 验证连接

# 启动Golang服务
systemctl start qq-monitor

# 查看日志
tail -f /var/log/qq-monitor.log

7 系统配置与测试

7.1 访问管理界面

http://你的服务器公网IP:19090

7.2 仅启用微信通知

操作步骤:

  1. 在管理界面勾选「启用微信通知」
  2. 添加人员时填写微信Token
  3. 发送测试消息验证

7.3 仅启用邮件通知

操作步骤:

  1. 邮件配置:
    • 启用:是
    • SMTP服务器:smtp.exmail.qq.com(或你的企业邮箱SMTP)
    • 端口:587
    • 邮箱账号:luo.yu@opg.cn
    • 密码:你的邮箱密码或授权码
  2. 保存配置
  3. 添加人员时填写邮箱
  4. 发送测试消息验证

7.4 仅启用短信通知

操作步骤:

  1. 短信配置:
    • 启用:是
    • 填写SecretId、SecretKey、AppID、签名、模板ID
  2. 保存配置
  3. 添加人员时填写手机号
  4. 发送测试消息验证

7.5 微信+邮件通知

操作步骤:

  1. 启用微信通知
  2. 配置并保存邮件
  3. 两种方式都会自动启用

7.6 三种方式全部启用

操作步骤:

  1. 依次启用微信、配置邮件、配置短信
  2. 添加人员时填写三种联系方式
  3. 发送测试消息

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 重新扫码

文章作者: 罗宇
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 罗宇 !
  目录