1090 lines
34 KiB
TypeScript
1090 lines
34 KiB
TypeScript
import "server-only"
|
||
|
||
/**
|
||
* 私信数据访问层
|
||
*
|
||
* 职责:
|
||
* - getMessages / getMessageById / getMessageThread: 私信查询
|
||
* - createMessage / markMessageAsRead / deleteMessage: 私信 CRUD
|
||
* - getUnreadMessageCount: 未读私信计数
|
||
* - getRecipients: 获取收件人列表(按 DataScope 过滤)
|
||
* - isReceiverAllowed: 校验收件人是否在 sender 的 DataScope 内(P0-1 安全二次校验)
|
||
*
|
||
* 通知相关函数(createNotification / getNotifications /
|
||
* markNotificationAsRead / markAllNotificationsAsRead / getUnreadNotificationCount)
|
||
* 已迁移到 notifications/data-access.ts,请直接从该模块导入。
|
||
*
|
||
* 变更历史:
|
||
* - P0-2: getMessageThread 增加 userId 参数,做权限校验
|
||
* - P0-3: getMessagesPageData 编排迁出(移至 messages/page.tsx 页面层)
|
||
*/
|
||
|
||
import { createId } from "@paralleldrive/cuid2"
|
||
import { and, count, desc, eq, gte, inArray, isNull, like, lte, or, type SQL } from "drizzle-orm"
|
||
|
||
import { db } from "@/shared/db"
|
||
import {
|
||
messages,
|
||
messageDrafts,
|
||
messageTemplates,
|
||
messageReports,
|
||
userBlocks,
|
||
users,
|
||
} from "@/shared/db/schema"
|
||
import {
|
||
getClassesByGradeId,
|
||
getStudentIdsByClassIds,
|
||
getTeacherIdsByClassIds,
|
||
getStudentActiveClassId,
|
||
} from "@/modules/classes/data-access"
|
||
import { getParentIdsByStudentIds } from "@/modules/parent/data-access"
|
||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||
import { getFileAttachmentsByTarget } from "@/modules/files/data-access"
|
||
import type { DataScope } from "@/shared/types/permissions"
|
||
import type { PaginatedResult } from "@/modules/notifications/types"
|
||
import { cacheFn } from "@/shared/lib/cache"
|
||
import type {
|
||
Message,
|
||
GetMessagesParams,
|
||
CreateMessageInput,
|
||
RecipientOption,
|
||
RecipientRole,
|
||
MessageDraft,
|
||
CreateMessageDraftInput,
|
||
UpdateMessageDraftInput,
|
||
MessageTemplate,
|
||
CreateMessageTemplateInput,
|
||
UpdateMessageTemplateInput,
|
||
RecipientResolverContext,
|
||
MessagingRoleConfig,
|
||
MessageReport,
|
||
CreateMessageReportInput,
|
||
MessageReportReason,
|
||
UserBlock,
|
||
MessageAttachment,
|
||
} from "./types"
|
||
|
||
const toIso = (d: Date | null | undefined): string | null => (d ? d.toISOString() : null)
|
||
|
||
const toIsoRequired = (d: Date): string => d.toISOString()
|
||
|
||
interface MessageRow {
|
||
id: string
|
||
senderId: string
|
||
receiverId: string
|
||
subject: string | null
|
||
content: string
|
||
isRead: boolean
|
||
isStarred: boolean
|
||
recalledAt: Date | null
|
||
readAt: Date | null
|
||
parentMessageId: string | null
|
||
groupMessageId: string | null
|
||
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,
|
||
isStarred: r.isStarred,
|
||
recalledAt: toIso(r.recalledAt),
|
||
readAt: toIso(r.readAt),
|
||
parentMessageId: r.parentMessageId,
|
||
groupMessageId: r.groupMessageId,
|
||
createdAt: toIsoRequired(r.createdAt),
|
||
})
|
||
|
||
export const getMessagesRaw = 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: SQL[] = []
|
||
if (params.type === "inbox") {
|
||
conds.push(eq(messages.receiverId, params.userId))
|
||
conds.push(isNull(messages.receiverDeletedAt))
|
||
} else if (params.type === "sent") {
|
||
conds.push(eq(messages.senderId, params.userId))
|
||
conds.push(isNull(messages.senderDeletedAt))
|
||
} else {
|
||
// all: 仅返回当前用户未删除的消息(发送方未删 或 接收方未删)
|
||
const cond = or(
|
||
and(eq(messages.receiverId, params.userId), isNull(messages.receiverDeletedAt)),
|
||
and(eq(messages.senderId, params.userId), isNull(messages.senderDeletedAt))
|
||
)
|
||
if (cond) conds.push(cond)
|
||
}
|
||
|
||
// 关键词搜索(匹配 subject 或 content)
|
||
if (params.keyword && params.keyword.trim().length > 0) {
|
||
const kw = `%${params.keyword.trim()}%`
|
||
const kwCond = or(like(messages.subject, kw), like(messages.content, kw))
|
||
if (kwCond) conds.push(kwCond)
|
||
}
|
||
|
||
// V2-P2-13c: 仅返回星标消息
|
||
if (params.starredOnly) {
|
||
conds.push(eq(messages.isStarred, true))
|
||
}
|
||
|
||
// P2-11: 按发件人 ID 筛选
|
||
if (params.senderId) {
|
||
conds.push(eq(messages.senderId, params.senderId))
|
||
}
|
||
|
||
// P2-11: 按日期范围筛选
|
||
if (params.dateFrom) {
|
||
const fromDate = new Date(params.dateFrom)
|
||
if (!isNaN(fromDate.getTime())) {
|
||
conds.push(gte(messages.createdAt, fromDate))
|
||
}
|
||
}
|
||
if (params.dateTo) {
|
||
const toDate = new Date(params.dateTo)
|
||
if (!isNaN(toDate.getTime())) {
|
||
conds.push(lte(messages.createdAt, toDate))
|
||
}
|
||
}
|
||
|
||
// P2-11: 按已读状态筛选
|
||
if (params.isRead === true) {
|
||
conds.push(eq(messages.isRead, true))
|
||
} else if (params.isRead === false) {
|
||
conds.push(eq(messages.isRead, false))
|
||
}
|
||
|
||
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 getMessages = cacheFn(getMessagesRaw, {
|
||
tags: ["messaging"],
|
||
ttl: 60,
|
||
keyParts: ["messaging", "getMessages"],
|
||
})
|
||
|
||
export const getMessageByIdRaw = async (
|
||
id: string,
|
||
userId: string,
|
||
): Promise<Message | null> => {
|
||
const [row] = await db
|
||
.select()
|
||
.from(messages)
|
||
.where(
|
||
and(
|
||
eq(messages.id, id),
|
||
or(
|
||
and(eq(messages.senderId, userId), isNull(messages.senderDeletedAt)),
|
||
and(eq(messages.receiverId, userId), isNull(messages.receiverDeletedAt))
|
||
)
|
||
)
|
||
)
|
||
.limit(1)
|
||
if (!row) return null
|
||
const nameMap = await resolveUserNames([row.senderId, row.receiverId])
|
||
return mapMessage(row, nameMap)
|
||
}
|
||
|
||
export const getMessageById = cacheFn(getMessageByIdRaw, {
|
||
tags: ["messaging"],
|
||
ttl: 60,
|
||
keyParts: ["messaging", "getMessageById"],
|
||
})
|
||
|
||
export const getMessageThreadRaw = async (
|
||
messageId: string,
|
||
userId: string,
|
||
): Promise<Message[]> => {
|
||
// P0-2: 校验当前用户对根消息有访问权(必须是发送方或接收方)
|
||
const [root] = await db
|
||
.select()
|
||
.from(messages)
|
||
.where(
|
||
and(
|
||
eq(messages.id, messageId),
|
||
or(eq(messages.senderId, userId), eq(messages.receiverId, userId))
|
||
)
|
||
)
|
||
.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 const getMessageThread = cacheFn(getMessageThreadRaw, {
|
||
tags: ["messaging"],
|
||
ttl: 60,
|
||
keyParts: ["messaging", "getMessageThread"],
|
||
})
|
||
|
||
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
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// P2-2: 群组消息(教师→全班家长,fan-out on write)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface SendGroupMessageInput {
|
||
senderId: string
|
||
classId: string
|
||
subject: string | null
|
||
content: string
|
||
}
|
||
|
||
export interface SendGroupMessageResult {
|
||
groupMessageId: string
|
||
recipientIds: string[]
|
||
}
|
||
|
||
/**
|
||
* P2-2: 群发消息给指定班级的所有家长。
|
||
*
|
||
* 采用 fan-out on write 策略:为每个家长创建独立的 messages 行,
|
||
* 共享同一 groupMessageId 便于后续聚合查询(如"已发送群组消息"列表)。
|
||
*
|
||
* 通过 classes data-access 获取学生 ID,再通过 parent data-access
|
||
* 获取家长 ID,避免直接 JOIN classEnrollments / parentStudentRelations 表。
|
||
*/
|
||
export async function sendGroupMessage(
|
||
data: SendGroupMessageInput
|
||
): Promise<SendGroupMessageResult> {
|
||
// 1. 获取班级所有学生 ID
|
||
const studentIds = await getStudentIdsByClassIds([data.classId])
|
||
// 2. 获取这些学生的所有家长 ID(去重)
|
||
const parentIds = await getParentIdsByStudentIds(studentIds)
|
||
// 过滤掉发送者自己(避免教师给自己发消息)
|
||
const recipientIds = Array.from(new Set(parentIds)).filter((id) => id !== data.senderId)
|
||
|
||
if (recipientIds.length === 0) {
|
||
return { groupMessageId: "", recipientIds: [] }
|
||
}
|
||
|
||
// 3. 生成 groupMessageId 并批量插入
|
||
const groupMessageId = createId()
|
||
const now = new Date()
|
||
const rows = recipientIds.map((receiverId) => ({
|
||
id: createId(),
|
||
senderId: data.senderId,
|
||
receiverId,
|
||
subject: data.subject ?? null,
|
||
content: data.content,
|
||
parentMessageId: null,
|
||
groupMessageId,
|
||
createdAt: now,
|
||
}))
|
||
|
||
await db.insert(messages).values(rows)
|
||
|
||
return { groupMessageId, recipientIds }
|
||
}
|
||
|
||
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> {
|
||
const now = new Date()
|
||
// 软删除:发送方删除设置 senderDeletedAt,接收方删除设置 receiverDeletedAt,互不影响
|
||
// 使用事务保证两次 UPDATE 的原子性,避免部分失败导致数据不一致
|
||
await db.transaction(async (tx) => {
|
||
await tx
|
||
.update(messages)
|
||
.set({ senderDeletedAt: now })
|
||
.where(and(eq(messages.id, id), eq(messages.senderId, userId)))
|
||
await tx
|
||
.update(messages)
|
||
.set({ receiverDeletedAt: now })
|
||
.where(and(eq(messages.id, id), eq(messages.receiverId, userId)))
|
||
})
|
||
}
|
||
|
||
export async function toggleMessageStar(id: string, userId: string): Promise<void> {
|
||
// 查询当前星标状态
|
||
const [row] = await db
|
||
.select({ isStarred: messages.isStarred })
|
||
.from(messages)
|
||
.where(and(eq(messages.id, id), eq(messages.receiverId, userId)))
|
||
.limit(1)
|
||
|
||
if (!row) return
|
||
|
||
await db
|
||
.update(messages)
|
||
.set({ isStarred: !row.isStarred })
|
||
.where(and(eq(messages.id, id), eq(messages.receiverId, userId)))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// P2-1: 批量操作
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** P2-1: 批量标记已读(仅当前用户为接收方的消息) */
|
||
export async function bulkMarkMessagesAsRead(ids: string[], userId: string): Promise<number> {
|
||
if (ids.length === 0) return 0
|
||
const result = await db
|
||
.update(messages)
|
||
.set({ isRead: true, readAt: new Date() })
|
||
.where(and(
|
||
inArray(messages.id, ids),
|
||
eq(messages.receiverId, userId),
|
||
eq(messages.isRead, false)
|
||
))
|
||
// MySqlRawQueryResult 是 [ResultSetHeader, FieldPacket[]] 元组,affectedRows 在 [0]
|
||
return result[0]?.affectedRows ?? 0
|
||
}
|
||
|
||
/** P2-1: 批量删除(软删除,发送方/接收方各自标记) */
|
||
export async function bulkDeleteMessages(ids: string[], userId: string): Promise<void> {
|
||
if (ids.length === 0) return
|
||
const now = new Date()
|
||
await db.transaction(async (tx) => {
|
||
await tx
|
||
.update(messages)
|
||
.set({ senderDeletedAt: now })
|
||
.where(and(inArray(messages.id, ids), eq(messages.senderId, userId)))
|
||
await tx
|
||
.update(messages)
|
||
.set({ receiverDeletedAt: now })
|
||
.where(and(inArray(messages.id, ids), eq(messages.receiverId, userId)))
|
||
})
|
||
}
|
||
|
||
/** P2-1: 批量切换星标(仅当前用户为接收方的消息) */
|
||
export async function bulkToggleMessagesStar(ids: string[], userId: string): Promise<void> {
|
||
if (ids.length === 0) return
|
||
// 查询当前状态
|
||
const rows = await db
|
||
.select({ id: messages.id, isStarred: messages.isStarred })
|
||
.from(messages)
|
||
.where(and(inArray(messages.id, ids), eq(messages.receiverId, userId)))
|
||
|
||
if (rows.length === 0) return
|
||
|
||
// 按目标状态分组
|
||
const toStar = rows.filter((r) => !r.isStarred).map((r) => r.id)
|
||
const toUnstar = rows.filter((r) => r.isStarred).map((r) => r.id)
|
||
|
||
if (toStar.length > 0) {
|
||
await db
|
||
.update(messages)
|
||
.set({ isStarred: true })
|
||
.where(and(inArray(messages.id, toStar), eq(messages.receiverId, userId)))
|
||
}
|
||
if (toUnstar.length > 0) {
|
||
await db
|
||
.update(messages)
|
||
.set({ isStarred: false })
|
||
.where(and(inArray(messages.id, toUnstar), eq(messages.receiverId, userId)))
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// P2-4: 消息撤回
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** P2-4: 撤回时间窗口(毫秒),发送后 2 分钟内可撤回 */
|
||
export const MESSAGE_RECALL_WINDOW_MS = 2 * 60 * 1000
|
||
|
||
/**
|
||
* P2-4: 撤回消息。
|
||
*
|
||
* 规则:
|
||
* - 仅发送方可以撤回自己的消息
|
||
* - 必须在 MESSAGE_RECALL_WINDOW_MS 时间窗口内
|
||
* - 已撤回的消息(recalledAt 非空)不能重复撤回
|
||
*
|
||
* 返回值:
|
||
* - "ok": 撤回成功
|
||
* - "not_found": 消息不存在或不属于当前用户
|
||
* - "expired": 超过撤回时间窗口
|
||
* - "already_recalled": 消息已被撤回
|
||
*/
|
||
export async function recallMessage(
|
||
id: string,
|
||
userId: string
|
||
): Promise<"ok" | "not_found" | "expired" | "already_recalled"> {
|
||
const [row] = await db
|
||
.select({
|
||
id: messages.id,
|
||
senderId: messages.senderId,
|
||
recalledAt: messages.recalledAt,
|
||
createdAt: messages.createdAt,
|
||
})
|
||
.from(messages)
|
||
.where(and(eq(messages.id, id), eq(messages.senderId, userId)))
|
||
.limit(1)
|
||
|
||
if (!row) return "not_found"
|
||
if (row.recalledAt) return "already_recalled"
|
||
|
||
const elapsed = Date.now() - row.createdAt.getTime()
|
||
if (elapsed > MESSAGE_RECALL_WINDOW_MS) return "expired"
|
||
|
||
await db
|
||
.update(messages)
|
||
.set({ recalledAt: new Date() })
|
||
.where(eq(messages.id, id))
|
||
|
||
return "ok"
|
||
}
|
||
|
||
export const getUnreadMessageCountRaw = async (userId: string): Promise<number> => {
|
||
const [row] = await db
|
||
.select({ value: count() })
|
||
.from(messages)
|
||
.where(and(eq(messages.receiverId, userId), eq(messages.isRead, false), isNull(messages.receiverDeletedAt)))
|
||
return Number(row?.value ?? 0)
|
||
}
|
||
|
||
export const getUnreadMessageCount = cacheFn(getUnreadMessageCountRaw, {
|
||
tags: ["messaging"],
|
||
ttl: 60,
|
||
keyParts: ["messaging", "getUnreadMessageCount"],
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// P2-7: 配置驱动的角色收件人解析器
|
||
//
|
||
// 每个 DataScope.type 对应一个 RecipientResolver,将解析逻辑与主流程解耦。
|
||
// 新增角色或调整 DataScope 时仅需在 RECIPIENT_RESOLVERS 中增/改配置项,
|
||
// 无需修改 getRecipients 主流程(开闭原则)。
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const resolveAdminRecipients = async (ctx: RecipientResolverContext): Promise<RecipientOption[]> => {
|
||
const { userId } = ctx
|
||
const all = await db.select({ id: users.id, name: users.name, email: users.email }).from(users)
|
||
// P1-6: admin 角色分支补 role
|
||
return all
|
||
.filter((r) => r.id !== userId)
|
||
.map((r) => ({ ...r, name: r.name ?? r.email, role: "admin" as RecipientRole }))
|
||
}
|
||
|
||
const resolveClassTaughtRecipients = async (ctx: RecipientResolverContext): Promise<RecipientOption[]> => {
|
||
const { userId, scope } = ctx
|
||
if (scope.type !== "class_taught" || scope.classIds.length === 0) return []
|
||
// 通过 classes data-access 获取学生 ID,避免直接 JOIN classEnrollments 表
|
||
const studentIds = await getStudentIdsByClassIds(scope.classIds)
|
||
const userMap = await getUserNamesByIds(studentIds)
|
||
return Array.from(userMap.values())
|
||
.filter((u) => u.id !== userId)
|
||
.map((u) => ({ id: u.id, name: u.name ?? u.email, email: u.email, role: "student" as RecipientRole }))
|
||
}
|
||
|
||
const resolveGradeManagedRecipients = async (ctx: RecipientResolverContext): Promise<RecipientOption[]> => {
|
||
const { userId, scope } = ctx
|
||
if (scope.type !== "grade_managed" || scope.gradeIds.length === 0) return []
|
||
// 通过 classes data-access 获取年级下所有班级,再获取学生 ID,
|
||
// 避免直接 JOIN classes / classEnrollments 表
|
||
const classLists = await Promise.all(scope.gradeIds.map((g) => getClassesByGradeId(g)))
|
||
const classIds = classLists.flat().map((c) => c.id)
|
||
const studentIds = await getStudentIdsByClassIds(classIds)
|
||
const userMap = await getUserNamesByIds(studentIds)
|
||
return Array.from(userMap.values())
|
||
.filter((u) => u.id !== userId)
|
||
.map((u) => ({ id: u.id, name: u.name ?? u.email, email: u.email, role: "student" as RecipientRole }))
|
||
}
|
||
|
||
const resolveClassMembersRecipients = async (ctx: RecipientResolverContext): Promise<RecipientOption[]> => {
|
||
const { userId, scope } = ctx
|
||
if (scope.type !== "class_members" || scope.classIds.length === 0) return []
|
||
// 学生可以给自己班级的任课教师/班主任发消息
|
||
const teacherIds = await getTeacherIdsByClassIds(scope.classIds)
|
||
const userMap = await getUserNamesByIds(teacherIds)
|
||
return Array.from(userMap.values())
|
||
.filter((u) => u.id !== userId)
|
||
.map((u) => ({ id: u.id, name: u.name ?? u.email, email: u.email, role: "teacher" as RecipientRole }))
|
||
}
|
||
|
||
const resolveChildrenRecipients = async (ctx: RecipientResolverContext): Promise<RecipientOption[]> => {
|
||
const { userId, scope } = ctx
|
||
if (scope.type !== "children" || scope.childrenIds.length === 0) return []
|
||
// 家长可以给孩子的班主任/任课教师发消息
|
||
const classIds = await Promise.all(scope.childrenIds.map((id) => getStudentActiveClassId(id)))
|
||
const validClassIds = classIds.filter((id): id is string => id !== null)
|
||
const teacherIds = await getTeacherIdsByClassIds(validClassIds)
|
||
const userMap = await getUserNamesByIds(teacherIds)
|
||
return Array.from(userMap.values())
|
||
.filter((u) => u.id !== userId)
|
||
.map((u) => ({ id: u.id, name: u.name ?? u.email, email: u.email, role: "teacher" as RecipientRole }))
|
||
}
|
||
|
||
/** P2-7: 收件人解析器注册表,按 DataScope.type 索引 */
|
||
const RECIPIENT_RESOLVERS: Record<DataScope["type"], MessagingRoleConfig> = {
|
||
all: { scopeType: "all", resolve: resolveAdminRecipients },
|
||
owned: { scopeType: "owned", resolve: async () => [] },
|
||
class_taught: { scopeType: "class_taught", resolve: resolveClassTaughtRecipients },
|
||
grade_managed: { scopeType: "grade_managed", resolve: resolveGradeManagedRecipients },
|
||
class_members: { scopeType: "class_members", resolve: resolveClassMembersRecipients },
|
||
children: { scopeType: "children", resolve: resolveChildrenRecipients },
|
||
}
|
||
|
||
/**
|
||
* P2-7: 获取收件人列表(配置驱动)。
|
||
*
|
||
* 根据 DataScope.type 从 RECIPIENT_RESOLVERS 注册表查找对应解析器并调用。
|
||
* 新增角色或 DataScope 类型仅需:
|
||
* 1. 实现新的 RecipientResolver
|
||
* 2. 在 RECIPIENT_RESOLVERS 中新增配置项
|
||
* 无需修改本函数。
|
||
*/
|
||
export const getRecipientsRaw = async (
|
||
userId: string,
|
||
scope: DataScope,
|
||
): Promise<RecipientOption[]> => {
|
||
const config = RECIPIENT_RESOLVERS[scope.type]
|
||
if (!config) return []
|
||
return config.resolve({ userId, scope })
|
||
}
|
||
|
||
export const getRecipients = cacheFn(getRecipientsRaw, {
|
||
tags: ["messaging"],
|
||
ttl: 60,
|
||
keyParts: ["messaging", "getRecipients"],
|
||
})
|
||
|
||
/**
|
||
* P0-1: 校验收件人是否在 sender 的 DataScope 允许范围内。
|
||
*
|
||
* 在 sendMessageAction 中作为 Server Action 二次校验,防止绕过前端 Select
|
||
* 直接构造 FormData 提交任意 receiverId 的越权发送。
|
||
*
|
||
* 实现复用 getRecipients 的结果,避免重复解析 DataScope。
|
||
*/
|
||
export async function isReceiverAllowed(
|
||
userId: string,
|
||
receiverId: string,
|
||
scope: DataScope
|
||
): Promise<boolean> {
|
||
const recipients = await getRecipients(userId, scope)
|
||
return recipients.some((r) => r.id === receiverId)
|
||
}
|
||
|
||
/**
|
||
* 消息详情页编排函数:获取消息详情,并自动标记为已读(若当前用户为接收方且未读)。
|
||
* 使用 next/server 的 after() 实现非阻塞标记,避免阻塞页面渲染。
|
||
*
|
||
* 注:消息首页编排(getMessagesPageData)已迁出至 messages/page.tsx 页面层,
|
||
* 由页面通过 Promise.all 调用 messaging.data-access 和 notifications.data-access
|
||
* 保持模块独立性(P0-3 修复,避免 data-access 层跨模块动态 import)。
|
||
*/
|
||
export async function getMessageDetailPageData(
|
||
id: string,
|
||
userId: string
|
||
): Promise<Message | null> {
|
||
const message = await getMessageById(id, userId)
|
||
if (!message) return null
|
||
|
||
// 自动标记已读:仅当当前用户为接收方且消息未读时
|
||
if (!message.isRead && message.receiverId === userId) {
|
||
await markMessageAsRead(id, userId)
|
||
}
|
||
|
||
return message
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// P2-5: 消息举报 + 用户屏蔽
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* P2-5: 举报消息。
|
||
*
|
||
* 防重复:同一用户对同一消息只能举报一次。已举报则返回 "already_reported"。
|
||
*/
|
||
export async function reportMessage(
|
||
data: CreateMessageReportInput
|
||
): Promise<"ok" | "already_reported"> {
|
||
// 检查是否已举报过该消息
|
||
const [existing] = await db
|
||
.select({ id: messageReports.id })
|
||
.from(messageReports)
|
||
.where(
|
||
and(
|
||
eq(messageReports.reporterId, data.reporterId),
|
||
eq(messageReports.messageId, data.messageId)
|
||
)
|
||
)
|
||
.limit(1)
|
||
|
||
if (existing) return "already_reported"
|
||
|
||
const id = createId()
|
||
await db.insert(messageReports).values({
|
||
id,
|
||
reporterId: data.reporterId,
|
||
messageId: data.messageId,
|
||
reportedUserId: data.reportedUserId,
|
||
reason: data.reason,
|
||
description: data.description ?? null,
|
||
})
|
||
return "ok"
|
||
}
|
||
|
||
/**
|
||
* P2-5: 检查用户是否已举报某条消息(用于 UI 按钮禁用状态)。
|
||
*/
|
||
export async function hasUserReportedMessage(
|
||
reporterId: string,
|
||
messageId: string
|
||
): Promise<boolean> {
|
||
const [row] = await db
|
||
.select({ id: messageReports.id })
|
||
.from(messageReports)
|
||
.where(
|
||
and(
|
||
eq(messageReports.reporterId, reporterId),
|
||
eq(messageReports.messageId, messageId)
|
||
)
|
||
)
|
||
.limit(1)
|
||
return !!row
|
||
}
|
||
|
||
/**
|
||
* P2-5: 屏蔽用户。
|
||
*
|
||
* 利用 unique 索引防止重复屏蔽:插入冲突时视为"已屏蔽"。
|
||
*/
|
||
export async function blockUser(
|
||
blockerId: string,
|
||
blockedId: string
|
||
): Promise<"ok" | "already_blocked" | "self_block"> {
|
||
if (blockerId === blockedId) return "self_block"
|
||
|
||
// 检查是否已屏蔽
|
||
const [existing] = await db
|
||
.select({ id: userBlocks.id })
|
||
.from(userBlocks)
|
||
.where(
|
||
and(
|
||
eq(userBlocks.blockerId, blockerId),
|
||
eq(userBlocks.blockedId, blockedId)
|
||
)
|
||
)
|
||
.limit(1)
|
||
|
||
if (existing) return "already_blocked"
|
||
|
||
const id = createId()
|
||
await db.insert(userBlocks).values({ id, blockerId, blockedId })
|
||
return "ok"
|
||
}
|
||
|
||
/**
|
||
* P2-5: 取消屏蔽用户。
|
||
*/
|
||
export async function unblockUser(
|
||
blockerId: string,
|
||
blockedId: string
|
||
): Promise<void> {
|
||
await db
|
||
.delete(userBlocks)
|
||
.where(
|
||
and(
|
||
eq(userBlocks.blockerId, blockerId),
|
||
eq(userBlocks.blockedId, blockedId)
|
||
)
|
||
)
|
||
}
|
||
|
||
/**
|
||
* P2-5: 检查 blocker 是否屏蔽了 blocked(用于发送消息前校验)。
|
||
*/
|
||
export async function isUserBlocked(
|
||
blockerId: string,
|
||
blockedId: string
|
||
): Promise<boolean> {
|
||
const [row] = await db
|
||
.select({ id: userBlocks.id })
|
||
.from(userBlocks)
|
||
.where(
|
||
and(
|
||
eq(userBlocks.blockerId, blockerId),
|
||
eq(userBlocks.blockedId, blockedId)
|
||
)
|
||
)
|
||
.limit(1)
|
||
return !!row
|
||
}
|
||
|
||
/**
|
||
* P2-5: 判断两个用户之间是否存在屏蔽关系(任一方向)。
|
||
* 用于 sendMessage 校验:发送方或接收方任一屏蔽对方,则禁止发送。
|
||
*/
|
||
export async function isEitherUserBlocked(
|
||
userIdA: string,
|
||
userIdB: string
|
||
): Promise<boolean> {
|
||
const [row] = await db
|
||
.select({ id: userBlocks.id })
|
||
.from(userBlocks)
|
||
.where(
|
||
or(
|
||
and(eq(userBlocks.blockerId, userIdA), eq(userBlocks.blockedId, userIdB)),
|
||
and(eq(userBlocks.blockerId, userIdB), eq(userBlocks.blockedId, userIdA))
|
||
)
|
||
)
|
||
.limit(1)
|
||
return !!row
|
||
}
|
||
|
||
/**
|
||
* P2-5: 获取用户屏蔽的所有用户 ID(用于收件人列表过滤)。
|
||
*/
|
||
export async function getBlockedUserIds(blockerId: string): Promise<string[]> {
|
||
const rows = await db
|
||
.select({ blockedId: userBlocks.blockedId })
|
||
.from(userBlocks)
|
||
.where(eq(userBlocks.blockerId, blockerId))
|
||
return rows.map((r) => r.blockedId)
|
||
}
|
||
|
||
/** P2-5: 映射举报行到接口类型 */
|
||
const mapMessageReport = (r: {
|
||
id: string
|
||
reporterId: string
|
||
messageId: string
|
||
reportedUserId: string
|
||
reason: string
|
||
description: string | null
|
||
status: string
|
||
createdAt: Date
|
||
updatedAt: Date
|
||
}): MessageReport => ({
|
||
id: r.id,
|
||
reporterId: r.reporterId,
|
||
messageId: r.messageId,
|
||
reportedUserId: r.reportedUserId,
|
||
reason: r.reason as MessageReportReason,
|
||
description: r.description,
|
||
status: r.status as MessageReport["status"],
|
||
createdAt: r.createdAt.toISOString(),
|
||
updatedAt: r.updatedAt.toISOString(),
|
||
})
|
||
|
||
/**
|
||
* P2-5: 获取举报列表(admin 管理用,按状态筛选)。
|
||
*/
|
||
export async function getMessageReports(
|
||
status?: MessageReport["status"]
|
||
): Promise<MessageReport[]> {
|
||
const where = status ? eq(messageReports.status, status) : undefined
|
||
const query = db
|
||
.select()
|
||
.from(messageReports)
|
||
.orderBy(desc(messageReports.createdAt))
|
||
const rows = where ? await query.where(where) : await query
|
||
return rows.map(mapMessageReport)
|
||
}
|
||
|
||
/** P2-5: 映射屏蔽行到接口类型 */
|
||
const mapUserBlock = (r: {
|
||
id: string
|
||
blockerId: string
|
||
blockedId: string
|
||
createdAt: Date
|
||
}): UserBlock => ({
|
||
id: r.id,
|
||
blockerId: r.blockerId,
|
||
blockedId: r.blockedId,
|
||
createdAt: r.createdAt.toISOString(),
|
||
})
|
||
|
||
/**
|
||
* P2-5: 获取用户屏蔽列表。
|
||
*/
|
||
export async function getUserBlocks(blockerId: string): Promise<UserBlock[]> {
|
||
const rows = await db
|
||
.select()
|
||
.from(userBlocks)
|
||
.where(eq(userBlocks.blockerId, blockerId))
|
||
.orderBy(desc(userBlocks.createdAt))
|
||
return rows.map(mapUserBlock)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// P2-3: 消息附件(复用 fileAttachments 表,targetType="message")
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** P2-3: 附件 target 类型常量,用于 fileAttachments 表关联 */
|
||
export const MESSAGE_ATTACHMENT_TARGET_TYPE = "message"
|
||
|
||
/**
|
||
* P2-3: 获取指定消息的所有附件。
|
||
*
|
||
* 复用 files 模块的 getFileAttachmentsByTarget,避免直接查询 fileAttachments 表。
|
||
*/
|
||
export async function getMessageAttachments(messageId: string): Promise<MessageAttachment[]> {
|
||
const files = await getFileAttachmentsByTarget(MESSAGE_ATTACHMENT_TARGET_TYPE, messageId)
|
||
return files.map((f) => ({
|
||
id: f.id,
|
||
filename: f.filename,
|
||
originalName: f.originalName,
|
||
mimeType: f.mimeType,
|
||
size: f.size,
|
||
url: f.url,
|
||
// files 模块 FileAttachment.createdAt 已为 ISO 字符串
|
||
createdAt: f.createdAt,
|
||
}))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// V2-P2-13c: 消息草稿 CRUD(message_drafts 表)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const mapDraft = (
|
||
r: {
|
||
id: string
|
||
userId: string
|
||
receiverId: string | null
|
||
subject: string | null
|
||
content: string | null
|
||
parentMessageId: string | null
|
||
deviceId: string | null
|
||
version: number
|
||
createdAt: Date
|
||
updatedAt: Date
|
||
},
|
||
nameMap: Map<string, string>
|
||
): MessageDraft => ({
|
||
id: r.id,
|
||
userId: r.userId,
|
||
receiverId: r.receiverId,
|
||
receiverName: r.receiverId ? (nameMap.get(r.receiverId) ?? null) : null,
|
||
subject: r.subject,
|
||
content: r.content,
|
||
parentMessageId: r.parentMessageId,
|
||
deviceId: r.deviceId,
|
||
version: r.version,
|
||
createdAt: toIsoRequired(r.createdAt),
|
||
updatedAt: toIsoRequired(r.updatedAt),
|
||
})
|
||
|
||
export const getMessageDraftsRaw = async (
|
||
userId: string,
|
||
): Promise<MessageDraft[]> => {
|
||
const rows = await db
|
||
.select()
|
||
.from(messageDrafts)
|
||
.where(eq(messageDrafts.userId, userId))
|
||
.orderBy(desc(messageDrafts.updatedAt))
|
||
|
||
const receiverIds = rows.map((r) => r.receiverId).filter((id): id is string => id !== null)
|
||
const nameMap = await resolveUserNames(receiverIds)
|
||
|
||
return rows.map((r) => mapDraft(r, nameMap))
|
||
}
|
||
|
||
export const getMessageDrafts = cacheFn(getMessageDraftsRaw, {
|
||
tags: ["messaging"],
|
||
ttl: 60,
|
||
keyParts: ["messaging", "getMessageDrafts"],
|
||
})
|
||
|
||
export async function createMessageDraft(data: CreateMessageDraftInput): Promise<string> {
|
||
const id = createId()
|
||
await db.insert(messageDrafts).values({
|
||
id,
|
||
userId: data.userId,
|
||
receiverId: data.receiverId ?? null,
|
||
subject: data.subject ?? null,
|
||
content: data.content ?? null,
|
||
parentMessageId: data.parentMessageId ?? null,
|
||
// P2-10: 记录来源设备
|
||
deviceId: data.deviceId ?? null,
|
||
// P2-10: 初始版本号 1
|
||
version: 1,
|
||
})
|
||
return id
|
||
}
|
||
|
||
/**
|
||
* P2-10: 更新草稿(带乐观锁)。
|
||
*
|
||
* - 客户端传入 expectedVersion,与数据库当前 version 比对
|
||
* - 匹配则更新并 version+1
|
||
* - 不匹配(冲突)返回 "conflict",客户端应拉取最新版本并提示用户
|
||
*
|
||
* 返回值:
|
||
* - "ok": 更新成功
|
||
* - "not_found": 草稿不存在或不属于当前用户
|
||
* - "conflict": 版本冲突(其他设备已更新)
|
||
*/
|
||
export async function updateMessageDraft(
|
||
id: string,
|
||
userId: string,
|
||
data: UpdateMessageDraftInput
|
||
): Promise<"ok" | "not_found" | "conflict"> {
|
||
// P2-10: 乐观锁 - 先查询当前版本
|
||
const [existing] = await db
|
||
.select({ id: messageDrafts.id, version: messageDrafts.version })
|
||
.from(messageDrafts)
|
||
.where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId)))
|
||
.limit(1)
|
||
|
||
if (!existing) return "not_found"
|
||
|
||
// P2-10: 版本冲突检测
|
||
if (data.expectedVersion !== undefined && data.expectedVersion !== existing.version) {
|
||
return "conflict"
|
||
}
|
||
|
||
await db
|
||
.update(messageDrafts)
|
||
.set({
|
||
...(data.receiverId !== undefined && { receiverId: data.receiverId }),
|
||
...(data.subject !== undefined && { subject: data.subject }),
|
||
...(data.content !== undefined && { content: data.content }),
|
||
...(data.parentMessageId !== undefined && { parentMessageId: data.parentMessageId }),
|
||
...(data.deviceId !== undefined && { deviceId: data.deviceId }),
|
||
// P2-10: 版本号自增
|
||
version: existing.version + 1,
|
||
})
|
||
.where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId)))
|
||
|
||
return "ok"
|
||
}
|
||
|
||
export async function deleteMessageDraft(id: string, userId: string): Promise<void> {
|
||
await db
|
||
.delete(messageDrafts)
|
||
.where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId)))
|
||
}
|
||
|
||
export async function getMessageDraftById(id: string, userId: string): Promise<MessageDraft | null> {
|
||
const [row] = await db
|
||
.select()
|
||
.from(messageDrafts)
|
||
.where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId)))
|
||
.limit(1)
|
||
|
||
if (!row) return null
|
||
|
||
const nameMap = row.receiverId ? await resolveUserNames([row.receiverId]) : new Map<string, string>()
|
||
return mapDraft(row, nameMap)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// P2-6: 快捷回复模板
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface MessageTemplateRow {
|
||
id: string
|
||
userId: string
|
||
title: string
|
||
content: string
|
||
sortOrder: number
|
||
updatedAt: Date
|
||
createdAt: Date
|
||
}
|
||
|
||
const mapTemplate = (r: MessageTemplateRow): MessageTemplate => ({
|
||
id: r.id,
|
||
userId: r.userId,
|
||
title: r.title,
|
||
content: r.content,
|
||
sortOrder: r.sortOrder,
|
||
updatedAt: toIsoRequired(r.updatedAt),
|
||
createdAt: toIsoRequired(r.createdAt),
|
||
})
|
||
|
||
export const getMessageTemplatesRaw = async (
|
||
userId: string,
|
||
): Promise<MessageTemplate[]> => {
|
||
const rows = await db
|
||
.select()
|
||
.from(messageTemplates)
|
||
.where(eq(messageTemplates.userId, userId))
|
||
.orderBy(messageTemplates.sortOrder, desc(messageTemplates.createdAt))
|
||
return rows.map(mapTemplate)
|
||
}
|
||
|
||
export const getMessageTemplates = cacheFn(getMessageTemplatesRaw, {
|
||
tags: ["messaging"],
|
||
ttl: 60,
|
||
keyParts: ["messaging", "getMessageTemplates"],
|
||
})
|
||
|
||
export async function createMessageTemplate(data: CreateMessageTemplateInput): Promise<string> {
|
||
const id = createId()
|
||
await db.insert(messageTemplates).values({
|
||
id,
|
||
userId: data.userId,
|
||
title: data.title,
|
||
content: data.content,
|
||
sortOrder: data.sortOrder ?? 0,
|
||
})
|
||
return id
|
||
}
|
||
|
||
export async function updateMessageTemplate(
|
||
id: string,
|
||
userId: string,
|
||
data: UpdateMessageTemplateInput
|
||
): Promise<void> {
|
||
const updateData: Partial<{ title: string; content: string; sortOrder: number }> = {}
|
||
if (data.title !== undefined) updateData.title = data.title
|
||
if (data.content !== undefined) updateData.content = data.content
|
||
if (data.sortOrder !== undefined) updateData.sortOrder = data.sortOrder
|
||
|
||
if (Object.keys(updateData).length === 0) return
|
||
|
||
await db
|
||
.update(messageTemplates)
|
||
.set(updateData)
|
||
.where(and(eq(messageTemplates.id, id), eq(messageTemplates.userId, userId)))
|
||
}
|
||
|
||
export async function deleteMessageTemplate(id: string, userId: string): Promise<void> {
|
||
await db
|
||
.delete(messageTemplates)
|
||
.where(and(eq(messageTemplates.id, id), eq(messageTemplates.userId, userId)))
|
||
}
|