refactor(data-access): 组 C 模块迁移至 cacheFn(lesson-preparation/elective/announcements/messaging/notifications/audit/onboarding/invitation-codes/scheduling/settings)
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, count, desc, eq, inArray, or, sql } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { announcements, announcementReads, users } from "@/shared/db/schema"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
import { cacheFn } from "@/shared/lib/cache"
|
||||
import type {
|
||||
Announcement,
|
||||
AnnouncementInsertData,
|
||||
@@ -43,8 +43,9 @@ const mapRow = (row: AnnouncementRow): Announcement => ({
|
||||
updatedAt: toIsoRequired(row.updatedAt),
|
||||
})
|
||||
|
||||
export const getAnnouncements = cache(
|
||||
async (params?: GetAnnouncementsParams): Promise<Announcement[]> => {
|
||||
export const getAnnouncementsRaw = async (
|
||||
params?: GetAnnouncementsParams,
|
||||
): Promise<Announcement[]> => {
|
||||
const page = Math.max(1, params?.page ?? 1)
|
||||
const pageSize = Math.max(1, params?.pageSize ?? 20)
|
||||
const offset = (page - 1) * pageSize
|
||||
@@ -105,7 +106,12 @@ export const getAnnouncements = cache(
|
||||
|
||||
return rows.map(mapRow)
|
||||
}
|
||||
)
|
||||
|
||||
export const getAnnouncements = cacheFn(getAnnouncementsRaw, {
|
||||
tags: ["announcements"],
|
||||
ttl: 60,
|
||||
keyParts: ["announcements", "getAnnouncements"],
|
||||
})
|
||||
|
||||
/**
|
||||
* P2-6: 统计符合条件的公告总数(用于分页)。
|
||||
@@ -150,32 +156,38 @@ export async function countAnnouncements(
|
||||
return row?.value ?? 0
|
||||
}
|
||||
|
||||
export const getAnnouncementById = cache(
|
||||
async (id: string): Promise<Announcement | null> => {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: announcements.id,
|
||||
title: announcements.title,
|
||||
content: announcements.content,
|
||||
type: announcements.type,
|
||||
status: announcements.status,
|
||||
targetGradeId: announcements.targetGradeId,
|
||||
targetClassId: announcements.targetClassId,
|
||||
authorId: announcements.authorId,
|
||||
authorName: users.name,
|
||||
publishedAt: announcements.publishedAt,
|
||||
isPinned: announcements.isPinned,
|
||||
createdAt: announcements.createdAt,
|
||||
updatedAt: announcements.updatedAt,
|
||||
})
|
||||
.from(announcements)
|
||||
.leftJoin(users, eq(users.id, announcements.authorId))
|
||||
.where(eq(announcements.id, id))
|
||||
.limit(1)
|
||||
export const getAnnouncementByIdRaw = async (
|
||||
id: string,
|
||||
): Promise<Announcement | null> => {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: announcements.id,
|
||||
title: announcements.title,
|
||||
content: announcements.content,
|
||||
type: announcements.type,
|
||||
status: announcements.status,
|
||||
targetGradeId: announcements.targetGradeId,
|
||||
targetClassId: announcements.targetClassId,
|
||||
authorId: announcements.authorId,
|
||||
authorName: users.name,
|
||||
publishedAt: announcements.publishedAt,
|
||||
isPinned: announcements.isPinned,
|
||||
createdAt: announcements.createdAt,
|
||||
updatedAt: announcements.updatedAt,
|
||||
})
|
||||
.from(announcements)
|
||||
.leftJoin(users, eq(users.id, announcements.authorId))
|
||||
.where(eq(announcements.id, id))
|
||||
.limit(1)
|
||||
|
||||
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(
|
||||
data: AnnouncementInsertData
|
||||
@@ -552,13 +564,28 @@ export async function getUserAnnouncementsPageData(
|
||||
* - school: 全校所有用户
|
||||
* - grade: 该年级下所有用户
|
||||
* - class: 该班级学生 + 任课教师 + 班主任
|
||||
*
|
||||
* P3-7: school 类型采用分页遍历 getAllUserIds,每页 1000 条,
|
||||
* 避免单次查询超大学校全量用户导致 OOM。
|
||||
*/
|
||||
export async function resolveAnnouncementTargetUserIds(
|
||||
announcement: Announcement
|
||||
): Promise<string[]> {
|
||||
if (announcement.type === "school") {
|
||||
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) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { and, asc, desc, eq, sql, type SQL } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
courseSelections,
|
||||
electiveCourses,
|
||||
} from "@/shared/db/schema"
|
||||
import { cacheFn } from "@/shared/lib/cache"
|
||||
|
||||
import {
|
||||
buildCourseSelect,
|
||||
@@ -101,50 +101,68 @@ const resolveStudentDisplayNames = async (rows: SelectionCoreRow[]): Promise<Map
|
||||
return studentNames
|
||||
}
|
||||
|
||||
export const getCourseSelections = cache(
|
||||
async (
|
||||
courseId: string
|
||||
): Promise<CourseSelectionWithDetails[]> => {
|
||||
const rows = await buildSelectionCoreSelect()
|
||||
.where(eq(courseSelections.courseId, courseId))
|
||||
.orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))
|
||||
const studentNames = await resolveStudentDisplayNames(rows)
|
||||
return rows.map((r) => mapSelectionRow(r, studentNames))
|
||||
}
|
||||
)
|
||||
export const getCourseSelectionsRaw = async (
|
||||
courseId: string,
|
||||
): Promise<CourseSelectionWithDetails[]> => {
|
||||
const rows = await buildSelectionCoreSelect()
|
||||
.where(eq(courseSelections.courseId, courseId))
|
||||
.orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))
|
||||
const studentNames = await resolveStudentDisplayNames(rows)
|
||||
return rows.map((r) => mapSelectionRow(r, studentNames))
|
||||
}
|
||||
|
||||
export const getStudentSelections = cache(
|
||||
async (
|
||||
studentId: string
|
||||
): Promise<CourseSelectionWithDetails[]> => {
|
||||
const rows = await buildSelectionCoreSelect()
|
||||
.where(eq(courseSelections.studentId, studentId))
|
||||
.orderBy(desc(courseSelections.selectedAt))
|
||||
const studentNames = await resolveStudentDisplayNames(rows)
|
||||
return rows.map((r) => mapSelectionRow(r, studentNames))
|
||||
}
|
||||
)
|
||||
|
||||
export const getStudentGradeId = cache(async (studentId: string): Promise<string | null> => {
|
||||
return getStudentGradeResolver().getStudentActiveGradeId(studentId)
|
||||
export const getCourseSelections = cacheFn(getCourseSelectionsRaw, {
|
||||
tags: ["elective"],
|
||||
ttl: 300,
|
||||
keyParts: ["elective", "getCourseSelections"],
|
||||
})
|
||||
|
||||
export const getAvailableCoursesForStudent = cache(
|
||||
async (
|
||||
studentId: string,
|
||||
gradeId?: string | null
|
||||
): Promise<ElectiveCourseWithDetails[]> => {
|
||||
const resolvedGradeId = gradeId ?? (await getStudentGradeId(studentId))
|
||||
const conditions: SQL[] = [eq(electiveCourses.status, "open")]
|
||||
if (resolvedGradeId) {
|
||||
conditions.push(
|
||||
sql`(${electiveCourses.gradeId} = ${resolvedGradeId} OR ${electiveCourses.gradeId} IS NULL)`
|
||||
)
|
||||
}
|
||||
const rows: CourseCoreRow[] = await buildCourseSelect()
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(electiveCourses.createdAt))
|
||||
const displayMaps = await resolveCourseDisplayNames(rows)
|
||||
return rows.map((r) => mapCourseRow(r, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames))
|
||||
export const getStudentSelectionsRaw = async (
|
||||
studentId: string,
|
||||
): Promise<CourseSelectionWithDetails[]> => {
|
||||
const rows = await buildSelectionCoreSelect()
|
||||
.where(eq(courseSelections.studentId, studentId))
|
||||
.orderBy(desc(courseSelections.selectedAt))
|
||||
const studentNames = await resolveStudentDisplayNames(rows)
|
||||
return rows.map((r) => mapSelectionRow(r, studentNames))
|
||||
}
|
||||
|
||||
export const getStudentSelections = cacheFn(getStudentSelectionsRaw, {
|
||||
tags: ["elective"],
|
||||
ttl: 300,
|
||||
keyParts: ["elective", "getStudentSelections"],
|
||||
})
|
||||
|
||||
export const getStudentGradeIdRaw = async (studentId: string): Promise<string | null> => {
|
||||
return getStudentGradeResolver().getStudentActiveGradeId(studentId)
|
||||
}
|
||||
|
||||
export const getStudentGradeId = cacheFn(getStudentGradeIdRaw, {
|
||||
tags: ["elective"],
|
||||
ttl: 300,
|
||||
keyParts: ["elective", "getStudentGradeId"],
|
||||
})
|
||||
|
||||
export const getAvailableCoursesForStudentRaw = async (
|
||||
studentId: string,
|
||||
gradeId?: string | null,
|
||||
): Promise<ElectiveCourseWithDetails[]> => {
|
||||
const resolvedGradeId = gradeId ?? (await getStudentGradeId(studentId))
|
||||
const conditions: SQL[] = [eq(electiveCourses.status, "open")]
|
||||
if (resolvedGradeId) {
|
||||
conditions.push(
|
||||
sql`(${electiveCourses.gradeId} = ${resolvedGradeId} OR ${electiveCourses.gradeId} IS NULL)`
|
||||
)
|
||||
}
|
||||
)
|
||||
const rows: CourseCoreRow[] = await buildCourseSelect()
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(electiveCourses.createdAt))
|
||||
const displayMaps = await resolveCourseDisplayNames(rows)
|
||||
return rows.map((r) => mapCourseRow(r, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames))
|
||||
}
|
||||
|
||||
export const getAvailableCoursesForStudent = cacheFn(getAvailableCoursesForStudentRaw, {
|
||||
tags: ["elective"],
|
||||
ttl: 300,
|
||||
keyParts: ["elective", "getAvailableCoursesForStudent"],
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { eq, and } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { systemSettings } from "@/shared/db/schema"
|
||||
import { cacheFn } from "@/shared/lib/cache"
|
||||
|
||||
/**
|
||||
* 选课模块配置化设置(P2-4 新增)。
|
||||
@@ -55,23 +55,29 @@ async function readSettingValue(key: string): Promise<string | null> {
|
||||
*
|
||||
* @param gradeId 学生所在年级 ID(可选)
|
||||
*/
|
||||
export const getElectiveCreditLimit = cache(
|
||||
async (gradeId?: string | null): Promise<number> => {
|
||||
if (gradeId) {
|
||||
const gradeValue = await readSettingValue(`creditLimit:grade:${gradeId}`)
|
||||
if (gradeValue !== null) {
|
||||
const parsed = Number(gradeValue)
|
||||
if (!Number.isNaN(parsed) && parsed > 0) return parsed
|
||||
}
|
||||
}
|
||||
const defaultValue = await readSettingValue("creditLimit:default")
|
||||
if (defaultValue !== null) {
|
||||
const parsed = Number(defaultValue)
|
||||
export const getElectiveCreditLimitRaw = async (
|
||||
gradeId?: string | null,
|
||||
): Promise<number> => {
|
||||
if (gradeId) {
|
||||
const gradeValue = await readSettingValue(`creditLimit:grade:${gradeId}`)
|
||||
if (gradeValue !== null) {
|
||||
const parsed = Number(gradeValue)
|
||||
if (!Number.isNaN(parsed) && parsed > 0) return parsed
|
||||
}
|
||||
return DEFAULT_MAX_CREDIT_PER_TERM
|
||||
}
|
||||
)
|
||||
const defaultValue = await readSettingValue("creditLimit:default")
|
||||
if (defaultValue !== null) {
|
||||
const parsed = Number(defaultValue)
|
||||
if (!Number.isNaN(parsed) && parsed > 0) return parsed
|
||||
}
|
||||
return DEFAULT_MAX_CREDIT_PER_TERM
|
||||
}
|
||||
|
||||
export const getElectiveCreditLimit = cacheFn(getElectiveCreditLimitRaw, {
|
||||
tags: ["elective"],
|
||||
ttl: 300,
|
||||
keyParts: ["elective", "getElectiveCreditLimit"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取容量阈值通知比例(P2-4 新增)。
|
||||
@@ -79,16 +85,20 @@ export const getElectiveCreditLimit = cache(
|
||||
* 当课程 `enrolledCount >= capacity * threshold` 时触发管理员通知。
|
||||
* 默认 0.9(90%)。
|
||||
*/
|
||||
export const getCapacityNotifyThreshold = cache(
|
||||
async (): Promise<number> => {
|
||||
const value = await readSettingValue("capacityNotifyThreshold")
|
||||
if (value !== null) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isNaN(parsed) && parsed > 0 && parsed <= 1) return parsed
|
||||
}
|
||||
return DEFAULT_CAPACITY_NOTIFY_THRESHOLD
|
||||
export const getCapacityNotifyThresholdRaw = async (): Promise<number> => {
|
||||
const value = await readSettingValue("capacityNotifyThreshold")
|
||||
if (value !== null) {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isNaN(parsed) && parsed > 0 && parsed <= 1) return parsed
|
||||
}
|
||||
)
|
||||
return DEFAULT_CAPACITY_NOTIFY_THRESHOLD
|
||||
}
|
||||
|
||||
export const getCapacityNotifyThreshold = cacheFn(getCapacityNotifyThresholdRaw, {
|
||||
tags: ["elective"],
|
||||
ttl: 300,
|
||||
keyParts: ["elective", "getCapacityNotifyThreshold"],
|
||||
})
|
||||
|
||||
/** 导出默认值常量(供测试与文档引用) */
|
||||
export const ELECTIVE_DEFAULTS = {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { count, eq, sql } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { courseSelections, electiveCourses } from "@/shared/db/schema"
|
||||
import { cacheFn } from "@/shared/lib/cache"
|
||||
|
||||
/**
|
||||
* 选课模块管理员概览统计(P1-13 新增)。
|
||||
@@ -30,59 +30,63 @@ export interface ElectiveOverviewStats {
|
||||
* - 使用 SQL 聚合而非拉全表后 reduce,避免大数据量内存峰值
|
||||
* - admin 不做 scope 过滤(统计全部课程)
|
||||
*/
|
||||
export const getElectiveOverviewStats = cache(
|
||||
async (): Promise<ElectiveOverviewStats> => {
|
||||
// 并行执行聚合查询
|
||||
const [totalRow, enrolledRow, utilizationRow, pendingRow] = await Promise.all([
|
||||
// 1. 课程总数
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(electiveCourses),
|
||||
export const getElectiveOverviewStatsRaw = async (): Promise<ElectiveOverviewStats> => {
|
||||
// 并行执行聚合查询
|
||||
const [totalRow, enrolledRow, utilizationRow, pendingRow] = await Promise.all([
|
||||
// 1. 课程总数
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(electiveCourses),
|
||||
|
||||
// 2. 总选课人数(活跃选课记录数)
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(courseSelections)
|
||||
.where(
|
||||
sql`${courseSelections.status} IN ('selected', 'enrolled', 'waitlist')`
|
||||
),
|
||||
// 2. 总选课人数(活跃选课记录数)
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(courseSelections)
|
||||
.where(
|
||||
sql`${courseSelections.status} IN ('selected', 'enrolled', 'waitlist')`
|
||||
),
|
||||
|
||||
// 3. 平均容量使用率(capacity > 0 时计算 enrolledCount/capacity 平均值)
|
||||
db
|
||||
.select({
|
||||
avg: sql<number>`COALESCE(
|
||||
AVG(
|
||||
CASE
|
||||
WHEN ${electiveCourses.capacity} > 0
|
||||
THEN ${electiveCourses.enrolledCount}::float / ${electiveCourses.capacity}
|
||||
ELSE 0
|
||||
END
|
||||
) * 100,
|
||||
0
|
||||
)`,
|
||||
})
|
||||
.from(electiveCourses),
|
||||
// 3. 平均容量使用率(capacity > 0 时计算 enrolledCount/capacity 平均值)
|
||||
db
|
||||
.select({
|
||||
avg: sql<number>`COALESCE(
|
||||
AVG(
|
||||
CASE
|
||||
WHEN ${electiveCourses.capacity} > 0
|
||||
THEN ${electiveCourses.enrolledCount}::float / ${electiveCourses.capacity}
|
||||
ELSE 0
|
||||
END
|
||||
) * 100,
|
||||
0
|
||||
)`,
|
||||
})
|
||||
.from(electiveCourses),
|
||||
|
||||
// 4. 待抽签课程数:lottery 模式且 status=open 且有 selected 状态的选课记录
|
||||
db
|
||||
.select({ total: sql<number>`count(distinct ${electiveCourses.id})` })
|
||||
.from(electiveCourses)
|
||||
.innerJoin(
|
||||
courseSelections,
|
||||
eq(courseSelections.courseId, electiveCourses.id)
|
||||
)
|
||||
.where(
|
||||
sql`${electiveCourses.selectionMode} = 'lottery'
|
||||
AND ${electiveCourses.status} = 'open'
|
||||
AND ${courseSelections.status} = 'selected'`
|
||||
),
|
||||
])
|
||||
// 4. 待抽签课程数:lottery 模式且 status=open 且有 selected 状态的选课记录
|
||||
db
|
||||
.select({ total: sql<number>`count(distinct ${electiveCourses.id})` })
|
||||
.from(electiveCourses)
|
||||
.innerJoin(
|
||||
courseSelections,
|
||||
eq(courseSelections.courseId, electiveCourses.id)
|
||||
)
|
||||
.where(
|
||||
sql`${electiveCourses.selectionMode} = 'lottery'
|
||||
AND ${electiveCourses.status} = 'open'
|
||||
AND ${courseSelections.status} = 'selected'`
|
||||
),
|
||||
])
|
||||
|
||||
return {
|
||||
totalCourses: totalRow[0]?.total ?? 0,
|
||||
totalEnrolled: enrolledRow[0]?.total ?? 0,
|
||||
avgUtilization: Math.round(Number(utilizationRow[0]?.avg ?? 0)),
|
||||
pendingLottery: pendingRow[0]?.total ?? 0,
|
||||
}
|
||||
return {
|
||||
totalCourses: totalRow[0]?.total ?? 0,
|
||||
totalEnrolled: enrolledRow[0]?.total ?? 0,
|
||||
avgUtilization: Math.round(Number(utilizationRow[0]?.avg ?? 0)),
|
||||
pendingLottery: pendingRow[0]?.total ?? 0,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export const getElectiveOverviewStats = cacheFn(getElectiveOverviewStatsRaw, {
|
||||
tags: ["elective"],
|
||||
ttl: 300,
|
||||
keyParts: ["elective", "getElectiveOverviewStats"],
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import { db } from "@/shared/db"
|
||||
import { electiveCourses } from "@/shared/db/schema"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
import { safeParseDate } from "@/shared/lib/action-utils"
|
||||
import { cacheFn } from "@/shared/lib/cache"
|
||||
|
||||
import type {
|
||||
ElectiveCourseWithDetails,
|
||||
@@ -131,10 +132,9 @@ export const resolveCourseDisplayNames = async (rows: CourseCoreRow[]): Promise<
|
||||
return { teacherNames, subjectNames, gradeNames }
|
||||
}
|
||||
|
||||
export const getElectiveCourses = cache(
|
||||
async (
|
||||
params?: GetElectiveCoursesParams & { scope?: DataScope; currentUserId?: string }
|
||||
): Promise<ElectiveCourseWithDetails[]> => {
|
||||
export const getElectiveCoursesRaw = async (
|
||||
params?: GetElectiveCoursesParams & { scope?: DataScope; currentUserId?: string }
|
||||
): Promise<ElectiveCourseWithDetails[]> => {
|
||||
const conditions: SQL[] = []
|
||||
if (params?.status)
|
||||
conditions.push(
|
||||
@@ -160,18 +160,29 @@ export const getElectiveCourses = cache(
|
||||
const displayMaps = await resolveCourseDisplayNames(rows)
|
||||
return rows.map((r) => mapCourseRow(r, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames))
|
||||
}
|
||||
)
|
||||
|
||||
export const getElectiveCourseById = cache(
|
||||
async (id: string): Promise<ElectiveCourseWithDetails | null> => {
|
||||
const [row] = await buildCourseSelect()
|
||||
.where(eq(electiveCourses.id, id))
|
||||
.limit(1)
|
||||
if (!row) return null
|
||||
const displayMaps = await resolveCourseDisplayNames([row])
|
||||
return mapCourseRow(row, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames)
|
||||
}
|
||||
)
|
||||
export const getElectiveCourses = cacheFn(getElectiveCoursesRaw, {
|
||||
tags: ["elective"],
|
||||
ttl: 300,
|
||||
keyParts: ["elective", "getElectiveCourses"],
|
||||
})
|
||||
|
||||
export const getElectiveCourseByIdRaw = async (
|
||||
id: string,
|
||||
): Promise<ElectiveCourseWithDetails | null> => {
|
||||
const [row] = await buildCourseSelect()
|
||||
.where(eq(electiveCourses.id, id))
|
||||
.limit(1)
|
||||
if (!row) return null
|
||||
const displayMaps = await resolveCourseDisplayNames([row])
|
||||
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(
|
||||
data: CreateElectiveCourseInput,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import "server-only";
|
||||
|
||||
import { cache } from "react";
|
||||
import { and, desc, eq, inArray, like, or, sql, type SQL } from "drizzle-orm";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
} from "@/shared/db/schema";
|
||||
import type { DataScope } from "@/shared/types/permissions";
|
||||
import { escapeLikePattern } from "@/shared/lib/action-utils";
|
||||
import { cacheFn } from "@/shared/lib/cache";
|
||||
import { SYSTEM_TEMPLATES } from "./constants";
|
||||
import {
|
||||
migrateV1ToV2,
|
||||
@@ -217,18 +217,17 @@ function buildScopeCondition(scope: DataScope, userId: string): SQL[] {
|
||||
}
|
||||
|
||||
// ---- 课案列表 ----
|
||||
export const getLessonPlans = cache(
|
||||
async (
|
||||
params: {
|
||||
query?: string;
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
subjectId?: string;
|
||||
status?: string;
|
||||
},
|
||||
scope: DataScope,
|
||||
userId: string,
|
||||
): Promise<LessonPlanListItem[]> => {
|
||||
export const getLessonPlansRaw = async (
|
||||
params: {
|
||||
query?: string;
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
subjectId?: string;
|
||||
status?: string;
|
||||
},
|
||||
scope: DataScope,
|
||||
userId: string,
|
||||
): Promise<LessonPlanListItem[]> => {
|
||||
const conditions: SQL[] = [
|
||||
sql`${lessonPlans.status} != 'archived'`,
|
||||
];
|
||||
@@ -317,27 +316,39 @@ export const getLessonPlans = cache(
|
||||
}
|
||||
|
||||
return grouped;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getLessonPlans = cacheFn(getLessonPlansRaw, {
|
||||
tags: ["lesson-preparation"],
|
||||
ttl: 300,
|
||||
keyParts: ["lesson-preparation", "getLessonPlans"],
|
||||
});
|
||||
|
||||
// ---- 单课案 ----
|
||||
// 安全说明:此函数仅校验 creator 或 published 状态。
|
||||
// 对于 parent/student 角色,调用方(页面层)必须额外校验 plan.gradeId
|
||||
// 是否在 ctx.dataScope.gradeIds 范围内,防止跨年级信息泄露。
|
||||
export const getLessonPlanById = cache(
|
||||
async (id: string, userId: string): Promise<LessonPlan | null> => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(lessonPlans)
|
||||
.where(eq(lessonPlans.id, id))
|
||||
.limit(1);
|
||||
if (rows.length === 0) return null;
|
||||
const row = rows[0];
|
||||
// 权限:creator 可看 draft;非 creator 仅 published
|
||||
if (row.creatorId !== userId && row.status !== "published") return null;
|
||||
return mapRowToLessonPlan(row);
|
||||
},
|
||||
);
|
||||
export const getLessonPlanByIdRaw = async (
|
||||
id: string,
|
||||
userId: string,
|
||||
): Promise<LessonPlan | null> => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(lessonPlans)
|
||||
.where(eq(lessonPlans.id, id))
|
||||
.limit(1);
|
||||
if (rows.length === 0) return null;
|
||||
const row = rows[0];
|
||||
// 权限:creator 可看 draft;非 creator 仅 published
|
||||
if (row.creatorId !== userId && row.status !== "published") return null;
|
||||
return mapRowToLessonPlan(row);
|
||||
};
|
||||
|
||||
export const getLessonPlanById = cacheFn(getLessonPlanByIdRaw, {
|
||||
tags: ["lesson-preparation"],
|
||||
ttl: 300,
|
||||
keyParts: ["lesson-preparation", "getLessonPlanById"],
|
||||
});
|
||||
|
||||
// ---- 创建 ----
|
||||
// V2-2 修复:接受 translateTitle 函数,将 SYSTEM_TEMPLATES 中的 i18n 键翻译为实际文本
|
||||
|
||||
@@ -19,7 +19,6 @@ import "server-only"
|
||||
* - P0-3: getMessagesPageData 编排迁出(移至 messages/page.tsx 页面层)
|
||||
*/
|
||||
|
||||
import { cache } from "react"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
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 type { DataScope } from "@/shared/types/permissions"
|
||||
import type { PaginatedResult } from "@/modules/notifications/types"
|
||||
import { cacheFn } from "@/shared/lib/cache"
|
||||
import type {
|
||||
Message,
|
||||
GetMessagesParams,
|
||||
@@ -110,8 +110,9 @@ const mapMessage = (r: MessageRow, nameMap: Map<string, string>): Message => ({
|
||||
createdAt: toIsoRequired(r.createdAt),
|
||||
})
|
||||
|
||||
export const getMessages = cache(
|
||||
async (params: GetMessagesParams): Promise<PaginatedResult<Message>> => {
|
||||
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
|
||||
@@ -181,30 +182,45 @@ export const getMessages = cache(
|
||||
const total = Number(totalRow?.value ?? 0)
|
||||
return { items: rows.map((r) => mapMessage(r, nameMap)), total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
|
||||
}
|
||||
)
|
||||
|
||||
export const getMessageById = cache(
|
||||
async (id: string, userId: string): Promise<Message | null> => {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(
|
||||
and(
|
||||
eq(messages.id, id),
|
||||
or(
|
||||
and(eq(messages.senderId, userId), isNull(messages.senderDeletedAt)),
|
||||
and(eq(messages.receiverId, userId), isNull(messages.receiverDeletedAt))
|
||||
)
|
||||
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)
|
||||
}
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
if (!row) return null
|
||||
const nameMap = await resolveUserNames([row.senderId, row.receiverId])
|
||||
return mapMessage(row, nameMap)
|
||||
}
|
||||
|
||||
export const getMessageThread = cache(async (messageId: string, 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: 校验当前用户对根消息有访问权(必须是发送方或接收方)
|
||||
const [root] = await db
|
||||
.select()
|
||||
@@ -227,6 +243,12 @@ export const getMessageThread = cache(async (messageId: string, userId: string):
|
||||
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> {
|
||||
@@ -453,12 +475,18 @@ export async function recallMessage(
|
||||
return "ok"
|
||||
}
|
||||
|
||||
export const getUnreadMessageCount = cache(async (userId: string): Promise<number> => {
|
||||
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"],
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -546,13 +574,20 @@ const RECIPIENT_RESOLVERS: Record<DataScope["type"], MessagingRoleConfig> = {
|
||||
* 2. 在 RECIPIENT_RESOLVERS 中新增配置项
|
||||
* 无需修改本函数。
|
||||
*/
|
||||
export const getRecipients = cache(
|
||||
async (userId: string, scope: DataScope): Promise<RecipientOption[]> => {
|
||||
const config = RECIPIENT_RESOLVERS[scope.type]
|
||||
if (!config) return []
|
||||
return config.resolve({ userId, scope })
|
||||
}
|
||||
)
|
||||
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 允许范围内。
|
||||
@@ -872,20 +907,26 @@ const mapDraft = (
|
||||
updatedAt: toIsoRequired(r.updatedAt),
|
||||
})
|
||||
|
||||
export const getMessageDrafts = cache(
|
||||
async (userId: string): Promise<MessageDraft[]> => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(messageDrafts)
|
||||
.where(eq(messageDrafts.userId, userId))
|
||||
.orderBy(desc(messageDrafts.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)
|
||||
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))
|
||||
}
|
||||
)
|
||||
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()
|
||||
@@ -994,16 +1035,22 @@ const mapTemplate = (r: MessageTemplateRow): MessageTemplate => ({
|
||||
createdAt: toIsoRequired(r.createdAt),
|
||||
})
|
||||
|
||||
export const getMessageTemplates = cache(
|
||||
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 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()
|
||||
|
||||
@@ -17,12 +17,12 @@ import "server-only"
|
||||
* 未来扩展 users 表增加 wechat_open_id 列后,此处补充查询即可。
|
||||
*/
|
||||
|
||||
import { cache } from "react"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, count, desc, eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { messageNotifications, notificationLogs, users } from "@/shared/db/schema"
|
||||
import { cacheFn } from "@/shared/lib/cache"
|
||||
import type { ChannelRecipient } from "./channels/types"
|
||||
import type {
|
||||
ChannelSendResult,
|
||||
@@ -78,28 +78,35 @@ const mapNotification = (r: NotificationRow): Notification => ({
|
||||
// 站内通知 CRUD(message_notifications 表)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const getNotifications = cache(
|
||||
async (userId: string, params?: GetNotificationsParams): Promise<PaginatedResult<Notification>> => {
|
||||
const page = Math.max(1, params?.page ?? 1)
|
||||
const pageSize = Math.max(1, params?.pageSize ?? 20)
|
||||
const offset = (page - 1) * pageSize
|
||||
const conds = [eq(messageNotifications.userId, userId)]
|
||||
if (params?.unreadOnly) conds.push(eq(messageNotifications.isRead, false))
|
||||
// V2-P2-13b: 默认仅返回未归档通知
|
||||
const unarchivedOnly = params?.unarchivedOnly ?? true
|
||||
if (unarchivedOnly) conds.push(eq(messageNotifications.isArchived, false))
|
||||
// V2-P2-13b: 按优先级筛选
|
||||
if (params?.priority) conds.push(eq(messageNotifications.priority, params.priority))
|
||||
const where = and(...conds)
|
||||
export const getNotificationsRaw = async (
|
||||
userId: string,
|
||||
params?: GetNotificationsParams,
|
||||
): Promise<PaginatedResult<Notification>> => {
|
||||
const page = Math.max(1, params?.page ?? 1)
|
||||
const pageSize = Math.max(1, params?.pageSize ?? 20)
|
||||
const offset = (page - 1) * pageSize
|
||||
const conds = [eq(messageNotifications.userId, userId)]
|
||||
if (params?.unreadOnly) conds.push(eq(messageNotifications.isRead, false))
|
||||
// V2-P2-13b: 默认仅返回未归档通知
|
||||
const unarchivedOnly = params?.unarchivedOnly ?? true
|
||||
if (unarchivedOnly) conds.push(eq(messageNotifications.isArchived, false))
|
||||
// V2-P2-13b: 按优先级筛选
|
||||
if (params?.priority) conds.push(eq(messageNotifications.priority, params.priority))
|
||||
const where = and(...conds)
|
||||
|
||||
const [rows, [totalRow]] = await Promise.all([
|
||||
db.select().from(messageNotifications).where(where).orderBy(desc(messageNotifications.createdAt)).limit(pageSize).offset(offset),
|
||||
db.select({ value: count() }).from(messageNotifications).where(where),
|
||||
])
|
||||
const total = Number(totalRow?.value ?? 0)
|
||||
return { items: rows.map(mapNotification), total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
|
||||
}
|
||||
)
|
||||
const [rows, [totalRow]] = await Promise.all([
|
||||
db.select().from(messageNotifications).where(where).orderBy(desc(messageNotifications.createdAt)).limit(pageSize).offset(offset),
|
||||
db.select({ value: count() }).from(messageNotifications).where(where),
|
||||
])
|
||||
const total = Number(totalRow?.value ?? 0)
|
||||
return { items: rows.map(mapNotification), total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
|
||||
}
|
||||
|
||||
export const getNotifications = cacheFn(getNotificationsRaw, {
|
||||
tags: ["notifications"],
|
||||
ttl: 60,
|
||||
keyParts: ["notifications", "getNotifications"],
|
||||
})
|
||||
|
||||
export async function createNotification(data: CreateNotificationInput): Promise<string> {
|
||||
const id = createId()
|
||||
@@ -115,6 +122,37 @@ export async function createNotification(data: CreateNotificationInput): Promise
|
||||
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> {
|
||||
await db
|
||||
.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)))
|
||||
}
|
||||
|
||||
export const getUnreadNotificationCount = cache(async (userId: string): Promise<number> => {
|
||||
export const getUnreadNotificationCountRaw = async (userId: string): Promise<number> => {
|
||||
const [row] = await db
|
||||
.select({ value: count() })
|
||||
.from(messageNotifications)
|
||||
.where(and(eq(messageNotifications.userId, userId), eq(messageNotifications.isRead, false)))
|
||||
return Number(row?.value ?? 0)
|
||||
}
|
||||
|
||||
export const getUnreadNotificationCount = cacheFn(getUnreadNotificationCountRaw, {
|
||||
tags: ["notifications"],
|
||||
ttl: 60,
|
||||
keyParts: ["notifications", "getUnreadNotificationCount"],
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -159,31 +203,37 @@ export const getUnreadNotificationCount = cache(async (userId: string): Promise<
|
||||
* 获取用户联系方式(手机号、邮箱)。
|
||||
* wechatOpenId 暂不支持(users 表无此字段),返回 undefined。
|
||||
*/
|
||||
export const getUserContactInfo = cache(
|
||||
async (userId: string): Promise<ChannelRecipient> => {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
phone: users.phone,
|
||||
email: users.email,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1)
|
||||
export const getUserContactInfoRaw = async (
|
||||
userId: string,
|
||||
): Promise<ChannelRecipient> => {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
phone: users.phone,
|
||||
email: users.email,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!row) {
|
||||
return { userId }
|
||||
}
|
||||
|
||||
return {
|
||||
userId: row.id,
|
||||
phone: row.phone ?? undefined,
|
||||
email: row.email ?? undefined,
|
||||
// users 表暂无 wechat_open_id 字段;扩展 schema 后在此补充
|
||||
wechatOpenId: undefined,
|
||||
}
|
||||
if (!row) {
|
||||
return { userId }
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
userId: row.id,
|
||||
phone: row.phone ?? undefined,
|
||||
email: row.email ?? undefined,
|
||||
// users 表暂无 wechat_open_id 字段;扩展 schema 后在此补充
|
||||
wechatOpenId: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const getUserContactInfo = cacheFn(getUserContactInfoRaw, {
|
||||
tags: ["notifications"],
|
||||
ttl: 60,
|
||||
keyParts: ["notifications", "getUserContactInfo"],
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 发送日志
|
||||
|
||||
@@ -13,12 +13,12 @@ import "server-only"
|
||||
* 消除 notifications -> messaging 的反向依赖(P0-4 / P1-5 修复)。
|
||||
*/
|
||||
|
||||
import { cache } from "react"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { notificationPreferences } from "@/shared/db/schema"
|
||||
import { cacheFn } from "@/shared/lib/cache"
|
||||
import type {
|
||||
NotificationPreferences,
|
||||
UpdateNotificationPreferencesInput,
|
||||
@@ -65,53 +65,59 @@ const DEFAULTS = {
|
||||
* 获取用户的通知偏好设置
|
||||
* 如果用户尚无记录,则自动创建一条默认记录并返回
|
||||
*/
|
||||
export const getNotificationPreferences = cache(
|
||||
async (userId: string): Promise<NotificationPreferences> => {
|
||||
// 先查询
|
||||
const [existing] = await db
|
||||
export const getNotificationPreferencesRaw = async (
|
||||
userId: string,
|
||||
): Promise<NotificationPreferences> => {
|
||||
// 先查询
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (existing) {
|
||||
return mapRow(existing)
|
||||
}
|
||||
|
||||
// 不存在则创建默认记录
|
||||
const id = createId()
|
||||
try {
|
||||
await db.insert(notificationPreferences).values({
|
||||
id,
|
||||
userId,
|
||||
...DEFAULTS,
|
||||
})
|
||||
const [created] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.id, id))
|
||||
.limit(1)
|
||||
if (created) return mapRow(created)
|
||||
} catch {
|
||||
// 并发情况下可能违反唯一约束,回退到查询
|
||||
const [fallback] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (existing) {
|
||||
return mapRow(existing)
|
||||
}
|
||||
|
||||
// 不存在则创建默认记录
|
||||
const id = createId()
|
||||
try {
|
||||
await db.insert(notificationPreferences).values({
|
||||
id,
|
||||
userId,
|
||||
...DEFAULTS,
|
||||
})
|
||||
const [created] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.id, id))
|
||||
.limit(1)
|
||||
if (created) return mapRow(created)
|
||||
} catch {
|
||||
// 并发情况下可能违反唯一约束,回退到查询
|
||||
const [fallback] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, userId))
|
||||
.limit(1)
|
||||
if (fallback) return mapRow(fallback)
|
||||
}
|
||||
|
||||
// 极端情况:返回内存中的默认值(不带 id)
|
||||
return {
|
||||
id: "",
|
||||
userId,
|
||||
...DEFAULTS,
|
||||
createdAt: toIso(new Date()),
|
||||
updatedAt: toIso(new Date()),
|
||||
}
|
||||
if (fallback) return mapRow(fallback)
|
||||
}
|
||||
)
|
||||
|
||||
// 极端情况:返回内存中的默认值(不带 id)
|
||||
return {
|
||||
id: "",
|
||||
userId,
|
||||
...DEFAULTS,
|
||||
createdAt: toIso(new Date()),
|
||||
updatedAt: toIso(new Date()),
|
||||
}
|
||||
}
|
||||
|
||||
export const getNotificationPreferences = cacheFn(getNotificationPreferencesRaw, {
|
||||
tags: ["notifications"],
|
||||
ttl: 60,
|
||||
keyParts: ["notifications", "getNotificationPreferences"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 更新(或创建)用户的通知偏好设置
|
||||
|
||||
Reference in New Issue
Block a user