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:
252
src/modules/messaging/data-access.ts
Normal file
252
src/modules/messaging/data-access.ts
Normal 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 []
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user