Files
NextEdu/src/modules/notifications/channels/wechat-channel.ts
SpecialX 6585e10c6f feat(P2): 实现质量保障类5项功能(无障碍/视觉回归/通知渠道/漏洞扫描/灾备)
## 新增功能

### 1. 屏幕阅读器兼容性增强(a11y)
- 无障碍工具库:src/shared/lib/a11y.ts
- aria-live Hook:src/shared/hooks/use-aria-live.ts
- a11y 组件:skip-link/visually-hidden/focus-trap/aria-status
- 增强 UI:table.tsx 系统性 ARIA role,dialog.tsx aria-modal
- 审计文档:docs/accessibility/a11y-audit.md(WCAG 2.1 AA 清单)

### 2. 视觉回归测试
- 测试套件:tests/visual/(homepage + 3 个 dashboard)
- 3 视口(desktop/tablet/mobile)× 2 主题(light/dark)
- 动态元素遮罩,避免误报
- playwright.config.ts 新增 visual-chromium 项目
- 文档:docs/testing/visual-regression.md

### 3. 短信/微信推送渠道集成
- 新模块:src/modules/notifications/
- 4 个渠道:SMS(阿里云/腾讯云)、WeChat(公众号)、Email(SMTP)、In-App
- 分发器按用户偏好并行多渠道发送
- 外部 SDK 动态 import,Mock 模式开发可用
- 文档:docs/notifications/channels.md

### 4. 漏洞扫描 CI 集成
- CI security-scan job:npm audit + Snyk + Trivy FS + OWASP ZAP
- 独立工作流 security.yml:每周一深度扫描 + 容器镜像扫描
- 配置:suppressions.json + .trivyignore
- 本地脚本:security-scan.sh/ps1
- 文档:docs/security/scanning.md(SLA 分级)

### 5. 灾备方案
- 脚本:backup-verify/backup-offsite-sync/dr-drill/failover/health-check
- CI 增强:备份后校验+异地同步,每周灾备演练
- 独立工作流 dr-drill.yml:每周一凌晨 4 点自动演练
- 文档:docs/dr/dr-plan.md(RTO 4h/RPO 24h)+ dr-runbook.md(6 故障场景)

## 验证
- npx tsc --noEmit:0 错误
- npm run lint:0 错误 0 警告
2026-06-17 20:18:29 +08:00

209 lines
6.2 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.
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<string> {
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<string, { value: string }> {
const custom = (payload.metadata?.wechatKeywords as Record<string, string> | 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<ChannelSendResult> {
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<ChannelSendResult[]> {
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<ChannelSendResult> {
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<ChannelSendResult[]> {
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()
}