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 警告
This commit is contained in:
119
src/modules/notifications/actions.ts
Normal file
119
src/modules/notifications/actions.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
"use server"
|
||||
|
||||
/**
|
||||
* 通知 Server Actions
|
||||
*
|
||||
* - sendNotificationAction: 发送通知给指定用户(需要 MESSAGE_SEND 权限)
|
||||
* - sendClassNotificationAction: 发送班级通知(教师权限,按班级查询学生后批量发送)
|
||||
*
|
||||
* 权限说明:
|
||||
* 项目无独立 NOTIFICATION_SEND 权限点,复用 MESSAGE_SEND(教师/管理员/年级主任均拥有)。
|
||||
* 班级通知按教师所教班级过滤,确保教师只能给自己班级发通知。
|
||||
*/
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { classEnrollments, classes } from "@/shared/db/schema"
|
||||
import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
import { sendNotification, sendBatchNotifications } from "./dispatcher"
|
||||
import type { NotificationPayload, ChannelSendResult } from "./types"
|
||||
|
||||
/**
|
||||
* 发送通知给指定用户。
|
||||
*
|
||||
* @param payload 通知负载(payload.userId 为接收人)
|
||||
*/
|
||||
export async function sendNotificationAction(
|
||||
payload: NotificationPayload
|
||||
): Promise<ActionState<ChannelSendResult[]>> {
|
||||
try {
|
||||
await requirePermission(Permissions.MESSAGE_SEND)
|
||||
|
||||
if (!payload.userId || !payload.title || !payload.content) {
|
||||
return { success: false, message: "Missing required fields: userId, title, content" }
|
||||
}
|
||||
|
||||
const results = await sendNotification(payload)
|
||||
const allSuccess = results.every((r) => r.success)
|
||||
return {
|
||||
success: allSuccess,
|
||||
message: allSuccess ? "Notification sent" : "Some channels failed",
|
||||
data: results,
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送班级通知(批量发送给班级所有学生)。
|
||||
*
|
||||
* 教师只能给自己所教班级发送通知(通过 dataScope 校验)。
|
||||
*
|
||||
* @param classId 班级 ID
|
||||
* @param payload 通知负载模板(payload.userId 会被覆盖为每个学生的 userId)
|
||||
*/
|
||||
export async function sendClassNotificationAction(
|
||||
classId: string,
|
||||
payload: Omit<NotificationPayload, "userId">
|
||||
): Promise<ActionState<ChannelSendResult[][]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
|
||||
|
||||
if (!classId || !payload.title || !payload.content) {
|
||||
return { success: false, message: "Missing required fields: classId, title, content" }
|
||||
}
|
||||
|
||||
// 权限校验: 教师只能给自己所教班级发通知;管理员可发任意班级
|
||||
if (ctx.dataScope.type !== "all") {
|
||||
const allowedClassIds =
|
||||
ctx.dataScope.type === "class_taught" ? ctx.dataScope.classIds : []
|
||||
if (!allowedClassIds.includes(classId)) {
|
||||
return { success: false, message: "You can only send notifications to your own classes" }
|
||||
}
|
||||
}
|
||||
|
||||
// 查询班级所有学生
|
||||
const [classRow] = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(eq(classes.id, classId))
|
||||
.limit(1)
|
||||
|
||||
if (!classRow) {
|
||||
return { success: false, message: "Class not found" }
|
||||
}
|
||||
|
||||
const enrollments = await db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(eq(classEnrollments.classId, classId))
|
||||
|
||||
if (enrollments.length === 0) {
|
||||
return { success: true, message: "No students in this class", data: [] }
|
||||
}
|
||||
|
||||
// 构造每个学生的通知负载
|
||||
const payloads: NotificationPayload[] = enrollments.map((e) => ({
|
||||
...payload,
|
||||
userId: e.studentId,
|
||||
}))
|
||||
|
||||
const results = await sendBatchNotifications(payloads)
|
||||
return {
|
||||
success: true,
|
||||
message: `Notification sent to ${enrollments.length} students`,
|
||||
data: results,
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
183
src/modules/notifications/channels/email-channel.ts
Normal file
183
src/modules/notifications/channels/email-channel.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import "server-only"
|
||||
|
||||
/**
|
||||
* 邮件通知渠道
|
||||
*
|
||||
* 使用 nodemailer(动态 import)通过 SMTP 发送邮件。
|
||||
* 支持 HTML 邮件模板,根据 payload.type 渲染不同颜色样式。
|
||||
*
|
||||
* 环境变量:
|
||||
* - EMAIL_HOST: SMTP 主机
|
||||
* - EMAIL_PORT: SMTP 端口(默认 587)
|
||||
* - EMAIL_USER: SMTP 用户名
|
||||
* - EMAIL_PASS: SMTP 密码
|
||||
* - EMAIL_FROM: 发件人地址(默认 noreply@example.com)
|
||||
*
|
||||
* Mock 实现: 当 EMAIL_HOST 未配置时启用,仅记录日志不实际发送。
|
||||
*/
|
||||
|
||||
import type {
|
||||
NotificationPayload,
|
||||
ChannelSendResult,
|
||||
NotificationChannel,
|
||||
} from "../types"
|
||||
import type { NotificationChannelSender, ChannelRecipient } from "./types"
|
||||
|
||||
const channel: NotificationChannel = "email"
|
||||
|
||||
/** 从环境变量读取邮件配置 */
|
||||
function getEmailConfig() {
|
||||
return {
|
||||
host: process.env.EMAIL_HOST,
|
||||
port: Number(process.env.EMAIL_PORT ?? "587"),
|
||||
user: process.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_PASS,
|
||||
from: process.env.EMAIL_FROM ?? "noreply@example.com",
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否启用邮件渠道(SMTP 主机配置即启用) */
|
||||
export function isEmailEnabled(): boolean {
|
||||
return Boolean(process.env.EMAIL_HOST)
|
||||
}
|
||||
|
||||
/** 根据通知类型返回主题色(用于 HTML 模板) */
|
||||
function getTypeColor(type: NotificationPayload["type"]): string {
|
||||
switch (type) {
|
||||
case "success":
|
||||
return "#16a34a"
|
||||
case "warning":
|
||||
return "#d97706"
|
||||
case "error":
|
||||
return "#dc2626"
|
||||
case "info":
|
||||
default:
|
||||
return "#2563eb"
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成 HTML 邮件内容 */
|
||||
function buildHtmlContent(payload: NotificationPayload): string {
|
||||
const color = getTypeColor(payload.type)
|
||||
const actionLink = payload.actionUrl
|
||||
? `<p style="margin-top:16px;"><a href="${payload.actionUrl}" style="color:${color};text-decoration:none;">点击查看详情 →</a></p>`
|
||||
: ""
|
||||
return `
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
|
||||
<div style="border-left:4px solid ${color};padding-left:16px;">
|
||||
<h2 style="color:${color};margin:0 0 12px 0;">${escapeHtml(payload.title)}</h2>
|
||||
<p style="color:#374151;line-height:1.6;margin:0;">${escapeHtml(payload.content)}</p>
|
||||
${actionLink}
|
||||
</div>
|
||||
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;" />
|
||||
<p style="color:#9ca3af;font-size:12px;margin:0;">此邮件由系统自动发送,请勿回复。</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
/** HTML 转义,防止 XSS */
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
/** Mock 邮件发送器(开发环境使用) */
|
||||
class MockEmailSender implements NotificationChannelSender {
|
||||
readonly channel = channel
|
||||
|
||||
async send(
|
||||
payload: NotificationPayload,
|
||||
recipient: ChannelRecipient
|
||||
): Promise<ChannelSendResult> {
|
||||
console.info(
|
||||
`[MockEmail] send to ${recipient.email ?? "(no email)"}: subject="${payload.title}"`
|
||||
)
|
||||
return {
|
||||
channel,
|
||||
success: true,
|
||||
messageId: `mock-email-${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)))
|
||||
}
|
||||
}
|
||||
|
||||
/** Nodemailer SMTP 邮件发送器 */
|
||||
class NodemailerEmailSender implements NotificationChannelSender {
|
||||
readonly channel = channel
|
||||
|
||||
async send(
|
||||
payload: NotificationPayload,
|
||||
recipient: ChannelRecipient
|
||||
): Promise<ChannelSendResult> {
|
||||
if (!recipient.email) {
|
||||
return { channel, success: false, error: "Missing recipient email", sentAt: new Date() }
|
||||
}
|
||||
|
||||
const config = getEmailConfig()
|
||||
if (!config.host) {
|
||||
return { channel, success: false, error: "EMAIL_HOST not configured", sentAt: new Date() }
|
||||
}
|
||||
|
||||
try {
|
||||
// 动态 import nodemailer,避免增加构建体积
|
||||
const nodemailer = await import("nodemailer")
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.port === 465,
|
||||
auth: config.user
|
||||
? { user: config.user, pass: config.pass ?? "" }
|
||||
: undefined,
|
||||
})
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: config.from,
|
||||
to: recipient.email,
|
||||
subject: payload.title,
|
||||
html: buildHtmlContent(payload),
|
||||
text: payload.content,
|
||||
})
|
||||
|
||||
return {
|
||||
channel,
|
||||
success: true,
|
||||
messageId: info.messageId,
|
||||
sentAt: new Date(),
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
channel,
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : "Email 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)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建邮件渠道发送器。
|
||||
* 配置了 EMAIL_HOST 时使用 Nodemailer,否则使用 Mock 实现。
|
||||
*/
|
||||
export function createEmailSender(): NotificationChannelSender {
|
||||
if (isEmailEnabled()) {
|
||||
return new NodemailerEmailSender()
|
||||
}
|
||||
return new MockEmailSender()
|
||||
}
|
||||
83
src/modules/notifications/channels/in-app-channel.ts
Normal file
83
src/modules/notifications/channels/in-app-channel.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import "server-only"
|
||||
|
||||
/**
|
||||
* 站内消息渠道
|
||||
*
|
||||
* 封装现有 messaging 模块的 data-access.createNotification,
|
||||
* 将其适配为统一的 NotificationChannelSender 接口。
|
||||
*
|
||||
* 这是默认渠道,总是启用。所有通知都会写入 message_notifications 表,
|
||||
* 用户可在站内通知中心查看。
|
||||
*
|
||||
* 注意: messaging.NotificationType 为 "message" | "announcement" | "homework" | "grade",
|
||||
* 而本模块 NotificationPayload.type 为 "info" | "warning" | "error" | "success"。
|
||||
* 此处将 payload.type 作为字符串写入 DB(DB 列为 varchar(128),支持任意值),
|
||||
* 不破坏现有 messaging 模块的类型约束。
|
||||
*/
|
||||
|
||||
import type {
|
||||
NotificationPayload,
|
||||
ChannelSendResult,
|
||||
NotificationChannel,
|
||||
} from "../types"
|
||||
import type { NotificationChannelSender, ChannelRecipient } from "./types"
|
||||
import { createNotification } from "@/modules/messaging/data-access"
|
||||
|
||||
const channel: NotificationChannel = "in_app"
|
||||
|
||||
/** 站内消息发送器(调用现有 messaging data-access) */
|
||||
class InAppChannelSender implements NotificationChannelSender {
|
||||
readonly channel = channel
|
||||
|
||||
async send(
|
||||
payload: NotificationPayload,
|
||||
recipient: ChannelRecipient
|
||||
): Promise<ChannelSendResult> {
|
||||
try {
|
||||
// 校验收件人一致,防止误发
|
||||
if (recipient.userId !== payload.userId) {
|
||||
return {
|
||||
channel,
|
||||
success: false,
|
||||
error: "Recipient userId does not match payload.userId",
|
||||
sentAt: new Date(),
|
||||
}
|
||||
}
|
||||
const id = await createNotification({
|
||||
userId: payload.userId,
|
||||
// DB 列为 varchar(128),支持任意字符串;保留 payload.type 语义
|
||||
type: payload.type as "message" | "announcement" | "homework" | "grade",
|
||||
title: payload.title,
|
||||
content: payload.content,
|
||||
link: payload.actionUrl ?? null,
|
||||
})
|
||||
return {
|
||||
channel,
|
||||
success: true,
|
||||
messageId: id,
|
||||
sentAt: new Date(),
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
channel,
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : "In-app notification 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)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建站内消息渠道发送器。
|
||||
* 站内渠道总是启用,无需配置。
|
||||
*/
|
||||
export function createInAppSender(): NotificationChannelSender {
|
||||
return new InAppChannelSender()
|
||||
}
|
||||
236
src/modules/notifications/channels/sms-channel.ts
Normal file
236
src/modules/notifications/channels/sms-channel.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import "server-only"
|
||||
|
||||
/**
|
||||
* 短信通知渠道
|
||||
*
|
||||
* 支持的 Provider:
|
||||
* - aliyun: 阿里云短信(@alicloud/dysmsapi20170525,动态 import)
|
||||
* - tencent: 腾讯云短信(tencentcloud-sdk,动态 import)
|
||||
* - mock: 开发环境模拟(仅记录日志,不实际发送)
|
||||
*
|
||||
* 环境变量:
|
||||
* - SMS_PROVIDER: "aliyun" | "tencent" | "mock"(默认 mock)
|
||||
* - SMS_ACCESS_KEY_ID / SMS_ACCESS_KEY_SECRET
|
||||
* - SMS_SIGN_NAME: 短信签名
|
||||
* - SMS_TEMPLATE_CODE: 短信模板 ID
|
||||
*
|
||||
* 模板变量替换:将 payload.title / payload.content 填入模板变量 name/title/content。
|
||||
*/
|
||||
|
||||
import type {
|
||||
NotificationPayload,
|
||||
ChannelSendResult,
|
||||
NotificationChannel,
|
||||
} from "../types"
|
||||
import type { NotificationChannelSender, ChannelRecipient } from "./types"
|
||||
|
||||
const channel: NotificationChannel = "sms"
|
||||
|
||||
/** 从环境变量读取 SMS 配置 */
|
||||
function getSmsConfig() {
|
||||
return {
|
||||
provider: (process.env.SMS_PROVIDER ?? "mock") as "aliyun" | "tencent" | "mock",
|
||||
accessKeyId: process.env.SMS_ACCESS_KEY_ID,
|
||||
accessKeySecret: process.env.SMS_ACCESS_KEY_SECRET,
|
||||
signName: process.env.SMS_SIGN_NAME,
|
||||
templateCode: process.env.SMS_TEMPLATE_CODE,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造短信模板变量。
|
||||
* 阿里云/腾讯云模板使用 ${name} / ${title} / ${content} 占位符。
|
||||
*/
|
||||
function buildTemplateParams(payload: NotificationPayload): Record<string, string> {
|
||||
return {
|
||||
title: payload.title,
|
||||
content: payload.content,
|
||||
type: payload.type,
|
||||
}
|
||||
}
|
||||
|
||||
/** Mock 短信发送器(开发环境使用) */
|
||||
class MockSmsSender implements NotificationChannelSender {
|
||||
readonly channel = channel
|
||||
|
||||
async send(
|
||||
payload: NotificationPayload,
|
||||
recipient: ChannelRecipient
|
||||
): Promise<ChannelSendResult> {
|
||||
const params = buildTemplateParams(payload)
|
||||
// 开发环境仅记录日志,不实际发送
|
||||
console.info(
|
||||
`[MockSms] send to ${recipient.phone ?? "(no phone)"}: title="${payload.title}" params=`,
|
||||
params
|
||||
)
|
||||
return {
|
||||
channel,
|
||||
success: true,
|
||||
messageId: `mock-sms-${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 AliyunSmsSender implements NotificationChannelSender {
|
||||
readonly channel = channel
|
||||
private config: ReturnType<typeof getSmsConfig>
|
||||
|
||||
constructor() {
|
||||
this.config = getSmsConfig()
|
||||
}
|
||||
|
||||
async send(
|
||||
payload: NotificationPayload,
|
||||
recipient: ChannelRecipient
|
||||
): Promise<ChannelSendResult> {
|
||||
if (!recipient.phone) {
|
||||
return { channel, success: false, error: "Missing recipient phone", sentAt: new Date() }
|
||||
}
|
||||
if (!this.config.accessKeyId || !this.config.accessKeySecret) {
|
||||
return { channel, success: false, error: "SMS_ACCESS_KEY_ID/SECRET not configured", sentAt: new Date() }
|
||||
}
|
||||
|
||||
try {
|
||||
// 动态 import 阿里云 SDK,避免增加构建体积
|
||||
const { default: Dysmsapi } = await import("@alicloud/dysmsapi20170525")
|
||||
const { default: OpenApi } = await import("@alicloud/openapi-client")
|
||||
const { default: Credential } = await import("@alicloud/credentials")
|
||||
|
||||
const credential = new Credential({
|
||||
accessKeyId: this.config.accessKeyId,
|
||||
accessKeySecret: this.config.accessKeySecret,
|
||||
})
|
||||
const apiConfig = new OpenApi({ credential })
|
||||
apiConfig.endpoint = "dysmsapi.aliyuncs.com"
|
||||
const client = new Dysmsapi(apiConfig)
|
||||
|
||||
const { SendSmsRequest } = await import("@alicloud/dysmsapi20170525")
|
||||
const request = new SendSmsRequest({
|
||||
phoneNumbers: recipient.phone,
|
||||
signName: this.config.signName,
|
||||
templateCode: this.config.templateCode,
|
||||
templateParam: JSON.stringify(buildTemplateParams(payload)),
|
||||
})
|
||||
|
||||
const response = await client.sendSms(request)
|
||||
const code = response?.body?.code
|
||||
const success = code === "OK"
|
||||
return {
|
||||
channel,
|
||||
success,
|
||||
messageId: response?.body?.bizId ?? undefined,
|
||||
error: success ? undefined : response?.body?.message,
|
||||
sentAt: new Date(),
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
channel,
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : "Aliyun SMS 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)))
|
||||
}
|
||||
}
|
||||
|
||||
/** 腾讯云短信发送器 */
|
||||
class TencentSmsSender implements NotificationChannelSender {
|
||||
readonly channel = channel
|
||||
private config: ReturnType<typeof getSmsConfig>
|
||||
|
||||
constructor() {
|
||||
this.config = getSmsConfig()
|
||||
}
|
||||
|
||||
async send(
|
||||
payload: NotificationPayload,
|
||||
recipient: ChannelRecipient
|
||||
): Promise<ChannelSendResult> {
|
||||
if (!recipient.phone) {
|
||||
return { channel, success: false, error: "Missing recipient phone", sentAt: new Date() }
|
||||
}
|
||||
if (!this.config.accessKeyId || !this.config.accessKeySecret) {
|
||||
return { channel, success: false, error: "SMS_ACCESS_KEY_ID/SECRET not configured", sentAt: new Date() }
|
||||
}
|
||||
|
||||
try {
|
||||
// 动态 import 腾讯云 SDK
|
||||
const tencentcloud = await import("tencentcloud-sdk-nodejs")
|
||||
const SmsClient = tencentcloud.sms.v20210111.Client
|
||||
|
||||
const client = new SmsClient({
|
||||
credential: {
|
||||
secretId: this.config.accessKeyId,
|
||||
secretKey: this.config.accessKeySecret,
|
||||
},
|
||||
region: "ap-guangzhou",
|
||||
profile: {
|
||||
httpProfile: { endpoint: "sms.tencentcloudapi.com" },
|
||||
},
|
||||
})
|
||||
|
||||
const params = buildTemplateParams(payload)
|
||||
const response = await client.SendSms({
|
||||
PhoneNumberSet: [`+86${recipient.phone}`],
|
||||
SmsSdkAppId: this.config.templateCode ?? "",
|
||||
SignName: this.config.signName ?? "",
|
||||
TemplateId: this.config.templateCode ?? "",
|
||||
TemplateParamSet: [params.title, params.content],
|
||||
})
|
||||
|
||||
const sendStatus = response?.SendStatusSet?.[0]
|
||||
const success = sendStatus?.Code === "Ok"
|
||||
return {
|
||||
channel,
|
||||
success,
|
||||
messageId: sendStatus?.SerialNo ?? undefined,
|
||||
error: success ? undefined : sendStatus?.Message,
|
||||
sentAt: new Date(),
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
channel,
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : "Tencent SMS 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)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 SMS 渠道发送器(根据 SMS_PROVIDER 环境变量选择实现)。
|
||||
* 默认使用 Mock 实现,确保开发环境无外部服务时也可用。
|
||||
*/
|
||||
export function createSmsSender(): NotificationChannelSender {
|
||||
const config = getSmsConfig()
|
||||
switch (config.provider) {
|
||||
case "aliyun":
|
||||
return new AliyunSmsSender()
|
||||
case "tencent":
|
||||
return new TencentSmsSender()
|
||||
case "mock":
|
||||
default:
|
||||
return new MockSmsSender()
|
||||
}
|
||||
}
|
||||
38
src/modules/notifications/channels/types.ts
Normal file
38
src/modules/notifications/channels/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 渠道发送者接口定义
|
||||
*
|
||||
* 所有渠道(SMS/微信/邮件/站内)均实现 NotificationChannelSender 接口,
|
||||
* 由 dispatcher 统一调度。新增渠道只需实现此接口并注册到 dispatcher。
|
||||
*/
|
||||
|
||||
import type { NotificationPayload, ChannelSendResult, NotificationChannel } from "../types"
|
||||
|
||||
/** 渠道接收人信息(由 data-access.getUserContactInfo 填充) */
|
||||
export interface ChannelRecipient {
|
||||
userId: string
|
||||
/** 手机号(SMS 渠道必需) */
|
||||
phone?: string
|
||||
/** 邮箱(邮件渠道必需) */
|
||||
email?: string
|
||||
/** 微信 OpenID(微信公众号模板消息渠道必需) */
|
||||
wechatOpenId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知渠道发送者接口
|
||||
*
|
||||
* 实现方需保证:
|
||||
* - send 失败时抛出 Error 或返回 success:false(不抛出以避免阻塞其他渠道)
|
||||
* - sendBatch 内部并行发送,单条失败不影响其他条
|
||||
* - 所有实现需在文件首行 import "server-only"
|
||||
*/
|
||||
export interface NotificationChannelSender {
|
||||
/** 渠道标识 */
|
||||
readonly channel: NotificationChannel
|
||||
/** 发送单条通知 */
|
||||
send(payload: NotificationPayload, recipient: ChannelRecipient): Promise<ChannelSendResult>
|
||||
/** 批量发送(默认实现串行调用 send,可覆写为并行) */
|
||||
sendBatch(
|
||||
payloads: Array<{ payload: NotificationPayload; recipient: ChannelRecipient }>
|
||||
): Promise<ChannelSendResult[]>
|
||||
}
|
||||
208
src/modules/notifications/channels/wechat-channel.ts
Normal file
208
src/modules/notifications/channels/wechat-channel.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
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()
|
||||
}
|
||||
86
src/modules/notifications/data-access.ts
Normal file
86
src/modules/notifications/data-access.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import "server-only"
|
||||
|
||||
/**
|
||||
* 通知数据访问层
|
||||
*
|
||||
* 职责:
|
||||
* - getUserNotificationPreferences: 获取用户通知偏好(复用 messaging 模块)
|
||||
* - getUserContactInfo: 获取用户联系方式(手机/邮箱,用于渠道发送)
|
||||
* - logNotificationSend: 记录发送日志(当前项目无 notification_logs 表,使用 console 输出)
|
||||
*
|
||||
* 注意: users 表当前无 wechatOpenId 字段,wechatOpenId 暂返回 undefined。
|
||||
* 未来扩展 users 表增加 wechat_open_id 列后,此处补充查询即可。
|
||||
*/
|
||||
|
||||
import { cache } from "react"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { users } from "@/shared/db/schema"
|
||||
import { getNotificationPreferences } from "@/modules/messaging/notification-preferences"
|
||||
import type { NotificationPreferences } from "@/modules/messaging/types"
|
||||
import type { ChannelRecipient } from "./channels/types"
|
||||
import type { ChannelSendResult } from "./types"
|
||||
|
||||
/**
|
||||
* 获取用户通知偏好(复用 messaging 模块的 cache 包装函数)。
|
||||
* 若用户无记录,messaging 模块会自动创建默认记录。
|
||||
*/
|
||||
export async function getUserNotificationPreferences(
|
||||
userId: string
|
||||
): Promise<NotificationPreferences> {
|
||||
return getNotificationPreferences(userId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户联系方式(手机号、邮箱)。
|
||||
* wechatOpenId 暂不支持(users 表无此字段),返回 undefined。
|
||||
*/
|
||||
export const getUserContactInfo = cache(
|
||||
async (userId: string): Promise<ChannelRecipient> => {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
phone: users.phone,
|
||||
email: users.email,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!row) {
|
||||
return { userId }
|
||||
}
|
||||
|
||||
return {
|
||||
userId: row.id,
|
||||
phone: row.phone ?? undefined,
|
||||
email: row.email ?? undefined,
|
||||
// users 表暂无 wechat_open_id 字段;扩展 schema 后在此补充
|
||||
wechatOpenId: undefined,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 记录通知发送日志。
|
||||
*
|
||||
* 当前项目无 notification_logs 表,使用 console.info 输出。
|
||||
* 未来新增 notification_logs 表后,可在此处写入 DB。
|
||||
*/
|
||||
export function logNotificationSend(result: ChannelSendResult): void {
|
||||
const status = result.success ? "OK" : "FAIL"
|
||||
const errorPart = result.error ? ` error="${result.error}"` : ""
|
||||
console.info(
|
||||
`[NotificationLog] ${status} channel=${result.channel} messageId=${result.messageId ?? "-"}${errorPart}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量记录发送日志。
|
||||
*/
|
||||
export function logNotificationSendBatch(results: ChannelSendResult[]): void {
|
||||
for (const result of results) {
|
||||
logNotificationSend(result)
|
||||
}
|
||||
}
|
||||
152
src/modules/notifications/dispatcher.ts
Normal file
152
src/modules/notifications/dispatcher.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import "server-only"
|
||||
|
||||
/**
|
||||
* 通知分发器
|
||||
*
|
||||
* 职责:
|
||||
* - 根据用户通知偏好(notification_preferences)决定使用哪些渠道
|
||||
* - 并行发送到多个渠道(in_app 总是启用)
|
||||
* - 记录每个渠道的发送结果到日志
|
||||
*
|
||||
* 渠道选择逻辑:
|
||||
* - in_app: 总是启用(pushEnabled 控制是否发送站内推送)
|
||||
* - sms: smsEnabled && 用户有手机号
|
||||
* - email: emailEnabled && 用户有邮箱
|
||||
* - wechat: pushEnabled && 用户有 wechatOpenId(当前 users 表无此字段,暂不发送)
|
||||
*
|
||||
* 注意: 通知偏好中的 homeworkNotifications/gradeNotifications 等是按通知类别控制,
|
||||
* 由调用方在构造 payload 时决定是否调用 sendNotification。
|
||||
*/
|
||||
|
||||
import type { NotificationPayload, ChannelSendResult, NotificationChannel } from "./types"
|
||||
import type { NotificationChannelSender, ChannelRecipient } from "./channels/types"
|
||||
import { createSmsSender } from "./channels/sms-channel"
|
||||
import { createWechatSender } from "./channels/wechat-channel"
|
||||
import { createEmailSender } from "./channels/email-channel"
|
||||
import { createInAppSender } from "./channels/in-app-channel"
|
||||
import {
|
||||
getUserNotificationPreferences,
|
||||
getUserContactInfo,
|
||||
logNotificationSendBatch,
|
||||
} from "./data-access"
|
||||
|
||||
/** 渠道发送器实例缓存(避免每次发送重新创建) */
|
||||
interface SenderRegistry {
|
||||
in_app: NotificationChannelSender
|
||||
sms: NotificationChannelSender
|
||||
wechat: NotificationChannelSender
|
||||
email: NotificationChannelSender
|
||||
}
|
||||
|
||||
let senderRegistry: SenderRegistry | null = null
|
||||
|
||||
/** 获取渠道发送器注册表(单例) */
|
||||
function getSenders(): SenderRegistry {
|
||||
if (!senderRegistry) {
|
||||
senderRegistry = {
|
||||
in_app: createInAppSender(),
|
||||
sms: createSmsSender(),
|
||||
wechat: createWechatSender(),
|
||||
email: createEmailSender(),
|
||||
}
|
||||
}
|
||||
return senderRegistry
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户通知偏好和联系方式,决定启用的渠道列表。
|
||||
*/
|
||||
function selectChannels(
|
||||
prefs: {
|
||||
smsEnabled: boolean
|
||||
emailEnabled: boolean
|
||||
pushEnabled: boolean
|
||||
},
|
||||
contact: ChannelRecipient
|
||||
): NotificationChannel[] {
|
||||
const channels: NotificationChannel[] = []
|
||||
|
||||
// 站内消息总是启用(pushEnabled 控制站内推送,默认 true)
|
||||
if (prefs.pushEnabled) {
|
||||
channels.push("in_app")
|
||||
}
|
||||
|
||||
// SMS: 偏好启用且有手机号
|
||||
if (prefs.smsEnabled && contact.phone) {
|
||||
channels.push("sms")
|
||||
}
|
||||
|
||||
// Email: 偏好启用且有邮箱
|
||||
if (prefs.emailEnabled && contact.email) {
|
||||
channels.push("email")
|
||||
}
|
||||
|
||||
// WeChat: 偏好启用且有 openId(当前 users 表无此字段,暂不会触发)
|
||||
if (prefs.pushEnabled && contact.wechatOpenId) {
|
||||
channels.push("wechat")
|
||||
}
|
||||
|
||||
// 兜底: 如果所有渠道都未启用,至少发站内消息
|
||||
if (channels.length === 0) {
|
||||
channels.push("in_app")
|
||||
}
|
||||
|
||||
return channels
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送单条通知到用户。
|
||||
*
|
||||
* 根据用户通知偏好选择渠道,并行发送,返回所有渠道的发送结果。
|
||||
*
|
||||
* @param payload 通知负载(payload.userId 决定接收人)
|
||||
* @returns 各渠道的发送结果
|
||||
*/
|
||||
export async function sendNotification(
|
||||
payload: NotificationPayload
|
||||
): Promise<ChannelSendResult[]> {
|
||||
const userId = payload.userId
|
||||
|
||||
// 并行获取用户偏好和联系方式
|
||||
const [prefs, contact] = await Promise.all([
|
||||
getUserNotificationPreferences(userId),
|
||||
getUserContactInfo(userId),
|
||||
])
|
||||
|
||||
const channels = selectChannels(prefs, contact)
|
||||
const senders = getSenders()
|
||||
|
||||
// 并行发送到所有选中渠道
|
||||
const results = await Promise.all(
|
||||
channels.map((ch) => {
|
||||
const sender = senders[ch]
|
||||
return sender.send(payload, contact)
|
||||
})
|
||||
)
|
||||
|
||||
// 记录发送日志
|
||||
logNotificationSendBatch(results)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量发送通知到多个用户。
|
||||
*
|
||||
* 每个用户的渠道选择独立计算,并行发送。
|
||||
*
|
||||
* @param payloads 通知负载数组(每个 payload.userId 决定各自接收人)
|
||||
* @returns 各用户各渠道的发送结果(按输入顺序对应)
|
||||
*/
|
||||
export async function sendBatchNotifications(
|
||||
payloads: NotificationPayload[]
|
||||
): Promise<ChannelSendResult[][]> {
|
||||
// 并行处理每个 payload
|
||||
const results = await Promise.all(payloads.map((p) => sendNotification(p)))
|
||||
|
||||
// 汇总日志
|
||||
const flatResults = results.flat()
|
||||
logNotificationSendBatch(flatResults)
|
||||
|
||||
return results
|
||||
}
|
||||
47
src/modules/notifications/external-sdk.d.ts
vendored
Normal file
47
src/modules/notifications/external-sdk.d.ts
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/**
|
||||
* 外部 SDK 类型声明(可选依赖)
|
||||
*
|
||||
* 这些 SDK 通过动态 import 在运行时加载,开发环境(Mock 模式)无需安装。
|
||||
* 安装对应 SDK 后,其自带的类型声明将覆盖此处的 any 声明。
|
||||
*
|
||||
* 安装命令见 docs/notifications/channels.md
|
||||
*/
|
||||
|
||||
declare module "@alicloud/dysmsapi20170525" {
|
||||
const _default: any
|
||||
export { _default as default }
|
||||
export class SendSmsRequest {
|
||||
constructor(params?: Record<string, unknown>)
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@alicloud/openapi-client" {
|
||||
const _default: new (config: any) => any
|
||||
export { _default as default }
|
||||
}
|
||||
|
||||
declare module "@alicloud/credentials" {
|
||||
const _default: new (config: any) => any
|
||||
export { _default as default }
|
||||
}
|
||||
|
||||
declare module "tencentcloud-sdk-nodejs" {
|
||||
const _default: {
|
||||
sms: {
|
||||
v20210111: {
|
||||
Client: new (config: any) => any
|
||||
}
|
||||
}
|
||||
}
|
||||
export = _default
|
||||
}
|
||||
|
||||
declare module "nodemailer" {
|
||||
const _default: {
|
||||
createTransport: (config: any) => {
|
||||
sendMail: (options: any) => Promise<{ messageId: string }>
|
||||
}
|
||||
}
|
||||
export = _default
|
||||
}
|
||||
38
src/modules/notifications/index.ts
Normal file
38
src/modules/notifications/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 通知渠道集成模块
|
||||
*
|
||||
* 对外导出:
|
||||
* - sendNotification / sendBatchNotifications: 分发器入口
|
||||
* - 类型定义: NotificationPayload, ChannelSendResult, NotificationChannel 等
|
||||
* - 渠道发送器工厂: createSmsSender, createWechatSender, createEmailSender, createInAppSender
|
||||
*
|
||||
* 典型用法:
|
||||
* ```ts
|
||||
* import { sendNotification } from "@/modules/notifications"
|
||||
* await sendNotification({
|
||||
* userId: "user-xxx",
|
||||
* title: "作业提醒",
|
||||
* content: "您有一份新作业待提交",
|
||||
* type: "info",
|
||||
* actionUrl: "/homework/123",
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
|
||||
export { sendNotification, sendBatchNotifications } from "./dispatcher"
|
||||
export type {
|
||||
NotificationChannel,
|
||||
NotificationPayload,
|
||||
ChannelSendResult,
|
||||
NotificationChannelConfig,
|
||||
SmsChannelConfig,
|
||||
WechatChannelConfig,
|
||||
EmailChannelConfig,
|
||||
} from "./types"
|
||||
export type { NotificationChannelSender, ChannelRecipient } from "./channels/types"
|
||||
|
||||
// 渠道发送器工厂(供高级用法直接调用单个渠道)
|
||||
export { createSmsSender } from "./channels/sms-channel"
|
||||
export { createWechatSender, isWechatEnabled } from "./channels/wechat-channel"
|
||||
export { createEmailSender, isEmailEnabled } from "./channels/email-channel"
|
||||
export { createInAppSender } from "./channels/in-app-channel"
|
||||
70
src/modules/notifications/types.ts
Normal file
70
src/modules/notifications/types.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 通知渠道类型定义
|
||||
*
|
||||
* 本文件定义了通知分发系统使用的核心类型:
|
||||
* - NotificationChannel: 支持的渠道枚举
|
||||
* - NotificationPayload: 通知负载(跨渠道统一)
|
||||
* - ChannelSendResult: 单次发送结果
|
||||
* - NotificationChannelConfig: 渠道配置(从环境变量加载)
|
||||
*/
|
||||
|
||||
/** 支持的通知渠道 */
|
||||
export type NotificationChannel = "in_app" | "email" | "sms" | "wechat"
|
||||
|
||||
/** 通知负载(跨渠道统一格式) */
|
||||
export interface NotificationPayload {
|
||||
userId: string
|
||||
title: string
|
||||
content: string
|
||||
/** 通知语义类型(用于渠道内模板映射,不与 messaging.NotificationType 耦合) */
|
||||
type: "info" | "warning" | "error" | "success"
|
||||
metadata?: Record<string, unknown>
|
||||
/** 点击通知后的跳转地址(站内相对路径或外链) */
|
||||
actionUrl?: string
|
||||
}
|
||||
|
||||
/** 单次渠道发送结果 */
|
||||
export interface ChannelSendResult {
|
||||
channel: NotificationChannel
|
||||
success: boolean
|
||||
/** 渠道返回的消息 ID(用于追踪) */
|
||||
messageId?: string
|
||||
/** 失败时的错误信息 */
|
||||
error?: string
|
||||
sentAt: Date
|
||||
}
|
||||
|
||||
/** SMS 渠道配置 */
|
||||
export interface SmsChannelConfig {
|
||||
provider: "aliyun" | "tencent" | "mock"
|
||||
accessKeyId?: string
|
||||
accessKeySecret?: string
|
||||
signName?: string
|
||||
templateCode?: string
|
||||
}
|
||||
|
||||
/** 微信公众号渠道配置 */
|
||||
export interface WechatChannelConfig {
|
||||
appId?: string
|
||||
appSecret?: string
|
||||
templateId?: string
|
||||
}
|
||||
|
||||
/** 邮件渠道配置 */
|
||||
export interface EmailChannelConfig {
|
||||
provider: "resend" | "nodemailer" | "mock"
|
||||
apiKey?: string
|
||||
from?: string
|
||||
host?: string
|
||||
port?: number
|
||||
user?: string
|
||||
pass?: string
|
||||
}
|
||||
|
||||
/** 通知渠道总配置 */
|
||||
export interface NotificationChannelConfig {
|
||||
enabled: boolean
|
||||
sms?: SmsChannelConfig
|
||||
wechat?: WechatChannelConfig
|
||||
email?: EmailChannelConfig
|
||||
}
|
||||
Reference in New Issue
Block a user