/** * 微信服务号消息推送服务(患者端) * * 功能: * 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 { 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(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 { 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(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 { 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 { 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(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 { 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 { 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 { 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 { 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();