Fixed issues: - Remove simulateUpload function from DashboardPage Step 3 - Map department to description field when creating KB - Add upload modal in WorkspacePage knowledge assets tab - Fix DocumentUpload import path (../../stores to ../stores) Known issue: Dify API validation error during document upload (file uploaded but DB record failed, needs investigation) Testing: KB creation works, upload dialog opens correctly
498 lines
13 KiB
TypeScript
498 lines
13 KiB
TypeScript
/**
|
||
* 微信服务号消息推送服务(患者端)
|
||
*
|
||
* 功能:
|
||
* 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();
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|