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:
SpecialX
2026-06-17 20:18:29 +08:00
parent b86255f0ea
commit 6585e10c6f
53 changed files with 7491 additions and 37 deletions

View 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" }
}
}

View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
}
/** 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()
}

View 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 作为字符串写入 DBDB 列为 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()
}

View 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()
}
}

View 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[]>
}

View 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()
}

View 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)
}
}

View 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
}

View 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
}

View 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"

View 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
}