Files
NextEdu/src/modules/messaging/data-access.ts

1090 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: 消息草稿 CRUDmessage_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)))
}