mirror of
https://github.com/wangwangit/qywx-push.git
synced 2026-05-06 20:43:05 +08:00
Initial commit
This commit is contained in:
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal 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
21
Dockerfile
Normal 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
21
Dockerfile.alternative
Normal 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
219
MODIFICATIONS_SUMMARY.md
Normal 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
100
README.md
Normal 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
17
docker-compose.yml
Normal 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
14
env.template
Normal 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
2820
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal 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
322
public/api-docs.html
Normal 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
164
public/index.html
Normal 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
584
public/script.js
Normal 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
36
server.js
Normal 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
178
src/api/routes.js
Normal 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
50
src/core/crypto.js
Normal 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
251
src/core/database.js
Normal 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;
|
||||
95
src/core/wechat-callback.js
Normal file
95
src/core/wechat-callback.js
Normal 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
160
src/core/wechat.js
Normal 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
391
src/services/notifier.js
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user