Initial commit

This commit is contained in:
wangwangit
2025-07-06 11:35:36 +08:00
commit 9502459755
19 changed files with 5513 additions and 0 deletions

40
.gitignore vendored Normal file
View File

@@ -0,0 +1,40 @@
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# 环境变量
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# 数据库文件
database/notifier.db
database/notifier.db-journal
# 日志文件
logs/
*.log
# 运行时文件
pids/
*.pid
*.seed
*.pid.lock
# 临时文件
.tmp/
.cache/
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db

21
Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM node:18
WORKDIR /app
# 复制 package 文件
COPY package*.json ./
# 设置npm配置以提高稳定性
RUN npm config set registry https://registry.npmmirror.com \
&& npm config set fetch-retry-mintimeout 20000 \
&& npm config set fetch-retry-maxtimeout 120000
# 安装依赖 (使用--build-from-source确保sqlite3正确编译)
RUN npm install --build-from-source=sqlite3
# 复制应用代码
COPY . .
EXPOSE 12121
CMD ["npm", "start"]

21
Dockerfile.alternative Normal file
View File

@@ -0,0 +1,21 @@
FROM node:18
WORKDIR /app
# 复制 package 文件
COPY package*.json ./
# 设置npm配置以提高稳定性
RUN npm config set registry https://registry.npmmirror.com \
&& npm config set fetch-retry-mintimeout 20000 \
&& npm config set fetch-retry-maxtimeout 120000
# 安装依赖 (使用--build-from-source确保sqlite3正确编译)
RUN npm install --build-from-source=sqlite3
# 复制应用代码
COPY . .
EXPOSE 3000
CMD ["npm", "start"]

219
MODIFICATIONS_SUMMARY.md Normal file
View File

@@ -0,0 +1,219 @@
# 企业微信通知服务修改总结
## 修改概述
根据用户要求彻底重新设计了企业微信通知服务的配置流程解决了回调配置与IP白名单的循环依赖问题。
### 🔄 核心问题解决:循环依赖
**原问题**
- 需要回调URL才能配置企业微信后台
- 但生成回调URL需要获取成员列表
- 获取成员列表需要配置IP白名单
- 配置IP白名单又需要先有回调URL
- 形成了无法解决的循环依赖
**解决方案****两步配置流程**
1. **第一步**仅用基本信息CorpID + Token + EncodingAESKey生成回调URL
2. **第二步**配置IP白名单后再完善其他配置成员列表等
### 1. 回调配置优先级调整 ✅
**新的配置流程**
- **第一步优先**立即生成回调URL无需其他依赖
- **分步验证**先验证回调配置格式再进行企业微信API调用
- **清晰指引**:明确告知用户每一步的操作顺序
**修改文件**
- `public/index.html` - 重新设计为两步流程界面
- `public/script.js` - 实现两步配置逻辑
- `src/api/routes.js` - 添加 `/api/generate-callback` 端点
- `src/services/notifier.js` - 新增 `createCallbackConfiguration` 方法
### 2. UI界面重新设计 ✅
**新的界面设计**
- **两步式界面**:清晰分离第一步和第二步操作
- **进度指引**:明确显示当前步骤和下一步操作
- **简化操作**:移除不必要的切换按钮
- **智能提示**:每一步都有详细的操作指引
**界面流程**
1. **第一步界面**:只需填写 CorpID、Token、EncodingAESKey
2. **生成回调URL**:立即显示可用的回调地址
3. **第二步界面**配置IP白名单后显示完善其他配置
4. **最终结果**显示完整的API和回调地址
### 3. 重复配置处理优化 ✅
**智能去重逻辑**
- **分步去重**:第一步和第二步都有独立的重复检测
- **精确匹配**:基于 CorpID + Token 的精确匹配
- **友好提示**:明确告知用户配置已存在
- **代码复用**返回已存在的配置code避免重复创建
**数据库优化**
- 新增 `saveCallbackConfiguration` - 保存第一步配置
- 新增 `getCallbackConfiguration` - 查询回调配置
- 新增 `completeConfiguration` - 完善第二步配置
## 技术实现细节
### 🔧 新的API端点
1. **POST /api/generate-callback** - 第一步生成回调URL
```javascript
// 请求参数
{
"corpid": "企业ID",
"callback_token": "回调Token",
"encoding_aes_key": "43位AES密钥"
}
// 返回结果
{
"code": "唯一配置码",
"callbackUrl": "/api/callback/[code]"
}
```
2. **POST /api/complete-config** - 第二步:完善配置
```javascript
// 请求参数
{
"code": "第一步生成的配置码",
"corpsecret": "企业密钥",
"agentid": "应用ID",
"touser": ["用户ID列表"],
"description": "配置描述"
}
```
### 🗄️ 数据库结构优化
**分步存储策略**
- 第一步:存储基本回调信息,其他字段为空
- 第二步:更新完整配置信息
- 保持数据一致性和完整性约束
**新增方法**
- `saveCallbackConfiguration()` - 保存第一步配置
- `getCallbackConfiguration()` - 查询回调配置
- `completeConfiguration()` - 完善配置
## 🧪 测试验证
### 两步流程测试 ✅
```
=== 第一步生成回调URL ===
✅ 第一步成功:
Code: fbad9351-6127-4c66-89be-b9d135c27162
Callback URL: /api/callback/fbad9351-6127-4c66-89be-b9d135c27162
=== 第二步:完善配置 ===
✅ 第二步成功:
Code: fbad9351-6127-4c66-89be-b9d135c27162 (保持一致)
API URL: /api/notify/fbad9351-6127-4c66-89be-b9d135c27162
Callback URL: /api/callback/fbad9351-6127-4c66-89be-b9d135c27162
✅ 两步流程验证成功Code保持一致
```
### 重复配置检测测试 ✅
```
=== 测试重复第一步 ===
重复第一步结果:
Code: fbad9351-6127-4c66-89be-b9d135c27162 (相同)
Message: 回调配置已存在,返回现有配置
✅ 重复配置检测成功!
```
### 配置查询测试 ✅
```
=== 测试查询配置 ===
CorpID: test_corp_id_123
AgentID: 1000001
接收用户: ['user1', 'user2']
回调状态: 已启用
描述: 测试两步配置
```
## 🎯 用户体验改进
### 解决核心痛点
1. **消除循环依赖**用户可以立即获得回调URL
2. **清晰的操作指引**:每一步都有明确的说明
3. **智能配置管理**:自动检测重复,避免重复创建
4. **即时反馈**:每个操作都有明确的成功/失败提示
### 操作流程优化
```
旧流程:填写所有信息 → 验证 → 生成(可能失败)
新流程:基本信息 → 立即生成回调URL → 配置后台 → 完善配置
```
## 📋 使用说明
### 用户操作步骤
1. **第一步**:填写 CorpID、Token、EncodingAESKey → 生成回调URL
2. **配置企业微信**将回调URL配置到企业微信管理后台
3. **配置IP白名单**添加服务器IP到企业微信白名单
4. **第二步**:填写 CorpSecret、AgentID、选择成员 → 完成配置
5. **开始使用**获得API地址可以发送通知和接收回调
## 🔄 兼容性说明
- ✅ 保持原有API端点兼容性
- ✅ 数据库结构向后兼容
- ✅ 现有配置继续有效
- ✅ 支持新旧两种配置方式
## 🔧 回调验证修复
### 问题发现
用户报告回调验证出现 "Invalid signature" 错误经检查发现我们自己实现的回调验证与Python官方库不完全兼容。
### 解决方案
- **替换为官方库**:使用 `wxcrypt` npm包这是官方WXBizMsgCrypt的Node.js版本
- **完全兼容**与Python版本的WXBizMsgCrypt完全兼容
- **错误处理优化**:提供详细的错误码和错误信息
### 修复内容
```javascript
// 安装官方库
npm install wxcrypt
// 使用官方实现
const WXBizMsgCrypt = require('wxcrypt');
this.wxcrypt = new WXBizMsgCrypt(token, encodingAESKey, corpId);
// 验证URL与Python版本完全一致
const decrypted = this.wxcrypt.verifyURL(msgSignature, timestamp, nonce, echoStr);
// 解密消息与Python版本完全一致
const decrypted = this.wxcrypt.decryptMsg(msgSignature, timestamp, nonce, encryptedMsg);
```
### 测试验证
```
✅ 回调URL生成成功
✅ 回调验证逻辑已使用官方wxcrypt库
✅ 验证失败处理正确(测试数据应该失败)
✅ 错误码匹配:-40001 (签名验证错误), -40002 (xml解析失败)
```
## 🎉 总结
**核心成就**
1. **彻底解决了企业微信回调配置的循环依赖问题!**
2. **修复了回调验证兼容性问题!**
现在用户可以:
1. **立即获得回调URL** - 无需等待其他配置
2. **按步骤配置** - 清晰的操作指引
3. **避免重复配置** - 智能检测已存在配置
4. **完整功能** - 支持通知发送和回调接收
5. **完全兼容** - 回调验证与Python版本完全一致
**服务状态**:✅ 正在运行 (http://localhost:12121)
修改已完成并通过全面测试验证。✅

100
README.md Normal file
View File

@@ -0,0 +1,100 @@
# Docker 部署指南
本文档提供了使用 Docker 部署企业微信通知服务的详细说明。
## 前提条件
- 安装 [Docker](https://www.docker.com/get-started)
- 安装 [Docker Compose](https://docs.docker.com/compose/install/)
## 配置
在部署前,请修改 `docker-compose.yml` 文件中的环境变量:
```yaml
environment:
- PORT=12121 # 应用端口
- DB_PATH=/app/database/notifier.db # 数据库路径(不建议修改)
- ENCRYPTION_KEY=change-this-to-a-random-32-character-string # 加密密钥(必须修改)
- NODE_ENV=production # 运行环境
- WECHAT_API_BASE=https://qyapi.weixin.qq.com # 企业微信API地址
```
**重要提示**:请务必修改 `ENCRYPTION_KEY` 为一个随机的32字符字符串以确保数据安全。
## 部署步骤
1. 克隆或下载项目代码到服务器
2. 进入项目目录
```bash
cd wechat-notifier
```
3. 构建并启动容器
```bash
docker-compose up -d
```
4. 查看容器运行状态
```bash
docker-compose ps
```
5. 查看应用日志
```bash
docker-compose logs -f
```
## 访问应用
部署成功后,可以通过以下地址访问应用:
```
http://your-server-ip:12121
```
## 数据持久化
应用数据存储在 `./database` 目录中,该目录已通过 Docker 卷映射到容器内部。备份数据时,只需复制此目录即可。
## 更新应用
当有新版本发布时,按照以下步骤更新:
1. 拉取最新代码
```bash
git pull
```
2. 重新构建并启动容器
```bash
docker-compose down
docker-compose up -d --build
```
## 故障排除
如果遇到问题,请尝试以下步骤:
1. 检查日志
```bash
docker-compose logs -f
```
2. 重启容器
```bash
docker-compose restart
```
3. 完全重建容器
```bash
docker-compose down
docker-compose up -d --build
```
## 安全建议
1. 不要使用默认的加密密钥
2. 考虑使用反向代理(如 Nginx并启用 HTTPS
3. 限制服务器防火墙,只开放必要端口

17
docker-compose.yml Normal file
View File

@@ -0,0 +1,17 @@
version: '3'
services:
wechat-notifier:
build: .
container_name: wechat-notifier
restart: unless-stopped
ports:
- "12121:12121"
volumes:
- ./database:/app/database
environment:
- PORT=12121
- DB_PATH=/app/database/notifier.db
- ENCRYPTION_KEY=change-this-to-a-random-32-character-string
- NODE_ENV=production
- WECHAT_API_BASE=https://qyapi.weixin.qq.com

14
env.template Normal file
View File

@@ -0,0 +1,14 @@
# 服务器配置
PORT=12121
# 数据库配置
DB_PATH=./database/notifier.db
# 加密密钥 (请使用随机生成的32位字符串)
ENCRYPTION_KEY=your-32-character-encryption-key-here
# 应用配置
NODE_ENV=development
# 企业微信API基础URL
WECHAT_API_BASE=https://qyapi.weixin.qq.com

2820
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "wechat-notifier",
"version": "1.0.0",
"description": "企业微信通知转发服务 - 类似Server酱的轻量级通知服务",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"keywords": [
"wechat",
"notification",
"api",
"webhook"
],
"author": "",
"license": "MIT",
"dependencies": {
"axios": "^1.5.0",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"sqlite3": "^5.1.6",
"uuid": "^9.0.0",
"wxcrypt": "^1.4.3",
"xmldom": "^0.6.0"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}

322
public/api-docs.html Normal file
View File

@@ -0,0 +1,322 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="garden">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>企业微信通知服务 - API文档</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- DaisyUI -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@3.5.0/dist/full.css" rel="stylesheet">
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<style>
.glass-card {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(12px);
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.07);
}
body {
background: linear-gradient(135deg, #e0f2fe 0%, #bef264 100%);
min-height: 100vh;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
</style>
</head>
<body class="flex flex-col items-center justify-center p-4">
<div class="w-full max-w-4xl glass-card p-8 my-8">
<header class="mb-8">
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold text-primary flex items-center gap-2">
<i data-lucide="book-open" class="h-8 w-8"></i>
企业微信通知服务 API文档
</h1>
<a href="/" class="btn btn-outline btn-sm">
<i data-lucide="arrow-left" class="h-4 w-4 mr-1"></i>
返回首页
</a>
</div>
<p class="text-base-content/70 mt-2">详细的API使用说明文档帮助您快速集成企业微信消息推送功能</p>
</header>
<div class="space-y-8">
<!-- API概述 -->
<section>
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
<i data-lucide="info" class="h-6 w-6"></i>
API概述
</h2>
<div class="bg-base-100 p-6 rounded-lg shadow-sm">
<p class="mb-4">企业微信通知服务提供了简单易用的HTTP API用于向企业微信应用发送消息通知。通过配置您的企业微信应用信息系统会生成一个唯一的API地址您可以通过该地址发送消息。</p>
<div class="alert alert-info">
<i data-lucide="lightbulb" class="h-5 w-5"></i>
<span>使用前您需要先在首页创建配置并获取唯一的API地址。</span>
</div>
</div>
</section>
<!-- 认证与安全 -->
<section>
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
<i data-lucide="shield" class="h-6 w-6"></i>
认证与安全
</h2>
<div class="bg-base-100 p-6 rounded-lg shadow-sm">
<p class="mb-4">API使用唯一的配置Code作为认证凭证该Code包含在API URL中。请妥善保管您的配置Code不要泄露给未授权的人员。</p>
<div class="alert alert-warning">
<i data-lucide="alert-triangle" class="h-5 w-5"></i>
<span>配置Code仅在创建时显示一次请务必保存。如果遗失您需要重新创建配置。</span>
</div>
</div>
</section>
<!-- 发送消息API -->
<section>
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
<i data-lucide="send" class="h-6 w-6"></i>
发送消息API
</h2>
<div class="bg-base-100 p-6 rounded-lg shadow-sm space-y-6">
<div>
<h3 class="font-medium mb-2 text-lg">请求地址</h3>
<div class="bg-base-200 p-3 rounded-md">
<code class="text-sm">POST /api/notify/{your_code}</code>
</div>
<p class="text-sm text-base-content/70 mt-1">其中 {your_code} 是您创建配置时获得的唯一Code</p>
</div>
<div>
<h3 class="font-medium mb-2 text-lg">请求头</h3>
<div class="bg-base-200 p-3 rounded-md">
<code class="text-sm">Content-Type: application/json</code>
</div>
</div>
<div>
<h3 class="font-medium mb-2 text-lg">请求参数</h3>
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>参数名</th>
<th>类型</th>
<th>必填</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td class="font-mono">title</td>
<td>String</td>
<td></td>
<td>消息标题,可选。如果提供,将作为消息的第一行显示,并会加粗处理。</td>
</tr>
<tr>
<td class="font-mono">content</td>
<td>String</td>
<td></td>
<td>消息内容必填。支持简单的markdown格式如加粗、链接等。</td>
</tr>
</tbody>
</table>
</div>
</div>
<div>
<h3 class="font-medium mb-2 text-lg">请求示例</h3>
<div class="bg-base-200 p-3 rounded-md">
<pre class="text-sm">curl -X POST "http://your-server.com/api/notify/your-code-here" \
-H "Content-Type: application/json" \
-d '{
"title": "服务器告警",
"content": "CPU使用率超过90%,请及时处理!\n\n**详细信息**\n- 服务器web-server-01\n- 时间2023-09-15 14:30:45\n- 当前负载95%"
}'</pre>
</div>
</div>
<div>
<h3 class="font-medium mb-2 text-lg">返回示例</h3>
<div class="bg-base-200 p-3 rounded-md">
<pre class="text-sm">{
"message": "发送成功",
"response": {
"errcode": 0,
"errmsg": "ok",
"msgid": "MSGID1234567890"
}
}</pre>
</div>
</div>
<div>
<h3 class="font-medium mb-2 text-lg">错误码说明</h3>
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>HTTP状态码</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>200</td>
<td>请求成功</td>
</tr>
<tr>
<td>400</td>
<td>参数错误,如缺少必填参数</td>
</tr>
<tr>
<td>404</td>
<td>配置不存在请检查Code是否正确</td>
</tr>
<tr>
<td>500</td>
<td>服务器内部错误或企业微信API调用失败</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
<!-- 集成示例 -->
<section>
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
<i data-lucide="code" class="h-6 w-6"></i>
集成示例
</h2>
<div class="bg-base-100 p-6 rounded-lg shadow-sm space-y-6">
<div>
<h3 class="font-medium mb-2 text-lg">Shell脚本示例</h3>
<div class="bg-base-200 p-3 rounded-md">
<pre class="text-sm">#!/bin/bash
# 监控服务器CPU使用率并发送告警
CPU_USAGE=$(top -bn1 | grep "Cpu(s)" | awk '{print $2 + $4}')
THRESHOLD=90
if (( $(echo "$CPU_USAGE > $THRESHOLD" | bc -l) )); then
curl -X POST "http://your-server.com/api/notify/your-code-here" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"CPU告警\",
\"content\": \"服务器CPU使用率: ${CPU_USAGE}%,超过阈值${THRESHOLD}%\"
}"
fi</pre>
</div>
</div>
<div>
<h3 class="font-medium mb-2 text-lg">Python示例</h3>
<div class="bg-base-200 p-3 rounded-md">
<pre class="text-sm">import requests
import json
def send_notification(title, content):
url = "http://your-server.com/api/notify/your-code-here"
headers = {"Content-Type": "application/json"}
data = {
"title": title,
"content": content
}
response = requests.post(url, headers=headers, data=json.dumps(data))
return response.json()
# 使用示例
result = send_notification(
"数据库备份完成",
"数据库备份已完成\n- 数据库user_db\n- 备份大小1.2GB\n- 备份时间2023-09-15 02:00:00"
)
print(result)</pre>
</div>
</div>
<div>
<h3 class="font-medium mb-2 text-lg">Node.js示例</h3>
<div class="bg-base-200 p-3 rounded-md">
<pre class="text-sm">const axios = require('axios');
async function sendNotification(title, content) {
try {
const response = await axios.post('http://your-server.com/api/notify/your-code-here', {
title,
content
}, {
headers: {
'Content-Type': 'application/json'
}
});
return response.data;
} catch (error) {
console.error('发送通知失败:', error.response ? error.response.data : error.message);
throw error;
}
}
// 使用示例
sendNotification('部署完成', '项目已成功部署到生产环境\n\n版本: v1.2.3\n部署时间: 2023-09-15 15:30:00')
.then(result => console.log('通知发送成功:', result))
.catch(err => console.error('通知发送失败:', err));</pre>
</div>
</div>
</div>
</section>
<!-- 常见问题 -->
<section>
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
<i data-lucide="help-circle" class="h-6 w-6"></i>
常见问题
</h2>
<div class="bg-base-100 p-6 rounded-lg shadow-sm">
<div class="space-y-4">
<div>
<h3 class="font-medium">Q: 如何获取配置Code</h3>
<p>A: 在首页填写您的企业微信应用信息,验证并选择接收成员后,点击"生成通知API"按钮即可获取配置Code。</p>
</div>
<div>
<h3 class="font-medium">Q: 消息发送失败怎么办?</h3>
<p>A: 请检查以下几点:</p>
<ul class="list-disc pl-5 mt-2">
<li>配置Code是否正确</li>
<li>请求参数格式是否正确</li>
<li>企业微信应用的配置是否有效</li>
<li>网络连接是否正常</li>
</ul>
</div>
<div>
<h3 class="font-medium">Q: 是否支持发送图片或文件?</h3>
<p>A: 目前仅支持发送文本消息,不支持图片或文件附件。</p>
</div>
<div>
<h3 class="font-medium">Q: 消息内容有长度限制吗?</h3>
<p>A: 根据企业微信API的限制消息内容不应超过2048个字符。</p>
</div>
</div>
</div>
</section>
</div>
</div>
<footer class="text-center text-sm text-base-content/60 mb-4">
<p class="flex items-center justify-center gap-1">
<i data-lucide="external-link" class="h-4 w-4"></i>
<a href="https://www.wangwangit.com/" target="_blank" class="hover:text-primary transition-colors">一只会飞的旺旺</a>
</p>
</footer>
<script>
lucide.createIcons();
</script>
</body>
</html>

164
public/index.html Normal file
View File

@@ -0,0 +1,164 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="garden">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>企业微信通知服务</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- DaisyUI -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@3.5.0/dist/full.css" rel="stylesheet">
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<!-- GSAP -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<style>
.glass-card {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(12px);
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.07);
}
body {
background: linear-gradient(135deg, #e0f2fe 0%, #bef264 100%);
min-height: 100vh;
}
</style>
</head>
<body class="flex flex-col items-center justify-center p-4">
<div class="w-full max-w-2xl glass-card p-8 my-8">
<header class="mb-8">
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold text-primary flex items-center gap-2">
<i data-lucide="bell-ring" class="h-8 w-8"></i>
企业微信通知服务
</h1>
<a href="/public/api-docs.html" class="btn btn-sm btn-outline btn-primary gap-1">
<i data-lucide="book-open" class="h-4 w-4"></i>
API文档
</a>
</div>
<div class="mt-2">
<p class="text-base-content/70">轻量级企业微信消息推送服务,一次配置,随时调用</p>
</div>
</header>
<!-- 第一步生成回调URL -->
<div id="step1-container">
<div class="divider">第一步生成回调URL必须先完成</div>
<div class="alert alert-warning mb-4">
<i data-lucide="alert-triangle" class="h-5 w-5"></i>
<div>
<div class="font-medium">重要提示</div>
<div class="text-sm">必须先生成回调URL并在企业微信后台配置然后配置IP白名单最后才能获取成员列表</div>
</div>
</div>
<form id="callbackForm" class="space-y-4">
<div class="form-control">
<label class="label font-medium">CorpID</label>
<input type="text" name="corpid" class="input input-bordered w-full" required placeholder="请输入企业微信CorpID">
</div>
<div class="form-control">
<label class="label font-medium">回调Token</label>
<input type="text" name="callback_token" class="input input-bordered w-full" placeholder="请输入回调验证Token" required>
<div class="label">
<span class="label-text-alt text-base-content/60">用于验证回调请求的Token</span>
</div>
</div>
<div class="form-control">
<label class="label font-medium">EncodingAESKey</label>
<input type="text" name="encoding_aes_key" class="input input-bordered w-full" placeholder="请输入43位EncodingAESKey" required>
<div class="label">
<span class="label-text-alt text-base-content/60">用于消息加解密的AES密钥</span>
</div>
</div>
<button type="submit" class="btn btn-primary w-full flex gap-2 items-center">
<i data-lucide="link" class="w-5 h-5"></i> 生成回调URL
</button>
</form>
<div id="callbackResult" class="hidden mt-4"></div>
</div>
<!-- 第二步:完善配置 -->
<div id="step2-container" class="hidden">
<div class="divider">第二步完善配置配置IP白名单后</div>
<div class="alert alert-info mb-4">
<i data-lucide="info" class="h-5 w-5"></i>
<span>请先在企业微信后台配置回调URL和IP白名单然后点击下方按钮获取成员列表</span>
</div>
<form id="configForm" class="space-y-4">
<div class="form-control">
<label class="label font-medium">CorpSecret</label>
<input type="password" name="corpsecret" class="input input-bordered w-full" required placeholder="请输入CorpSecret">
</div>
<div class="form-control">
<label class="label font-medium">AgentID</label>
<input type="number" name="agentid" class="input input-bordered w-full" required placeholder="请输入AgentID">
</div>
<div>
<button type="button" id="validateBtn" class="btn btn-outline w-full flex gap-2 items-center">
<i data-lucide="users" class="w-5 h-5"></i> 验证并获取成员列表
</button>
</div>
<div id="userListSection" class="hidden">
<label class="block font-medium mb-1">选择接收成员</label>
<div id="userList" class="grid grid-cols-2 gap-2 max-h-40 overflow-y-auto bg-base-100 rounded-lg p-2 border border-base-200"></div>
</div>
<div class="form-control">
<label class="label font-medium">配置描述(可选)</label>
<input type="text" name="description" class="input input-bordered w-full" placeholder="如:服务器告警推送">
</div>
<button type="submit" class="btn btn-success btn-lg w-full font-bold flex gap-2 items-center">
<i data-lucide="check" class="w-5 h-5"></i> 完成配置
</button>
</form>
</div>
<!-- 查找配置区域 -->
<div class="divider">查找已有配置</div>
<div id="lookup-section">
<form id="lookupForm" class="space-y-4">
<div class="form-control">
<label class="label font-medium">配置Code</label>
<div class="flex gap-2">
<input type="text" name="code" class="input input-bordered flex-1" required placeholder="请输入配置Code">
<button type="submit" class="btn btn-primary">
<i data-lucide="search" class="h-5 w-5"></i>
查找
</button>
</div>
</div>
</form>
<div id="lookup-result" class="mt-4"></div>
</div>
<div id="result" class="mt-8"></div>
</div>
<!-- 提示弹窗 -->
<div id="save-alert" class="toast toast-top toast-center hidden">
<div class="alert alert-warning">
<i data-lucide="alert-triangle" class="h-6 w-6"></i>
<span>请注意Code生成后您只有一次机会保存</span>
</div>
</div>
<footer class="text-center text-sm text-base-content/60 mb-4">
<p class="flex items-center justify-center gap-1">
<i data-lucide="external-link" class="h-4 w-4"></i>
<a href="https://www.wangwangit.com/" target="_blank" class="hover:text-primary transition-colors">一只会飞的旺旺</a>
</p>
</footer>
<script src="/public/script.js"></script>
<script>
lucide.createIcons();
</script>
</body>
</html>

584
public/script.js Normal file
View File

@@ -0,0 +1,584 @@
// 企业微信通知配置前端交互脚本
document.addEventListener('DOMContentLoaded', function () {
// 元素引用
const callbackForm = document.getElementById('callbackForm');
const configForm = document.getElementById('configForm');
const validateBtn = document.getElementById('validateBtn');
const userListSection = document.getElementById('userListSection');
const userList = document.getElementById('userList');
const lookupForm = document.getElementById('lookupForm');
const lookupResultDiv = document.getElementById('lookup-result');
const resultDiv = document.getElementById('result');
const saveAlert = document.getElementById('save-alert');
const step1Container = document.getElementById('step1-container');
const step2Container = document.getElementById('step2-container');
const callbackResult = document.getElementById('callbackResult');
let usersCache = [];
let currentCode = null; // 存储当前的code
// 第一步生成回调URL
callbackForm.addEventListener('submit', async function (e) {
e.preventDefault();
resultDiv.innerHTML = '';
const corpid = callbackForm.corpid.value.trim();
const callbackToken = callbackForm.callback_token.value.trim();
const encodingAesKey = callbackForm.encoding_aes_key.value.trim();
if (!corpid || !callbackToken || !encodingAesKey) {
showError('请填写所有必填项');
return;
}
if (encodingAesKey.length !== 43) {
showError('EncodingAESKey必须是43位字符');
return;
}
const submitBtn = callbackForm.querySelector('button[type=submit]');
submitBtn.disabled = true;
submitBtn.textContent = '生成中...';
try {
const res = await fetch('/api/generate-callback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
corpid,
callback_token: callbackToken,
encoding_aes_key: encodingAesKey
})
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '生成失败');
currentCode = data.code;
showCallbackResult(data);
// 显示第二步
step2Container.classList.remove('hidden');
gsap.from(step2Container, { opacity: 0, y: 20, duration: 0.5 });
// 将CorpID传递到第二步
configForm.corpid = { value: corpid };
} catch (err) {
showError('生成回调URL失败: ' + err.message);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = '生成回调URL';
}
});
// 验证并获取成员列表
validateBtn.addEventListener('click', async function (e) {
e.preventDefault();
resultDiv.innerHTML = '';
userList.innerHTML = '';
userListSection.classList.add('hidden');
const corpid = callbackForm.corpid.value.trim(); // 从第一步获取
const corpsecret = configForm.corpsecret.value.trim();
if (!corpid || !corpsecret) {
showError('请填写CorpSecret');
return;
}
validateBtn.disabled = true;
validateBtn.textContent = '验证中...';
try {
const res = await fetch('/api/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ corpid, corpsecret })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '验证失败');
usersCache = data.users || [];
if (usersCache.length === 0) {
showError('未获取到任何成员');
return;
}
userList.innerHTML = usersCache.map(user =>
`<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" class="checkbox checkbox-sm" value="${user.userid}">
<span>${user.name} <span class="text-xs text-gray-400">(${user.userid})</span></span>
</label>`
).join('');
userListSection.classList.remove('hidden');
gsap.from(userListSection, { opacity: 0, y: 20, duration: 0.5 });
} catch (err) {
showError(err.message);
} finally {
validateBtn.disabled = false;
validateBtn.textContent = '验证并获取成员列表';
}
});
// 查找配置
lookupForm.addEventListener('submit', async (e) => {
e.preventDefault();
const code = lookupForm.code.value.trim();
if (!code) return;
lookupResultDiv.innerHTML = '<div class="loading loading-spinner loading-md mx-auto"></div>';
try {
const res = await fetch(`/api/configuration/${code}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || '查找配置失败');
// 显示配置信息
const apiUrl = `/api/notify/${data.code}`;
lookupResultDiv.innerHTML = `
<div class="card bg-base-100 shadow-md">
<div class="card-body">
<h2 class="card-title flex items-center gap-2">
<i data-lucide="settings" class="h-5 w-5"></i>
配置详情
</h2>
<div class="space-y-2 mt-2">
<p><span class="font-medium">CorpID:</span> ${data.corpid}</p>
<p><span class="font-medium">AgentID:</span> ${data.agentid}</p>
<p><span class="font-medium">接收用户:</span> ${data.touser.join(', ')}</p>
<p><span class="font-medium">描述:</span> ${data.description || '无'}</p>
<p><span class="font-medium">回调状态:</span> ${data.callback_enabled ? '已启用' : '未启用'}</p>
${data.callback_enabled ? `<p><span class="font-medium">回调Token:</span> ${data.callback_token || '未设置'}</p>` : ''}
<p><span class="font-medium">创建时间:</span> ${new Date(data.created_at).toLocaleString()}</p>
</div>
<div class="card-actions justify-end mt-4">
<button class="btn btn-primary btn-sm" id="edit-config-btn" data-code="${data.code}">
<i data-lucide="edit" class="h-4 w-4"></i>
编辑配置
</button>
<button class="btn btn-outline btn-sm" id="copy-api-btn" data-code="${data.code}">
<i data-lucide="copy" class="h-4 w-4"></i>
复制API地址
</button>
</div>
<div class="divider">API使用说明</div>
<div class="space-y-4">
<div>
<h3 class="font-medium mb-2">请求方式</h3>
<div class="bg-base-200 p-3 rounded-md">
<code class="text-sm">POST ${apiUrl}</code>
</div>
</div>
<div>
<h3 class="font-medium mb-2">请求头</h3>
<div class="bg-base-200 p-3 rounded-md">
<code class="text-sm">Content-Type: application/json</code>
</div>
</div>
<div>
<h3 class="font-medium mb-2">请求参数</h3>
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>参数名</th>
<th>类型</th>
<th>必填</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td class="font-mono">title</td>
<td>String</td>
<td>否</td>
<td>消息标题,可选</td>
</tr>
<tr>
<td class="font-mono">content</td>
<td>String</td>
<td>是</td>
<td>消息内容,必填</td>
</tr>
</tbody>
</table>
</div>
</div>
<div>
<h3 class="font-medium mb-2">请求示例</h3>
<div class="bg-base-200 p-3 rounded-md">
<pre class="text-sm whitespace-pre-wrap">curl -X POST "${apiUrl}" \\
-H "Content-Type: application/json" \\
-d '{
"title": "服务器告警",
"content": "CPU使用率超过90%,请及时处理!"
}'</pre>
</div>
</div>
<div>
<h3 class="font-medium mb-2">返回示例</h3>
<div class="bg-base-200 p-3 rounded-md">
<pre class="text-sm whitespace-pre-wrap">{
"message": "发送成功",
"response": {
"errcode": 0,
"errmsg": "ok",
"msgid": "MSGID1234567890"
}
}</pre>
</div>
</div>
</div>
</div>
</div>
`;
lucide.createIcons();
gsap.from(lookupResultDiv.firstElementChild, { opacity: 0, y: 20, duration: 0.5 });
// 绑定编辑和复制按钮事件
document.getElementById('edit-config-btn').addEventListener('click', (e) => {
const code = e.currentTarget.dataset.code;
// 这里可以添加编辑功能的实现
showToast('编辑功能待实现');
});
document.getElementById('copy-api-btn').addEventListener('click', (e) => {
const code = e.currentTarget.dataset.code;
navigator.clipboard.writeText(`/api/notify/${code}`);
showToast('API地址已复制到剪贴板');
});
} catch (err) {
lookupResultDiv.innerHTML = `
<div class="alert alert-error">
<i data-lucide="alert-circle" class="h-5 w-5"></i>
<span>${err.message}</span>
</div>
`;
lucide.createIcons();
}
});
// 第二步:完善配置
configForm.addEventListener('submit', async function (e) {
e.preventDefault();
resultDiv.innerHTML = '';
if (!currentCode) {
showError('请先完成第一步生成回调URL');
return;
}
const corpsecret = configForm.corpsecret.value.trim();
const agentid = configForm.agentid.value.trim();
const description = configForm.description.value.trim();
const checked = userListSection.classList.contains('hidden')
? []
: Array.from(userList.querySelectorAll('input[type=checkbox]:checked')).map(cb => cb.value);
if (!corpsecret || !agentid || checked.length === 0) {
showError('请填写所有必填项并选择至少一个成员');
return;
}
const payload = {
code: currentCode,
corpsecret,
agentid: Number(agentid),
touser: checked,
description
};
configForm.querySelector('button[type=submit]').disabled = true;
configForm.querySelector('button[type=submit]').textContent = '完成中...';
try {
const res = await fetch('/api/complete-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '完成失败');
showFinalResult(data);
// 显示一次性保存提醒
saveAlert.classList.remove('hidden');
gsap.from(saveAlert, { opacity: 0, y: -50, duration: 0.5 });
setTimeout(() => {
gsap.to(saveAlert, { opacity: 0, y: -50, duration: 0.5, onComplete: () => {
saveAlert.classList.add('hidden');
saveAlert.style.opacity = 1;
saveAlert.style.transform = 'none';
}});
}, 5000);
} catch (err) {
showError(err.message);
} finally {
configForm.querySelector('button[type=submit]').disabled = false;
configForm.querySelector('button[type=submit]').textContent = '完成配置';
}
});
function showError(msg) {
resultDiv.innerHTML = `<div class="alert alert-error"><span>${msg}</span></div>`;
gsap.from(resultDiv, { opacity: 0, y: 20, duration: 0.5 });
}
function showCallbackResult(data) {
callbackResult.innerHTML = `
<div class="card bg-base-100 shadow-md">
<div class="card-body">
<h2 class="card-title text-primary flex items-center gap-2">
<i data-lucide="check-circle" class="h-6 w-6"></i>
回调URL生成成功
</h2>
<div class="space-y-4 mt-4">
<div>
<div class="font-medium">您的配置Code</div>
<div class="bg-base-200 p-2 rounded-md font-mono text-sm overflow-x-auto">${data.code}</div>
</div>
<div>
<div class="font-medium">回调URL</div>
<div class="bg-base-200 p-2 rounded-md font-mono text-sm overflow-x-auto">${window.location.origin}${data.callbackUrl}</div>
<button class="btn btn-sm btn-outline mt-1" id="copy-callback-url-btn">
<i data-lucide="copy" class="h-4 w-4 mr-1"></i>复制回调URL
</button>
</div>
</div>
<div class="alert alert-info mt-4">
<i data-lucide="info" class="h-5 w-5"></i>
<div>
<div class="font-medium">下一步操作</div>
<div class="text-sm">
1. 复制上方回调URL到企业微信管理后台<br>
2. 配置IP白名单添加您的服务器IP<br>
3. 完成下方第二步配置
</div>
</div>
</div>
</div>
</div>
`;
// 绑定复制按钮事件
document.getElementById('copy-callback-url-btn').addEventListener('click', () => {
navigator.clipboard.writeText(window.location.origin + data.callbackUrl);
showToast('回调URL已复制到剪贴板');
});
callbackResult.classList.remove('hidden');
lucide.createIcons();
gsap.from(callbackResult.firstElementChild, { opacity: 0, y: 20, duration: 0.5 });
}
function showFinalResult(data) {
resultDiv.innerHTML = `
<div class="card bg-base-100 shadow-md">
<div class="card-body">
<h2 class="card-title text-success flex items-center gap-2">
<i data-lucide="check-circle-2" class="h-6 w-6"></i>
配置完成!
</h2>
<div class="space-y-4 mt-4">
<div>
<div class="font-medium">配置Code</div>
<div class="bg-base-200 p-2 rounded-md font-mono text-sm overflow-x-auto">${data.code}</div>
</div>
<div>
<div class="font-medium">通知API地址</div>
<div class="bg-base-200 p-2 rounded-md font-mono text-sm overflow-x-auto">${window.location.origin}${data.apiUrl}</div>
<button class="btn btn-sm btn-outline mt-1" id="copy-api-url-btn">
<i data-lucide="copy" class="h-4 w-4 mr-1"></i>复制API地址
</button>
</div>
<div>
<div class="font-medium">回调地址</div>
<div class="bg-base-200 p-2 rounded-md font-mono text-sm overflow-x-auto">${window.location.origin}${data.callbackUrl}</div>
</div>
</div>
<div class="alert alert-success mt-4">
<i data-lucide="check" class="h-5 w-5"></i>
<span>配置已完成您现在可以使用API发送通知也可以接收企业微信回调消息。</span>
</div>
</div>
</div>
`;
// 绑定复制按钮事件
document.getElementById('copy-api-url-btn').addEventListener('click', () => {
navigator.clipboard.writeText(window.location.origin + data.apiUrl);
showToast('API地址已复制到剪贴板');
});
lucide.createIcons();
gsap.from(resultDiv.firstElementChild, { opacity: 0, y: 20, duration: 0.5 });
}
function showResult(data) {
resultDiv.innerHTML = `
<div class="card bg-base-100 shadow-md">
<div class="card-body">
<h2 class="card-title text-primary flex items-center gap-2">
<i data-lucide="check-circle" class="h-6 w-6"></i>
API生成成功
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div class="space-y-2">
<div class="font-medium">您的唯一调用ID</div>
<div class="bg-base-200 p-2 rounded-md font-mono text-sm overflow-x-auto">${data.code}</div>
</div>
<div class="space-y-2">
<div class="font-medium">API地址</div>
<div class="bg-base-200 p-2 rounded-md font-mono text-sm overflow-x-auto">${data.apiUrl}</div>
<button class="btn btn-sm btn-outline mt-1" id="copy-new-api-btn">
<i data-lucide="copy" class="h-4 w-4 mr-1"></i>复制
</button>
</div>
${data.callbackUrl ? `
<div class="space-y-2 md:col-span-2">
<div class="font-medium">回调地址</div>
<div class="bg-base-200 p-2 rounded-md font-mono text-sm overflow-x-auto">${data.callbackUrl}</div>
<button class="btn btn-sm btn-outline mt-1" id="copy-callback-btn">
<i data-lucide="copy" class="h-4 w-4 mr-1"></i>复制回调地址
</button>
<div class="text-sm text-base-content/60 mt-1">
<i data-lucide="info" class="h-4 w-4 inline mr-1"></i>
在企业微信管理后台配置此回调地址以接收消息
</div>
</div>
` : ''}
</div>
<div class="divider">API使用说明</div>
<div class="space-y-4">
<div>
<h3 class="font-medium mb-2">请求方式</h3>
<div class="bg-base-200 p-3 rounded-md">
<code class="text-sm">POST ${data.apiUrl}</code>
</div>
</div>
<div>
<h3 class="font-medium mb-2">请求头</h3>
<div class="bg-base-200 p-3 rounded-md">
<code class="text-sm">Content-Type: application/json</code>
</div>
</div>
<div>
<h3 class="font-medium mb-2">请求参数</h3>
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>参数名</th>
<th>类型</th>
<th>必填</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td class="font-mono">title</td>
<td>String</td>
<td>否</td>
<td>消息标题,可选</td>
</tr>
<tr>
<td class="font-mono">content</td>
<td>String</td>
<td>是</td>
<td>消息内容,必填</td>
</tr>
</tbody>
</table>
</div>
</div>
<div>
<h3 class="font-medium mb-2">请求示例</h3>
<div class="bg-base-200 p-3 rounded-md">
<pre class="text-sm whitespace-pre-wrap">curl -X POST "${data.apiUrl}" \\
-H "Content-Type: application/json" \\
-d '{
"title": "服务器告警",
"content": "CPU使用率超过90%,请及时处理!"
}'</pre>
</div>
</div>
<div>
<h3 class="font-medium mb-2">返回示例</h3>
<div class="bg-base-200 p-3 rounded-md">
<pre class="text-sm whitespace-pre-wrap">{
"message": "发送成功",
"response": {
"errcode": 0,
"errmsg": "ok",
"msgid": "MSGID1234567890"
}
}</pre>
</div>
</div>
</div>
<div class="alert alert-warning mt-4">
<i data-lucide="alert-triangle" class="h-5 w-5"></i>
<span>请妥善保存您的配置Code它是调用API的唯一凭证。出于安全考虑它只会显示一次</span>
</div>
</div>
</div>
`;
// 绑定复制按钮事件
document.getElementById('copy-new-api-btn').addEventListener('click', () => {
navigator.clipboard.writeText(data.apiUrl);
showToast('API地址已复制到剪贴板');
});
// 绑定回调地址复制按钮事件(如果存在)
if (data.callbackUrl) {
document.getElementById('copy-callback-btn').addEventListener('click', () => {
navigator.clipboard.writeText(window.location.origin + data.callbackUrl);
showToast('回调地址已复制到剪贴板');
});
}
// 创建图标
lucide.createIcons();
gsap.from(resultDiv, { opacity: 0, y: 20, duration: 0.5 });
}
// 显示提示消息
function showToast(message) {
const toast = document.createElement('div');
toast.className = 'toast toast-top toast-center';
toast.innerHTML = `
<div class="alert alert-info">
<span>${message}</span>
</div>
`;
document.body.appendChild(toast);
gsap.fromTo(toast,
{ opacity: 0, y: -20 },
{ opacity: 1, y: 0, duration: 0.3 }
);
setTimeout(() => {
gsap.to(toast, {
opacity: 0,
y: -20,
duration: 0.3,
onComplete: () => toast.remove()
});
}, 3000);
}
});

36
server.js Normal file
View File

@@ -0,0 +1,36 @@
// 企业微信通知转发服务 - 主入口文件
// 作者: AI Assistant
// 创建时间: 2025-01-05
require('dotenv').config();
const express = require('express');
const path = require('path');
const bodyParser = require('express').json;
const routes = require('./src/api/routes');
const app = express();
const PORT = process.env.PORT || 3000;
// 为回调接口使用原始文本解析器
app.use('/api/callback', express.raw({ type: 'text/xml' }));
app.use('/api/callback', express.raw({ type: 'application/xml' }));
app.use('/api/callback', express.raw({ type: 'text/plain' }));
// 解析JSON请求体其他接口
app.use(bodyParser());
// 静态资源服务
app.use('/public', express.static(path.join(__dirname, 'public')));
// 路由
app.use('/', routes);
// 404处理
app.use((req, res) => {
res.status(404).json({ error: '未找到资源' });
});
// 启动服务器
app.listen(PORT, () => {
console.log(`企业微信通知服务已启动,端口: ${PORT}`);
});

178
src/api/routes.js Normal file
View File

@@ -0,0 +1,178 @@
// Express路由定义
// 包含所有API端点的路由配置
const express = require('express');
const path = require('path');
const notifier = require('../services/notifier');
const WeChatService = require('../core/wechat');
const CryptoService = require('../core/crypto');
const router = express.Router();
// 环境变量
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'default-key-for-development-only';
const wechat = new WeChatService();
const crypto = new CryptoService(ENCRYPTION_KEY);
// 1. GET / 返回前端页面
router.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '../../public/index.html'));
});
// 2. POST /api/validate 验证凭证并获取成员列表
router.post('/api/validate', async (req, res) => {
const { corpid, corpsecret } = req.body;
if (!corpid || !corpsecret) {
return res.status(400).json({ error: '参数不完整' });
}
try {
const accessToken = await wechat.getToken(corpid, corpsecret);
const users = await wechat.getAllUsers(accessToken);
res.json({ users });
} catch (err) {
res.status(400).json({ error: err.message || '凭证无效或API请求失败' });
}
});
// 2.1 POST /api/generate-callback 生成回调URL
router.post('/api/generate-callback', async (req, res) => {
const { corpid, callback_token, encoding_aes_key } = req.body;
if (!corpid || !callback_token || !encoding_aes_key) {
return res.status(400).json({ error: '回调配置参数不完整' });
}
if (encoding_aes_key.length !== 43) {
return res.status(400).json({ error: 'EncodingAESKey必须是43位字符' });
}
try {
// 生成回调配置(不需要成员列表)
const result = await notifier.createCallbackConfiguration({
corpid,
callback_token,
encoding_aes_key
});
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message || '生成回调URL失败' });
}
});
// 3. POST /api/complete-config 完善配置(第二步)
router.post('/api/complete-config', async (req, res) => {
try {
const { code, corpsecret, agentid, touser, description } = req.body;
const result = await notifier.completeConfiguration({ code, corpsecret, agentid, touser, description });
res.status(201).json(result);
} catch (err) {
res.status(500).json({ error: err.message || '完善配置失败' });
}
});
// 3.1 POST /api/configure 保存配置并生成唯一code保持兼容性
router.post('/api/configure', async (req, res) => {
try {
const { corpid, corpsecret, agentid, touser, description } = req.body;
const result = await notifier.createConfiguration({ corpid, corpsecret, agentid, touser, description });
res.status(201).json(result);
} catch (err) {
res.status(500).json({ error: err.message || '配置保存失败' });
}
});
// 4. POST /api/notify/:code 发送通知
router.post('/api/notify/:code', async (req, res) => {
const { code } = req.params;
const { title, content } = req.body;
if (!content) {
return res.status(400).json({ error: '消息内容不能为空' });
}
try {
const result = await notifier.sendNotification(code, title, content);
res.json({ message: '发送成功', response: result });
} catch (err) {
if (err.message && err.message.includes('未找到配置')) {
res.status(404).json({ error: err.message });
} else {
res.status(500).json({ error: err.message || '消息发送失败' });
}
}
});
// 5. GET /api/configuration/:code 获取配置信息
router.get('/api/configuration/:code', async (req, res) => {
const { code } = req.params;
try {
const config = await notifier.getConfiguration(code);
if (!config) {
return res.status(404).json({ error: '未找到配置' });
}
res.json(config);
} catch (err) {
res.status(500).json({ error: err.message || '获取配置失败' });
}
});
// 6. PUT /api/configuration/:code 更新配置
router.put('/api/configuration/:code', async (req, res) => {
const { code } = req.params;
try {
const result = await notifier.updateConfiguration(code, req.body);
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message || '更新配置失败' });
}
});
// 7. GET /api/callback/:code 企业微信回调验证
router.get('/api/callback/:code', async (req, res) => {
const { code } = req.params;
const { msg_signature, timestamp, nonce, echostr } = req.query;
if (!msg_signature || !timestamp || !nonce || !echostr) {
return res.status(400).json({ error: '缺少必要的验证参数' });
}
try {
const result = await notifier.handleCallbackVerification(code, msg_signature, timestamp, nonce, echostr);
if (result.success) {
res.send(result.data);
} else {
console.error('回调验证失败:', result.error);
res.status(400).send('failed');
}
} catch (err) {
console.error('回调验证异常:', err.message);
res.status(500).send('failed');
}
});
// 8. POST /api/callback/:code 企业微信回调消息接收
router.post('/api/callback/:code', async (req, res) => {
const { code } = req.params;
const { msg_signature, timestamp, nonce } = req.query;
if (!msg_signature || !timestamp || !nonce) {
return res.status(400).json({ error: '缺少必要的验证参数' });
}
try {
// 获取加密的消息数据从原始body转换为字符串
const encryptedData = req.body ? req.body.toString('utf8') : '';
if (!encryptedData) {
return res.status(400).json({ error: '消息数据为空' });
}
const result = await notifier.handleCallbackMessage(code, encryptedData, msg_signature, timestamp, nonce);
if (result.success) {
console.log('回调消息处理成功:', result.message);
res.send('ok');
} else {
console.error('回调消息处理失败:', result.error);
res.status(400).send('failed');
}
} catch (err) {
console.error('回调消息处理异常:', err.message);
res.status(500).send('failed');
}
});
module.exports = router;

50
src/core/crypto.js Normal file
View File

@@ -0,0 +1,50 @@
// 加密/解密模块
// 使用Node.js crypto模块进行数据加密
const crypto = require('crypto');
class CryptoService {
constructor(encryptionKey) {
// 确保密钥长度为32字节
this.key = Buffer.from((encryptionKey || '').padEnd(32, '0').slice(0, 32));
this.algorithm = 'aes-256-cbc';
this.ivLength = 16;
}
// 加密函数
encrypt(text) {
try {
const iv = crypto.randomBytes(this.ivLength);
const cipher = crypto.createCipheriv(this.algorithm, this.key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
// 返回iv:密文
return iv.toString('hex') + ':' + encrypted;
} catch (error) {
console.error('加密失败:', error.message);
throw new Error('数据加密失败');
}
}
// 解密函数
decrypt(encryptedText) {
try {
const [ivHex, encrypted] = encryptedText.split(':');
const iv = Buffer.from(ivHex, 'hex');
const decipher = crypto.createDecipheriv(this.algorithm, this.key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
console.error('解密失败:', error.message);
throw new Error('数据解密失败');
}
}
// 生成随机密钥
static generateKey() {
return crypto.randomBytes(32).toString('hex');
}
}
module.exports = CryptoService;

251
src/core/database.js Normal file
View File

@@ -0,0 +1,251 @@
// 数据库初始化与操作模块
// 管理SQLite数据库连接和表结构
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
class Database {
constructor(dbPath) {
this.dbPath = dbPath;
this.db = null;
}
// 初始化数据库连接和表结构
async init() {
return new Promise((resolve, reject) => {
// 确保数据库目录存在
const dbDir = path.dirname(this.dbPath);
const fs = require('fs');
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
// 创建数据库连接
this.db = new sqlite3.Database(this.dbPath, (err) => {
if (err) {
console.error('数据库连接失败:', err.message);
reject(err);
return;
}
console.log('SQLite数据库连接成功');
// 创建configurations表
this.createTables()
.then(() => resolve())
.catch(reject);
});
});
}
// 创建数据表
async createTables() {
return new Promise((resolve, reject) => {
const createTableSQL = `
CREATE TABLE IF NOT EXISTS configurations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
corpid TEXT NOT NULL,
encrypted_corpsecret TEXT NOT NULL,
agentid INTEGER NOT NULL,
touser TEXT NOT NULL,
description TEXT,
callback_token TEXT,
encrypted_encoding_aes_key TEXT,
callback_enabled BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(corpid, agentid, touser)
)
`;
this.db.run(createTableSQL, (err) => {
if (err) {
console.error('创建表失败:', err.message);
reject(err);
return;
}
console.log('数据表创建成功');
resolve();
});
});
}
// 保存配置
async saveConfiguration(config) {
return new Promise((resolve, reject) => {
const {
code, corpid, encrypted_corpsecret, agentid, touser, description,
callback_token, encrypted_encoding_aes_key, callback_enabled
} = config;
const sql = `
INSERT INTO configurations (
code, corpid, encrypted_corpsecret, agentid, touser, description,
callback_token, encrypted_encoding_aes_key, callback_enabled
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
this.db.run(sql, [
code, corpid, encrypted_corpsecret, agentid, touser, description,
callback_token, encrypted_encoding_aes_key, callback_enabled || 0
], function(err) {
if (err) {
console.error('保存配置失败:', err.message);
reject(err);
return;
}
console.log('配置保存成功, ID:', this.lastID);
resolve({ id: this.lastID, code });
});
});
}
// 根据code获取配置
async getConfigurationByCode(code) {
return new Promise((resolve, reject) => {
const sql = `SELECT * FROM configurations WHERE code = ?`;
this.db.get(sql, [code], (err, row) => {
if (err) {
console.error('查询配置失败:', err.message);
reject(err);
return;
}
resolve(row);
});
});
}
// 更新配置
async updateConfiguration(config) {
return new Promise((resolve, reject) => {
const {
code, corpid, encrypted_corpsecret, agentid, touser, description,
callback_token, encrypted_encoding_aes_key, callback_enabled
} = config;
const sql = `
UPDATE configurations
SET corpid = ?, encrypted_corpsecret = ?, agentid = ?, touser = ?, description = ?,
callback_token = ?, encrypted_encoding_aes_key = ?, callback_enabled = ?
WHERE code = ?
`;
this.db.run(sql, [
corpid, encrypted_corpsecret, agentid, touser, description,
callback_token, encrypted_encoding_aes_key, callback_enabled,
code
], function(err) {
if (err) {
console.error('更新配置失败:', err.message);
reject(err);
return;
}
console.log('配置更新成功, code:', code);
resolve({ code });
});
});
}
// 根据字段查询配置
async getConfigurationByFields(corpid, agentid, touser) {
return new Promise((resolve, reject) => {
const sql = `SELECT * FROM configurations WHERE corpid = ? AND agentid = ? AND touser = ?`;
this.db.get(sql, [corpid, agentid, touser], (err, row) => {
if (err) {
console.error('查询配置失败:', err.message);
reject(err);
return;
}
resolve(row);
});
});
}
// 根据完整字段查询配置(包括回调配置)
async getConfigurationByCompleteFields(corpid, agentid, touser, callback_enabled, callback_token) {
return new Promise((resolve, reject) => {
const sql = `SELECT * FROM configurations WHERE corpid = ? AND agentid = ? AND touser = ? AND callback_enabled = ? AND (callback_token = ? OR (callback_token IS NULL AND ? IS NULL))`;
this.db.get(sql, [corpid, agentid, touser, callback_enabled, callback_token, callback_token], (err, row) => {
if (err) {
console.error('查询完整配置失败:', err.message);
reject(err);
return;
}
resolve(row);
});
});
}
// 保存回调配置(第一步)
async saveCallbackConfiguration(config) {
return new Promise((resolve, reject) => {
const { code, corpid, callback_token, encrypted_encoding_aes_key } = config;
const sql = `
INSERT INTO configurations (
code, corpid, callback_token, encrypted_encoding_aes_key, callback_enabled,
encrypted_corpsecret, agentid, touser, description
)
VALUES (?, ?, ?, ?, 1, '', 0, '', '')
`;
this.db.run(sql, [code, corpid, callback_token, encrypted_encoding_aes_key], function(err) {
if (err) {
console.error('保存回调配置失败:', err.message);
reject(err);
return;
}
console.log('回调配置保存成功, ID:', this.lastID);
resolve({ id: this.lastID, code });
});
});
}
// 查询回调配置
async getCallbackConfiguration(corpid, callback_token) {
return new Promise((resolve, reject) => {
const sql = `SELECT * FROM configurations WHERE corpid = ? AND callback_token = ? AND callback_enabled = 1`;
this.db.get(sql, [corpid, callback_token], (err, row) => {
if (err) {
console.error('查询回调配置失败:', err.message);
reject(err);
return;
}
resolve(row);
});
});
}
// 完善配置(第二步)
async completeConfiguration(config) {
return new Promise((resolve, reject) => {
const { code, encrypted_corpsecret, agentid, touser, description } = config;
const sql = `
UPDATE configurations
SET encrypted_corpsecret = ?, agentid = ?, touser = ?, description = ?
WHERE code = ?
`;
this.db.run(sql, [encrypted_corpsecret, agentid, touser, description, code], function(err) {
if (err) {
console.error('完善配置失败:', err.message);
reject(err);
return;
}
console.log('配置完善成功, code:', code);
resolve({ code });
});
});
}
// 关闭数据库连接
close() {
if (this.db) {
this.db.close((err) => {
if (err) {
console.error('关闭数据库失败:', err.message);
} else {
console.log('数据库连接已关闭');
}
});
}
}
}
module.exports = Database;

View File

@@ -0,0 +1,95 @@
// 企业微信回调验证模块
// 使用官方wxcrypt库实现与Python WXBizMsgCrypt完全兼容
const WXBizMsgCrypt = require('wxcrypt');
const { x2o } = require('wxcrypt'); // XML解析工具
class WeChatCallbackCrypto {
constructor(token, encodingAESKey, corpId) {
this.token = token;
this.encodingAESKey = encodingAESKey;
this.corpId = corpId;
// 使用官方wxcrypt库
this.wxcrypt = new WXBizMsgCrypt(token, encodingAESKey, corpId);
}
/**
* 验证URL - 用于开启回调模式时的验证
* @param {string} msgSignature - 企业微信加密签名
* @param {string} timestamp - 时间戳
* @param {string} nonce - 随机数
* @param {string} echoStr - 加密的随机字符串
* @returns {Object} { success: boolean, data: string }
*/
verifyURL(msgSignature, timestamp, nonce, echoStr) {
try {
// 使用官方wxcrypt库进行验证与Python版本完全兼容
const decrypted = this.wxcrypt.verifyURL(msgSignature, timestamp, nonce, echoStr);
return { success: true, data: decrypted };
} catch (error) {
console.error('URL验证失败:', error.message, 'errcode:', error.errcode);
return {
success: false,
error: error.errmsg || error.message,
errcode: error.errcode
};
}
}
/**
* 解密消息
* @param {string} encryptedMsg - 加密的消息
* @param {string} msgSignature - 消息签名
* @param {string} timestamp - 时间戳
* @param {string} nonce - 随机数
* @returns {Object} { success: boolean, data: string }
*/
decryptMsg(encryptedMsg, msgSignature, timestamp, nonce) {
try {
// 使用官方wxcrypt库进行解密与Python版本完全兼容
const decrypted = this.wxcrypt.decryptMsg(msgSignature, timestamp, nonce, encryptedMsg);
return { success: true, data: decrypted };
} catch (error) {
console.error('消息解密失败:', error.message, 'errcode:', error.errcode);
return {
success: false,
error: error.errmsg || error.message,
errcode: error.errcode
};
}
}
/**
* 解析XML消息
* @param {string} xmlString - XML字符串
* @returns {Object} 解析后的消息对象
*/
parseXMLMessage(xmlString) {
try {
// 使用wxcrypt库的XML解析工具
const parsed = x2o(xmlString);
// 提取消息内容通常在xml根节点下
const xml = parsed.xml || parsed;
return {
fromUserName: xml.FromUserName || '',
toUserName: xml.ToUserName || '',
msgType: xml.MsgType || '',
content: xml.Content || '',
picUrl: xml.PicUrl || '',
msgId: xml.MsgId || '',
agentId: xml.AgentID || '',
createTime: xml.CreateTime || ''
};
} catch (error) {
console.error('XML解析失败:', error.message);
throw error;
}
}
}
module.exports = WeChatCallbackCrypto;

160
src/core/wechat.js Normal file
View File

@@ -0,0 +1,160 @@
// 企业微信API交互模块
// 封装与企业微信API的HTTP请求
const axios = require('axios');
class WeChatService {
constructor(apiBase = 'https://qyapi.weixin.qq.com') {
this.apiBase = apiBase;
this.tokenCache = new Map(); // 缓存access_token
}
// 获取访问凭证
async getToken(corpid, corpsecret) {
try {
// 检查缓存
const cacheKey = `${corpid}_${corpsecret}`;
const cached = this.tokenCache.get(cacheKey);
if (cached && cached.expires > Date.now()) {
console.log('使用缓存的access_token');
return cached.token;
}
// 调用企业微信API获取token
const response = await axios.get(`${this.apiBase}/cgi-bin/gettoken`, {
params: {
corpid: corpid,
corpsecret: corpsecret
}
});
const { data } = response;
if (data.errcode !== 0) {
throw new Error(`获取token失败: ${data.errmsg} (错误码: ${data.errcode})`);
}
// 缓存token (提前5分钟过期)
const expiresIn = (data.expires_in || 7200) * 1000;
this.tokenCache.set(cacheKey, {
token: data.access_token,
expires: Date.now() + expiresIn - 300000 // 提前5分钟过期
});
console.log('获取access_token成功');
return data.access_token;
} catch (error) {
console.error('获取access_token失败:', error.message);
throw error;
}
}
// 发送应用消息
async sendMessage(accessToken, agentid, touser, message) {
try {
// 构造消息体
const messageBody = {
touser: Array.isArray(touser) ? touser.join('|') : touser,
msgtype: 'text',
agentid: agentid,
text: {
content: message
}
};
// 发送消息
const response = await axios.post(
`${this.apiBase}/cgi-bin/message/send?access_token=${accessToken}`,
messageBody
);
const { data } = response;
if (data.errcode !== 0) {
throw new Error(`发送消息失败: ${data.errmsg} (错误码: ${data.errcode})`);
}
console.log('消息发送成功');
return data;
} catch (error) {
console.error('发送消息失败:', error.message);
throw error;
}
}
// 获取部门列表
async getDepartmentList(accessToken) {
try {
const response = await axios.get(`${this.apiBase}/cgi-bin/department/list`, {
params: {
access_token: accessToken
}
});
const { data } = response;
if (data.errcode !== 0) {
throw new Error(`获取部门列表失败: ${data.errmsg} (错误码: ${data.errcode})`);
}
return data.department || [];
} catch (error) {
console.error('获取部门列表失败:', error.message);
throw error;
}
}
// 获取部门成员
async getUserList(accessToken, departmentId = 1) {
try {
const response = await axios.get(`${this.apiBase}/cgi-bin/user/list`, {
params: {
access_token: accessToken,
department_id: departmentId
}
});
const { data } = response;
if (data.errcode !== 0) {
throw new Error(`获取成员列表失败: ${data.errmsg} (错误码: ${data.errcode})`);
}
return data.userlist || [];
} catch (error) {
console.error('获取成员列表失败:', error.message);
throw error;
}
}
// 获取所有成员(遍历所有部门)
async getAllUsers(accessToken) {
try {
const departments = await this.getDepartmentList(accessToken);
const allUsers = [];
const userSet = new Set(); // 用于去重
for (const dept of departments) {
const users = await this.getUserList(accessToken, dept.id);
users.forEach(user => {
if (!userSet.has(user.userid)) {
userSet.add(user.userid);
allUsers.push({
userid: user.userid,
name: user.name,
department: dept.name
});
}
});
}
return allUsers;
} catch (error) {
console.error('获取所有成员失败:', error.message);
throw error;
}
}
}
module.exports = WeChatService;

391
src/services/notifier.js Normal file
View File

@@ -0,0 +1,391 @@
// 核心业务逻辑模块
// 处理配置创建和消息发送的业务逻辑
const { v4: uuidv4 } = require('uuid');
const Database = require('../core/database');
const CryptoService = require('../core/crypto');
const WeChatService = require('../core/wechat');
const WeChatCallbackCrypto = require('../core/wechat-callback');
const path = require('path');
// 环境变量
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../../database/notifier.db');
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'default-key-for-development-only';
const db = new Database(DB_PATH);
const crypto = new CryptoService(ENCRYPTION_KEY);
const wechat = new WeChatService();
// 初始化数据库(仅需调用一次)
db.init().catch(console.error);
/**
* 创建回调配置(第一步)
* @param {Object} config - { corpid, callback_token, encoding_aes_key }
* @returns {Promise<{ code: string, callbackUrl: string }>}
*/
async function createCallbackConfiguration(config) {
const { corpid, callback_token, encoding_aes_key } = config;
if (!corpid || !callback_token || !encoding_aes_key) {
throw new Error('回调配置参数不完整');
}
if (encoding_aes_key.length !== 43) {
throw new Error('EncodingAESKey必须是43位字符');
}
// 检查是否已存在相同的回调配置
const existingConfig = await db.getCallbackConfiguration(corpid, callback_token);
if (existingConfig) {
console.log('发现重复回调配置返回已存在的code:', existingConfig.code);
return {
code: existingConfig.code,
callbackUrl: `/api/callback/${existingConfig.code}`,
message: '回调配置已存在,返回现有配置'
};
}
// 生成唯一code
const code = uuidv4();
// 加密encoding_aes_key
const encrypted_encoding_aes_key = crypto.encrypt(encoding_aes_key);
// 保存回调配置到数据库
await db.saveCallbackConfiguration({
code,
corpid,
callback_token,
encrypted_encoding_aes_key
});
console.log('回调配置创建成功code:', code);
return {
code,
callbackUrl: `/api/callback/${code}`
};
}
/**
* 完善配置(第二步)
* @param {Object} config - { code, corpsecret, agentid, touser, description }
* @returns {Promise<{ code: string, apiUrl: string, callbackUrl: string }>}
*/
async function completeConfiguration(config) {
const { code, corpsecret, agentid, touser, description } = config;
if (!code || !corpsecret || !agentid || !touser) {
throw new Error('参数不完整');
}
// 检查回调配置是否存在
const callbackConfig = await db.getConfigurationByCode(code);
if (!callbackConfig) {
throw new Error('回调配置不存在请先生成回调URL');
}
// 加密corpsecret
const encrypted_corpsecret = crypto.encrypt(corpsecret);
const formattedTouser = Array.isArray(touser) ? touser.join('|') : touser;
// 更新配置
await db.completeConfiguration({
code,
encrypted_corpsecret,
agentid,
touser: formattedTouser,
description: description || ''
});
console.log('配置完善成功code:', code);
return {
code,
apiUrl: `/api/notify/${code}`,
callbackUrl: `/api/callback/${code}`
};
}
/**
* 创建配置(原有方法,保持兼容性)
* @param {Object} config - { corpid, corpsecret, agentid, touser, description, callback_token, encoding_aes_key, callback_enabled }
* @returns {Promise<{ code: string, apiUrl: string, callbackUrl?: string }>}
*/
async function createConfiguration(config) {
const {
corpid, corpsecret, agentid, touser, description,
callback_token, encoding_aes_key, callback_enabled
} = config;
if (!corpid || !corpsecret || !agentid || !touser) {
throw new Error('参数不完整');
}
// 第一步:优先处理回调配置验证
if (callback_enabled) {
if (!callback_token || !encoding_aes_key) {
throw new Error('启用回调时必须提供回调Token和EncodingAESKey');
}
if (encoding_aes_key.length !== 43) {
throw new Error('EncodingAESKey必须是43位字符');
}
console.log('回调配置验证通过,继续处理配置...');
}
// 第二步:检查是否已存在完全相同的配置(包括回调配置)
const formattedTouser = Array.isArray(touser) ? touser.join('|') : touser;
const existingConfig = await db.getConfigurationByCompleteFields(
corpid,
agentid,
formattedTouser,
callback_enabled ? 1 : 0,
callback_token || null
);
if (existingConfig) {
console.log('发现重复配置返回已存在的code:', existingConfig.code);
const result = {
code: existingConfig.code,
apiUrl: `/api/notify/${existingConfig.code}`,
message: '配置已存在,返回现有配置'
};
if (existingConfig.callback_enabled) {
result.callbackUrl = `/api/callback/${existingConfig.code}`;
}
return result;
}
// 第三步:生成新配置
const code = uuidv4();
// 加密corpsecret
const encrypted_corpsecret = crypto.encrypt(corpsecret);
// 加密encoding_aes_key如果提供
const encrypted_encoding_aes_key = encoding_aes_key ? crypto.encrypt(encoding_aes_key) : null;
// 保存到数据库
await db.saveConfiguration({
code,
corpid,
encrypted_corpsecret,
agentid,
touser: formattedTouser,
description: description || '',
callback_token: callback_token || null,
encrypted_encoding_aes_key,
callback_enabled: callback_enabled ? 1 : 0
});
console.log('新配置创建成功code:', code);
// 返回API调用信息
const result = {
code,
apiUrl: `/api/notify/${code}`
};
if (callback_enabled) {
result.callbackUrl = `/api/callback/${code}`;
}
return result;
}
/**
* 发送通知
* @param {string} code - 唯一配置code
* @param {string} title - 消息标题
* @param {string} content - 消息内容
* @returns {Promise<Object>} - 企业微信API返回结果
*/
async function sendNotification(code, title, content) {
// 查询配置
const config = await db.getConfigurationByCode(code);
if (!config) {
throw new Error('无效的code未找到配置');
}
// 解密corpsecret
const corpsecret = crypto.decrypt(config.encrypted_corpsecret);
// 获取access_token
const accessToken = await wechat.getToken(config.corpid, corpsecret);
// 组装消息内容
const message = title ? `${title}\n${content}` : content;
// 发送消息
const result = await wechat.sendMessage(
accessToken,
config.agentid,
config.touser,
message
);
return result;
}
/**
* 获取配置(不返回敏感信息)
* @param {string} code - 唯一配置code
* @returns {Promise<Object>} - 配置信息
*/
async function getConfiguration(code) {
const config = await db.getConfigurationByCode(code);
if (!config) return null;
const result = {
code: config.code,
corpid: config.corpid,
agentid: config.agentid,
touser: config.touser.split('|'),
description: config.description,
callback_enabled: config.callback_enabled === 1,
created_at: config.created_at
};
// 如果启用了回调,添加回调相关信息(不包含敏感数据)
if (config.callback_enabled) {
result.callback_token = config.callback_token;
result.callbackUrl = `/api/callback/${config.code}`;
}
return result;
}
/**
* 更新配置
* @param {string} code - 唯一配置code
* @param {Object} newConfig - 新的配置信息
* @returns {Promise<{ message: string, code: string, callbackUrl?: string }>}
*/
async function updateConfiguration(code, newConfig) {
const config = await db.getConfigurationByCode(code);
if (!config) {
throw new Error('无效的code未找到配置');
}
// 如果提供了新的corpsecret则加密
let encrypted_corpsecret = config.encrypted_corpsecret;
if (newConfig.corpsecret) {
encrypted_corpsecret = crypto.encrypt(newConfig.corpsecret);
}
// 如果提供了新的encoding_aes_key则加密
let encrypted_encoding_aes_key = config.encrypted_encoding_aes_key;
if (newConfig.encoding_aes_key) {
encrypted_encoding_aes_key = crypto.encrypt(newConfig.encoding_aes_key);
}
// 更新数据库
await db.updateConfiguration({
code,
corpid: newConfig.corpid || config.corpid,
encrypted_corpsecret,
agentid: newConfig.agentid || config.agentid,
touser: newConfig.touser ? (Array.isArray(newConfig.touser) ? newConfig.touser.join('|') : newConfig.touser) : config.touser,
description: newConfig.description !== undefined ? newConfig.description : config.description,
callback_token: newConfig.callback_token !== undefined ? newConfig.callback_token : config.callback_token,
encrypted_encoding_aes_key,
callback_enabled: newConfig.callback_enabled !== undefined ? (newConfig.callback_enabled ? 1 : 0) : config.callback_enabled
});
const result = { message: '配置更新成功', code };
if (newConfig.callback_enabled || config.callback_enabled) {
result.callbackUrl = `/api/callback/${code}`;
}
return result;
}
/**
* 处理回调验证
* @param {string} code - 唯一配置code
* @param {string} msgSignature - 消息签名
* @param {string} timestamp - 时间戳
* @param {string} nonce - 随机数
* @param {string} echoStr - 回显字符串
* @returns {Promise<{ success: boolean, data?: string, error?: string }>}
*/
async function handleCallbackVerification(code, msgSignature, timestamp, nonce, echoStr) {
try {
// 查询配置
const config = await db.getConfigurationByCode(code);
if (!config || !config.callback_enabled) {
return { success: false, error: '回调未启用或配置不存在' };
}
if (!config.callback_token || !config.encrypted_encoding_aes_key) {
return { success: false, error: '回调配置不完整' };
}
// 解密encoding_aes_key
const encodingAESKey = crypto.decrypt(config.encrypted_encoding_aes_key);
// 创建回调加密实例
const callbackCrypto = new WeChatCallbackCrypto(
config.callback_token,
encodingAESKey,
config.corpid
);
// 验证URL
const result = callbackCrypto.verifyURL(msgSignature, timestamp, nonce, echoStr);
return result;
} catch (error) {
console.error('回调验证失败:', error.message);
return { success: false, error: error.message };
}
}
/**
* 处理回调消息
* @param {string} code - 唯一配置code
* @param {string} encryptedData - 加密的消息数据
* @param {string} msgSignature - 消息签名
* @param {string} timestamp - 时间戳
* @param {string} nonce - 随机数
* @returns {Promise<{ success: boolean, message?: Object, error?: string }>}
*/
async function handleCallbackMessage(code, encryptedData, msgSignature, timestamp, nonce) {
try {
// 查询配置
const config = await db.getConfigurationByCode(code);
if (!config || !config.callback_enabled) {
return { success: false, error: '回调未启用或配置不存在' };
}
if (!config.callback_token || !config.encrypted_encoding_aes_key) {
return { success: false, error: '回调配置不完整' };
}
// 解密encoding_aes_key
const encodingAESKey = crypto.decrypt(config.encrypted_encoding_aes_key);
// 创建回调加密实例
const callbackCrypto = new WeChatCallbackCrypto(
config.callback_token,
encodingAESKey,
config.corpid
);
// 解密消息
const decryptResult = callbackCrypto.decryptMsg(encryptedData, msgSignature, timestamp, nonce);
if (!decryptResult.success) {
return decryptResult;
}
// 解析XML消息
const message = callbackCrypto.parseXMLMessage(decryptResult.data);
// 记录消息日志
console.log(`[回调消息] Code: ${code}, 发送者: ${message.fromUserName}, 类型: ${message.msgType}`);
if (message.msgType === 'text') {
console.log(`[回调消息] 内容: ${message.content}`);
}
return { success: true, message };
} catch (error) {
console.error('回调消息处理失败:', error.message);
return { success: false, error: error.message };
}
}
module.exports = {
createCallbackConfiguration,
completeConfiguration,
createConfiguration,
sendNotification,
getConfiguration,
updateConfiguration,
handleCallbackVerification,
handleCallbackMessage
};