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:
2026-01-04 22:53:42 +08:00
parent dfc472810b
commit b31255031e
167 changed files with 3055 additions and 2 deletions

View File

@@ -31,3 +31,4 @@ Status: Day 1 complete (11/11 tasks), ready for Day 2

View File

@@ -259,5 +259,6 @@

View 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分钟
- 部署到SAE5-10分钟
- 配置微信公众平台3分钟
- **总计10-15分钟**
---
**立即开始部署!** 🚀

View File

@@ -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=<待申请>
```
## 📝 配置项说明

View 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
**状态**: ✅ 配置就绪,等待测试

View 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密钥
**WindowsPowerShell**
```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分钟**

View File

@@ -54,5 +54,6 @@ WHERE table_schema = 'dc_schema'

View File

@@ -92,5 +92,6 @@ ORDER BY ordinal_position;

View File

@@ -105,5 +105,6 @@ runMigration()

View File

@@ -39,5 +39,6 @@ COMMENT ON COLUMN "dc_schema"."dc_tool_c_sessions"."column_mapping" IS '列名

View File

@@ -66,5 +66,6 @@ COMMENT ON COLUMN dc_schema.dc_tool_c_sessions.expires_at IS '过期时间(创

View File

@@ -108,3 +108,4 @@ Write-Host ""

View File

@@ -216,5 +216,6 @@ function extractCodeBlocks(obj, blocks = []) {

View File

@@ -235,5 +235,6 @@ checkDCTables();

View File

@@ -187,5 +187,6 @@ createAiHistoryTable()

View File

@@ -174,5 +174,6 @@ createToolCTable()

View File

@@ -171,5 +171,6 @@ createToolCTable()

View File

@@ -303,5 +303,6 @@ export function getBatchItems<T>(

View File

@@ -339,5 +339,6 @@ runTests().catch((error) => {

View File

@@ -318,5 +318,6 @@ Content-Type: application/json

View File

@@ -254,5 +254,6 @@ export const conflictDetectionService = new ConflictDetectionService();

View File

@@ -204,5 +204,6 @@ curl -X POST http://localhost:3000/api/v1/dc/tool-c/test/execute \

View File

@@ -258,5 +258,6 @@ export const streamAIController = new StreamAIController();

View File

@@ -169,3 +169,4 @@ logger.info('[SessionMemory] 会话记忆管理器已启动', {

View File

@@ -103,3 +103,4 @@ async function checkTableStructure() {
checkTableStructure();

View File

@@ -90,3 +90,4 @@ checkProjectConfig().catch(console.error);

View File

@@ -72,3 +72,4 @@ async function main() {
main();

View File

@@ -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();

View 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&timestamp=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

View File

@@ -0,0 +1,167 @@
/**
* 生成微信服务号Token和EncodingAESKey
*
* 功能:
* 1. 生成符合要求的Token3-32位
* 2. 生成符合要求的EncodingAESKey43位
* 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('');

View File

@@ -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: 后续添加其他路由
// - 项目管理路由
// - 影子状态路由
// - 任务管理路由
// - 患者绑定路由
// - 患者信息查询路由
}

View 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; // 跳转URLH5
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();

View File

@@ -126,3 +126,4 @@ testDifyIntegration().catch(error => {
process.exit(1);
});

View File

@@ -155,3 +155,4 @@ testIitDatabase()

View 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');
}

View File

@@ -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);
}
})();

View File

@@ -248,3 +248,4 @@ main().catch((error) => {
});

View 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&timestamp=$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 ""

View File

@@ -225,3 +225,4 @@ export interface CachedProtocolRules {

View File

@@ -404,5 +404,6 @@ SET session_replication_role = 'origin';

View File

@@ -106,5 +106,6 @@ WHERE key = 'verify_test';

View File

@@ -249,5 +249,6 @@ verifyDatabase()

View File

@@ -39,5 +39,6 @@ export {}

View File

@@ -62,5 +62,6 @@ Write-Host "✅ 完成!" -ForegroundColor Green

View File

@@ -349,5 +349,6 @@ runAdvancedTests().catch(error => {

View File

@@ -415,5 +415,6 @@ runAllTests()

View File

@@ -373,5 +373,6 @@ runAllTests()

View File

@@ -157,5 +157,6 @@ Set-Location ..

View File

@@ -601,3 +601,4 @@ async saveProcessedData(recordId, newData) {

View File

@@ -788,3 +788,4 @@ export const AsyncProgressBar: React.FC<AsyncProgressBarProps> = ({

View File

@@ -1279,5 +1279,6 @@ interface FulltextScreeningResult {

View File

@@ -393,5 +393,6 @@ GET /api/v1/asl/fulltext-screening/tasks/:taskId/export

View File

@@ -495,5 +495,6 @@ Failed to open file '\\tmp\\extraction_service\\temp_10000_test.pdf'

View File

@@ -561,5 +561,6 @@ df['creatinine'] = pd.to_numeric(df['creatinine'], errors='coerce')

View File

@@ -976,5 +976,6 @@ export const aiController = new AIController();

View File

@@ -1310,5 +1310,6 @@ npm install react-markdown

View File

@@ -218,5 +218,6 @@ FMA___基线 | FMA___1个月 | FMA___2个月

View File

@@ -376,5 +376,6 @@ formula = "FMA总分0-100 / 100"

View File

@@ -210,5 +210,6 @@ async handleFillnaMice(request, reply) {

View File

@@ -182,5 +182,6 @@ method: 'mean' | 'median' | 'mode' | 'constant' | 'ffill' | 'bfill'

View File

@@ -633,5 +633,6 @@ import { logger } from '../../../../common/logging/index.js';

View File

@@ -436,5 +436,6 @@ import { ChatContainer } from '@/shared/components/Chat';

View File

@@ -346,5 +346,6 @@ const initialMessages = defaultMessages.length > 0 ? defaultMessages : [{

View File

@@ -634,5 +634,6 @@ http://localhost:5173/data-cleaning/tool-c

View File

@@ -422,5 +422,6 @@ Docs: docs/03-业务模块/DC-数据清洗整理/06-开发记录/DC模块重建

View File

@@ -295,5 +295,6 @@ ConflictDetectionService // 冲突检测(字段级对比)

View File

@@ -459,5 +459,6 @@ Tool B后端代码**100%复用**了平台通用能力层,无任何重复开发

View File

@@ -236,5 +236,6 @@ $ node scripts/check-dc-tables.mjs

View File

@@ -469,5 +469,6 @@ ${fields.map((f, i) => `${i + 1}. ${f.name}${f.desc}`).join('\n')}

View File

@@ -676,3 +676,4 @@ private async processMessageAsync(xmlData: any) {
**版本历史**:
- v1.0 (2026-01-04): 初始版本Phase 1.5完成

View File

@@ -1070,3 +1070,4 @@ async function testIntegration() {

View File

@@ -211,3 +211,4 @@ Content-Type: application/json

View File

@@ -631,3 +631,4 @@ REDCap API: exportRecords success { recordCount: 1 }
**✅ 测试状态**: 全部通过
**✅ 部署状态**: 已部署到开发环境

View File

@@ -637,3 +637,4 @@ backend/src/modules/iit-manager/

View File

@@ -787,3 +787,4 @@ CREATE TABLE iit_schema.wechat_tokens (

View File

@@ -544,3 +544,4 @@ Day 3 的开发工作虽然遇到了多个技术问题,但最终成功完成

View File

@@ -311,3 +311,4 @@ AI: "出生日期2017-01-04
**下一步**: Phase 2 - Function Calling + Dify知识库

View File

@@ -255,3 +255,4 @@ Day 4: REDCap EMWebhook推送← 作为增强,而非核心

View File

@@ -669,3 +669,4 @@ const answer = `根据研究方案[1]和CRF表格[2],纳入标准包括:
**版本历史**:
- v1.0 (2026-01-04): 初始版本Phase 1.5完成后整理

View File

@@ -759,3 +759,4 @@ docker exec redcap-apache php /tmp/create-redcap-password.php

View File

@@ -141,3 +141,4 @@ AIclinicalresearch/redcap-docker-dev/

View File

@@ -876,5 +876,6 @@ ACR镜像仓库

View File

@@ -1365,3 +1365,4 @@ SAE应用配置:

Some files were not shown because too many files have changed in this diff Show More