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:
@@ -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 {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user