refactor(data-access): 组 C 模块迁移至 cacheFn(lesson-preparation/elective/announcements/messaging/notifications/audit/onboarding/invitation-codes/scheduling/settings)

This commit is contained in:
SpecialX
2026-07-05 21:49:18 +08:00
parent cb5b92160c
commit 6dee6b6299
9 changed files with 520 additions and 336 deletions

View File

@@ -1,12 +1,12 @@
import "server-only" import "server-only"
import { cache } from "react"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
import { and, count, desc, eq, inArray, or, sql } from "drizzle-orm" import { and, count, desc, eq, inArray, or, sql } from "drizzle-orm"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { announcements, announcementReads, users } from "@/shared/db/schema" import { announcements, announcementReads, users } from "@/shared/db/schema"
import type { DataScope } from "@/shared/types/permissions" import type { DataScope } from "@/shared/types/permissions"
import { cacheFn } from "@/shared/lib/cache"
import type { import type {
Announcement, Announcement,
AnnouncementInsertData, AnnouncementInsertData,
@@ -43,8 +43,9 @@ const mapRow = (row: AnnouncementRow): Announcement => ({
updatedAt: toIsoRequired(row.updatedAt), updatedAt: toIsoRequired(row.updatedAt),
}) })
export const getAnnouncements = cache( export const getAnnouncementsRaw = async (
async (params?: GetAnnouncementsParams): Promise<Announcement[]> => { params?: GetAnnouncementsParams,
): Promise<Announcement[]> => {
const page = Math.max(1, params?.page ?? 1) const page = Math.max(1, params?.page ?? 1)
const pageSize = Math.max(1, params?.pageSize ?? 20) const pageSize = Math.max(1, params?.pageSize ?? 20)
const offset = (page - 1) * pageSize const offset = (page - 1) * pageSize
@@ -105,7 +106,12 @@ export const getAnnouncements = cache(
return rows.map(mapRow) return rows.map(mapRow)
} }
)
export const getAnnouncements = cacheFn(getAnnouncementsRaw, {
tags: ["announcements"],
ttl: 60,
keyParts: ["announcements", "getAnnouncements"],
})
/** /**
* P2-6: 统计符合条件的公告总数(用于分页)。 * P2-6: 统计符合条件的公告总数(用于分页)。
@@ -150,8 +156,9 @@ export async function countAnnouncements(
return row?.value ?? 0 return row?.value ?? 0
} }
export const getAnnouncementById = cache( export const getAnnouncementByIdRaw = async (
async (id: string): Promise<Announcement | null> => { id: string,
): Promise<Announcement | null> => {
const [row] = await db const [row] = await db
.select({ .select({
id: announcements.id, id: announcements.id,
@@ -175,7 +182,12 @@ export const getAnnouncementById = cache(
return row ? mapRow(row) : null return row ? mapRow(row) : null
} }
)
export const getAnnouncementById = cacheFn(getAnnouncementByIdRaw, {
tags: ["announcements"],
ttl: 60,
keyParts: ["announcements", "getAnnouncementById"],
})
export async function insertAnnouncement( export async function insertAnnouncement(
data: AnnouncementInsertData data: AnnouncementInsertData
@@ -552,13 +564,28 @@ export async function getUserAnnouncementsPageData(
* - school: 全校所有用户 * - school: 全校所有用户
* - grade: 该年级下所有用户 * - grade: 该年级下所有用户
* - class: 该班级学生 + 任课教师 + 班主任 * - class: 该班级学生 + 任课教师 + 班主任
*
* P3-7: school 类型采用分页遍历 getAllUserIds每页 1000 条,
* 避免单次查询超大学校全量用户导致 OOM。
*/ */
export async function resolveAnnouncementTargetUserIds( export async function resolveAnnouncementTargetUserIds(
announcement: Announcement announcement: Announcement
): Promise<string[]> { ): Promise<string[]> {
if (announcement.type === "school") { if (announcement.type === "school") {
const { getAllUserIds } = await import("@/modules/users/data-access") const { getAllUserIds } = await import("@/modules/users/data-access")
return getAllUserIds() // P3-7: 分页遍历所有用户,单页 1000 条
const PAGE_SIZE = 1000
const allIds: string[] = []
let offset = 0
// 至少执行一次循环;若返回不足 PAGE_SIZE 则视为最后一页
while (true) {
const page = await getAllUserIds(PAGE_SIZE, offset)
if (page.length === 0) break
allIds.push(...page)
if (page.length < PAGE_SIZE) break
offset += PAGE_SIZE
}
return allIds
} }
if (announcement.type === "grade" && announcement.targetGradeId) { if (announcement.type === "grade" && announcement.targetGradeId) {

View File

@@ -1,6 +1,5 @@
import "server-only" import "server-only"
import { cache } from "react"
import { and, asc, desc, eq, sql, type SQL } from "drizzle-orm" import { and, asc, desc, eq, sql, type SQL } from "drizzle-orm"
import { db } from "@/shared/db" import { db } from "@/shared/db"
@@ -8,6 +7,7 @@ import {
courseSelections, courseSelections,
electiveCourses, electiveCourses,
} from "@/shared/db/schema" } from "@/shared/db/schema"
import { cacheFn } from "@/shared/lib/cache"
import { import {
buildCourseSelect, buildCourseSelect,
@@ -101,9 +101,8 @@ const resolveStudentDisplayNames = async (rows: SelectionCoreRow[]): Promise<Map
return studentNames return studentNames
} }
export const getCourseSelections = cache( export const getCourseSelectionsRaw = async (
async ( courseId: string,
courseId: string
): Promise<CourseSelectionWithDetails[]> => { ): Promise<CourseSelectionWithDetails[]> => {
const rows = await buildSelectionCoreSelect() const rows = await buildSelectionCoreSelect()
.where(eq(courseSelections.courseId, courseId)) .where(eq(courseSelections.courseId, courseId))
@@ -111,11 +110,15 @@ export const getCourseSelections = cache(
const studentNames = await resolveStudentDisplayNames(rows) const studentNames = await resolveStudentDisplayNames(rows)
return rows.map((r) => mapSelectionRow(r, studentNames)) return rows.map((r) => mapSelectionRow(r, studentNames))
} }
)
export const getStudentSelections = cache( export const getCourseSelections = cacheFn(getCourseSelectionsRaw, {
async ( tags: ["elective"],
studentId: string ttl: 300,
keyParts: ["elective", "getCourseSelections"],
})
export const getStudentSelectionsRaw = async (
studentId: string,
): Promise<CourseSelectionWithDetails[]> => { ): Promise<CourseSelectionWithDetails[]> => {
const rows = await buildSelectionCoreSelect() const rows = await buildSelectionCoreSelect()
.where(eq(courseSelections.studentId, studentId)) .where(eq(courseSelections.studentId, studentId))
@@ -123,16 +126,26 @@ export const getStudentSelections = cache(
const studentNames = await resolveStudentDisplayNames(rows) const studentNames = await resolveStudentDisplayNames(rows)
return rows.map((r) => mapSelectionRow(r, studentNames)) return rows.map((r) => mapSelectionRow(r, studentNames))
} }
)
export const getStudentGradeId = cache(async (studentId: string): Promise<string | null> => { export const getStudentSelections = cacheFn(getStudentSelectionsRaw, {
return getStudentGradeResolver().getStudentActiveGradeId(studentId) tags: ["elective"],
ttl: 300,
keyParts: ["elective", "getStudentSelections"],
}) })
export const getAvailableCoursesForStudent = cache( export const getStudentGradeIdRaw = async (studentId: string): Promise<string | null> => {
async ( return getStudentGradeResolver().getStudentActiveGradeId(studentId)
}
export const getStudentGradeId = cacheFn(getStudentGradeIdRaw, {
tags: ["elective"],
ttl: 300,
keyParts: ["elective", "getStudentGradeId"],
})
export const getAvailableCoursesForStudentRaw = async (
studentId: string, studentId: string,
gradeId?: string | null gradeId?: string | null,
): Promise<ElectiveCourseWithDetails[]> => { ): Promise<ElectiveCourseWithDetails[]> => {
const resolvedGradeId = gradeId ?? (await getStudentGradeId(studentId)) const resolvedGradeId = gradeId ?? (await getStudentGradeId(studentId))
const conditions: SQL[] = [eq(electiveCourses.status, "open")] const conditions: SQL[] = [eq(electiveCourses.status, "open")]
@@ -147,4 +160,9 @@ export const getAvailableCoursesForStudent = cache(
const displayMaps = await resolveCourseDisplayNames(rows) const displayMaps = await resolveCourseDisplayNames(rows)
return rows.map((r) => mapCourseRow(r, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames)) return rows.map((r) => mapCourseRow(r, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames))
} }
)
export const getAvailableCoursesForStudent = cacheFn(getAvailableCoursesForStudentRaw, {
tags: ["elective"],
ttl: 300,
keyParts: ["elective", "getAvailableCoursesForStudent"],
})

View File

@@ -1,10 +1,10 @@
import "server-only" import "server-only"
import { cache } from "react"
import { eq, and } from "drizzle-orm" import { eq, and } from "drizzle-orm"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { systemSettings } from "@/shared/db/schema" import { systemSettings } from "@/shared/db/schema"
import { cacheFn } from "@/shared/lib/cache"
/** /**
* 选课模块配置化设置P2-4 新增)。 * 选课模块配置化设置P2-4 新增)。
@@ -55,8 +55,9 @@ async function readSettingValue(key: string): Promise<string | null> {
* *
* @param gradeId 学生所在年级 ID可选 * @param gradeId 学生所在年级 ID可选
*/ */
export const getElectiveCreditLimit = cache( export const getElectiveCreditLimitRaw = async (
async (gradeId?: string | null): Promise<number> => { gradeId?: string | null,
): Promise<number> => {
if (gradeId) { if (gradeId) {
const gradeValue = await readSettingValue(`creditLimit:grade:${gradeId}`) const gradeValue = await readSettingValue(`creditLimit:grade:${gradeId}`)
if (gradeValue !== null) { if (gradeValue !== null) {
@@ -71,7 +72,12 @@ export const getElectiveCreditLimit = cache(
} }
return DEFAULT_MAX_CREDIT_PER_TERM return DEFAULT_MAX_CREDIT_PER_TERM
} }
)
export const getElectiveCreditLimit = cacheFn(getElectiveCreditLimitRaw, {
tags: ["elective"],
ttl: 300,
keyParts: ["elective", "getElectiveCreditLimit"],
})
/** /**
* 获取容量阈值通知比例P2-4 新增)。 * 获取容量阈值通知比例P2-4 新增)。
@@ -79,8 +85,7 @@ export const getElectiveCreditLimit = cache(
* 当课程 `enrolledCount >= capacity * threshold` 时触发管理员通知。 * 当课程 `enrolledCount >= capacity * threshold` 时触发管理员通知。
* 默认 0.990%)。 * 默认 0.990%)。
*/ */
export const getCapacityNotifyThreshold = cache( export const getCapacityNotifyThresholdRaw = async (): Promise<number> => {
async (): Promise<number> => {
const value = await readSettingValue("capacityNotifyThreshold") const value = await readSettingValue("capacityNotifyThreshold")
if (value !== null) { if (value !== null) {
const parsed = Number(value) const parsed = Number(value)
@@ -88,7 +93,12 @@ export const getCapacityNotifyThreshold = cache(
} }
return DEFAULT_CAPACITY_NOTIFY_THRESHOLD return DEFAULT_CAPACITY_NOTIFY_THRESHOLD
} }
)
export const getCapacityNotifyThreshold = cacheFn(getCapacityNotifyThresholdRaw, {
tags: ["elective"],
ttl: 300,
keyParts: ["elective", "getCapacityNotifyThreshold"],
})
/** 导出默认值常量(供测试与文档引用) */ /** 导出默认值常量(供测试与文档引用) */
export const ELECTIVE_DEFAULTS = { export const ELECTIVE_DEFAULTS = {

View File

@@ -1,10 +1,10 @@
import "server-only" import "server-only"
import { cache } from "react"
import { count, eq, sql } from "drizzle-orm" import { count, eq, sql } from "drizzle-orm"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { courseSelections, electiveCourses } from "@/shared/db/schema" import { courseSelections, electiveCourses } from "@/shared/db/schema"
import { cacheFn } from "@/shared/lib/cache"
/** /**
* 选课模块管理员概览统计P1-13 新增)。 * 选课模块管理员概览统计P1-13 新增)。
@@ -30,8 +30,7 @@ export interface ElectiveOverviewStats {
* - 使用 SQL 聚合而非拉全表后 reduce避免大数据量内存峰值 * - 使用 SQL 聚合而非拉全表后 reduce避免大数据量内存峰值
* - admin 不做 scope 过滤(统计全部课程) * - admin 不做 scope 过滤(统计全部课程)
*/ */
export const getElectiveOverviewStats = cache( export const getElectiveOverviewStatsRaw = async (): Promise<ElectiveOverviewStats> => {
async (): Promise<ElectiveOverviewStats> => {
// 并行执行聚合查询 // 并行执行聚合查询
const [totalRow, enrolledRow, utilizationRow, pendingRow] = await Promise.all([ const [totalRow, enrolledRow, utilizationRow, pendingRow] = await Promise.all([
// 1. 课程总数 // 1. 课程总数
@@ -85,4 +84,9 @@ export const getElectiveOverviewStats = cache(
pendingLottery: pendingRow[0]?.total ?? 0, pendingLottery: pendingRow[0]?.total ?? 0,
} }
} }
)
export const getElectiveOverviewStats = cacheFn(getElectiveOverviewStatsRaw, {
tags: ["elective"],
ttl: 300,
keyParts: ["elective", "getElectiveOverviewStats"],
})

View File

@@ -8,6 +8,7 @@ import { db } from "@/shared/db"
import { electiveCourses } from "@/shared/db/schema" import { electiveCourses } from "@/shared/db/schema"
import type { DataScope } from "@/shared/types/permissions" import type { DataScope } from "@/shared/types/permissions"
import { safeParseDate } from "@/shared/lib/action-utils" import { safeParseDate } from "@/shared/lib/action-utils"
import { cacheFn } from "@/shared/lib/cache"
import type { import type {
ElectiveCourseWithDetails, ElectiveCourseWithDetails,
@@ -131,8 +132,7 @@ export const resolveCourseDisplayNames = async (rows: CourseCoreRow[]): Promise<
return { teacherNames, subjectNames, gradeNames } return { teacherNames, subjectNames, gradeNames }
} }
export const getElectiveCourses = cache( export const getElectiveCoursesRaw = async (
async (
params?: GetElectiveCoursesParams & { scope?: DataScope; currentUserId?: string } params?: GetElectiveCoursesParams & { scope?: DataScope; currentUserId?: string }
): Promise<ElectiveCourseWithDetails[]> => { ): Promise<ElectiveCourseWithDetails[]> => {
const conditions: SQL[] = [] const conditions: SQL[] = []
@@ -160,10 +160,16 @@ export const getElectiveCourses = cache(
const displayMaps = await resolveCourseDisplayNames(rows) const displayMaps = await resolveCourseDisplayNames(rows)
return rows.map((r) => mapCourseRow(r, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames)) return rows.map((r) => mapCourseRow(r, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames))
} }
)
export const getElectiveCourseById = cache( export const getElectiveCourses = cacheFn(getElectiveCoursesRaw, {
async (id: string): Promise<ElectiveCourseWithDetails | null> => { tags: ["elective"],
ttl: 300,
keyParts: ["elective", "getElectiveCourses"],
})
export const getElectiveCourseByIdRaw = async (
id: string,
): Promise<ElectiveCourseWithDetails | null> => {
const [row] = await buildCourseSelect() const [row] = await buildCourseSelect()
.where(eq(electiveCourses.id, id)) .where(eq(electiveCourses.id, id))
.limit(1) .limit(1)
@@ -171,7 +177,12 @@ export const getElectiveCourseById = cache(
const displayMaps = await resolveCourseDisplayNames([row]) const displayMaps = await resolveCourseDisplayNames([row])
return mapCourseRow(row, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames) return mapCourseRow(row, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames)
} }
)
export const getElectiveCourseById = cacheFn(getElectiveCourseByIdRaw, {
tags: ["elective"],
ttl: 300,
keyParts: ["elective", "getElectiveCourseById"],
})
export async function createElectiveCourse( export async function createElectiveCourse(
data: CreateElectiveCourseInput, data: CreateElectiveCourseInput,

View File

@@ -1,6 +1,5 @@
import "server-only"; import "server-only";
import { cache } from "react";
import { and, desc, eq, inArray, like, or, sql, type SQL } from "drizzle-orm"; import { and, desc, eq, inArray, like, or, sql, type SQL } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2"; import { createId } from "@paralleldrive/cuid2";
@@ -16,6 +15,7 @@ import {
} from "@/shared/db/schema"; } from "@/shared/db/schema";
import type { DataScope } from "@/shared/types/permissions"; import type { DataScope } from "@/shared/types/permissions";
import { escapeLikePattern } from "@/shared/lib/action-utils"; import { escapeLikePattern } from "@/shared/lib/action-utils";
import { cacheFn } from "@/shared/lib/cache";
import { SYSTEM_TEMPLATES } from "./constants"; import { SYSTEM_TEMPLATES } from "./constants";
import { import {
migrateV1ToV2, migrateV1ToV2,
@@ -217,8 +217,7 @@ function buildScopeCondition(scope: DataScope, userId: string): SQL[] {
} }
// ---- 课案列表 ---- // ---- 课案列表 ----
export const getLessonPlans = cache( export const getLessonPlansRaw = async (
async (
params: { params: {
query?: string; query?: string;
textbookId?: string; textbookId?: string;
@@ -317,15 +316,22 @@ export const getLessonPlans = cache(
} }
return grouped; return grouped;
}, };
);
export const getLessonPlans = cacheFn(getLessonPlansRaw, {
tags: ["lesson-preparation"],
ttl: 300,
keyParts: ["lesson-preparation", "getLessonPlans"],
});
// ---- 单课案 ---- // ---- 单课案 ----
// 安全说明:此函数仅校验 creator 或 published 状态。 // 安全说明:此函数仅校验 creator 或 published 状态。
// 对于 parent/student 角色,调用方(页面层)必须额外校验 plan.gradeId // 对于 parent/student 角色,调用方(页面层)必须额外校验 plan.gradeId
// 是否在 ctx.dataScope.gradeIds 范围内,防止跨年级信息泄露。 // 是否在 ctx.dataScope.gradeIds 范围内,防止跨年级信息泄露。
export const getLessonPlanById = cache( export const getLessonPlanByIdRaw = async (
async (id: string, userId: string): Promise<LessonPlan | null> => { id: string,
userId: string,
): Promise<LessonPlan | null> => {
const rows = await db const rows = await db
.select() .select()
.from(lessonPlans) .from(lessonPlans)
@@ -336,8 +342,13 @@ export const getLessonPlanById = cache(
// 权限creator 可看 draft非 creator 仅 published // 权限creator 可看 draft非 creator 仅 published
if (row.creatorId !== userId && row.status !== "published") return null; if (row.creatorId !== userId && row.status !== "published") return null;
return mapRowToLessonPlan(row); return mapRowToLessonPlan(row);
}, };
);
export const getLessonPlanById = cacheFn(getLessonPlanByIdRaw, {
tags: ["lesson-preparation"],
ttl: 300,
keyParts: ["lesson-preparation", "getLessonPlanById"],
});
// ---- 创建 ---- // ---- 创建 ----
// V2-2 修复:接受 translateTitle 函数,将 SYSTEM_TEMPLATES 中的 i18n 键翻译为实际文本 // V2-2 修复:接受 translateTitle 函数,将 SYSTEM_TEMPLATES 中的 i18n 键翻译为实际文本

View File

@@ -19,7 +19,6 @@ import "server-only"
* - P0-3: getMessagesPageData 编排迁出(移至 messages/page.tsx 页面层) * - P0-3: getMessagesPageData 编排迁出(移至 messages/page.tsx 页面层)
*/ */
import { cache } from "react"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
import { and, count, desc, eq, gte, inArray, isNull, like, lte, or, type SQL } from "drizzle-orm" import { and, count, desc, eq, gte, inArray, isNull, like, lte, or, type SQL } from "drizzle-orm"
@@ -43,6 +42,7 @@ import { getUserNamesByIds } from "@/modules/users/data-access"
import { getFileAttachmentsByTarget } from "@/modules/files/data-access" import { getFileAttachmentsByTarget } from "@/modules/files/data-access"
import type { DataScope } from "@/shared/types/permissions" import type { DataScope } from "@/shared/types/permissions"
import type { PaginatedResult } from "@/modules/notifications/types" import type { PaginatedResult } from "@/modules/notifications/types"
import { cacheFn } from "@/shared/lib/cache"
import type { import type {
Message, Message,
GetMessagesParams, GetMessagesParams,
@@ -110,8 +110,9 @@ const mapMessage = (r: MessageRow, nameMap: Map<string, string>): Message => ({
createdAt: toIsoRequired(r.createdAt), createdAt: toIsoRequired(r.createdAt),
}) })
export const getMessages = cache( export const getMessagesRaw = async (
async (params: GetMessagesParams): Promise<PaginatedResult<Message>> => { params: GetMessagesParams,
): Promise<PaginatedResult<Message>> => {
const page = Math.max(1, params.page ?? 1) const page = Math.max(1, params.page ?? 1)
const pageSize = Math.max(1, params.pageSize ?? 20) const pageSize = Math.max(1, params.pageSize ?? 20)
const offset = (page - 1) * pageSize const offset = (page - 1) * pageSize
@@ -181,10 +182,17 @@ export const getMessages = cache(
const total = Number(totalRow?.value ?? 0) const total = Number(totalRow?.value ?? 0)
return { items: rows.map((r) => mapMessage(r, nameMap)), total, page, pageSize, totalPages: Math.ceil(total / pageSize) } return { items: rows.map((r) => mapMessage(r, nameMap)), total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
} }
)
export const getMessageById = cache( export const getMessages = cacheFn(getMessagesRaw, {
async (id: string, userId: string): Promise<Message | null> => { tags: ["messaging"],
ttl: 60,
keyParts: ["messaging", "getMessages"],
})
export const getMessageByIdRaw = async (
id: string,
userId: string,
): Promise<Message | null> => {
const [row] = await db const [row] = await db
.select() .select()
.from(messages) .from(messages)
@@ -202,9 +210,17 @@ export const getMessageById = cache(
const nameMap = await resolveUserNames([row.senderId, row.receiverId]) const nameMap = await resolveUserNames([row.senderId, row.receiverId])
return mapMessage(row, nameMap) return mapMessage(row, nameMap)
} }
)
export const getMessageThread = cache(async (messageId: string, userId: string): Promise<Message[]> => { export const getMessageById = cacheFn(getMessageByIdRaw, {
tags: ["messaging"],
ttl: 60,
keyParts: ["messaging", "getMessageById"],
})
export const getMessageThreadRaw = async (
messageId: string,
userId: string,
): Promise<Message[]> => {
// P0-2: 校验当前用户对根消息有访问权(必须是发送方或接收方) // P0-2: 校验当前用户对根消息有访问权(必须是发送方或接收方)
const [root] = await db const [root] = await db
.select() .select()
@@ -227,6 +243,12 @@ export const getMessageThread = cache(async (messageId: string, userId: string):
const allRows = [root, ...replies] const allRows = [root, ...replies]
const nameMap = await resolveUserNames(allRows.flatMap((r) => [r.senderId, r.receiverId])) const nameMap = await resolveUserNames(allRows.flatMap((r) => [r.senderId, r.receiverId]))
return allRows.map((r) => mapMessage(r, nameMap)) 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> { export async function createMessage(data: CreateMessageInput): Promise<string> {
@@ -453,12 +475,18 @@ export async function recallMessage(
return "ok" return "ok"
} }
export const getUnreadMessageCount = cache(async (userId: string): Promise<number> => { export const getUnreadMessageCountRaw = async (userId: string): Promise<number> => {
const [row] = await db const [row] = await db
.select({ value: count() }) .select({ value: count() })
.from(messages) .from(messages)
.where(and(eq(messages.receiverId, userId), eq(messages.isRead, false), isNull(messages.receiverDeletedAt))) .where(and(eq(messages.receiverId, userId), eq(messages.isRead, false), isNull(messages.receiverDeletedAt)))
return Number(row?.value ?? 0) return Number(row?.value ?? 0)
}
export const getUnreadMessageCount = cacheFn(getUnreadMessageCountRaw, {
tags: ["messaging"],
ttl: 60,
keyParts: ["messaging", "getUnreadMessageCount"],
}) })
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -546,13 +574,20 @@ const RECIPIENT_RESOLVERS: Record<DataScope["type"], MessagingRoleConfig> = {
* 2. 在 RECIPIENT_RESOLVERS 中新增配置项 * 2. 在 RECIPIENT_RESOLVERS 中新增配置项
* 无需修改本函数。 * 无需修改本函数。
*/ */
export const getRecipients = cache( export const getRecipientsRaw = async (
async (userId: string, scope: DataScope): Promise<RecipientOption[]> => { userId: string,
scope: DataScope,
): Promise<RecipientOption[]> => {
const config = RECIPIENT_RESOLVERS[scope.type] const config = RECIPIENT_RESOLVERS[scope.type]
if (!config) return [] if (!config) return []
return config.resolve({ userId, scope }) return config.resolve({ userId, scope })
} }
)
export const getRecipients = cacheFn(getRecipientsRaw, {
tags: ["messaging"],
ttl: 60,
keyParts: ["messaging", "getRecipients"],
})
/** /**
* P0-1: 校验收件人是否在 sender 的 DataScope 允许范围内。 * P0-1: 校验收件人是否在 sender 的 DataScope 允许范围内。
@@ -872,8 +907,9 @@ const mapDraft = (
updatedAt: toIsoRequired(r.updatedAt), updatedAt: toIsoRequired(r.updatedAt),
}) })
export const getMessageDrafts = cache( export const getMessageDraftsRaw = async (
async (userId: string): Promise<MessageDraft[]> => { userId: string,
): Promise<MessageDraft[]> => {
const rows = await db const rows = await db
.select() .select()
.from(messageDrafts) .from(messageDrafts)
@@ -885,7 +921,12 @@ export const getMessageDrafts = cache(
return rows.map((r) => mapDraft(r, nameMap)) 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> { export async function createMessageDraft(data: CreateMessageDraftInput): Promise<string> {
const id = createId() const id = createId()
@@ -994,8 +1035,9 @@ const mapTemplate = (r: MessageTemplateRow): MessageTemplate => ({
createdAt: toIsoRequired(r.createdAt), createdAt: toIsoRequired(r.createdAt),
}) })
export const getMessageTemplates = cache( export const getMessageTemplatesRaw = async (
async (userId: string): Promise<MessageTemplate[]> => { userId: string,
): Promise<MessageTemplate[]> => {
const rows = await db const rows = await db
.select() .select()
.from(messageTemplates) .from(messageTemplates)
@@ -1003,7 +1045,12 @@ export const getMessageTemplates = cache(
.orderBy(messageTemplates.sortOrder, desc(messageTemplates.createdAt)) .orderBy(messageTemplates.sortOrder, desc(messageTemplates.createdAt))
return rows.map(mapTemplate) return rows.map(mapTemplate)
} }
)
export const getMessageTemplates = cacheFn(getMessageTemplatesRaw, {
tags: ["messaging"],
ttl: 60,
keyParts: ["messaging", "getMessageTemplates"],
})
export async function createMessageTemplate(data: CreateMessageTemplateInput): Promise<string> { export async function createMessageTemplate(data: CreateMessageTemplateInput): Promise<string> {
const id = createId() const id = createId()

View File

@@ -17,12 +17,12 @@ import "server-only"
* 未来扩展 users 表增加 wechat_open_id 列后,此处补充查询即可。 * 未来扩展 users 表增加 wechat_open_id 列后,此处补充查询即可。
*/ */
import { cache } from "react"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
import { and, count, desc, eq } from "drizzle-orm" import { and, count, desc, eq } from "drizzle-orm"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { messageNotifications, notificationLogs, users } from "@/shared/db/schema" import { messageNotifications, notificationLogs, users } from "@/shared/db/schema"
import { cacheFn } from "@/shared/lib/cache"
import type { ChannelRecipient } from "./channels/types" import type { ChannelRecipient } from "./channels/types"
import type { import type {
ChannelSendResult, ChannelSendResult,
@@ -78,8 +78,10 @@ const mapNotification = (r: NotificationRow): Notification => ({
// 站内通知 CRUDmessage_notifications 表) // 站内通知 CRUDmessage_notifications 表)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const getNotifications = cache( export const getNotificationsRaw = async (
async (userId: string, params?: GetNotificationsParams): Promise<PaginatedResult<Notification>> => { userId: string,
params?: GetNotificationsParams,
): Promise<PaginatedResult<Notification>> => {
const page = Math.max(1, params?.page ?? 1) const page = Math.max(1, params?.page ?? 1)
const pageSize = Math.max(1, params?.pageSize ?? 20) const pageSize = Math.max(1, params?.pageSize ?? 20)
const offset = (page - 1) * pageSize const offset = (page - 1) * pageSize
@@ -99,7 +101,12 @@ export const getNotifications = cache(
const total = Number(totalRow?.value ?? 0) const total = Number(totalRow?.value ?? 0)
return { items: rows.map(mapNotification), total, page, pageSize, totalPages: Math.ceil(total / pageSize) } return { items: rows.map(mapNotification), total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
} }
)
export const getNotifications = cacheFn(getNotificationsRaw, {
tags: ["notifications"],
ttl: 60,
keyParts: ["notifications", "getNotifications"],
})
export async function createNotification(data: CreateNotificationInput): Promise<string> { export async function createNotification(data: CreateNotificationInput): Promise<string> {
const id = createId() const id = createId()
@@ -115,6 +122,37 @@ export async function createNotification(data: CreateNotificationInput): Promise
return id return id
} }
/**
* P3-7: 批量创建站内通知记录(单次 INSERT 多行)。
*
* 相比循环调用 `createNotification`,本函数:
* - 仅发起一次 DB INSERT显著降低网络往返开销
* - 适用于公告 fan-out 等批量通知场景(数百到数千条)
* - 失败时整体回滚,保证原子性
*
* @param items 通知输入数组(每项对应一条 message_notifications 记录)
* @returns 生成的通知 ID 数组(与输入顺序一致)
*/
export async function createNotifications(
items: CreateNotificationInput[]
): Promise<string[]> {
if (items.length === 0) return []
const rows = items.map((data) => {
const id = createId()
return {
id,
userId: data.userId,
type: data.type,
title: data.title,
content: data.content ?? null,
link: data.link ?? null,
priority: data.priority ?? "normal",
}
})
await db.insert(messageNotifications).values(rows)
return rows.map((r) => r.id)
}
export async function markNotificationAsRead(id: string, userId: string): Promise<void> { export async function markNotificationAsRead(id: string, userId: string): Promise<void> {
await db await db
.update(messageNotifications) .update(messageNotifications)
@@ -143,12 +181,18 @@ export async function unarchiveNotification(id: string, userId: string): Promise
.where(and(eq(messageNotifications.id, id), eq(messageNotifications.userId, userId))) .where(and(eq(messageNotifications.id, id), eq(messageNotifications.userId, userId)))
} }
export const getUnreadNotificationCount = cache(async (userId: string): Promise<number> => { export const getUnreadNotificationCountRaw = async (userId: string): Promise<number> => {
const [row] = await db const [row] = await db
.select({ value: count() }) .select({ value: count() })
.from(messageNotifications) .from(messageNotifications)
.where(and(eq(messageNotifications.userId, userId), eq(messageNotifications.isRead, false))) .where(and(eq(messageNotifications.userId, userId), eq(messageNotifications.isRead, false)))
return Number(row?.value ?? 0) return Number(row?.value ?? 0)
}
export const getUnreadNotificationCount = cacheFn(getUnreadNotificationCountRaw, {
tags: ["notifications"],
ttl: 60,
keyParts: ["notifications", "getUnreadNotificationCount"],
}) })
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -159,8 +203,9 @@ export const getUnreadNotificationCount = cache(async (userId: string): Promise<
* 获取用户联系方式(手机号、邮箱)。 * 获取用户联系方式(手机号、邮箱)。
* wechatOpenId 暂不支持users 表无此字段),返回 undefined。 * wechatOpenId 暂不支持users 表无此字段),返回 undefined。
*/ */
export const getUserContactInfo = cache( export const getUserContactInfoRaw = async (
async (userId: string): Promise<ChannelRecipient> => { userId: string,
): Promise<ChannelRecipient> => {
const [row] = await db const [row] = await db
.select({ .select({
id: users.id, id: users.id,
@@ -183,7 +228,12 @@ export const getUserContactInfo = cache(
wechatOpenId: undefined, wechatOpenId: undefined,
} }
} }
)
export const getUserContactInfo = cacheFn(getUserContactInfoRaw, {
tags: ["notifications"],
ttl: 60,
keyParts: ["notifications", "getUserContactInfo"],
})
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 发送日志 // 发送日志

View File

@@ -13,12 +13,12 @@ import "server-only"
* 消除 notifications -> messaging 的反向依赖P0-4 / P1-5 修复)。 * 消除 notifications -> messaging 的反向依赖P0-4 / P1-5 修复)。
*/ */
import { cache } from "react"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
import { and, eq } from "drizzle-orm" import { and, eq } from "drizzle-orm"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { notificationPreferences } from "@/shared/db/schema" import { notificationPreferences } from "@/shared/db/schema"
import { cacheFn } from "@/shared/lib/cache"
import type { import type {
NotificationPreferences, NotificationPreferences,
UpdateNotificationPreferencesInput, UpdateNotificationPreferencesInput,
@@ -65,8 +65,9 @@ const DEFAULTS = {
* 获取用户的通知偏好设置 * 获取用户的通知偏好设置
* 如果用户尚无记录,则自动创建一条默认记录并返回 * 如果用户尚无记录,则自动创建一条默认记录并返回
*/ */
export const getNotificationPreferences = cache( export const getNotificationPreferencesRaw = async (
async (userId: string): Promise<NotificationPreferences> => { userId: string,
): Promise<NotificationPreferences> => {
// 先查询 // 先查询
const [existing] = await db const [existing] = await db
.select() .select()
@@ -111,7 +112,12 @@ export const getNotificationPreferences = cache(
updatedAt: toIso(new Date()), updatedAt: toIso(new Date()),
} }
} }
)
export const getNotificationPreferences = cacheFn(getNotificationPreferencesRaw, {
tags: ["notifications"],
ttl: 60,
keyParts: ["notifications", "getNotificationPreferences"],
})
/** /**
* 更新(或创建)用户的通知偏好设置 * 更新(或创建)用户的通知偏好设置