feat(iit-manager): Add WeChat Official Account integration for patient notifications
Features: - PatientWechatCallbackController for URL verification and message handling - PatientWechatService for template and customer messages - Support for secure mode (message encryption/decryption) - Simplified route /wechat/patient/callback for WeChat config - Event handlers for subscribe/unsubscribe/text messages - Template message for visit reminders Technical details: - Reuse @wecom/crypto for encryption (compatible with Official Account) - Relaxed Fastify schema validation to prevent early request blocking - Access token caching (7000s with 5min pre-refresh) - Comprehensive logging for debugging Testing: Local URL verification passed, ready for SAE deployment Status: Code complete, waiting for WeChat platform configuration
This commit is contained in:
139
backend/DEPLOY_TO_SAE_FOR_WECHAT_MP.md
Normal file
139
backend/DEPLOY_TO_SAE_FOR_WECHAT_MP.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# 部署微信服务号到SAE(快速指南)
|
||||
|
||||
## 🎯 目标
|
||||
将微信服务号回调服务部署到SAE生产环境,使用域名:`iit.xunzhengyixue.com`
|
||||
|
||||
---
|
||||
|
||||
## 📋 Step 1: 确认环境变量配置
|
||||
|
||||
编辑 `backend/.env`,确认以下配置存在:
|
||||
|
||||
```env
|
||||
# 微信服务号配置
|
||||
WECHAT_MP_APP_ID=wx062568ff49e4570c
|
||||
WECHAT_MP_APP_SECRET=c0d19435d1a1e948939c16d767ec0faf
|
||||
WECHAT_MP_TOKEN=IitPatientWechat2026JanToken
|
||||
WECHAT_MP_ENCODING_AES_KEY=VIzwMGRG4Ll8Sd7fPxPXLlBaWdsh2rK2qIGpyaEoc1v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Step 2: 在SAE控制台配置环境变量
|
||||
|
||||
1. **登录阿里云SAE控制台**
|
||||
2. **进入应用管理 → 选择应用**
|
||||
3. **配置管理 → 环境变量**
|
||||
4. **添加以下环境变量**:
|
||||
|
||||
```
|
||||
WECHAT_MP_APP_ID=wx062568ff49e4570c
|
||||
WECHAT_MP_APP_SECRET=c0d19435d1a1e948939c16d767ec0faf
|
||||
WECHAT_MP_TOKEN=IitPatientWechat2026JanToken
|
||||
WECHAT_MP_ENCODING_AES_KEY=VIzwMGRG4Ll8Sd7fPxPXLlBaWdsh2rK2qIGpyaEoc1v
|
||||
```
|
||||
|
||||
5. **保存配置**
|
||||
|
||||
---
|
||||
|
||||
## 📋 Step 3: 部署代码到SAE
|
||||
|
||||
```bash
|
||||
cd D:\MyCursor\AIclinicalresearch\backend
|
||||
.\deploy-to-sae.ps1
|
||||
```
|
||||
|
||||
**等待部署完成**(约5-10分钟)
|
||||
|
||||
---
|
||||
|
||||
## 📋 Step 4: 验证部署
|
||||
|
||||
访问以下URL,确认服务正常:
|
||||
|
||||
```
|
||||
https://iit.xunzhengyixue.com/api/v1/iit/health
|
||||
```
|
||||
|
||||
**期望返回**:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"module": "iit-manager",
|
||||
"version": "1.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Step 5: 配置微信公众平台
|
||||
|
||||
1. **登录微信公众平台**:https://mp.weixin.qq.com/
|
||||
2. **进入:设置与开发 → 基本配置 → 服务器配置**
|
||||
3. **点击"修改配置"**
|
||||
4. **填写以下信息**:
|
||||
|
||||
| 配置项 | 值 |
|
||||
|--------|-----|
|
||||
| **URL** | `https://iit.xunzhengyixue.com/wechat/patient/callback` |
|
||||
| **Token** | `IitPatientWechat2026JanToken` |
|
||||
| **EncodingAESKey** | `VIzwMGRG4Ll8Sd7fPxPXLlBaWdsh2rK2qIGpyaEoc1v` |
|
||||
| **消息加解密方式** | **安全模式(推荐)** |
|
||||
| **数据格式** | **XML** |
|
||||
|
||||
5. **点击"提交"**
|
||||
6. **验证成功后点击"启用"**
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验证成功标志
|
||||
|
||||
### 配置阶段:
|
||||
- ✅ 页面显示"配置成功"
|
||||
- ✅ 服务器配置状态为"已启用"
|
||||
|
||||
### 测试阶段:
|
||||
1. **关注公众号**:AI for 临床研究
|
||||
2. **查看SAE日志**,应该看到:
|
||||
```
|
||||
📥 收到微信服务号回调消息
|
||||
🔐 检测到加密消息,开始解密...
|
||||
✅ 消息解密成功
|
||||
👤 用户关注公众号
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 如何查看SAE日志
|
||||
|
||||
1. **登录阿里云SAE控制台**
|
||||
2. **应用管理 → 选择应用 → 实例管理**
|
||||
3. **点击"日志" → "实时日志"**
|
||||
4. **查看最近的日志输出**
|
||||
|
||||
---
|
||||
|
||||
## 📝 优势
|
||||
|
||||
使用生产环境的优势:
|
||||
- ✅ 域名 `iit.xunzhengyixue.com` 已备案
|
||||
- ✅ HTTPS证书已配置
|
||||
- ✅ 已在企业微信中验证过
|
||||
- ✅ 无需natapp内网穿透
|
||||
- ✅ 稳定性更好
|
||||
- ✅ 无需配置域名验证
|
||||
|
||||
---
|
||||
|
||||
## ⏱️ 预计用时
|
||||
|
||||
- 配置环境变量:2分钟
|
||||
- 部署到SAE:5-10分钟
|
||||
- 配置微信公众平台:3分钟
|
||||
- **总计:10-15分钟**
|
||||
|
||||
---
|
||||
|
||||
**立即开始部署!** 🚀
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 企业微信环境变量配置说明
|
||||
# 微信环境变量配置说明(企业微信 + 服务号)
|
||||
|
||||
## 📋 必需的环境变量
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
```env
|
||||
# ==========================================
|
||||
# 企业微信配置
|
||||
# 企业微信配置(研究者端)
|
||||
# ==========================================
|
||||
|
||||
# 企业微信基础配置(应用信息)
|
||||
@@ -20,6 +20,21 @@ WECHAT_ENCODING_AES_KEY=V88eT3O9bMW897h4btr7v7qvQlmlMf31edTQCmuhOhO
|
||||
|
||||
# 测试用户ID(可选,仅测试环境使用)
|
||||
WECHAT_TEST_USER_ID=FengZhiBo
|
||||
|
||||
# ==========================================
|
||||
# 微信服务号配置(患者端)
|
||||
# ==========================================
|
||||
|
||||
# 微信服务号基础配置
|
||||
WECHAT_MP_APP_ID=wx062568ff49e4570c
|
||||
WECHAT_MP_APP_SECRET=c0d19435d1a1e948939c16d767ec0faf
|
||||
|
||||
# 微信服务号回调配置(消息加解密,安全模式)
|
||||
WECHAT_MP_TOKEN=<需要生成3-32位字符串>
|
||||
WECHAT_MP_ENCODING_AES_KEY=<需要生成43位字符串>
|
||||
|
||||
# 微信小程序配置(可选,后续开发)
|
||||
WECHAT_MINI_APP_ID=<待申请>
|
||||
```
|
||||
|
||||
## 📝 配置项说明
|
||||
|
||||
300
backend/WECHAT_MP_CONFIG_READY.md
Normal file
300
backend/WECHAT_MP_CONFIG_READY.md
Normal file
@@ -0,0 +1,300 @@
|
||||
# 微信服务号配置完成清单
|
||||
|
||||
> **生成时间**: 2026-01-04
|
||||
> **服务号**: AI for 临床研究
|
||||
> **AppID**: wx062568ff49e4570c
|
||||
|
||||
---
|
||||
|
||||
## 🎯 一、立即配置(复制即用)
|
||||
|
||||
### 1.1 环境变量配置
|
||||
|
||||
**请将以下内容添加到 `backend/.env` 文件:**
|
||||
|
||||
```env
|
||||
# ==========================================
|
||||
# 微信服务号配置(患者端)
|
||||
# ==========================================
|
||||
|
||||
# 微信服务号基础配置
|
||||
WECHAT_MP_APP_ID=wx062568ff49e4570c
|
||||
WECHAT_MP_APP_SECRET=c0d19435d1a1e948939c16d767ec0faf
|
||||
|
||||
# 微信服务号回调配置(消息加解密,安全模式)
|
||||
WECHAT_MP_TOKEN=IitPatientWechat2026JanToken
|
||||
WECHAT_MP_ENCODING_AES_KEY=7yK9mN4pQ2wX5vL8hG3jR6tU1nB0cF9eM7aZ4xS2dY5
|
||||
|
||||
# 微信小程序配置(可选,后续开发)
|
||||
WECHAT_MINI_APP_ID=
|
||||
```
|
||||
|
||||
### 1.2 微信公众平台配置
|
||||
|
||||
**登录地址**: https://mp.weixin.qq.com/
|
||||
**配置路径**: 设置与开发 → 基本配置 → 服务器配置 → 修改配置
|
||||
|
||||
**填写以下参数:**
|
||||
|
||||
| 配置项 | 值 |
|
||||
|--------|-----|
|
||||
| **URL** | `https://iit.xunzhengyixue.com/api/v1/iit/patient-wechat/callback` |
|
||||
| **Token** | `IitPatientWechat2026JanToken` |
|
||||
| **EncodingAESKey** | `7yK9mN4pQ2wX5vL8hG3jR6tU1nB0cF9eM7aZ4xS2dY5` |
|
||||
| **消息加解密方式** | ✅ **安全模式(推荐)** |
|
||||
| **数据格式** | ✅ **XML** |
|
||||
|
||||
**本地开发URL**(使用natapp):
|
||||
```
|
||||
https://devlocal.xunzhengyixue.com/api/v1/iit/patient-wechat/callback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 二、配置步骤(按顺序执行)
|
||||
|
||||
### Step 1: 更新环境变量 ✅
|
||||
|
||||
```bash
|
||||
# 1. 打开 backend/.env 文件
|
||||
# 2. 复制上面1.1节的配置内容
|
||||
# 3. 粘贴到文件末尾
|
||||
# 4. 保存文件
|
||||
```
|
||||
|
||||
### Step 2: 验证配置 ✅
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npx tsx src/modules/iit-manager/test-patient-wechat-config.ts
|
||||
```
|
||||
|
||||
**期望输出**:
|
||||
```
|
||||
✅ 配置检查通过!可以开始配置微信公众平台
|
||||
```
|
||||
|
||||
### Step 3: 启动后端服务 🔥
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**期望输出**:
|
||||
```
|
||||
✅ 微信服务号回调控制器初始化成功
|
||||
✅ 微信服务号服务初始化成功
|
||||
Registered route: GET /api/v1/iit/patient-wechat/callback
|
||||
Registered route: POST /api/v1/iit/patient-wechat/callback
|
||||
Server listening on http://0.0.0.0:3001
|
||||
```
|
||||
|
||||
### Step 4: 本地开发启动natapp(可选)
|
||||
|
||||
```bash
|
||||
# 如果是本地开发,需要启动natapp内网穿透
|
||||
cd D:\tools\natapp
|
||||
natapp.exe -authtoken=YOUR_TOKEN -subdomain=devlocal
|
||||
```
|
||||
|
||||
### Step 5: 测试URL验证(本地)
|
||||
|
||||
新开一个终端,运行:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npx tsx src/modules/iit-manager/test-patient-wechat-url-verify.ts
|
||||
```
|
||||
|
||||
**期望输出**:
|
||||
```
|
||||
✅ URL验证成功!
|
||||
✅ 返回的echostr正确,验证通过!
|
||||
🎉 测试成功!您的服务端配置正确
|
||||
```
|
||||
|
||||
### Step 6: 配置微信公众平台 🔥
|
||||
|
||||
1. 登录微信公众平台:https://mp.weixin.qq.com/
|
||||
2. 进入:**设置与开发 → 基本配置 → 服务器配置**
|
||||
3. 点击 **"修改配置"**
|
||||
4. 填写上面1.2节的配置参数
|
||||
5. 点击 **"提交"**(微信会自动验证URL)
|
||||
6. 如果验证成功,点击 **"启用"**
|
||||
|
||||
**验证成功标志**:
|
||||
- ✅ 页面显示 "配置成功"
|
||||
- ✅ "服务器配置" 状态为 "已启用"
|
||||
|
||||
**如果验证失败**:
|
||||
- 查看后端日志是否收到GET请求
|
||||
- 确认Token和EncodingAESKey与.env中完全一致
|
||||
- 确认服务器可以从公网访问
|
||||
|
||||
### Step 7: 测试消息接收
|
||||
|
||||
1. 用测试微信号关注公众号:`AI for 临床研究`
|
||||
2. 发送文本消息:`你好`
|
||||
3. 查看后端日志,应该看到:
|
||||
|
||||
```
|
||||
📥 收到微信服务号回调消息
|
||||
🔐 检测到加密消息,开始解密...
|
||||
✅ 消息解密成功
|
||||
💬 处理文本消息: 你好
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 三、常见问题排查
|
||||
|
||||
### Q1: URL验证失败,提示"Token验证失败"
|
||||
|
||||
**原因**:Token配置不一致
|
||||
|
||||
**解决方法**:
|
||||
1. 确认 `.env` 文件中的 `WECHAT_MP_TOKEN` 是 `IitPatientWechat2026JanToken`
|
||||
2. 确认微信公众平台配置的Token也是 `IitPatientWechat2026JanToken`
|
||||
3. 注意大小写和空格
|
||||
|
||||
### Q2: URL验证失败,提示"请求URL超时"
|
||||
|
||||
**原因**:服务器无法访问
|
||||
|
||||
**解决方法**:
|
||||
1. 确认后端服务已启动:`npm run dev`
|
||||
2. 本地开发确认natapp已启动
|
||||
3. 生产环境确认SAE应用正常运行
|
||||
4. 浏览器访问:`https://iit.xunzhengyixue.com/api/v1/iit/health` 测试连通性
|
||||
|
||||
### Q3: 提示"配置失败(48001)"
|
||||
|
||||
**原因**:AppID或AppSecret不正确
|
||||
|
||||
**解决方法**:
|
||||
1. 确认 `.env` 中的 `WECHAT_MP_APP_ID` 和 `WECHAT_MP_APP_SECRET` 正确
|
||||
2. 登录微信公众平台 → 开发 → 基本配置 → 查看AppID和AppSecret
|
||||
3. 重新复制粘贴,确保无多余空格
|
||||
|
||||
### Q4: 消息解密失败
|
||||
|
||||
**原因**:EncodingAESKey不一致或长度错误
|
||||
|
||||
**解决方法**:
|
||||
1. 确认 `.env` 中的 `WECHAT_MP_ENCODING_AES_KEY` 长度为43位
|
||||
2. 确认与微信公众平台配置完全一致(包括大小写)
|
||||
3. 如果还是失败,重新生成EncodingAESKey并同步更新
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 四、重要提示
|
||||
|
||||
### 安全提示
|
||||
1. ❗ **Token和EncodingAESKey不要提交到Git**
|
||||
2. ❗ **AppSecret不要泄露,不要提交到Git**
|
||||
3. ❗ 生产环境配置在SAE环境变量中,不要写在代码里
|
||||
|
||||
### 配置原则
|
||||
1. ✅ `.env` 和微信公众平台的Token/EncodingAESKey必须**完全一致**
|
||||
2. ✅ 修改配置后需要**重启服务**才能生效
|
||||
3. ✅ 本地开发和生产环境可以使用**相同的Token**(推荐)
|
||||
4. ✅ 如果配置错误,可以重新生成新的Token(运行 `generate-wechat-tokens.ts`)
|
||||
|
||||
### IP白名单
|
||||
生产环境需要配置IP白名单:
|
||||
1. 登录微信公众平台 → 基本配置 → IP白名单
|
||||
2. 添加SAE应用的出口IP
|
||||
3. 可以从SAE控制台查看应用的出口IP
|
||||
|
||||
---
|
||||
|
||||
## 📊 五、开发进度
|
||||
|
||||
### 已完成 ✅
|
||||
- [x] PatientWechatCallbackController(服务号回调控制器)
|
||||
- [x] PatientWechatService(模板消息推送服务)
|
||||
- [x] 路由配置(GET/POST /api/v1/iit/patient-wechat/callback)
|
||||
- [x] 环境变量配置文档
|
||||
- [x] 配置检查脚本
|
||||
- [x] URL验证测试脚本
|
||||
- [x] Token生成脚本
|
||||
- [x] 接入指南文档
|
||||
|
||||
### 待测试 ⏳
|
||||
- [ ] URL验证(配置微信公众平台)
|
||||
- [ ] 消息接收(关注事件、文本消息)
|
||||
- [ ] 消息解密(安全模式)
|
||||
|
||||
### 后续开发 📝
|
||||
- [ ] 患者绑定功能(数据表 + H5页面)
|
||||
- [ ] 模板消息推送(访视提醒)
|
||||
- [ ] 客服消息回复(智能对话)
|
||||
- [ ] 微信小程序开发
|
||||
|
||||
---
|
||||
|
||||
## 🚀 六、现在就开始!
|
||||
|
||||
### 方案A:生产环境部署(推荐)
|
||||
|
||||
```bash
|
||||
# 1. 更新 backend/.env 文件(复制上面的配置)
|
||||
|
||||
# 2. 验证配置
|
||||
cd backend
|
||||
npx tsx src/modules/iit-manager/test-patient-wechat-config.ts
|
||||
|
||||
# 3. 部署到SAE
|
||||
./deploy-to-sae.ps1
|
||||
|
||||
# 4. 配置微信公众平台(使用生产URL)
|
||||
|
||||
# 5. 测试功能(关注公众号、发送消息)
|
||||
```
|
||||
|
||||
### 方案B:本地开发测试
|
||||
|
||||
```bash
|
||||
# 1. 更新 backend/.env 文件(复制上面的配置)
|
||||
|
||||
# 2. 验证配置
|
||||
cd backend
|
||||
npx tsx src/modules/iit-manager/test-patient-wechat-config.ts
|
||||
|
||||
# 3. 启动natapp
|
||||
cd D:\tools\natapp
|
||||
natapp.exe -authtoken=YOUR_TOKEN -subdomain=devlocal
|
||||
|
||||
# 4. 启动后端服务
|
||||
cd D:\MyCursor\AIclinicalresearch\backend
|
||||
npm run dev
|
||||
|
||||
# 5. 测试URL验证
|
||||
npx tsx src/modules/iit-manager/test-patient-wechat-url-verify.ts
|
||||
|
||||
# 6. 配置微信公众平台(使用本地开发URL)
|
||||
|
||||
# 7. 测试功能(关注公众号、发送消息)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**推荐:先执行方案B(本地测试),测试通过后再执行方案A(生产部署)**
|
||||
|
||||
---
|
||||
|
||||
## 📞 需要帮助?
|
||||
|
||||
如有问题,可以:
|
||||
1. 查看后端日志(`backend/logs/`)
|
||||
2. 运行配置检查脚本
|
||||
3. 查看接入指南文档(`backend/src/modules/iit-manager/docs/微信服务号接入指南.md`)
|
||||
4. 联系技术负责人
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-01-04
|
||||
**文档版本**: v1.0
|
||||
**状态**: ✅ 配置就绪,等待测试
|
||||
|
||||
162
backend/WECHAT_MP_QUICK_FIX.md
Normal file
162
backend/WECHAT_MP_QUICK_FIX.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# 微信服务号配置错误修复(200002)
|
||||
|
||||
## ❌ 错误信息
|
||||
```
|
||||
检查消息推送配置失败: invalid args, 200002
|
||||
```
|
||||
|
||||
## 🔍 问题原因
|
||||
EncodingAESKey格式不符合微信服务号要求。
|
||||
|
||||
## ✅ 解决方案(2选1)
|
||||
|
||||
### 方案1:使用微信平台随机生成(推荐⭐)
|
||||
|
||||
**步骤:**
|
||||
|
||||
1. 在微信公众平台配置页面
|
||||
2. **EncodingAESKey字段不要手动输入**
|
||||
3. 点击右侧的 **"随机生成"** 按钮
|
||||
4. 微信会自动生成43位密钥,例如:
|
||||
```
|
||||
abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG
|
||||
```
|
||||
5. **复制这个生成的密钥**
|
||||
6. 填写完整配置:
|
||||
```
|
||||
URL: https://devlocal.xunzhengyixue.com/api/v1/iit/patient-wechat/callback
|
||||
Token: IitPatientWechat2026JanToken
|
||||
EncodingAESKey: [点击随机生成后复制的密钥]
|
||||
消息加解密方式: 安全模式
|
||||
```
|
||||
7. 点击 **"提交"** 验证
|
||||
8. **验证成功后**,复制微信生成的EncodingAESKey到 `.env` 文件:
|
||||
```env
|
||||
WECHAT_MP_ENCODING_AES_KEY=[微信生成的密钥]
|
||||
```
|
||||
9. **重启后端服务**(重要!)
|
||||
|
||||
---
|
||||
|
||||
### 方案2:使用OpenSSL生成(备选)
|
||||
|
||||
如果方案1不行,使用OpenSSL生成标准Base64密钥:
|
||||
|
||||
**Windows(PowerShell):**
|
||||
```powershell
|
||||
# 生成Base64格式的43位密钥
|
||||
$bytes = [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32)
|
||||
$base64 = [Convert]::ToBase64String($bytes)
|
||||
$key = $base64.Substring(0, 43)
|
||||
Write-Host "EncodingAESKey: $key"
|
||||
```
|
||||
|
||||
**或者使用在线工具:**
|
||||
1. 访问:https://www.base64encode.org/
|
||||
2. 生成32字节随机数据
|
||||
3. Base64编码
|
||||
4. 截取前43位
|
||||
|
||||
---
|
||||
|
||||
## 📋 完整配置流程(推荐)
|
||||
|
||||
### Step 1: 使用微信平台生成密钥
|
||||
|
||||
1. 登录微信公众平台
|
||||
2. 进入:设置与开发 → 基本配置 → 服务器配置
|
||||
3. 点击"修改配置"
|
||||
4. 填写以下信息:
|
||||
|
||||
| 字段 | 值 |
|
||||
|-----|-----|
|
||||
| URL | `https://devlocal.xunzhengyixue.com/api/v1/iit/patient-wechat/callback` |
|
||||
| Token | `IitPatientWechat2026JanToken` |
|
||||
| EncodingAESKey | **点击"随机生成"** 然后复制生成的密钥 |
|
||||
| 消息加解密方式 | 安全模式(推荐) |
|
||||
|
||||
### Step 2: 提交验证
|
||||
|
||||
1. 点击"提交"
|
||||
2. 微信会发送GET请求验证
|
||||
3. 应该显示"配置成功"
|
||||
|
||||
### Step 3: 更新.env文件
|
||||
|
||||
编辑 `backend/.env`,替换EncodingAESKey:
|
||||
|
||||
```env
|
||||
# 原来的(有问题)
|
||||
# WECHAT_MP_ENCODING_AES_KEY=7yK9mN4pQ2wX5vL8hG3jR6tU1nB0cF9eM7aZ4xS2dY5
|
||||
|
||||
# 新的(从微信平台复制的)
|
||||
WECHAT_MP_ENCODING_AES_KEY=微信生成的43位密钥
|
||||
```
|
||||
|
||||
### Step 4: 重启后端服务
|
||||
|
||||
```bash
|
||||
# 停止当前服务(Ctrl+C)
|
||||
# 重新启动
|
||||
cd D:\MyCursor\AIclinicalresearch\backend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Step 5: 启用配置
|
||||
|
||||
返回微信公众平台,点击"启用"按钮
|
||||
|
||||
---
|
||||
|
||||
## 🧪 验证配置是否正确
|
||||
|
||||
### 方法1:查看后端日志
|
||||
|
||||
应该看到类似日志:
|
||||
```
|
||||
📥 收到微信服务号 URL 验证请求
|
||||
✅ URL 验证成功,返回 echostr
|
||||
```
|
||||
|
||||
### 方法2:测试关注事件
|
||||
|
||||
1. 微信扫码关注公众号
|
||||
2. 查看后端日志是否有:
|
||||
```
|
||||
📥 收到微信服务号回调消息
|
||||
🔐 检测到加密消息,开始解密...
|
||||
✅ 消息解密成功
|
||||
👤 用户关注公众号
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. ✅ **EncodingAESKey必须与微信平台配置完全一致**
|
||||
2. ✅ **修改.env后必须重启服务**
|
||||
3. ✅ **Token保持不变**:`IitPatientWechat2026JanToken`
|
||||
4. ✅ **推荐使用微信平台的"随机生成"功能**
|
||||
|
||||
---
|
||||
|
||||
## 💡 为什么会出现这个错误?
|
||||
|
||||
微信服务号对EncodingAESKey的格式有严格要求:
|
||||
- 必须是43位
|
||||
- 必须符合Base64字符集(A-Z, a-z, 0-9, +, /)
|
||||
- 我之前生成的密钥只包含字母和数字,缺少+和/,不符合标准
|
||||
|
||||
微信平台的"随机生成"功能会自动生成符合标准的密钥,所以推荐使用。
|
||||
|
||||
---
|
||||
|
||||
**立即行动:**
|
||||
1. 进入微信公众平台配置页面
|
||||
2. 点击"随机生成"EncodingAESKey
|
||||
3. 提交配置
|
||||
4. 复制密钥到.env
|
||||
5. 重启服务
|
||||
|
||||
**预计用时:3分钟**
|
||||
|
||||
@@ -54,5 +54,6 @@ WHERE table_schema = 'dc_schema'
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -92,5 +92,6 @@ ORDER BY ordinal_position;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -105,5 +105,6 @@ runMigration()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -39,5 +39,6 @@ COMMENT ON COLUMN "dc_schema"."dc_tool_c_sessions"."column_mapping" IS '列名
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -66,5 +66,6 @@ COMMENT ON COLUMN dc_schema.dc_tool_c_sessions.expires_at IS '过期时间(创
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -108,3 +108,4 @@ Write-Host ""
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -216,5 +216,6 @@ function extractCodeBlocks(obj, blocks = []) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -235,5 +235,6 @@ checkDCTables();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -187,5 +187,6 @@ createAiHistoryTable()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -174,5 +174,6 @@ createToolCTable()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -171,5 +171,6 @@ createToolCTable()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -303,5 +303,6 @@ export function getBatchItems<T>(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -339,5 +339,6 @@ runTests().catch((error) => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -280,5 +280,6 @@ runTest()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -318,5 +318,6 @@ Content-Type: application/json
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -254,5 +254,6 @@ export const conflictDetectionService = new ConflictDetectionService();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -204,5 +204,6 @@ curl -X POST http://localhost:3000/api/v1/dc/tool-c/test/execute \
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -258,5 +258,6 @@ export const streamAIController = new StreamAIController();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -169,3 +169,4 @@ logger.info('[SessionMemory] 会话记忆管理器已启动', {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -103,3 +103,4 @@ async function checkTableStructure() {
|
||||
|
||||
checkTableStructure();
|
||||
|
||||
|
||||
|
||||
@@ -90,3 +90,4 @@ checkProjectConfig().catch(console.error);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -72,3 +72,4 @@ async function main() {
|
||||
main();
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* 微信服务号回调控制器(患者端)
|
||||
*
|
||||
* 功能:
|
||||
* 1. 处理微信服务号 URL 验证(GET 请求)
|
||||
* 2. 接收用户消息和事件(POST 请求)
|
||||
* 3. 消息加解密(安全模式)
|
||||
* 4. 异步处理消息(规避 5 秒超时)
|
||||
* 5. 被动回复消息(可选)
|
||||
*
|
||||
* 关键技术:
|
||||
* - 异步回复模式:立即返回加密的空消息,后台异步处理
|
||||
* - XML 加解密:使用 @wecom/crypto 库(兼容微信服务号)
|
||||
* - Token验证:signature = sha1(sort(token, timestamp, nonce))
|
||||
*
|
||||
* 参考文档:
|
||||
* - https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html
|
||||
* - https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Message_encryption_and_decryption_instructions.html
|
||||
*/
|
||||
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import crypto from 'crypto';
|
||||
import xml2js from 'xml2js';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { createRequire } from 'module';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
|
||||
// 使用 createRequire 导入 CommonJS 模块(复用企业微信的加解密库)
|
||||
const require = createRequire(import.meta.url);
|
||||
const { decrypt, encrypt, getSignature } = require('@wecom/crypto');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const { parseStringPromise } = xml2js;
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
interface WechatMpVerifyQuery {
|
||||
signature: string; // 微信加密签名
|
||||
timestamp: string; // 时间戳
|
||||
nonce: string; // 随机数
|
||||
echostr: string; // 随机字符串(验证时返回)
|
||||
}
|
||||
|
||||
interface WechatMpCallbackQuery {
|
||||
signature?: string; // 明文模式
|
||||
timestamp: string;
|
||||
nonce: string;
|
||||
openid?: string; // 发送者openid
|
||||
encrypt_type?: string; // 加密类型(aes)
|
||||
msg_signature?: string; // 加密模式签名
|
||||
}
|
||||
|
||||
interface WechatMessageXml {
|
||||
xml: {
|
||||
ToUserName?: string[]; // 开发者微信号
|
||||
FromUserName?: string[]; // 发送方openid
|
||||
CreateTime?: string[]; // 消息创建时间
|
||||
MsgType?: string[]; // 消息类型(text, event等)
|
||||
Content?: string[]; // 文本消息内容
|
||||
MsgId?: string[]; // 消息id
|
||||
Event?: string[]; // 事件类型(subscribe, CLICK等)
|
||||
EventKey?: string[]; // 事件KEY值
|
||||
Encrypt?: string[]; // 加密消息体
|
||||
};
|
||||
}
|
||||
|
||||
interface UserMessage {
|
||||
fromUser: string; // 用户openid
|
||||
toUser: string; // 公众号原始ID
|
||||
content: string; // 消息内容
|
||||
msgId: string; // 消息ID
|
||||
msgType: string; // 消息类型
|
||||
createTime: number; // 创建时间
|
||||
event?: string; // 事件类型(如果是事件消息)
|
||||
eventKey?: string; // 事件Key
|
||||
}
|
||||
|
||||
// ==================== 微信服务号回调控制器 ====================
|
||||
|
||||
export class PatientWechatCallbackController {
|
||||
private token: string;
|
||||
private encodingAESKey: string;
|
||||
private appId: string;
|
||||
|
||||
constructor() {
|
||||
// 从环境变量读取配置
|
||||
this.token = process.env.WECHAT_MP_TOKEN || '';
|
||||
this.encodingAESKey = process.env.WECHAT_MP_ENCODING_AES_KEY || '';
|
||||
this.appId = process.env.WECHAT_MP_APP_ID || '';
|
||||
|
||||
// 验证配置
|
||||
if (!this.token || !this.encodingAESKey || !this.appId) {
|
||||
logger.error('❌ 微信服务号回调配置不完整', {
|
||||
hasToken: !!this.token,
|
||||
hasAESKey: !!this.encodingAESKey,
|
||||
hasAppId: !!this.appId,
|
||||
});
|
||||
throw new Error('微信服务号回调配置不完整,请检查环境变量');
|
||||
}
|
||||
|
||||
logger.info('✅ 微信服务号回调控制器初始化成功', {
|
||||
appId: this.appId,
|
||||
tokenLength: this.token.length,
|
||||
aesKeyLength: this.encodingAESKey.length,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== URL 验证(GET) ====================
|
||||
|
||||
/**
|
||||
* 处理微信服务号 URL 验证请求
|
||||
*
|
||||
* 微信在配置回调 URL 时会发送 GET 请求验证:
|
||||
* 1. 验证 signature = sha1(sort(token, timestamp, nonce))
|
||||
* 2. 返回 echostr 原文(明文模式)或解密后的 echostr(加密模式)
|
||||
*
|
||||
* 注意:与企业微信不同,服务号验证时 echostr 是明文的
|
||||
*/
|
||||
async handleVerification(
|
||||
request: FastifyRequest<{ Querystring: WechatMpVerifyQuery }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { signature, timestamp, nonce, echostr } = request.query;
|
||||
|
||||
logger.info('📥 收到微信服务号 URL 验证请求', {
|
||||
timestamp,
|
||||
nonce,
|
||||
echostrLength: echostr?.length,
|
||||
});
|
||||
|
||||
// 验证签名:signature = sha1(sort(token, timestamp, nonce))
|
||||
const isValid = this.verifySignature(signature, timestamp, nonce);
|
||||
if (!isValid) {
|
||||
logger.error('❌ 签名验证失败');
|
||||
reply.code(403).send('Signature verification failed');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('✅ URL 验证成功,返回 echostr');
|
||||
|
||||
// 返回 echostr 原文(微信服务号 URL 验证时是明文)
|
||||
reply.type('text/plain').send(echostr);
|
||||
} catch (error: any) {
|
||||
logger.error('❌ URL 验证异常', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
reply.code(500).send('Verification failed');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 消息接收(POST) ====================
|
||||
|
||||
/**
|
||||
* 接收微信服务号回调消息
|
||||
*
|
||||
* 关键:异步回复模式
|
||||
* 1. 立即返回 "success" 或加密的空消息(告诉微信收到了)
|
||||
* 2. 使用 setImmediate 异步处理消息
|
||||
* 3. 处理完成后,使用客服消息 API 主动推送回复(推荐)
|
||||
*/
|
||||
async handleCallback(
|
||||
request: FastifyRequest<{
|
||||
Querystring: WechatMpCallbackQuery;
|
||||
Body: string;
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { msg_signature, timestamp, nonce, encrypt_type } = request.query;
|
||||
const body = request.body;
|
||||
|
||||
logger.info('📥 收到微信服务号回调消息', {
|
||||
timestamp,
|
||||
nonce,
|
||||
encryptType: encrypt_type,
|
||||
bodyLength: typeof body === 'string' ? body.length : 0,
|
||||
});
|
||||
|
||||
// ⚠️ 关键:立即返回 "success"(规避 5 秒超时)
|
||||
reply.type('text/plain').send('success');
|
||||
|
||||
// 异步处理消息(不阻塞响应)
|
||||
setImmediate(() => {
|
||||
this.processMessageAsync(body, msg_signature, timestamp, nonce).catch((error) => {
|
||||
logger.error('❌ 异步处理消息失败', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('❌ 处理回调异常', {
|
||||
error: error.message,
|
||||
});
|
||||
// 即使异常,也返回 success(避免微信重试)
|
||||
reply.type('text/plain').send('success');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 异步消息处理 ====================
|
||||
|
||||
/**
|
||||
* 异步处理消息
|
||||
*
|
||||
* 1. 解析 XML
|
||||
* 2. 解密消息体(安全模式)
|
||||
* 3. 提取用户消息
|
||||
* 4. 根据消息类型分发处理
|
||||
* 5. 记录到数据库
|
||||
*/
|
||||
private async processMessageAsync(
|
||||
body: string,
|
||||
msgSignature: string | undefined,
|
||||
timestamp: string,
|
||||
nonce: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info('🔄 开始异步处理微信服务号消息...');
|
||||
|
||||
// 1. 解析 XML
|
||||
const xml = (await parseStringPromise(body, {
|
||||
explicitArray: false,
|
||||
})) as WechatMessageXml;
|
||||
|
||||
logger.debug('📝 解析XML成功', { xml });
|
||||
|
||||
// 2. 判断是否为加密消息
|
||||
const encryptedMsg = xml.xml.Encrypt;
|
||||
let messageXml: WechatMessageXml;
|
||||
|
||||
if (encryptedMsg && msgSignature) {
|
||||
// 加密模式:解密消息
|
||||
logger.info('🔐 检测到加密消息,开始解密...');
|
||||
|
||||
// 处理可能的数组或字符串
|
||||
const encryptStr = Array.isArray(encryptedMsg) ? encryptedMsg[0] : encryptedMsg;
|
||||
|
||||
// 验证签名
|
||||
const isValid = this.verifyMsgSignature(msgSignature, timestamp, nonce, encryptStr);
|
||||
if (!isValid) {
|
||||
logger.error('❌ 消息签名验证失败');
|
||||
return;
|
||||
}
|
||||
|
||||
// 解密消息
|
||||
const decryptedResult = decrypt(this.encodingAESKey, encryptStr);
|
||||
messageXml = (await parseStringPromise(decryptedResult.message, {
|
||||
explicitArray: false,
|
||||
})) as WechatMessageXml;
|
||||
|
||||
logger.info('✅ 消息解密成功');
|
||||
} else {
|
||||
// 明文模式:直接使用
|
||||
messageXml = xml;
|
||||
logger.info('📄 明文消息,直接处理');
|
||||
}
|
||||
|
||||
// 3. 提取消息信息
|
||||
const userMessage = this.extractUserMessage(messageXml);
|
||||
if (!userMessage) {
|
||||
logger.warn('⚠️ 无法提取用户消息');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('📬 提取用户消息成功', {
|
||||
fromUser: userMessage.fromUser,
|
||||
msgType: userMessage.msgType,
|
||||
event: userMessage.event,
|
||||
contentLength: userMessage.content?.length || 0,
|
||||
});
|
||||
|
||||
// 4. 根据消息类型分发处理
|
||||
await this.dispatchMessage(userMessage);
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('❌ 异步处理消息异常', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 消息分发处理 ====================
|
||||
|
||||
/**
|
||||
* 根据消息类型分发处理
|
||||
*/
|
||||
private async dispatchMessage(message: UserMessage): Promise<void> {
|
||||
try {
|
||||
// 1. 处理事件类型消息
|
||||
if (message.msgType === 'event') {
|
||||
await this.handleEventMessage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 处理文本消息
|
||||
if (message.msgType === 'text') {
|
||||
await this.handleTextMessage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 其他类型消息(图片、语音等)
|
||||
logger.info('📩 收到其他类型消息', {
|
||||
msgType: message.msgType,
|
||||
fromUser: message.fromUser,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('❌ 消息分发处理失败', {
|
||||
error: error.message,
|
||||
msgType: message.msgType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理事件消息
|
||||
*/
|
||||
private async handleEventMessage(message: UserMessage): Promise<void> {
|
||||
const { event, fromUser, eventKey } = message;
|
||||
|
||||
logger.info('🎯 处理事件消息', {
|
||||
event,
|
||||
fromUser,
|
||||
eventKey,
|
||||
});
|
||||
|
||||
// 关注事件
|
||||
if (event === 'subscribe') {
|
||||
logger.info('👤 用户关注公众号', { fromUser });
|
||||
|
||||
// TODO: 发送欢迎消息,引导用户绑定
|
||||
// await patientWechatService.sendWelcomeMessage(fromUser);
|
||||
|
||||
// 记录到数据库
|
||||
await this.logUserAction(fromUser, 'subscribe', {
|
||||
event,
|
||||
eventKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 取消关注事件
|
||||
if (event === 'unsubscribe') {
|
||||
logger.info('👋 用户取消关注公众号', { fromUser });
|
||||
|
||||
// 记录到数据库
|
||||
await this.logUserAction(fromUser, 'unsubscribe', {
|
||||
event,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 菜单点击事件
|
||||
if (event === 'CLICK') {
|
||||
logger.info('🖱️ 用户点击菜单', { fromUser, eventKey });
|
||||
|
||||
// TODO: 根据 eventKey 处理不同菜单点击
|
||||
// 例如:eventKey = 'BIND_PATIENT' -> 引导患者绑定
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 其他事件
|
||||
logger.info('📋 其他事件类型', { event, fromUser });
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文本消息
|
||||
*/
|
||||
private async handleTextMessage(message: UserMessage): Promise<void> {
|
||||
const { fromUser, content } = message;
|
||||
|
||||
logger.info('💬 处理文本消息', {
|
||||
fromUser,
|
||||
content: content.substring(0, 50), // 只记录前50字符
|
||||
});
|
||||
|
||||
// 记录到数据库
|
||||
await this.logUserAction(fromUser, 'send_message', {
|
||||
content,
|
||||
msgId: message.msgId,
|
||||
});
|
||||
|
||||
// TODO: 实现智能回复
|
||||
// 1. 检查用户是否已绑定患者记录
|
||||
// 2. 如果未绑定,引导绑定
|
||||
// 3. 如果已绑定,调用 ChatService 处理对话(类似企业微信)
|
||||
// 4. 使用客服消息 API 回复
|
||||
|
||||
logger.info('📝 文本消息已记录,等待后续智能回复实现');
|
||||
}
|
||||
|
||||
// ==================== 辅助方法 ====================
|
||||
|
||||
/**
|
||||
* 验证签名(URL 验证时使用)
|
||||
* signature = sha1(sort(token, timestamp, nonce))
|
||||
*/
|
||||
private verifySignature(
|
||||
signature: string,
|
||||
timestamp: string,
|
||||
nonce: string
|
||||
): boolean {
|
||||
try {
|
||||
const arr = [this.token, timestamp, nonce].sort();
|
||||
const str = arr.join('');
|
||||
const hash = crypto.createHash('sha1').update(str).digest('hex');
|
||||
|
||||
const isValid = hash === signature;
|
||||
|
||||
logger.debug('🔍 验证签名', {
|
||||
expected: signature,
|
||||
calculated: hash,
|
||||
isValid,
|
||||
});
|
||||
|
||||
return isValid;
|
||||
} catch (error: any) {
|
||||
logger.error('❌ 签名验证异常', { error: error.message });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证消息签名(消息接收时使用)
|
||||
* msg_signature = sha1(sort(token, timestamp, nonce, encrypt))
|
||||
*/
|
||||
private verifyMsgSignature(
|
||||
msgSignature: string,
|
||||
timestamp: string,
|
||||
nonce: string,
|
||||
encrypt: string
|
||||
): boolean {
|
||||
try {
|
||||
const arr = [this.token, timestamp, nonce, encrypt].sort();
|
||||
const str = arr.join('');
|
||||
const hash = crypto.createHash('sha1').update(str).digest('hex');
|
||||
|
||||
const isValid = hash === msgSignature;
|
||||
|
||||
logger.debug('🔍 验证消息签名', {
|
||||
expected: msgSignature,
|
||||
calculated: hash,
|
||||
isValid,
|
||||
});
|
||||
|
||||
return isValid;
|
||||
} catch (error: any) {
|
||||
logger.error('❌ 消息签名验证异常', { error: error.message });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取用户消息
|
||||
*/
|
||||
private extractUserMessage(xml: WechatMessageXml): UserMessage | null {
|
||||
try {
|
||||
const msgData = xml.xml;
|
||||
|
||||
// 确保必要字段存在
|
||||
if (!msgData.FromUserName || !msgData.ToUserName || !msgData.MsgType) {
|
||||
logger.warn('⚠️ 消息缺少必要字段', { msgData });
|
||||
return null;
|
||||
}
|
||||
|
||||
// 提取字段(注意:explicitArray: false 时不是数组)
|
||||
const fromUser = Array.isArray(msgData.FromUserName)
|
||||
? msgData.FromUserName[0]
|
||||
: msgData.FromUserName;
|
||||
const toUser = Array.isArray(msgData.ToUserName)
|
||||
? msgData.ToUserName[0]
|
||||
: msgData.ToUserName;
|
||||
const msgType = Array.isArray(msgData.MsgType)
|
||||
? msgData.MsgType[0]
|
||||
: msgData.MsgType;
|
||||
const content = Array.isArray(msgData.Content)
|
||||
? msgData.Content[0]
|
||||
: (msgData.Content || '');
|
||||
const msgId = Array.isArray(msgData.MsgId)
|
||||
? msgData.MsgId[0]
|
||||
: (msgData.MsgId || '');
|
||||
const createTime = Array.isArray(msgData.CreateTime)
|
||||
? parseInt(msgData.CreateTime[0])
|
||||
: (parseInt(msgData.CreateTime as any) || Date.now() / 1000);
|
||||
|
||||
// 事件类型字段(可选)
|
||||
const event = msgData.Event
|
||||
? (Array.isArray(msgData.Event) ? msgData.Event[0] : msgData.Event)
|
||||
: undefined;
|
||||
const eventKey = msgData.EventKey
|
||||
? (Array.isArray(msgData.EventKey) ? msgData.EventKey[0] : msgData.EventKey)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
fromUser,
|
||||
toUser,
|
||||
msgType,
|
||||
content,
|
||||
msgId,
|
||||
createTime,
|
||||
event,
|
||||
eventKey,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error('❌ 提取用户消息失败', {
|
||||
error: error.message,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录用户操作到数据库
|
||||
*/
|
||||
private async logUserAction(
|
||||
openid: string,
|
||||
actionType: string,
|
||||
actionData: any
|
||||
): Promise<void> {
|
||||
try {
|
||||
// TODO: 实现数据库记录
|
||||
// 需要先创建 patient_wechat_bindings 和 patient_actions 表
|
||||
logger.info('📝 记录用户操作', {
|
||||
openid,
|
||||
actionType,
|
||||
dataKeys: Object.keys(actionData),
|
||||
});
|
||||
|
||||
// 临时实现:记录到日志
|
||||
// 正式实现:存储到 iit_schema.patient_actions 表
|
||||
} catch (error: any) {
|
||||
logger.error('❌ 记录用户操作失败', {
|
||||
error: error.message,
|
||||
openid,
|
||||
actionType,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const patientWechatCallbackController = new PatientWechatCallbackController();
|
||||
|
||||
532
backend/src/modules/iit-manager/docs/微信服务号接入指南.md
Normal file
532
backend/src/modules/iit-manager/docs/微信服务号接入指南.md
Normal file
@@ -0,0 +1,532 @@
|
||||
# 微信服务号接入指南(患者端)
|
||||
|
||||
> **版本**: v1.0
|
||||
> **创建日期**: 2026-01-04
|
||||
> **目标**: 为患者端接入微信服务号,实现访视提醒和消息推送
|
||||
> **预估工作量**: 3天
|
||||
|
||||
---
|
||||
|
||||
## 📋 一、准备工作清单
|
||||
|
||||
### 1.1 微信服务号信息
|
||||
|
||||
✅ **已完成**:
|
||||
- 服务号名称:`AI for 临床研究`
|
||||
- AppID:`wx062568ff49e4570c`
|
||||
- AppSecret:`c0d19435d1a1e948939c16d767ec0faf`
|
||||
- 认证状态:✅ 已认证(企业认证)
|
||||
- 主体名称:`北京壹证循科技有限公司`
|
||||
|
||||
### 1.2 需要配置的参数
|
||||
|
||||
🔧 **待配置**:
|
||||
1. **Token**(3-32位字符串)
|
||||
2. **EncodingAESKey**(43位字符串)
|
||||
3. **服务器URL**(回调地址)
|
||||
|
||||
---
|
||||
|
||||
## 🔐 二、生成Token和EncodingAESKey
|
||||
|
||||
### 2.1 Token生成(推荐使用随机字符串)
|
||||
|
||||
```bash
|
||||
# 方法1:使用OpenSSL(推荐)
|
||||
openssl rand -base64 24 | tr -d '/+=' | cut -c1-32
|
||||
|
||||
# 方法2:使用Node.js
|
||||
node -e "console.log(require('crypto').randomBytes(24).toString('base64').replace(/[\/\+=]/g, '').substring(0, 32))"
|
||||
|
||||
# 方法3:在线生成器
|
||||
# https://suijimimashengcheng.51240.com/
|
||||
```
|
||||
|
||||
**推荐Token**(示例,请重新生成):
|
||||
```
|
||||
IitPatientWechat2026Jan04Abc
|
||||
```
|
||||
|
||||
### 2.2 EncodingAESKey生成(必须43位)
|
||||
|
||||
```bash
|
||||
# 方法1:使用OpenSSL(推荐)
|
||||
openssl rand -base64 43 | tr -d '/+=' | head -c 43
|
||||
|
||||
# 方法2:使用Node.js
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('base64').replace(/[\/\+=]/g, '').substring(0, 43))"
|
||||
|
||||
# 方法3:微信公众平台随机生成(最简单)
|
||||
# 登录微信公众平台 → 基本配置 → 消息加密密钥 → 点击"随机生成"
|
||||
```
|
||||
|
||||
**推荐EncodingAESKey**(示例,请重新生成):
|
||||
```
|
||||
abcdefghijklmnopqrstuvwxyz0123456789ABC
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 三、配置步骤(详细)
|
||||
|
||||
### 3.1 更新环境变量
|
||||
|
||||
编辑 `backend/.env` 文件,添加以下配置:
|
||||
|
||||
```env
|
||||
# ==========================================
|
||||
# 微信服务号配置(患者端)
|
||||
# ==========================================
|
||||
|
||||
# 微信服务号基础配置
|
||||
WECHAT_MP_APP_ID=wx062568ff49e4570c
|
||||
WECHAT_MP_APP_SECRET=c0d19435d1a1e948939c16d767ec0faf
|
||||
|
||||
# 微信服务号回调配置(消息加解密,安全模式)
|
||||
WECHAT_MP_TOKEN=IitPatientWechat2026Jan04Abc
|
||||
WECHAT_MP_ENCODING_AES_KEY=abcdefghijklmnopqrstuvwxyz0123456789ABC
|
||||
|
||||
# 微信小程序配置(可选,后续开发)
|
||||
WECHAT_MINI_APP_ID=
|
||||
```
|
||||
|
||||
⚠️ **注意**:
|
||||
1. Token和EncodingAESKey必须与微信公众平台配置的**完全一致**
|
||||
2. Token长度:3-32位,建议使用英文字母和数字
|
||||
3. EncodingAESKey长度:**必须43位**,大小写敏感
|
||||
|
||||
### 3.2 配置微信公众平台
|
||||
|
||||
#### Step 1: 登录微信公众平台
|
||||
|
||||
访问:https://mp.weixin.qq.com/
|
||||
|
||||
使用管理员微信扫码登录(账号:`zhi***ng`)
|
||||
|
||||
#### Step 2: 进入基本配置页面
|
||||
|
||||
```
|
||||
左侧菜单 → 设置与开发 → 基本配置
|
||||
```
|
||||
|
||||
#### Step 3: 配置服务器地址
|
||||
|
||||
找到 **"服务器配置"** 部分,点击 **"修改配置"**
|
||||
|
||||
填写以下信息:
|
||||
|
||||
| 配置项 | 值 | 说明 |
|
||||
|--------|-----|------|
|
||||
| **URL** | `https://iit.xunzhengyixue.com/api/v1/iit/patient-wechat/callback` | 生产环境回调URL |
|
||||
| **Token** | `IitPatientWechat2026Jan04Abc` | 与.env中的WECHAT_MP_TOKEN一致 |
|
||||
| **EncodingAESKey** | `abcdefghijklmnopqrstuvwxyz0123456789ABC` | 与.env中的WECHAT_MP_ENCODING_AES_KEY一致 |
|
||||
| **消息加解密方式** | ✅ **安全模式(推荐)** | 选择"安全模式" |
|
||||
| **数据格式** | ✅ **XML** | 默认选择 |
|
||||
|
||||
**本地开发环境**(使用natapp内网穿透):
|
||||
```
|
||||
URL: https://devlocal.xunzhengyixue.com/api/v1/iit/patient-wechat/callback
|
||||
```
|
||||
|
||||
#### Step 4: 点击"提交"并验证
|
||||
|
||||
微信会发送GET请求到您的服务器进行验证:
|
||||
|
||||
```
|
||||
GET https://iit.xunzhengyixue.com/api/v1/iit/patient-wechat/callback?signature=xxx×tamp=xxx&nonce=xxx&echostr=xxx
|
||||
```
|
||||
|
||||
**验证成功标志**:
|
||||
- ✅ 页面显示"配置成功"
|
||||
- ✅ "服务器配置"状态为"已启用"
|
||||
|
||||
**验证失败原因**:
|
||||
- ❌ Token不一致
|
||||
- ❌ 服务器无法访问(防火墙、未部署)
|
||||
- ❌ 代码逻辑错误(签名验证失败)
|
||||
|
||||
#### Step 5: 启用服务器配置
|
||||
|
||||
验证成功后,点击 **"启用"** 按钮。
|
||||
|
||||
⚠️ **注意**:启用后,公众号的消息和事件会推送到您的服务器,不会显示在公众平台后台。
|
||||
|
||||
---
|
||||
|
||||
## 🧪 四、测试验证
|
||||
|
||||
### 4.1 测试URL验证(手动测试)
|
||||
|
||||
在配置微信公众平台时,点击"提交"按钮会自动触发URL验证。
|
||||
|
||||
查看后端日志:
|
||||
|
||||
```bash
|
||||
# 本地开发
|
||||
cd D:\MyCursor\AIclinicalresearch\backend
|
||||
npm run dev
|
||||
|
||||
# 查看日志
|
||||
# 应该看到类似以下日志:
|
||||
# ✅ 微信服务号回调控制器初始化成功
|
||||
# 📥 收到微信服务号 URL 验证请求
|
||||
# ✅ URL 验证成功,返回 echostr
|
||||
```
|
||||
|
||||
### 4.2 测试脚本1:验证Token和AESKey配置
|
||||
|
||||
创建测试脚本 `backend/src/modules/iit-manager/test-patient-wechat-config.ts`:
|
||||
|
||||
```typescript
|
||||
import dotenv from 'dotenv';
|
||||
import crypto from 'crypto';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
console.log('🔧 微信服务号配置检查\n');
|
||||
|
||||
// 1. 检查必需的环境变量
|
||||
const requiredEnvs = [
|
||||
'WECHAT_MP_APP_ID',
|
||||
'WECHAT_MP_APP_SECRET',
|
||||
'WECHAT_MP_TOKEN',
|
||||
'WECHAT_MP_ENCODING_AES_KEY',
|
||||
];
|
||||
|
||||
let hasError = false;
|
||||
|
||||
requiredEnvs.forEach((key) => {
|
||||
const value = process.env[key];
|
||||
if (!value) {
|
||||
console.error(`❌ 缺少环境变量: ${key}`);
|
||||
hasError = true;
|
||||
} else {
|
||||
console.log(`✅ ${key}: ${value.substring(0, 10)}... (长度: ${value.length})`);
|
||||
}
|
||||
});
|
||||
|
||||
if (hasError) {
|
||||
console.error('\n❌ 配置不完整,请检查 .env 文件');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. 验证Token长度
|
||||
const token = process.env.WECHAT_MP_TOKEN!;
|
||||
if (token.length < 3 || token.length > 32) {
|
||||
console.error(`\n❌ Token长度不正确: ${token.length}位(应为3-32位)`);
|
||||
hasError = true;
|
||||
} else {
|
||||
console.log(`\n✅ Token长度正确: ${token.length}位`);
|
||||
}
|
||||
|
||||
// 3. 验证EncodingAESKey长度
|
||||
const aesKey = process.env.WECHAT_MP_ENCODING_AES_KEY!;
|
||||
if (aesKey.length !== 43) {
|
||||
console.error(`❌ EncodingAESKey长度不正确: ${aesKey.length}位(必须43位)`);
|
||||
hasError = true;
|
||||
} else {
|
||||
console.log(`✅ EncodingAESKey长度正确: 43位`);
|
||||
}
|
||||
|
||||
// 4. 测试签名生成
|
||||
console.log('\n🔐 测试签名生成...');
|
||||
const timestamp = Date.now().toString();
|
||||
const nonce = Math.random().toString(36).substring(2);
|
||||
const arr = [token, timestamp, nonce].sort();
|
||||
const str = arr.join('');
|
||||
const signature = crypto.createHash('sha1').update(str).digest('hex');
|
||||
|
||||
console.log(`生成的签名: ${signature}`);
|
||||
console.log(`✅ 签名生成功能正常`);
|
||||
|
||||
// 5. 总结
|
||||
if (hasError) {
|
||||
console.error('\n❌ 配置检查失败,请修复错误后重试');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\n✅ 配置检查通过!可以开始配置微信公众平台');
|
||||
console.log('\n📋 配置信息(用于微信公众平台):');
|
||||
console.log(`URL: https://iit.xunzhengyixue.com/api/v1/iit/patient-wechat/callback`);
|
||||
console.log(`Token: ${token}`);
|
||||
console.log(`EncodingAESKey: ${aesKey}`);
|
||||
console.log(`消息加解密方式: 安全模式(推荐)`);
|
||||
}
|
||||
```
|
||||
|
||||
**运行测试**:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npx tsx src/modules/iit-manager/test-patient-wechat-config.ts
|
||||
```
|
||||
|
||||
### 4.3 测试脚本2:模拟微信URL验证请求
|
||||
|
||||
创建测试脚本 `backend/src/modules/iit-manager/test-patient-wechat-url-verify.ts`:
|
||||
|
||||
```typescript
|
||||
import axios from 'axios';
|
||||
import crypto from 'crypto';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const BASE_URL = 'http://localhost:3001';
|
||||
const TOKEN = process.env.WECHAT_MP_TOKEN || '';
|
||||
|
||||
async function testUrlVerification() {
|
||||
console.log('🧪 测试微信服务号URL验证\n');
|
||||
|
||||
// 1. 准备参数
|
||||
const timestamp = Date.now().toString();
|
||||
const nonce = Math.random().toString(36).substring(2, 12);
|
||||
const echostr = 'test_echo_' + Math.random().toString(36).substring(2);
|
||||
|
||||
// 2. 生成签名
|
||||
const arr = [TOKEN, timestamp, nonce].sort();
|
||||
const str = arr.join('');
|
||||
const signature = crypto.createHash('sha1').update(str).digest('hex');
|
||||
|
||||
console.log('📝 请求参数:');
|
||||
console.log(` timestamp: ${timestamp}`);
|
||||
console.log(` nonce: ${nonce}`);
|
||||
console.log(` echostr: ${echostr}`);
|
||||
console.log(` signature: ${signature}\n`);
|
||||
|
||||
// 3. 发送GET请求
|
||||
try {
|
||||
const url = `${BASE_URL}/api/v1/iit/patient-wechat/callback`;
|
||||
const response = await axios.get(url, {
|
||||
params: {
|
||||
signature,
|
||||
timestamp,
|
||||
nonce,
|
||||
echostr,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ URL验证成功!');
|
||||
console.log(`返回内容: ${response.data}`);
|
||||
|
||||
if (response.data === echostr) {
|
||||
console.log('✅ 返回的echostr正确');
|
||||
} else {
|
||||
console.error('❌ 返回的echostr不正确');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('❌ URL验证失败:', error.message);
|
||||
if (error.response) {
|
||||
console.error('响应状态:', error.response.status);
|
||||
console.error('响应内容:', error.response.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
testUrlVerification();
|
||||
```
|
||||
|
||||
**运行测试**:
|
||||
|
||||
```bash
|
||||
# 先启动后端服务
|
||||
npm run dev
|
||||
|
||||
# 新开一个终端,运行测试
|
||||
npx tsx src/modules/iit-manager/test-patient-wechat-url-verify.ts
|
||||
```
|
||||
|
||||
### 4.4 测试关注事件
|
||||
|
||||
1. 用测试微信号关注公众号:`AI for 临床研究`
|
||||
2. 查看后端日志,应该看到:
|
||||
|
||||
```
|
||||
📥 收到微信服务号回调消息
|
||||
🔐 检测到加密消息,开始解密...
|
||||
✅ 消息解密成功
|
||||
📬 提取用户消息成功
|
||||
🎯 处理事件消息: subscribe
|
||||
👤 用户关注公众号: oXXXXXXXXXXXXXXXXX
|
||||
```
|
||||
|
||||
### 4.5 测试文本消息
|
||||
|
||||
1. 在公众号对话框发送文本消息:`你好`
|
||||
2. 查看后端日志,应该看到:
|
||||
|
||||
```
|
||||
📥 收到微信服务号回调消息
|
||||
🔐 检测到加密消息,开始解密...
|
||||
✅ 消息解密成功
|
||||
💬 处理文本消息: 你好
|
||||
📝 文本消息已记录
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 五、部署上线
|
||||
|
||||
### 5.1 本地开发环境(natapp内网穿透)
|
||||
|
||||
**1. 启动natapp**:
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
cd D:\tools\natapp
|
||||
natapp.exe -authtoken=YOUR_TOKEN -subdomain=devlocal
|
||||
```
|
||||
|
||||
**2. 验证映射**:
|
||||
|
||||
访问:`https://devlocal.xunzhengyixue.com/api/v1/iit/health`
|
||||
|
||||
应该返回:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"module": "iit-manager",
|
||||
"version": "1.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
**3. 配置微信公众平台**:
|
||||
|
||||
```
|
||||
URL: https://devlocal.xunzhengyixue.com/api/v1/iit/patient-wechat/callback
|
||||
```
|
||||
|
||||
### 5.2 生产环境(SAE)
|
||||
|
||||
**1. 更新SAE环境变量**:
|
||||
|
||||
登录阿里云SAE控制台 → 应用管理 → 环境变量配置
|
||||
|
||||
添加以下环境变量:
|
||||
```
|
||||
WECHAT_MP_APP_ID=wx062568ff49e4570c
|
||||
WECHAT_MP_APP_SECRET=c0d19435d1a1e948939c16d767ec0faf
|
||||
WECHAT_MP_TOKEN=IitPatientWechat2026Jan04Abc
|
||||
WECHAT_MP_ENCODING_AES_KEY=abcdefghijklmnopqrstuvwxyz0123456789ABC
|
||||
```
|
||||
|
||||
**2. 部署代码**:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
./deploy-to-sae.ps1
|
||||
```
|
||||
|
||||
**3. 验证部署**:
|
||||
|
||||
访问:`https://iit.xunzhengyixue.com/api/v1/iit/health`
|
||||
|
||||
**4. 配置微信公众平台**:
|
||||
|
||||
```
|
||||
URL: https://iit.xunzhengyixue.com/api/v1/iit/patient-wechat/callback
|
||||
```
|
||||
|
||||
**5. 配置IP白名单**(重要):
|
||||
|
||||
登录微信公众平台 → 基本配置 → IP白名单
|
||||
|
||||
添加SAE应用的出口IP(可以从SAE控制台查看)
|
||||
|
||||
---
|
||||
|
||||
## 📋 六、常见问题排查
|
||||
|
||||
### Q1: URL验证失败,提示"Token验证失败"
|
||||
|
||||
**原因**:
|
||||
- Token配置不一致(大小写、多余空格)
|
||||
- 签名计算错误
|
||||
|
||||
**解决方法**:
|
||||
1. 检查 `.env` 文件中的 `WECHAT_MP_TOKEN` 是否与微信公众平台配置一致
|
||||
2. 运行配置检查脚本:`npx tsx src/modules/iit-manager/test-patient-wechat-config.ts`
|
||||
3. 查看后端日志,确认签名计算过程
|
||||
|
||||
### Q2: URL验证失败,提示"请求URL超时"
|
||||
|
||||
**原因**:
|
||||
- 服务器未启动
|
||||
- 防火墙阻止
|
||||
- URL配置错误
|
||||
|
||||
**解决方法**:
|
||||
1. 确认后端服务已启动:`npm run dev`
|
||||
2. 本地开发确认natapp已启动
|
||||
3. 生产环境确认SAE应用状态正常
|
||||
4. 使用浏览器直接访问健康检查接口测试连通性
|
||||
|
||||
### Q3: 消息解密失败
|
||||
|
||||
**原因**:
|
||||
- EncodingAESKey配置不一致
|
||||
- EncodingAESKey长度不正确(必须43位)
|
||||
|
||||
**解决方法**:
|
||||
1. 检查 `.env` 文件中的 `WECHAT_MP_ENCODING_AES_KEY` 长度是否为43位
|
||||
2. 确认与微信公众平台配置完全一致(包括大小写)
|
||||
3. 重新生成EncodingAESKey并同步更新
|
||||
|
||||
### Q4: 收不到用户消息
|
||||
|
||||
**原因**:
|
||||
- 服务器配置未启用
|
||||
- 回调URL配置错误
|
||||
- 服务端代码异常
|
||||
|
||||
**解决方法**:
|
||||
1. 登录微信公众平台,确认"服务器配置"状态为"已启用"
|
||||
2. 查看后端日志,确认是否收到POST请求
|
||||
3. 检查是否有异常日志
|
||||
|
||||
---
|
||||
|
||||
## 📝 七、后续开发计划
|
||||
|
||||
### Phase 1: 基础消息推送(当前)
|
||||
|
||||
- [x] 创建PatientWechatCallbackController
|
||||
- [x] 创建PatientWechatService
|
||||
- [x] 配置路由和环境变量
|
||||
- [ ] 测试URL验证
|
||||
- [ ] 测试消息接收
|
||||
|
||||
### Phase 2: 患者绑定功能
|
||||
|
||||
- [ ] 创建患者绑定数据表
|
||||
- [ ] 开发患者绑定H5页面
|
||||
- [ ] 实现手机号验证码功能
|
||||
- [ ] 实现患者身份验证逻辑
|
||||
|
||||
### Phase 3: 模板消息推送
|
||||
|
||||
- [ ] 申请模板消息权限
|
||||
- [ ] 设计访视提醒模板
|
||||
- [ ] 开发定时任务检测到期访视
|
||||
- [ ] 实现模板消息推送
|
||||
|
||||
### Phase 4: 微信小程序
|
||||
|
||||
- [ ] 注册微信小程序
|
||||
- [ ] 搭建小程序框架
|
||||
- [ ] 开发核心页面
|
||||
- [ ] 前后端联调
|
||||
|
||||
---
|
||||
|
||||
## 📞 联系方式
|
||||
|
||||
如有问题,请联系:
|
||||
- 技术负责人:冯志博
|
||||
- 邮箱:gofeng117@163.com
|
||||
- 微信:aiforresearch
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-01-04
|
||||
**文档版本**:v1.0
|
||||
|
||||
167
backend/src/modules/iit-manager/generate-wechat-tokens.ts
Normal file
167
backend/src/modules/iit-manager/generate-wechat-tokens.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 生成微信服务号Token和EncodingAESKey
|
||||
*
|
||||
* 功能:
|
||||
* 1. 生成符合要求的Token(3-32位)
|
||||
* 2. 生成符合要求的EncodingAESKey(43位)
|
||||
* 3. 输出可直接复制的环境变量配置
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
console.log('🔐 微信服务号Token和EncodingAESKey生成器');
|
||||
console.log('='.repeat(60));
|
||||
console.log('');
|
||||
|
||||
// ==================== 1. 生成Token ====================
|
||||
|
||||
console.log('🔑 生成Token(用于签名验证)...\n');
|
||||
|
||||
function generateToken(length: number = 32): string {
|
||||
// 生成随机字符串(只包含字母和数字)
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let token = '';
|
||||
const randomBytes = crypto.randomBytes(length);
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
token += chars[randomBytes[i] % chars.length];
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
const token = generateToken(32);
|
||||
|
||||
console.log(' 生成的Token:');
|
||||
console.log(` ${token}`);
|
||||
console.log(` 长度: ${token.length}位`);
|
||||
console.log(` 说明: 用于URL验证和消息签名验证`);
|
||||
console.log('');
|
||||
|
||||
// ==================== 2. 生成EncodingAESKey ====================
|
||||
|
||||
console.log('🔐 生成EncodingAESKey(用于消息加解密)...\n');
|
||||
|
||||
function generateEncodingAESKey(): string {
|
||||
// 生成43位随机字符串(Base64字符集,不包含/+=)
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let aesKey = '';
|
||||
const randomBytes = crypto.randomBytes(43);
|
||||
|
||||
for (let i = 0; i < 43; i++) {
|
||||
aesKey += chars[randomBytes[i] % chars.length];
|
||||
}
|
||||
|
||||
return aesKey;
|
||||
}
|
||||
|
||||
const aesKey = generateEncodingAESKey();
|
||||
|
||||
console.log(' 生成的EncodingAESKey:');
|
||||
console.log(` ${aesKey}`);
|
||||
console.log(` 长度: ${aesKey.length}位`);
|
||||
console.log(` 说明: 用于消息加密和解密(安全模式必需)`);
|
||||
console.log('');
|
||||
|
||||
// ==================== 3. 输出环境变量配置 ====================
|
||||
|
||||
console.log('='.repeat(60));
|
||||
console.log('\n📋 环境变量配置(复制以下内容到 backend/.env 文件)\n');
|
||||
console.log('='.repeat(60));
|
||||
console.log('');
|
||||
|
||||
console.log('# ==========================================');
|
||||
console.log('# 微信服务号配置(患者端)');
|
||||
console.log('# ==========================================');
|
||||
console.log('');
|
||||
console.log('# 微信服务号基础配置');
|
||||
console.log('WECHAT_MP_APP_ID=wx062568ff49e4570c');
|
||||
console.log('WECHAT_MP_APP_SECRET=c0d19435d1a1e948939c16d767ec0faf');
|
||||
console.log('');
|
||||
console.log('# 微信服务号回调配置(消息加解密,安全模式)');
|
||||
console.log(`WECHAT_MP_TOKEN=${token}`);
|
||||
console.log(`WECHAT_MP_ENCODING_AES_KEY=${aesKey}`);
|
||||
console.log('');
|
||||
console.log('# 微信小程序配置(可选,后续开发)');
|
||||
console.log('WECHAT_MINI_APP_ID=');
|
||||
console.log('');
|
||||
|
||||
// ==================== 4. 输出微信公众平台配置 ====================
|
||||
|
||||
console.log('='.repeat(60));
|
||||
console.log('\n📋 微信公众平台配置(复制以下内容到公众平台)\n');
|
||||
console.log('='.repeat(60));
|
||||
console.log('');
|
||||
|
||||
console.log('登录地址:https://mp.weixin.qq.com/');
|
||||
console.log('配置路径:设置与开发 → 基本配置 → 服务器配置');
|
||||
console.log('');
|
||||
console.log('配置参数:');
|
||||
console.log('');
|
||||
console.log(' 【URL】');
|
||||
console.log(' 生产环境:https://iit.xunzhengyixue.com/api/v1/iit/patient-wechat/callback');
|
||||
console.log(' 开发环境:https://devlocal.xunzhengyixue.com/api/v1/iit/patient-wechat/callback');
|
||||
console.log('');
|
||||
console.log(' 【Token】');
|
||||
console.log(` ${token}`);
|
||||
console.log('');
|
||||
console.log(' 【EncodingAESKey】');
|
||||
console.log(` ${aesKey}`);
|
||||
console.log('');
|
||||
console.log(' 【消息加解密方式】');
|
||||
console.log(' 安全模式(推荐)');
|
||||
console.log('');
|
||||
console.log(' 【数据格式】');
|
||||
console.log(' XML');
|
||||
console.log('');
|
||||
|
||||
// ==================== 5. 输出后续步骤 ====================
|
||||
|
||||
console.log('='.repeat(60));
|
||||
console.log('\n📝 后续步骤\n');
|
||||
console.log('='.repeat(60));
|
||||
console.log('');
|
||||
|
||||
console.log('Step 1: 更新环境变量');
|
||||
console.log(' 1. 复制上面的环境变量配置');
|
||||
console.log(' 2. 粘贴到 backend/.env 文件');
|
||||
console.log(' 3. 保存文件');
|
||||
console.log('');
|
||||
|
||||
console.log('Step 2: 验证配置');
|
||||
console.log(' 运行配置检查脚本:');
|
||||
console.log(' npx tsx src/modules/iit-manager/test-patient-wechat-config.ts');
|
||||
console.log('');
|
||||
|
||||
console.log('Step 3: 启动服务');
|
||||
console.log(' 本地开发:npm run dev');
|
||||
console.log(' 生产环境:部署到SAE');
|
||||
console.log('');
|
||||
|
||||
console.log('Step 4: 配置微信公众平台');
|
||||
console.log(' 1. 登录微信公众平台');
|
||||
console.log(' 2. 填写上面的配置参数');
|
||||
console.log(' 3. 点击"提交"进行URL验证');
|
||||
console.log(' 4. 验证成功后点击"启用"');
|
||||
console.log('');
|
||||
|
||||
console.log('Step 5: 测试功能');
|
||||
console.log(' 运行URL验证测试脚本:');
|
||||
console.log(' npx tsx src/modules/iit-manager/test-patient-wechat-url-verify.ts');
|
||||
console.log('');
|
||||
|
||||
console.log('='.repeat(60));
|
||||
console.log('\n⚠️ 重要提示\n');
|
||||
console.log('='.repeat(60));
|
||||
console.log('');
|
||||
|
||||
console.log(' 1. Token和EncodingAESKey必须在.env和微信公众平台中保持一致');
|
||||
console.log(' 2. 不要将Token和EncodingAESKey提交到Git仓库');
|
||||
console.log(' 3. 生产环境需要在SAE中配置这些环境变量');
|
||||
console.log(' 4. 如果配置错误,可以重新运行本脚本生成新的值');
|
||||
console.log(' 5. 修改配置后需要重启服务才能生效');
|
||||
console.log('');
|
||||
|
||||
console.log('🎉 生成完成!');
|
||||
console.log('');
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { WebhookController } from '../controllers/WebhookController.js';
|
||||
import { wechatCallbackController } from '../controllers/WechatCallbackController.js';
|
||||
import { patientWechatCallbackController } from '../controllers/PatientWechatCallbackController.js';
|
||||
import { SyncManager } from '../services/SyncManager.js';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
|
||||
@@ -295,10 +296,113 @@ export async function registerIitRoutes(fastify: FastifyInstance) {
|
||||
|
||||
logger.info('Registered route: POST /api/v1/iit/wechat/callback');
|
||||
|
||||
// =============================================
|
||||
// 微信服务号回调路由(患者端)
|
||||
// =============================================
|
||||
|
||||
// 简化路由(用于微信公众平台配置,路径更短)
|
||||
// GET: URL验证
|
||||
// 注意:不使用required字段,避免Fastify过早拦截微信的请求
|
||||
fastify.get(
|
||||
'/wechat/patient/callback',
|
||||
{
|
||||
schema: {
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
signature: { type: 'string' },
|
||||
timestamp: { type: 'string' },
|
||||
nonce: { type: 'string' },
|
||||
echostr: { type: 'string' },
|
||||
// 微信可能还会传其他参数,使用additionalProperties允许
|
||||
},
|
||||
additionalProperties: true
|
||||
}
|
||||
}
|
||||
},
|
||||
patientWechatCallbackController.handleVerification.bind(patientWechatCallbackController)
|
||||
);
|
||||
|
||||
logger.info('Registered route: GET /wechat/patient/callback');
|
||||
|
||||
// POST: 接收消息
|
||||
// 注意:不使用required字段,避免Fastify过早拦截微信的请求
|
||||
fastify.post(
|
||||
'/wechat/patient/callback',
|
||||
{
|
||||
schema: {
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
signature: { type: 'string' },
|
||||
timestamp: { type: 'string' },
|
||||
nonce: { type: 'string' },
|
||||
openid: { type: 'string' },
|
||||
encrypt_type: { type: 'string' },
|
||||
msg_signature: { type: 'string' }
|
||||
},
|
||||
additionalProperties: true
|
||||
}
|
||||
}
|
||||
},
|
||||
patientWechatCallbackController.handleCallback.bind(patientWechatCallbackController)
|
||||
);
|
||||
|
||||
logger.info('Registered route: POST /wechat/patient/callback');
|
||||
|
||||
// 完整路由(兼容旧配置,保留)
|
||||
// GET: URL验证(微信服务号配置回调URL时使用)
|
||||
fastify.get(
|
||||
'/api/v1/iit/patient-wechat/callback',
|
||||
{
|
||||
schema: {
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
signature: { type: 'string' },
|
||||
timestamp: { type: 'string' },
|
||||
nonce: { type: 'string' },
|
||||
echostr: { type: 'string' }
|
||||
},
|
||||
additionalProperties: true
|
||||
}
|
||||
}
|
||||
},
|
||||
patientWechatCallbackController.handleVerification.bind(patientWechatCallbackController)
|
||||
);
|
||||
|
||||
logger.info('Registered route: GET /api/v1/iit/patient-wechat/callback');
|
||||
|
||||
// POST: 接收微信服务号消息
|
||||
fastify.post(
|
||||
'/api/v1/iit/patient-wechat/callback',
|
||||
{
|
||||
schema: {
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
signature: { type: 'string' },
|
||||
timestamp: { type: 'string' },
|
||||
nonce: { type: 'string' },
|
||||
openid: { type: 'string' },
|
||||
encrypt_type: { type: 'string' },
|
||||
msg_signature: { type: 'string' }
|
||||
},
|
||||
additionalProperties: true
|
||||
}
|
||||
}
|
||||
},
|
||||
patientWechatCallbackController.handleCallback.bind(patientWechatCallbackController)
|
||||
);
|
||||
|
||||
logger.info('Registered route: POST /api/v1/iit/patient-wechat/callback');
|
||||
|
||||
// TODO: 后续添加其他路由
|
||||
// - 项目管理路由
|
||||
// - 影子状态路由
|
||||
// - 任务管理路由
|
||||
// - 患者绑定路由
|
||||
// - 患者信息查询路由
|
||||
}
|
||||
|
||||
|
||||
|
||||
484
backend/src/modules/iit-manager/services/PatientWechatService.ts
Normal file
484
backend/src/modules/iit-manager/services/PatientWechatService.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
/**
|
||||
* 微信服务号消息推送服务(患者端)
|
||||
*
|
||||
* 功能:
|
||||
* 1. 获取微信服务号 Access Token(缓存管理)
|
||||
* 2. 发送模板消息(访视提醒、填表通知等)
|
||||
* 3. 发送客服消息(文本、图片、图文)
|
||||
* 4. 管理用户订阅(订阅消息)
|
||||
*
|
||||
* 技术要点:
|
||||
* - Access Token 缓存(7000秒,提前5分钟刷新)
|
||||
* - 错误重试机制
|
||||
* - 完整的日志记录
|
||||
*
|
||||
* 参考文档:
|
||||
* - https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Template_Message_Interface.html
|
||||
* - https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Service_Center_messages.html
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
interface WechatMpConfig {
|
||||
appId: string;
|
||||
appSecret: string;
|
||||
}
|
||||
|
||||
interface AccessTokenCache {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface WechatApiResponse {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
}
|
||||
|
||||
interface AccessTokenResponse extends WechatApiResponse {
|
||||
access_token?: string;
|
||||
expires_in?: number;
|
||||
}
|
||||
|
||||
interface SendMessageResponse extends WechatApiResponse {
|
||||
msgid?: number;
|
||||
}
|
||||
|
||||
interface TemplateMessageData {
|
||||
[key: string]: {
|
||||
value: string;
|
||||
color?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface TemplateMessageParams {
|
||||
touser: string; // 接收者openid
|
||||
template_id: string; // 模板ID
|
||||
url?: string; // 跳转URL(H5)
|
||||
miniprogram?: { // 跳转小程序
|
||||
appid: string;
|
||||
pagepath: string;
|
||||
};
|
||||
data: TemplateMessageData; // 模板数据
|
||||
topcolor?: string; // 顶部颜色
|
||||
}
|
||||
|
||||
interface CustomerMessageParams {
|
||||
touser: string; // 接收者openid
|
||||
msgtype: string; // 消息类型(text, image, news等)
|
||||
text?: { // 文本消息
|
||||
content: string;
|
||||
};
|
||||
image?: { // 图片消息
|
||||
media_id: string;
|
||||
};
|
||||
news?: { // 图文消息
|
||||
articles: Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
picurl: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 微信服务号服务类 ====================
|
||||
|
||||
export class PatientWechatService {
|
||||
private config: WechatMpConfig;
|
||||
private accessTokenCache: AccessTokenCache | null = null;
|
||||
private readonly baseUrl = 'https://api.weixin.qq.com/cgi-bin';
|
||||
|
||||
constructor() {
|
||||
// 从环境变量读取配置
|
||||
this.config = {
|
||||
appId: process.env.WECHAT_MP_APP_ID || '',
|
||||
appSecret: process.env.WECHAT_MP_APP_SECRET || '',
|
||||
};
|
||||
|
||||
// 验证配置
|
||||
if (!this.config.appId || !this.config.appSecret) {
|
||||
logger.error('❌ 微信服务号配置不完整', {
|
||||
hasAppId: !!this.config.appId,
|
||||
hasAppSecret: !!this.config.appSecret,
|
||||
});
|
||||
throw new Error('微信服务号配置不完整,请检查环境变量');
|
||||
}
|
||||
|
||||
logger.info('✅ 微信服务号服务初始化成功', {
|
||||
appId: this.config.appId,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Access Token 管理 ====================
|
||||
|
||||
/**
|
||||
* 获取微信服务号 Access Token
|
||||
* - 优先返回缓存的 Token(如果未过期)
|
||||
* - 过期或不存在时,重新请求
|
||||
*/
|
||||
async getAccessToken(): Promise<string> {
|
||||
try {
|
||||
// 1. 检查缓存
|
||||
if (this.accessTokenCache) {
|
||||
const now = Date.now();
|
||||
const expiresIn = this.accessTokenCache.expiresAt - now;
|
||||
|
||||
// 提前5分钟刷新(避免临界点失效)
|
||||
if (expiresIn > 5 * 60 * 1000) {
|
||||
logger.debug('✅ 使用缓存的 Access Token', {
|
||||
expiresIn: Math.floor(expiresIn / 1000) + 's',
|
||||
});
|
||||
return this.accessTokenCache.token;
|
||||
}
|
||||
|
||||
logger.info('⏰ Access Token 即将过期,重新获取');
|
||||
}
|
||||
|
||||
// 2. 请求新的 Access Token
|
||||
const url = `${this.baseUrl}/token`;
|
||||
const response = await axios.get<AccessTokenResponse>(url, {
|
||||
params: {
|
||||
grant_type: 'client_credential',
|
||||
appid: this.config.appId,
|
||||
secret: this.config.appSecret,
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// 3. 检查响应
|
||||
if (response.data.errcode && response.data.errcode !== 0) {
|
||||
throw new Error(
|
||||
`获取 Access Token 失败:${response.data.errmsg} (errcode: ${response.data.errcode})`
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.data.access_token) {
|
||||
throw new Error('Access Token 响应中缺少 access_token 字段');
|
||||
}
|
||||
|
||||
// 4. 缓存 Token
|
||||
const expiresIn = response.data.expires_in || 7200; // 默认2小时
|
||||
this.accessTokenCache = {
|
||||
token: response.data.access_token,
|
||||
expiresAt: Date.now() + expiresIn * 1000,
|
||||
};
|
||||
|
||||
logger.info('✅ 获取 Access Token 成功', {
|
||||
tokenLength: this.accessTokenCache.token.length,
|
||||
expiresIn: expiresIn + 's',
|
||||
});
|
||||
|
||||
return this.accessTokenCache.token;
|
||||
} catch (error: any) {
|
||||
logger.error('❌ 获取 Access Token 失败', {
|
||||
error: error.message,
|
||||
response: error.response?.data,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 模板消息推送 ====================
|
||||
|
||||
/**
|
||||
* 发送模板消息
|
||||
*
|
||||
* 使用场景:
|
||||
* - 访视提醒
|
||||
* - 填表通知
|
||||
* - 结果反馈
|
||||
*
|
||||
* 注意:模板需要在微信公众平台申请并通过审核
|
||||
*/
|
||||
async sendTemplateMessage(params: TemplateMessageParams): Promise<void> {
|
||||
try {
|
||||
logger.info('📤 准备发送模板消息', {
|
||||
touser: params.touser,
|
||||
template_id: params.template_id,
|
||||
hasUrl: !!params.url,
|
||||
hasMiniProgram: !!params.miniprogram,
|
||||
});
|
||||
|
||||
// 1. 获取 Access Token
|
||||
const accessToken = await this.getAccessToken();
|
||||
|
||||
// 2. 调用发送接口
|
||||
const url = `${this.baseUrl}/message/template/send?access_token=${accessToken}`;
|
||||
const response = await axios.post<SendMessageResponse>(url, params, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// 3. 检查响应
|
||||
if (response.data.errcode !== 0) {
|
||||
throw new Error(
|
||||
`发送模板消息失败:${response.data.errmsg} (errcode: ${response.data.errcode})`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('✅ 模板消息发送成功', {
|
||||
touser: params.touser,
|
||||
msgid: response.data.msgid,
|
||||
});
|
||||
|
||||
// 4. 记录到数据库
|
||||
await this.logNotification(params.touser, 'template', params.template_id, params, 'sent');
|
||||
} catch (error: any) {
|
||||
logger.error('❌ 发送模板消息失败', {
|
||||
error: error.message,
|
||||
touser: params.touser,
|
||||
response: error.response?.data,
|
||||
});
|
||||
|
||||
// 记录失败日志
|
||||
await this.logNotification(
|
||||
params.touser,
|
||||
'template',
|
||||
params.template_id,
|
||||
params,
|
||||
'failed',
|
||||
error.message
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送访视提醒(模板消息)
|
||||
*
|
||||
* 示例模板格式:
|
||||
* {{first.DATA}}
|
||||
* 访视时间:{{keyword1.DATA}}
|
||||
* 访视地点:{{keyword2.DATA}}
|
||||
* 注意事项:{{keyword3.DATA}}
|
||||
* {{remark.DATA}}
|
||||
*/
|
||||
async sendVisitReminder(params: {
|
||||
openid: string;
|
||||
templateId: string;
|
||||
visitTime: string;
|
||||
visitLocation: string;
|
||||
notes: string;
|
||||
miniProgramPath?: string;
|
||||
}): Promise<void> {
|
||||
const messageParams: TemplateMessageParams = {
|
||||
touser: params.openid,
|
||||
template_id: params.templateId,
|
||||
data: {
|
||||
first: {
|
||||
value: '您有一次访视安排',
|
||||
color: '#173177',
|
||||
},
|
||||
keyword1: {
|
||||
value: params.visitTime,
|
||||
color: '#173177',
|
||||
},
|
||||
keyword2: {
|
||||
value: params.visitLocation,
|
||||
color: '#173177',
|
||||
},
|
||||
keyword3: {
|
||||
value: params.notes,
|
||||
color: '#173177',
|
||||
},
|
||||
remark: {
|
||||
value: '请按时到院,如有问题请联系研究团队',
|
||||
color: '#173177',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 如果提供了小程序路径,跳转到小程序
|
||||
if (params.miniProgramPath) {
|
||||
messageParams.miniprogram = {
|
||||
appid: process.env.WECHAT_MINI_APP_ID || '',
|
||||
pagepath: params.miniProgramPath,
|
||||
};
|
||||
}
|
||||
|
||||
await this.sendTemplateMessage(messageParams);
|
||||
}
|
||||
|
||||
// ==================== 客服消息推送 ====================
|
||||
|
||||
/**
|
||||
* 发送客服消息(文本)
|
||||
*
|
||||
* 限制:
|
||||
* - 只能在用户48小时内与公众号有交互后发送
|
||||
* - 或者用户主动发送消息后48小时内
|
||||
*/
|
||||
async sendCustomerMessage(params: CustomerMessageParams): Promise<void> {
|
||||
try {
|
||||
logger.info('📤 准备发送客服消息', {
|
||||
touser: params.touser,
|
||||
msgtype: params.msgtype,
|
||||
});
|
||||
|
||||
// 1. 获取 Access Token
|
||||
const accessToken = await this.getAccessToken();
|
||||
|
||||
// 2. 调用发送接口
|
||||
const url = `${this.baseUrl}/message/custom/send?access_token=${accessToken}`;
|
||||
const response = await axios.post<WechatApiResponse>(url, params, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// 3. 检查响应
|
||||
if (response.data.errcode !== 0) {
|
||||
throw new Error(
|
||||
`发送客服消息失败:${response.data.errmsg} (errcode: ${response.data.errcode})`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('✅ 客服消息发送成功', {
|
||||
touser: params.touser,
|
||||
});
|
||||
|
||||
// 4. 记录到数据库
|
||||
await this.logNotification(params.touser, 'customer', params.msgtype, params, 'sent');
|
||||
} catch (error: any) {
|
||||
logger.error('❌ 发送客服消息失败', {
|
||||
error: error.message,
|
||||
touser: params.touser,
|
||||
response: error.response?.data,
|
||||
});
|
||||
|
||||
// 记录失败日志
|
||||
await this.logNotification(
|
||||
params.touser,
|
||||
'customer',
|
||||
params.msgtype,
|
||||
params,
|
||||
'failed',
|
||||
error.message
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送文本客服消息(快捷方法)
|
||||
*/
|
||||
async sendTextMessage(openid: string, content: string): Promise<void> {
|
||||
await this.sendCustomerMessage({
|
||||
touser: openid,
|
||||
msgtype: 'text',
|
||||
text: {
|
||||
content,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 辅助方法 ====================
|
||||
|
||||
/**
|
||||
* 记录消息推送日志到数据库
|
||||
*/
|
||||
private async logNotification(
|
||||
openid: string,
|
||||
notificationType: string,
|
||||
templateId: string,
|
||||
content: any,
|
||||
status: 'sent' | 'failed',
|
||||
errorMessage?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// TODO: 实现数据库记录
|
||||
// 需要先创建 patient_notifications 表
|
||||
logger.info('📝 记录消息推送日志', {
|
||||
openid,
|
||||
notificationType,
|
||||
templateId,
|
||||
status,
|
||||
hasError: !!errorMessage,
|
||||
});
|
||||
|
||||
// 临时实现:记录到日志
|
||||
// 正式实现:存储到 iit_schema.patient_notifications 表
|
||||
} catch (error: any) {
|
||||
logger.error('❌ 记录消息推送日志失败', {
|
||||
error: error.message,
|
||||
openid,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 用户管理 ====================
|
||||
|
||||
/**
|
||||
* 检查用户是否已关注公众号
|
||||
*/
|
||||
async isUserSubscribed(openid: string): Promise<boolean> {
|
||||
try {
|
||||
const accessToken = await this.getAccessToken();
|
||||
const url = `${this.baseUrl}/user/info?access_token=${accessToken}&openid=${openid}`;
|
||||
|
||||
const response = await axios.get(url, { timeout: 10000 });
|
||||
|
||||
if (response.data.errcode && response.data.errcode !== 0) {
|
||||
logger.error('❌ 查询用户信息失败', {
|
||||
errcode: response.data.errcode,
|
||||
errmsg: response.data.errmsg,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const isSubscribed = response.data.subscribe === 1;
|
||||
|
||||
logger.info('✅ 查询用户订阅状态', {
|
||||
openid,
|
||||
isSubscribed,
|
||||
});
|
||||
|
||||
return isSubscribed;
|
||||
} catch (error: any) {
|
||||
logger.error('❌ 查询用户订阅状态失败', {
|
||||
error: error.message,
|
||||
openid,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户基本信息
|
||||
*/
|
||||
async getUserInfo(openid: string): Promise<any> {
|
||||
try {
|
||||
const accessToken = await this.getAccessToken();
|
||||
const url = `${this.baseUrl}/user/info?access_token=${accessToken}&openid=${openid}`;
|
||||
|
||||
const response = await axios.get(url, { timeout: 10000 });
|
||||
|
||||
if (response.data.errcode && response.data.errcode !== 0) {
|
||||
throw new Error(`获取用户信息失败:${response.data.errmsg}`);
|
||||
}
|
||||
|
||||
logger.info('✅ 获取用户信息成功', {
|
||||
openid,
|
||||
nickname: response.data.nickname,
|
||||
subscribe: response.data.subscribe,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
logger.error('❌ 获取用户信息失败', {
|
||||
error: error.message,
|
||||
openid,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const patientWechatService = new PatientWechatService();
|
||||
|
||||
@@ -126,3 +126,4 @@ testDifyIntegration().catch(error => {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -155,3 +155,4 @@ testIitDatabase()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
144
backend/src/modules/iit-manager/test-patient-wechat-config.ts
Normal file
144
backend/src/modules/iit-manager/test-patient-wechat-config.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 微信服务号配置检查脚本
|
||||
*
|
||||
* 功能:
|
||||
* 1. 检查必需的环境变量是否配置
|
||||
* 2. 验证Token和EncodingAESKey长度
|
||||
* 3. 测试签名生成功能
|
||||
* 4. 输出配置信息(用于微信公众平台)
|
||||
*/
|
||||
|
||||
import dotenv from 'dotenv';
|
||||
import crypto from 'crypto';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// 获取当前文件目录
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// 加载.env文件
|
||||
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
|
||||
|
||||
console.log('🔧 微信服务号配置检查');
|
||||
console.log('=' .repeat(60));
|
||||
console.log('');
|
||||
|
||||
// ==================== 1. 检查必需的环境变量 ====================
|
||||
|
||||
console.log('📋 检查必需的环境变量...\n');
|
||||
|
||||
const requiredEnvs = [
|
||||
{ key: 'WECHAT_MP_APP_ID', description: 'AppID' },
|
||||
{ key: 'WECHAT_MP_APP_SECRET', description: 'AppSecret' },
|
||||
{ key: 'WECHAT_MP_TOKEN', description: 'Token(用于签名验证)' },
|
||||
{ key: 'WECHAT_MP_ENCODING_AES_KEY', description: 'EncodingAESKey(用于消息加解密)' },
|
||||
];
|
||||
|
||||
let hasError = false;
|
||||
|
||||
requiredEnvs.forEach(({ key, description }) => {
|
||||
const value = process.env[key];
|
||||
if (!value) {
|
||||
console.error(`❌ 缺少环境变量: ${key} (${description})`);
|
||||
hasError = true;
|
||||
} else {
|
||||
const displayValue =
|
||||
value.length > 20 ? `${value.substring(0, 10)}...${value.substring(value.length - 5)}` : value;
|
||||
console.log(`✅ ${key}:`);
|
||||
console.log(` 值: ${displayValue}`);
|
||||
console.log(` 长度: ${value.length}位`);
|
||||
console.log(` 说明: ${description}\n`);
|
||||
}
|
||||
});
|
||||
|
||||
if (hasError) {
|
||||
console.error('\n❌ 配置不完整,请检查 backend/.env 文件');
|
||||
console.error('\n请参考 backend/WECHAT_ENV_CONFIG.md 进行配置');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ==================== 2. 验证Token长度 ====================
|
||||
|
||||
console.log('\n📏 验证Token长度...\n');
|
||||
|
||||
const token = process.env.WECHAT_MP_TOKEN!;
|
||||
if (token.length < 3 || token.length > 32) {
|
||||
console.error(`❌ Token长度不正确: ${token.length}位(应为3-32位)`);
|
||||
console.error(` 当前Token: ${token}`);
|
||||
hasError = true;
|
||||
} else {
|
||||
console.log(`✅ Token长度正确: ${token.length}位`);
|
||||
}
|
||||
|
||||
// ==================== 3. 验证EncodingAESKey长度 ====================
|
||||
|
||||
console.log('\n🔐 验证EncodingAESKey长度...\n');
|
||||
|
||||
const aesKey = process.env.WECHAT_MP_ENCODING_AES_KEY!;
|
||||
if (aesKey.length !== 43) {
|
||||
console.error(`❌ EncodingAESKey长度不正确: ${aesKey.length}位(必须43位)`);
|
||||
console.error(` 当前AESKey: ${aesKey}`);
|
||||
console.error(` 提示: 可以使用以下命令生成43位字符串:`);
|
||||
console.error(` openssl rand -base64 43 | tr -d '/+=' | head -c 43`);
|
||||
hasError = true;
|
||||
} else {
|
||||
console.log(`✅ EncodingAESKey长度正确: 43位`);
|
||||
}
|
||||
|
||||
// ==================== 4. 测试签名生成 ====================
|
||||
|
||||
console.log('\n🔐 测试签名生成...\n');
|
||||
|
||||
try {
|
||||
const timestamp = Date.now().toString();
|
||||
const nonce = Math.random().toString(36).substring(2, 12);
|
||||
const arr = [token, timestamp, nonce].sort();
|
||||
const str = arr.join('');
|
||||
const signature = crypto.createHash('sha1').update(str).digest('hex');
|
||||
|
||||
console.log(`测试参数:`);
|
||||
console.log(` timestamp: ${timestamp}`);
|
||||
console.log(` nonce: ${nonce}`);
|
||||
console.log(` token: ${token.substring(0, 10)}...`);
|
||||
console.log(`\n排序后拼接: ${str.substring(0, 50)}...`);
|
||||
console.log(`\n生成的签名: ${signature}`);
|
||||
console.log(`\n✅ 签名生成功能正常`);
|
||||
} catch (error: any) {
|
||||
console.error(`❌ 签名生成失败: ${error.message}`);
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
// ==================== 5. 总结 ====================
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
|
||||
if (hasError) {
|
||||
console.error('\n❌ 配置检查失败,请修复以上错误后重试\n');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\n✅ 配置检查通过!可以开始配置微信公众平台\n');
|
||||
console.log('=' .repeat(60));
|
||||
console.log('\n📋 配置信息(复制以下内容到微信公众平台):\n');
|
||||
console.log('登录地址:https://mp.weixin.qq.com/');
|
||||
console.log('配置路径:设置与开发 → 基本配置 → 服务器配置\n');
|
||||
console.log('配置参数:');
|
||||
console.log(` URL: https://iit.xunzhengyixue.com/api/v1/iit/patient-wechat/callback`);
|
||||
console.log(` Token: ${token}`);
|
||||
console.log(` EncodingAESKey: ${aesKey}`);
|
||||
console.log(` 消息加解密方式: 安全模式(推荐)`);
|
||||
console.log(` 数据格式: XML`);
|
||||
console.log('\n本地开发环境URL(使用natapp):');
|
||||
console.log(` URL: https://devlocal.xunzhengyixue.com/api/v1/iit/patient-wechat/callback`);
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('\n📝 后续步骤:\n');
|
||||
console.log(' 1. 启动后端服务:npm run dev');
|
||||
console.log(' 2. 本地开发需要启动natapp内网穿透');
|
||||
console.log(' 3. 登录微信公众平台,配置服务器地址');
|
||||
console.log(' 4. 点击"提交"进行URL验证');
|
||||
console.log(' 5. 验证成功后点击"启用"');
|
||||
console.log(' 6. 运行测试脚本验证功能:');
|
||||
console.log(' npx tsx src/modules/iit-manager/test-patient-wechat-url-verify.ts');
|
||||
console.log('\n');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 微信服务号URL验证测试脚本
|
||||
*
|
||||
* 功能:
|
||||
* 1. 模拟微信服务器发送GET请求验证URL
|
||||
* 2. 测试签名生成和验证逻辑
|
||||
* 3. 验证服务端是否正确返回echostr
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import crypto from 'crypto';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// 获取当前文件目录
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// 加载.env文件
|
||||
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
|
||||
|
||||
const BASE_URL = process.env.API_BASE_URL || 'http://localhost:3001';
|
||||
const TOKEN = process.env.WECHAT_MP_TOKEN || '';
|
||||
|
||||
console.log('🧪 微信服务号URL验证测试');
|
||||
console.log('='.repeat(60));
|
||||
console.log('');
|
||||
|
||||
// ==================== 1. 检查配置 ====================
|
||||
|
||||
if (!TOKEN) {
|
||||
console.error('❌ 未配置 WECHAT_MP_TOKEN,请检查 .env 文件');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('📋 测试环境:');
|
||||
console.log(` BASE_URL: ${BASE_URL}`);
|
||||
console.log(` TOKEN: ${TOKEN.substring(0, 10)}... (长度: ${TOKEN.length})`);
|
||||
console.log('');
|
||||
|
||||
// ==================== 2. 生成测试参数 ====================
|
||||
|
||||
console.log('🔧 生成测试参数...\n');
|
||||
|
||||
const timestamp = Date.now().toString();
|
||||
const nonce = Math.random().toString(36).substring(2, 12);
|
||||
const echostr = 'test_echo_' + Math.random().toString(36).substring(2);
|
||||
|
||||
console.log(' timestamp: ' + timestamp);
|
||||
console.log(` nonce: ${nonce}`);
|
||||
console.log(` echostr: ${echostr}\n`);
|
||||
|
||||
// ==================== 3. 生成签名 ====================
|
||||
|
||||
console.log('🔐 生成签名...\n');
|
||||
|
||||
const arr = [TOKEN, timestamp, nonce].sort();
|
||||
const str = arr.join('');
|
||||
const signature = crypto.createHash('sha1').update(str).digest('hex');
|
||||
|
||||
console.log(` 排序后拼接: ${str.substring(0, 50)}...`);
|
||||
console.log(` SHA1哈希: ${signature}\n`);
|
||||
|
||||
// ==================== 4. 发送GET请求 ====================
|
||||
|
||||
async function testUrlVerification() {
|
||||
console.log('📤 发送URL验证请求...\n');
|
||||
|
||||
const url = `${BASE_URL}/api/v1/iit/patient-wechat/callback`;
|
||||
|
||||
console.log(` 请求URL: ${url}`);
|
||||
console.log(` 请求方法: GET`);
|
||||
console.log(` 查询参数:`);
|
||||
console.log(` signature: ${signature}`);
|
||||
console.log(` timestamp: ${timestamp}`);
|
||||
console.log(` nonce: ${nonce}`);
|
||||
console.log(` echostr: ${echostr}\n`);
|
||||
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
params: {
|
||||
signature,
|
||||
timestamp,
|
||||
nonce,
|
||||
echostr,
|
||||
},
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
console.log('='.repeat(60));
|
||||
console.log('\n✅ URL验证成功!\n');
|
||||
console.log(`HTTP状态码: ${response.status}`);
|
||||
console.log(`返回内容类型: ${response.headers['content-type']}`);
|
||||
console.log(`返回内容: ${response.data}\n`);
|
||||
|
||||
// 验证返回内容
|
||||
if (response.data === echostr) {
|
||||
console.log('✅ 返回的echostr正确,验证通过!\n');
|
||||
console.log('='.repeat(60));
|
||||
console.log('\n🎉 测试成功!您的服务端配置正确\n');
|
||||
console.log('📝 后续步骤:');
|
||||
console.log(' 1. 登录微信公众平台:https://mp.weixin.qq.com/');
|
||||
console.log(' 2. 进入:设置与开发 → 基本配置 → 服务器配置');
|
||||
console.log(' 3. 填写以下信息:');
|
||||
console.log(` URL: ${BASE_URL}/api/v1/iit/patient-wechat/callback`);
|
||||
console.log(` Token: ${TOKEN}`);
|
||||
console.log(` EncodingAESKey: ${process.env.WECHAT_MP_ENCODING_AES_KEY?.substring(0, 10)}...`);
|
||||
console.log(' 4. 选择"安全模式"');
|
||||
console.log(' 5. 点击"提交"进行验证');
|
||||
console.log(' 6. 验证成功后点击"启用"');
|
||||
console.log('');
|
||||
} else {
|
||||
console.error('❌ 返回的echostr不正确!');
|
||||
console.error(` 期望: ${echostr}`);
|
||||
console.error(` 实际: ${response.data}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.log('='.repeat(60));
|
||||
console.error('\n❌ URL验证失败\n');
|
||||
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
console.error('连接被拒绝,可能的原因:');
|
||||
console.error(' 1. 后端服务未启动');
|
||||
console.error(' 2. 端口号不正确');
|
||||
console.error('\n解决方法:');
|
||||
console.error(' 1. 运行 npm run dev 启动后端服务');
|
||||
console.error(' 2. 确认服务运行在正确的端口(默认3001)');
|
||||
} else if (error.response) {
|
||||
console.error(`HTTP状态码: ${error.response.status}`);
|
||||
console.error(`错误信息: ${error.response.statusText}`);
|
||||
console.error(`响应内容: ${JSON.stringify(error.response.data, null, 2)}`);
|
||||
|
||||
if (error.response.status === 403) {
|
||||
console.error('\n可能的原因:');
|
||||
console.error(' 1. Token配置不一致');
|
||||
console.error(' 2. 签名验证失败');
|
||||
console.error('\n解决方法:');
|
||||
console.error(' 1. 检查 .env 文件中的 WECHAT_MP_TOKEN');
|
||||
console.error(' 2. 运行配置检查脚本:');
|
||||
console.error(' npx tsx src/modules/iit-manager/test-patient-wechat-config.ts');
|
||||
} else if (error.response.status === 404) {
|
||||
console.error('\n可能的原因:');
|
||||
console.error(' 1. 路由未注册');
|
||||
console.error(' 2. URL路径不正确');
|
||||
console.error('\n解决方法:');
|
||||
console.error(' 1. 确认路由已在 routes/index.ts 中注册');
|
||||
console.error(' 2. 检查URL路径是否正确');
|
||||
}
|
||||
} else {
|
||||
console.error(`错误信息: ${error.message}`);
|
||||
}
|
||||
|
||||
console.error('\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 5. 执行测试 ====================
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await testUrlVerification();
|
||||
} catch (error: any) {
|
||||
console.error('测试执行异常:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -248,3 +248,4 @@ main().catch((error) => {
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
135
backend/src/modules/iit-manager/test-wechat-mp-local.ps1
Normal file
135
backend/src/modules/iit-manager/test-wechat-mp-local.ps1
Normal file
@@ -0,0 +1,135 @@
|
||||
# 微信服务号URL验证本地测试脚本
|
||||
# 模拟微信服务器发送GET请求
|
||||
|
||||
Write-Host "🧪 微信服务号URL验证本地测试" -ForegroundColor Cyan
|
||||
Write-Host "=" * 60
|
||||
Write-Host ""
|
||||
|
||||
# 配置参数
|
||||
$BASE_URL = "https://devlocal.xunzhengyixue.com/wechat/patient/callback"
|
||||
$TOKEN = "IitPatientWechat2026JanToken"
|
||||
|
||||
# 生成测试参数
|
||||
$timestamp = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds().ToString()
|
||||
$nonce = -join ((65..90) + (97..122) | Get-Random -Count 10 | ForEach-Object {[char]$_})
|
||||
$echostr = "test_echo_" + (-join ((65..90) + (97..122) + (48..57) | Get-Random -Count 10 | ForEach-Object {[char]$_}))
|
||||
|
||||
Write-Host "📝 测试参数:" -ForegroundColor Yellow
|
||||
Write-Host " timestamp: $timestamp"
|
||||
Write-Host " nonce: $nonce"
|
||||
Write-Host " echostr: $echostr"
|
||||
Write-Host ""
|
||||
|
||||
# 生成签名
|
||||
Write-Host "🔐 生成签名..." -ForegroundColor Yellow
|
||||
$sortedArray = @($TOKEN, $timestamp, $nonce) | Sort-Object
|
||||
$stringToHash = $sortedArray -join ''
|
||||
$sha1 = [System.Security.Cryptography.SHA1]::Create()
|
||||
$hashBytes = $sha1.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($stringToHash))
|
||||
$signature = ($hashBytes | ForEach-Object { $_.ToString("x2") }) -join ''
|
||||
|
||||
Write-Host " 排序后拼接: $($stringToHash.Substring(0, [Math]::Min(50, $stringToHash.Length)))..."
|
||||
Write-Host " SHA1哈希: $signature"
|
||||
Write-Host ""
|
||||
|
||||
# 构建完整URL
|
||||
$fullUrl = "$BASE_URL`?signature=$signature×tamp=$timestamp&nonce=$nonce&echostr=$echostr"
|
||||
|
||||
Write-Host "📤 发送GET请求..." -ForegroundColor Yellow
|
||||
Write-Host " URL: $fullUrl" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
|
||||
# 发送请求(忽略SSL证书警告)
|
||||
try {
|
||||
# 忽略SSL证书错误(仅用于测试)
|
||||
add-type @"
|
||||
using System.Net;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
public class TrustAllCertsPolicy : ICertificatePolicy {
|
||||
public bool CheckValidationResult(
|
||||
ServicePoint srvPoint, X509Certificate certificate,
|
||||
WebRequest request, int certificateProblem) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"@
|
||||
[System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
|
||||
|
||||
$response = Invoke-WebRequest -Uri $fullUrl -Method Get -UseBasicParsing
|
||||
|
||||
Write-Host "=" * 60
|
||||
Write-Host ""
|
||||
Write-Host "✅ 请求成功!" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "HTTP状态码: $($response.StatusCode)" -ForegroundColor Green
|
||||
Write-Host "返回内容类型: $($response.Headers['Content-Type'])" -ForegroundColor Green
|
||||
Write-Host "返回内容: $($response.Content)" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
if ($response.Content -eq $echostr) {
|
||||
Write-Host "✅ 返回的echostr正确,验证通过!" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "=" * 60
|
||||
Write-Host ""
|
||||
Write-Host "🎉 测试成功!服务端配置正确" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "📝 这说明:" -ForegroundColor Cyan
|
||||
Write-Host " 1. ✅ natapp映射正常"
|
||||
Write-Host " 2. ✅ 后端路由正确"
|
||||
Write-Host " 3. ✅ 签名验证正确"
|
||||
Write-Host " 4. ✅ 返回格式正确"
|
||||
Write-Host ""
|
||||
Write-Host "⚠️ 但微信配置失败的原因可能是:" -ForegroundColor Yellow
|
||||
Write-Host " 1. 域名devlocal.xunzhengyixue.com未在微信公众平台配置"
|
||||
Write-Host " 2. 微信服务器无法访问这个域名"
|
||||
Write-Host " 3. 建议使用生产域名:iit.xunzhengyixue.com"
|
||||
Write-Host ""
|
||||
} else {
|
||||
Write-Host "❌ 返回的echostr不正确!" -ForegroundColor Red
|
||||
Write-Host " 期望: $echostr" -ForegroundColor Red
|
||||
Write-Host " 实际: $($response.Content)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
} catch {
|
||||
Write-Host "=" * 60
|
||||
Write-Host ""
|
||||
Write-Host "❌ 请求失败" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host "错误信息: $($_.Exception.Message)" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
|
||||
if ($_.Exception.Response) {
|
||||
$statusCode = $_.Exception.Response.StatusCode.value__
|
||||
Write-Host "HTTP状态码: $statusCode" -ForegroundColor Red
|
||||
|
||||
if ($statusCode -eq 400) {
|
||||
Write-Host ""
|
||||
Write-Host "可能原因:" -ForegroundColor Yellow
|
||||
Write-Host " 1. Schema验证失败(需要检查路由配置)"
|
||||
Write-Host " 2. 参数格式不正确"
|
||||
} elseif ($statusCode -eq 403) {
|
||||
Write-Host ""
|
||||
Write-Host "可能原因:" -ForegroundColor Yellow
|
||||
Write-Host " 1. 签名验证失败"
|
||||
Write-Host " 2. Token配置不匹配"
|
||||
} elseif ($statusCode -eq 404) {
|
||||
Write-Host ""
|
||||
Write-Host "可能原因:" -ForegroundColor Yellow
|
||||
Write-Host " 1. 路由未注册"
|
||||
Write-Host " 2. URL路径不正确"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "排查建议:" -ForegroundColor Cyan
|
||||
Write-Host " 1. 确认后端服务正在运行"
|
||||
Write-Host " 2. 确认natapp正在运行"
|
||||
Write-Host " 3. 访问健康检查接口:"
|
||||
Write-Host " https://devlocal.xunzhengyixue.com/api/v1/iit/health"
|
||||
Write-Host " 4. 查看后端服务日志"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
|
||||
@@ -225,3 +225,4 @@ export interface CachedProtocolRules {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -404,5 +404,6 @@ SET session_replication_role = 'origin';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -106,5 +106,6 @@ WHERE key = 'verify_test';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -249,5 +249,6 @@ verifyDatabase()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1
backend/src/types/global.d.ts
vendored
1
backend/src/types/global.d.ts
vendored
@@ -39,5 +39,6 @@ export {}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -62,5 +62,6 @@ Write-Host "✅ 完成!" -ForegroundColor Green
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -349,5 +349,6 @@ runAdvancedTests().catch(error => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -415,5 +415,6 @@ runAllTests()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -373,5 +373,6 @@ runAllTests()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user