Files
AIclinicalresearch/backend/src/modules/iit-manager/services/PatientWechatService.ts
HaHafeng 4088275290 fix(pkb): fix create KB and upload issues - remove simulated upload, fix department mapping, add upload modal
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
2026-01-13 13:17:20 +08:00

498 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 微信服务号消息推送服务(患者端)
*
* 功能:
* 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();