import "server-only" /** * 微信公众号模板消息渠道 * * 使用微信官方 API: * 1. 获取 access_token: GET https://api.weixin.qq.com/cgi-bin/token * 2. 发送模板消息: POST https://api.weixin.qq.com/cgi-bin/message/template/send * * access_token 带缓存(有效期 7200 秒,提前 5 分钟刷新),避免频繁请求。 * * 环境变量: * - WECHAT_APP_ID: 公众号 AppID * - WECHAT_APP_SECRET: 公众号 AppSecret * - WECHAT_TEMPLATE_ID: 模板消息 ID * * 模板数据映射: payload.title -> keyword1, payload.content -> keyword2, * payload.type -> keyword3(可在微信公众号后台配置模板字段)。 */ import type { NotificationPayload, ChannelSendResult, NotificationChannel, } from "../types" import type { NotificationChannelSender, ChannelRecipient } from "./types" const channel: NotificationChannel = "wechat" const WECHAT_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token" const WECHAT_SEND_URL = "https://api.weixin.qq.com/cgi-bin/message/template/send" /** access_token 缓存(进程级) */ interface TokenCache { accessToken: string expiresAt: number // 毫秒时间戳 } let tokenCache: TokenCache | null = null /** 从环境变量读取微信配置 */ function getWechatConfig() { return { appId: process.env.WECHAT_APP_ID, appSecret: process.env.WECHAT_APP_SECRET, templateId: process.env.WECHAT_TEMPLATE_ID, } } /** 是否启用微信渠道(配置完整才启用) */ export function isWechatEnabled(): boolean { const config = getWechatConfig() return Boolean(config.appId && config.appSecret && config.templateId) } /** * 获取微信 access_token(带缓存)。 * 缓存有效期内的 token 直接复用;过期或不存在时重新请求。 */ async function getAccessToken(): Promise { const now = Date.now() // 缓存有效(提前 5 分钟刷新,避免边界过期) if (tokenCache && tokenCache.expiresAt - now > 5 * 60 * 1000) { return tokenCache.accessToken } const config = getWechatConfig() if (!config.appId || !config.appSecret) { throw new Error("WECHAT_APP_ID/WECHAT_APP_SECRET not configured") } const url = `${WECHAT_TOKEN_URL}?grant_type=client_credential&appid=${encodeURIComponent( config.appId )}&secret=${encodeURIComponent(config.appSecret)}` const res = await fetch(url, { method: "GET" }) const data = (await res.json()) as { access_token?: string expires_in?: number errcode?: number errmsg?: string } if (!data.access_token) { throw new Error( `Failed to get WeChat access_token: ${data.errcode} ${data.errmsg ?? ""}` ) } tokenCache = { accessToken: data.access_token, expiresAt: now + (data.expires_in ?? 7200) * 1000, } return tokenCache.accessToken } /** * 将通知负载映射为微信模板数据。 * 默认映射: title -> keyword1, content -> keyword2, type -> keyword3。 * 如需自定义映射,可在 metadata 中提供 wechatKeywords 覆盖。 */ function buildTemplateData( payload: NotificationPayload ): Record { const custom = (payload.metadata?.wechatKeywords as Record | undefined) ?? {} return { keyword1: { value: custom.keyword1 ?? payload.title }, keyword2: { value: custom.keyword2 ?? payload.content }, keyword3: { value: custom.keyword3 ?? payload.type }, } } /** Mock 微信发送器(开发环境使用) */ class MockWechatSender implements NotificationChannelSender { readonly channel = channel async send( payload: NotificationPayload, recipient: ChannelRecipient ): Promise { console.info( `[MockWechat] send to openId=${recipient.wechatOpenId ?? "(no openId)"}: title="${payload.title}"` ) return { channel, success: true, messageId: `mock-wechat-${Date.now()}`, sentAt: new Date(), } } async sendBatch( items: Array<{ payload: NotificationPayload; recipient: ChannelRecipient }> ): Promise { return Promise.all(items.map((item) => this.send(item.payload, item.recipient))) } } /** 微信公众号模板消息发送器 */ class WechatTemplateSender implements NotificationChannelSender { readonly channel = channel async send( payload: NotificationPayload, recipient: ChannelRecipient ): Promise { if (!recipient.wechatOpenId) { return { channel, success: false, error: "Missing recipient wechatOpenId", sentAt: new Date() } } const config = getWechatConfig() if (!config.templateId) { return { channel, success: false, error: "WECHAT_TEMPLATE_ID not configured", sentAt: new Date() } } try { const accessToken = await getAccessToken() const url = `${WECHAT_SEND_URL}?access_token=${encodeURIComponent(accessToken)}` const body = { touser: recipient.wechatOpenId, template_id: config.templateId, url: payload.actionUrl ?? "", data: buildTemplateData(payload), } const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) const data = (await res.json()) as { errcode?: number errmsg?: string msgid?: number } const success = data.errcode === 0 return { channel, success, messageId: data.msgid != null ? String(data.msgid) : undefined, error: success ? undefined : `${data.errcode}: ${data.errmsg ?? ""}`, sentAt: new Date(), } } catch (e) { return { channel, success: false, error: e instanceof Error ? e.message : "WeChat send failed", sentAt: new Date(), } } } async sendBatch( items: Array<{ payload: NotificationPayload; recipient: ChannelRecipient }> ): Promise { return Promise.all(items.map((item) => this.send(item.payload, item.recipient))) } } /** * 创建微信渠道发送器。 * 配置完整时使用真实发送器,否则使用 Mock 实现。 */ export function createWechatSender(): NotificationChannelSender { if (isWechatEnabled()) { return new WechatTemplateSender() } return new MockWechatSender() }