feat: 完成 P1 全部功能 + 修复 proxy 导出 + 切换 MySQL 端口至 14013

## P1 功能(20 项)
- 站内消息系统、家长仪表盘、学生考勤管理
- Excel 导入导出、用户批量导入、成绩导出
- 排课规则+自动排课+课表调整
- 成绩趋势+对比分析、密码安全策略、速率限制
- 数据变更日志、文件预览+存储策略、全文检索
- 依赖审计集成 CI、数据库定时备份、E2E 测试完善
- 通知偏好管理

## 基础设施修复
- src/proxy.ts: 将 middleware 导出重命名为 proxy(Next.js 16 要求)
- .env: MySQL 端口从 13002 切换至 14013
- scripts/create-db.ts: 新增数据库初始化脚本

## 架构文档同步
- 004_architecture_impact_map.md 和 005_architecture_data.json
  完整记录所有新增表、模块、路由、权限、依赖关系
This commit is contained in:
SpecialX
2026-06-17 13:44:37 +08:00
parent 125f7ec54c
commit 3b6272c99d
195 changed files with 27274 additions and 416 deletions

View File

@@ -0,0 +1,252 @@
import "server-only"
import { cache } from "react"
import { createId } from "@paralleldrive/cuid2"
import { and, count, desc, eq, inArray, or } from "drizzle-orm"
import { db } from "@/shared/db"
import {
messages,
messageNotifications,
users,
classEnrollments,
classes,
} from "@/shared/db/schema"
import type { DataScope } from "@/shared/types/permissions"
import type {
Message,
Notification,
NotificationType,
GetMessagesParams,
GetNotificationsParams,
CreateMessageInput,
CreateNotificationInput,
PaginatedResult,
RecipientOption,
} from "./types"
const toIso = (d: Date | null | undefined): string | null => (d ? d.toISOString() : null)
interface MessageRow {
id: string
senderId: string
receiverId: string
subject: string | null
content: string
isRead: boolean
readAt: Date | null
parentMessageId: string | null
createdAt: Date
}
interface NotificationRow {
id: string
userId: string
type: string
title: string
content: string | null
link: string | null
isRead: boolean
createdAt: Date
}
async function resolveUserNames(userIds: string[]): Promise<Map<string, string>> {
const uniqueIds = [...new Set(userIds)].filter(Boolean)
if (uniqueIds.length === 0) return new Map()
const rows = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, uniqueIds))
return new Map(rows.map((r) => [r.id, r.name ?? r.id]))
}
const mapMessage = (r: MessageRow, nameMap: Map<string, string>): Message => ({
id: r.id,
senderId: r.senderId,
senderName: nameMap.get(r.senderId) ?? null,
receiverId: r.receiverId,
receiverName: nameMap.get(r.receiverId) ?? null,
subject: r.subject,
content: r.content,
isRead: r.isRead,
readAt: toIso(r.readAt),
parentMessageId: r.parentMessageId,
createdAt: toIso(r.createdAt) as string,
})
const mapNotification = (r: NotificationRow): Notification => ({
id: r.id,
userId: r.userId,
type: r.type as NotificationType,
title: r.title,
content: r.content,
link: r.link,
isRead: r.isRead,
createdAt: toIso(r.createdAt) as string,
})
export const getMessages = cache(
async (params: GetMessagesParams): Promise<PaginatedResult<Message>> => {
const page = Math.max(1, params.page ?? 1)
const pageSize = Math.max(1, params.pageSize ?? 20)
const offset = (page - 1) * pageSize
const conds = []
if (params.type === "inbox") conds.push(eq(messages.receiverId, params.userId))
else if (params.type === "sent") conds.push(eq(messages.senderId, params.userId))
else conds.push(or(eq(messages.receiverId, params.userId), eq(messages.senderId, params.userId))!)
const where = and(...conds)
const [rows, [totalRow]] = await Promise.all([
db.select().from(messages).where(where).orderBy(desc(messages.createdAt)).limit(pageSize).offset(offset),
db.select({ value: count() }).from(messages).where(where),
])
const userIds = rows.flatMap((r) => [r.senderId, r.receiverId])
const nameMap = await resolveUserNames(userIds)
const total = Number(totalRow?.value ?? 0)
return { items: rows.map((r) => mapMessage(r, nameMap)), total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
}
)
export const getMessageById = cache(
async (id: string, userId: string): Promise<Message | null> => {
const [row] = await db
.select()
.from(messages)
.where(and(eq(messages.id, id), or(eq(messages.senderId, userId), eq(messages.receiverId, userId))!))
.limit(1)
if (!row) return null
const nameMap = await resolveUserNames([row.senderId, row.receiverId])
return mapMessage(row, nameMap)
}
)
export const getMessageThread = cache(async (messageId: string): Promise<Message[]> => {
const [root] = await db.select().from(messages).where(eq(messages.id, messageId)).limit(1)
if (!root) return []
const replies = await db
.select()
.from(messages)
.where(eq(messages.parentMessageId, messageId))
.orderBy(desc(messages.createdAt))
const allRows = [root, ...replies]
const nameMap = await resolveUserNames(allRows.flatMap((r) => [r.senderId, r.receiverId]))
return allRows.map((r) => mapMessage(r, nameMap))
})
export async function createMessage(data: CreateMessageInput): Promise<string> {
const id = createId()
await db.insert(messages).values({
id,
senderId: data.senderId,
receiverId: data.receiverId,
subject: data.subject ?? null,
content: data.content,
parentMessageId: data.parentMessageId ?? null,
})
return id
}
export async function markMessageAsRead(id: string, userId: string): Promise<void> {
await db
.update(messages)
.set({ isRead: true, readAt: new Date() })
.where(and(eq(messages.id, id), eq(messages.receiverId, userId), eq(messages.isRead, false)))
}
export async function deleteMessage(id: string, userId: string): Promise<void> {
await db
.delete(messages)
.where(and(eq(messages.id, id), or(eq(messages.senderId, userId), eq(messages.receiverId, userId))!))
}
export const getUnreadMessageCount = cache(async (userId: string): Promise<number> => {
const [row] = await db
.select({ value: count() })
.from(messages)
.where(and(eq(messages.receiverId, userId), eq(messages.isRead, false)))
return Number(row?.value ?? 0)
})
export const getNotifications = cache(
async (userId: string, params?: GetNotificationsParams): Promise<PaginatedResult<Notification>> => {
const page = Math.max(1, params?.page ?? 1)
const pageSize = Math.max(1, params?.pageSize ?? 20)
const offset = (page - 1) * pageSize
const conds = [eq(messageNotifications.userId, userId)]
if (params?.unreadOnly) conds.push(eq(messageNotifications.isRead, false))
const where = and(...conds)
const [rows, [totalRow]] = await Promise.all([
db.select().from(messageNotifications).where(where).orderBy(desc(messageNotifications.createdAt)).limit(pageSize).offset(offset),
db.select({ value: count() }).from(messageNotifications).where(where),
])
const total = Number(totalRow?.value ?? 0)
return { items: rows.map(mapNotification), total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
}
)
export async function createNotification(data: CreateNotificationInput): Promise<string> {
const id = createId()
await db.insert(messageNotifications).values({
id,
userId: data.userId,
type: data.type,
title: data.title,
content: data.content ?? null,
link: data.link ?? null,
})
return id
}
export async function markNotificationAsRead(id: string, userId: string): Promise<void> {
await db
.update(messageNotifications)
.set({ isRead: true })
.where(and(eq(messageNotifications.id, id), eq(messageNotifications.userId, userId)))
}
export async function markAllNotificationsAsRead(userId: string): Promise<void> {
await db
.update(messageNotifications)
.set({ isRead: true })
.where(and(eq(messageNotifications.userId, userId), eq(messageNotifications.isRead, false)))
}
export const getUnreadNotificationCount = cache(async (userId: string): Promise<number> => {
const [row] = await db
.select({ value: count() })
.from(messageNotifications)
.where(and(eq(messageNotifications.userId, userId), eq(messageNotifications.isRead, false)))
return Number(row?.value ?? 0)
})
export const getRecipients = cache(
async (userId: string, scope: DataScope): Promise<RecipientOption[]> => {
if (scope.type === "all") {
const all = await db.select({ id: users.id, name: users.name, email: users.email }).from(users)
return all.filter((r) => r.id !== userId).map((r) => ({ ...r, name: r.name ?? r.email }))
}
if (scope.type === "class_taught" && scope.classIds.length > 0) {
const rows = await db
.selectDistinct({ id: users.id, name: users.name, email: users.email })
.from(users)
.innerJoin(classEnrollments, eq(classEnrollments.studentId, users.id))
.where(inArray(classEnrollments.classId, scope.classIds))
return rows.map((r) => ({ ...r, name: r.name ?? r.email, role: "student" }))
}
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
const rows = await db
.selectDistinct({ id: users.id, name: users.name, email: users.email })
.from(users)
.innerJoin(classEnrollments, eq(classEnrollments.studentId, users.id))
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
.where(inArray(classes.gradeId, scope.gradeIds))
return rows.map((r) => ({ ...r, name: r.name ?? r.email, role: "student" }))
}
return []
}
)