feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
import { and, asc, count, desc, eq, ilike, isNotNull, or, sql } from 'drizzle-orm'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { notifications } from '~/drizzle/schema'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import {
|
||||
canSendSystemNotification,
|
||||
NOTIFICATION_SOURCES,
|
||||
serializeNotificationSender
|
||||
} from '~~/server/utils/important-notification-policy'
|
||||
import {
|
||||
resolveNotificationHistoryFilters,
|
||||
resolveNotificationHistoryPagination
|
||||
} from '~~/server/utils/notification-history-policy'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createApiError(
|
||||
401,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_AUTH_REQUIRED,
|
||||
'请先登录后查看通知历史'
|
||||
)
|
||||
}
|
||||
|
||||
if (!canSendSystemNotification(user.role)) {
|
||||
throw createApiError(
|
||||
403,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_ADMIN_REQUIRED,
|
||||
'只有管理员可以查看通知历史'
|
||||
)
|
||||
}
|
||||
|
||||
const query = getQuery(event)
|
||||
const filters = resolveNotificationHistoryFilters({
|
||||
keyword: query.keyword,
|
||||
type: query.type,
|
||||
sender: query.sender,
|
||||
sortOrder: query.sortOrder
|
||||
})
|
||||
if (!filters) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_HISTORY_FILTER_INVALID,
|
||||
'通知历史筛选条件无效'
|
||||
)
|
||||
}
|
||||
|
||||
const { page, limit, offset } = resolveNotificationHistoryPagination(query.page, query.limit)
|
||||
const baseCondition = and(
|
||||
eq(notifications.type, 'SYSTEM_NOTICE'),
|
||||
eq(notifications.source, NOTIFICATION_SOURCES.ADMIN_MANUAL)
|
||||
)
|
||||
// Drizzle ORM 的 ilike 已对参数做参数化绑定,无需手动转义 % 和 _
|
||||
const keywordPattern = filters.keyword ? `%${filters.keyword}%` : null
|
||||
const keywordCondition = keywordPattern
|
||||
? or(ilike(notifications.title, keywordPattern), ilike(notifications.message, keywordPattern))
|
||||
: undefined
|
||||
const typeCondition =
|
||||
filters.type === 'IMPORTANT'
|
||||
? eq(notifications.important, true)
|
||||
: filters.type === 'NORMAL'
|
||||
? eq(notifications.important, false)
|
||||
: undefined
|
||||
const senderCondition = filters.senderId
|
||||
? eq(notifications.senderId, filters.senderId)
|
||||
: undefined
|
||||
const batchKey = sql<string>`coalesce(
|
||||
${notifications.batchId},
|
||||
'legacy-' || cast(${notifications.id} as text)
|
||||
)`
|
||||
|
||||
const notificationBatches = db
|
||||
.select({
|
||||
batchId: batchKey.as('batch_id'),
|
||||
notificationId: sql<number>`max(${notifications.id})`.as('notification_id'),
|
||||
title: notifications.title,
|
||||
message: notifications.message,
|
||||
important: notifications.important,
|
||||
senderId: notifications.senderId,
|
||||
senderName: notifications.senderName,
|
||||
senderUsername: notifications.senderUsername,
|
||||
createdAt: notifications.createdAt,
|
||||
recipientCount: count().as('recipient_count')
|
||||
})
|
||||
.from(notifications)
|
||||
.where(and(baseCondition, keywordCondition, typeCondition, senderCondition))
|
||||
.groupBy(
|
||||
batchKey,
|
||||
notifications.title,
|
||||
notifications.message,
|
||||
notifications.important,
|
||||
notifications.senderId,
|
||||
notifications.senderName,
|
||||
notifications.senderUsername,
|
||||
notifications.createdAt
|
||||
)
|
||||
.as('notification_batches')
|
||||
|
||||
const senderName = sql<string | null>`(
|
||||
array_agg(${notifications.senderName} order by ${notifications.createdAt} desc)
|
||||
)[1]`
|
||||
const senderUsername = sql<string | null>`(
|
||||
array_agg(${notifications.senderUsername} order by ${notifications.createdAt} desc)
|
||||
)[1]`
|
||||
const senderOptionsQuery = db
|
||||
.select({
|
||||
id: notifications.senderId,
|
||||
name: senderName.as('sender_name'),
|
||||
username: senderUsername.as('sender_username')
|
||||
})
|
||||
.from(notifications)
|
||||
.where(and(baseCondition, isNotNull(notifications.senderId)))
|
||||
.groupBy(notifications.senderId)
|
||||
.orderBy(asc(notifications.senderId))
|
||||
|
||||
const primarySort =
|
||||
filters.sortOrder === 'ASC'
|
||||
? asc(notificationBatches.createdAt)
|
||||
: desc(notificationBatches.createdAt)
|
||||
const secondarySort =
|
||||
filters.sortOrder === 'ASC'
|
||||
? asc(notificationBatches.notificationId)
|
||||
: desc(notificationBatches.notificationId)
|
||||
|
||||
try {
|
||||
const [historyRows, totalRows, senderRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(notificationBatches)
|
||||
.orderBy(primarySort, secondarySort)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db.select({ count: count() }).from(notificationBatches),
|
||||
senderOptionsQuery
|
||||
])
|
||||
|
||||
const total = Number(totalRows[0]?.count || 0)
|
||||
return {
|
||||
notifications: historyRows.map((row) => ({
|
||||
batchId: row.batchId,
|
||||
title: row.title,
|
||||
message: row.message,
|
||||
important: row.important,
|
||||
sender: serializeNotificationSender({
|
||||
senderId: row.senderId,
|
||||
senderName: row.senderName,
|
||||
senderUsername: row.senderUsername
|
||||
}),
|
||||
createdAt: row.createdAt,
|
||||
recipientCount: Number(row.recipientCount || 0)
|
||||
})),
|
||||
senders: senderRows.map((sender) => ({
|
||||
id: Number(sender.id),
|
||||
name: sender.name,
|
||||
username: sender.username
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.max(1, Math.ceil(total / limit))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取通知历史失败:', error)
|
||||
throw createApiError(
|
||||
500,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_HISTORY_FETCH_FAILED,
|
||||
'获取通知历史失败'
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { notifications } from '~/drizzle/schema'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import {
|
||||
canSendSystemNotification,
|
||||
NOTIFICATION_SOURCES
|
||||
} from '~~/server/utils/important-notification-policy'
|
||||
import { resolveNotificationBatchReference } from '~~/server/utils/notification-history-policy'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createApiError(401, SERVER_ERROR_CODES.NOTIFICATION_AUTH_REQUIRED, '请先登录后删除通知')
|
||||
}
|
||||
|
||||
if (!canSendSystemNotification(user.role)) {
|
||||
throw createApiError(
|
||||
403,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_ADMIN_REQUIRED,
|
||||
'只有管理员可以删除通知'
|
||||
)
|
||||
}
|
||||
|
||||
const batchReference = resolveNotificationBatchReference(getRouterParam(event, 'batchId'))
|
||||
if (!batchReference) {
|
||||
throw createApiError(404, SERVER_ERROR_CODES.NOTIFICATION_NOT_FOUND, '通知不存在')
|
||||
}
|
||||
|
||||
const batchCondition = batchReference.batchId
|
||||
? eq(notifications.batchId, batchReference.batchId)
|
||||
: eq(notifications.id, batchReference.notificationId!)
|
||||
|
||||
let deletedRows
|
||||
try {
|
||||
deletedRows = await db
|
||||
.delete(notifications)
|
||||
.where(
|
||||
and(
|
||||
eq(notifications.type, 'SYSTEM_NOTICE'),
|
||||
eq(notifications.source, NOTIFICATION_SOURCES.ADMIN_MANUAL),
|
||||
batchCondition
|
||||
)
|
||||
)
|
||||
.returning({ id: notifications.id })
|
||||
} catch (error) {
|
||||
console.error('删除通知失败:', error)
|
||||
throw createApiError(
|
||||
500,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_HISTORY_DELETE_FAILED,
|
||||
'删除通知失败'
|
||||
)
|
||||
}
|
||||
|
||||
if (deletedRows.length === 0) {
|
||||
throw createApiError(404, SERVER_ERROR_CODES.NOTIFICATION_NOT_FOUND, '通知不存在')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedCount: deletedRows.length
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
import { and, count, desc, eq } from 'drizzle-orm'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { notifications, users } from '~/drizzle/schema'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import {
|
||||
canSendSystemNotification,
|
||||
NOTIFICATION_SOURCES,
|
||||
serializeNotificationSender
|
||||
} from '~~/server/utils/important-notification-policy'
|
||||
import {
|
||||
resolveNotificationBatchReference,
|
||||
resolveNotificationHistoryPagination,
|
||||
resolveNotificationHistoryStatus
|
||||
} from '~~/server/utils/notification-history-policy'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createApiError(
|
||||
401,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_AUTH_REQUIRED,
|
||||
'请先登录后查看通知明细'
|
||||
)
|
||||
}
|
||||
|
||||
if (!canSendSystemNotification(user.role)) {
|
||||
throw createApiError(
|
||||
403,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_ADMIN_REQUIRED,
|
||||
'只有管理员可以查看通知明细'
|
||||
)
|
||||
}
|
||||
|
||||
const batchReference = resolveNotificationBatchReference(getRouterParam(event, 'batchId'))
|
||||
if (!batchReference) {
|
||||
throw createApiError(404, SERVER_ERROR_CODES.NOTIFICATION_NOT_FOUND, '通知不存在')
|
||||
}
|
||||
|
||||
const query = getQuery(event)
|
||||
const status = resolveNotificationHistoryStatus(query.status)
|
||||
if (!status) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_HISTORY_STATUS_INVALID,
|
||||
'通知历史状态筛选值无效'
|
||||
)
|
||||
}
|
||||
|
||||
const { page, limit, offset } = resolveNotificationHistoryPagination(query.page, query.limit)
|
||||
const batchCondition = batchReference.batchId
|
||||
? eq(notifications.batchId, batchReference.batchId)
|
||||
: eq(notifications.id, batchReference.notificationId!)
|
||||
const baseCondition = and(
|
||||
eq(notifications.type, 'SYSTEM_NOTICE'),
|
||||
eq(notifications.source, NOTIFICATION_SOURCES.ADMIN_MANUAL),
|
||||
batchCondition
|
||||
)!
|
||||
const filteredConditions = [baseCondition]
|
||||
if (status === 'READ') filteredConditions.push(eq(notifications.read, true))
|
||||
if (status === 'UNREAD') filteredConditions.push(eq(notifications.read, false))
|
||||
const filteredCondition = and(...filteredConditions)!
|
||||
|
||||
let queryResult
|
||||
|
||||
try {
|
||||
queryResult = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: notifications.id,
|
||||
read: notifications.read,
|
||||
updatedAt: notifications.updatedAt,
|
||||
userId: notifications.userId,
|
||||
username: users.username,
|
||||
userName: users.name,
|
||||
grade: users.grade,
|
||||
className: users.class
|
||||
})
|
||||
.from(notifications)
|
||||
.leftJoin(users, eq(notifications.userId, users.id))
|
||||
.where(filteredCondition)
|
||||
.orderBy(desc(notifications.id))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db.select({ count: count() }).from(notifications).where(filteredCondition),
|
||||
db
|
||||
.select({ read: notifications.read, count: count() })
|
||||
.from(notifications)
|
||||
.where(baseCondition)
|
||||
.groupBy(notifications.read),
|
||||
db
|
||||
.select({
|
||||
title: notifications.title,
|
||||
message: notifications.message,
|
||||
important: notifications.important,
|
||||
senderId: notifications.senderId,
|
||||
senderName: notifications.senderName,
|
||||
senderUsername: notifications.senderUsername,
|
||||
createdAt: notifications.createdAt
|
||||
})
|
||||
.from(notifications)
|
||||
.where(baseCondition)
|
||||
.limit(1)
|
||||
])
|
||||
} catch (error) {
|
||||
console.error('获取通知明细失败:', error)
|
||||
throw createApiError(
|
||||
500,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_HISTORY_FETCH_FAILED,
|
||||
'获取通知明细失败'
|
||||
)
|
||||
}
|
||||
|
||||
const [detailRows, totalRows, statsRows, metadataRows] = queryResult
|
||||
|
||||
const metadata = metadataRows[0]
|
||||
if (!metadata) {
|
||||
throw createApiError(404, SERVER_ERROR_CODES.NOTIFICATION_NOT_FOUND, '通知不存在')
|
||||
}
|
||||
|
||||
const stats = { total: 0, read: 0, unread: 0 }
|
||||
for (const row of statsRows) {
|
||||
const value = Number(row.count || 0)
|
||||
stats.total += value
|
||||
if (row.read) stats.read = value
|
||||
else stats.unread = value
|
||||
}
|
||||
|
||||
const total = Number(totalRows[0]?.count || 0)
|
||||
const { senderId, senderName, senderUsername, ...notificationMetadata } = metadata
|
||||
return {
|
||||
notification: {
|
||||
batchId: getRouterParam(event, 'batchId'),
|
||||
...notificationMetadata,
|
||||
sender: serializeNotificationSender({ senderId, senderName, senderUsername })
|
||||
},
|
||||
recipients: detailRows.map((row) => ({
|
||||
id: row.id,
|
||||
read: row.read,
|
||||
readAt: row.read ? row.updatedAt : null,
|
||||
recipient: {
|
||||
id: row.userId,
|
||||
username: row.username,
|
||||
name: row.userName,
|
||||
grade: row.grade,
|
||||
class: row.className
|
||||
}
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.max(1, Math.ceil(total / limit))
|
||||
},
|
||||
stats
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { notifications } from '~/drizzle/schema'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import {
|
||||
canSendSystemNotification,
|
||||
createNotificationSenderSnapshot,
|
||||
NOTIFICATION_SOURCES,
|
||||
NOTIFICATION_CONTENT_MAX_LENGTH,
|
||||
NOTIFICATION_TITLE_MAX_LENGTH,
|
||||
resolveImportantFlag,
|
||||
serializeNotificationSender
|
||||
} from '~~/server/utils/important-notification-policy'
|
||||
import { resolveNotificationBatchReference } from '~~/server/utils/notification-history-policy'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createApiError(401, SERVER_ERROR_CODES.NOTIFICATION_AUTH_REQUIRED, '请先登录后修改通知')
|
||||
}
|
||||
|
||||
if (!canSendSystemNotification(user.role)) {
|
||||
throw createApiError(
|
||||
403,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_ADMIN_REQUIRED,
|
||||
'只有管理员可以修改通知'
|
||||
)
|
||||
}
|
||||
|
||||
const batchReference = resolveNotificationBatchReference(getRouterParam(event, 'batchId'))
|
||||
if (!batchReference) {
|
||||
throw createApiError(404, SERVER_ERROR_CODES.NOTIFICATION_NOT_FOUND, '通知不存在')
|
||||
}
|
||||
|
||||
const body = await readBody<Record<string, unknown> | null>(event)
|
||||
const title = typeof body?.title === 'string' ? body.title.trim() : ''
|
||||
const rawContent = typeof body?.content === 'string' ? body.content : body?.message
|
||||
const content = typeof rawContent === 'string' ? rawContent.trim() : ''
|
||||
const important = resolveImportantFlag(body?.important)
|
||||
|
||||
if (important === null) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_IMPORTANT_INVALID,
|
||||
'重要通知标记必须是布尔值'
|
||||
)
|
||||
}
|
||||
|
||||
if (!title || !content) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_TITLE_CONTENT_REQUIRED,
|
||||
'通知标题和内容不能为空'
|
||||
)
|
||||
}
|
||||
|
||||
if (title.length > NOTIFICATION_TITLE_MAX_LENGTH) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_TITLE_TOO_LONG,
|
||||
`通知标题不能超过 ${NOTIFICATION_TITLE_MAX_LENGTH} 个字符`,
|
||||
{ params: [NOTIFICATION_TITLE_MAX_LENGTH] }
|
||||
)
|
||||
}
|
||||
|
||||
if (content.length > NOTIFICATION_CONTENT_MAX_LENGTH) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_CONTENT_TOO_LONG,
|
||||
`通知正文不能超过 ${NOTIFICATION_CONTENT_MAX_LENGTH} 个字符`,
|
||||
{ params: [NOTIFICATION_CONTENT_MAX_LENGTH] }
|
||||
)
|
||||
}
|
||||
|
||||
const batchCondition = batchReference.batchId
|
||||
? eq(notifications.batchId, batchReference.batchId)
|
||||
: eq(notifications.id, batchReference.notificationId!)
|
||||
const baseCondition = and(
|
||||
eq(notifications.type, 'SYSTEM_NOTICE'),
|
||||
eq(notifications.source, NOTIFICATION_SOURCES.ADMIN_MANUAL),
|
||||
batchCondition
|
||||
)!
|
||||
|
||||
if (important) {
|
||||
try {
|
||||
const batchId = randomUUID()
|
||||
const createdAt = new Date()
|
||||
const senderSnapshot = createNotificationSenderSnapshot({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username
|
||||
})
|
||||
|
||||
// 同一事务内删除旧批次并重新下发,避免旧内容继续弹窗、历史列表残留重复记录
|
||||
const recipientUserIds = await db.transaction(async (tx) => {
|
||||
const recipients = await tx
|
||||
.delete(notifications)
|
||||
.where(baseCondition)
|
||||
.returning({ userId: notifications.userId })
|
||||
|
||||
if (recipients.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const userIds = [...new Set(recipients.map(({ userId }) => userId))]
|
||||
await tx.insert(notifications).values(
|
||||
userIds.map((userId) => ({
|
||||
userId,
|
||||
type: 'SYSTEM_NOTICE',
|
||||
batchId,
|
||||
source: NOTIFICATION_SOURCES.ADMIN_MANUAL,
|
||||
...senderSnapshot,
|
||||
title,
|
||||
message: content,
|
||||
important: true,
|
||||
read: false,
|
||||
userDeleted: false,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
}))
|
||||
)
|
||||
return userIds
|
||||
})
|
||||
|
||||
if (recipientUserIds.length === 0) {
|
||||
throw createApiError(404, SERVER_ERROR_CODES.NOTIFICATION_NOT_FOUND, '通知不存在')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
createdNewBatch: true,
|
||||
updatedCount: recipientUserIds.length,
|
||||
notification: {
|
||||
batchId,
|
||||
title,
|
||||
message: content,
|
||||
important: true,
|
||||
sender: serializeNotificationSender(senderSnapshot),
|
||||
createdAt,
|
||||
recipientCount: recipientUserIds.length
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error && 'statusCode' in error) throw error
|
||||
console.error('重新发送重要通知失败:', error)
|
||||
throw createApiError(
|
||||
500,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_HISTORY_UPDATE_FAILED,
|
||||
'修改通知失败'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let updatedRows
|
||||
try {
|
||||
updatedRows = await db
|
||||
.update(notifications)
|
||||
.set({ title, message: content, important: false })
|
||||
.where(baseCondition)
|
||||
.returning({ id: notifications.id })
|
||||
} catch (error) {
|
||||
console.error('修改通知失败:', error)
|
||||
throw createApiError(
|
||||
500,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_HISTORY_UPDATE_FAILED,
|
||||
'修改通知失败'
|
||||
)
|
||||
}
|
||||
|
||||
if (updatedRows.length === 0) {
|
||||
throw createApiError(404, SERVER_ERROR_CODES.NOTIFICATION_NOT_FOUND, '通知不存在')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
updatedCount: updatedRows.length,
|
||||
notification: { title, message: content, important }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,220 @@
|
||||
import { and, eq, or } from 'drizzle-orm'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { users } from '~/drizzle/schema'
|
||||
import {
|
||||
createBatchSystemNotifications,
|
||||
createSystemNotification
|
||||
} from '~~/server/services/notificationService'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import {
|
||||
canSendSystemNotification,
|
||||
NOTIFICATION_CONTENT_MAX_LENGTH,
|
||||
NOTIFICATION_TITLE_MAX_LENGTH,
|
||||
resolveImportantFlag
|
||||
} from '~~/server/utils/important-notification-policy'
|
||||
|
||||
type NotificationFilter = {
|
||||
grade?: unknown
|
||||
class?: unknown
|
||||
classes?: unknown
|
||||
userIds?: unknown
|
||||
}
|
||||
|
||||
const supportedScopes = ['ALL', 'GRADE', 'CLASS', 'MULTI_CLASS', 'SPECIFIC_USERS'] as const
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
|
||||
if (!user) {
|
||||
throw createApiError(401, SERVER_ERROR_CODES.NOTIFICATION_AUTH_REQUIRED, '需要登录才能发送通知')
|
||||
}
|
||||
|
||||
if (!canSendSystemNotification(user.role)) {
|
||||
throw createApiError(
|
||||
403,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_ADMIN_REQUIRED,
|
||||
'只有管理员可以发送系统通知'
|
||||
)
|
||||
}
|
||||
|
||||
const body = await readBody<Record<string, unknown> | null>(event)
|
||||
const sender = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username
|
||||
}
|
||||
const title = typeof body?.title === 'string' ? body.title.trim() : ''
|
||||
const rawContent = typeof body?.content === 'string' ? body.content : body?.message
|
||||
const content = typeof rawContent === 'string' ? rawContent.trim() : ''
|
||||
const important = resolveImportantFlag(body?.important)
|
||||
|
||||
if (important === null) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_IMPORTANT_INVALID,
|
||||
'重要通知标记必须是布尔值'
|
||||
)
|
||||
}
|
||||
|
||||
if (!title || !content) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_TITLE_CONTENT_REQUIRED,
|
||||
'通知标题和内容不能为空'
|
||||
)
|
||||
}
|
||||
|
||||
if (title.length > NOTIFICATION_TITLE_MAX_LENGTH) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_TITLE_TOO_LONG,
|
||||
`通知标题不能超过 ${NOTIFICATION_TITLE_MAX_LENGTH} 个字符`,
|
||||
{ params: [NOTIFICATION_TITLE_MAX_LENGTH] }
|
||||
)
|
||||
}
|
||||
|
||||
if (content.length > NOTIFICATION_CONTENT_MAX_LENGTH) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_CONTENT_TOO_LONG,
|
||||
`通知正文不能超过 ${NOTIFICATION_CONTENT_MAX_LENGTH} 个字符`,
|
||||
{ params: [NOTIFICATION_CONTENT_MAX_LENGTH] }
|
||||
)
|
||||
}
|
||||
|
||||
const targetUserId = body?.userId
|
||||
if (targetUserId !== undefined) {
|
||||
// 兼容旧调用方传入数字字符串的情况
|
||||
const normalizedUserId =
|
||||
typeof targetUserId === 'string' && targetUserId.trim()
|
||||
? Number(targetUserId.trim())
|
||||
: targetUserId
|
||||
if (!Number.isInteger(normalizedUserId) || Number(normalizedUserId) <= 0) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.NOTIFICATION_USER_ID_INVALID, '无效的用户 ID')
|
||||
}
|
||||
|
||||
const result = await createSystemNotification(
|
||||
Number(normalizedUserId),
|
||||
title,
|
||||
content,
|
||||
important,
|
||||
sender
|
||||
)
|
||||
if (!result) {
|
||||
throw createApiError(500, SERVER_ERROR_CODES.NOTIFICATION_SEND_FAILED, '发送通知失败')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '通知发送成功',
|
||||
sentCount: 1,
|
||||
totalUsers: 1
|
||||
}
|
||||
}
|
||||
|
||||
const scope = typeof body?.scope === 'string' ? body.scope : ''
|
||||
if (!supportedScopes.includes(scope as (typeof supportedScopes)[number])) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.NOTIFICATION_SCOPE_INVALID, '无效的通知范围')
|
||||
}
|
||||
|
||||
const filter = (
|
||||
body?.filter && typeof body.filter === 'object' ? body.filter : {}
|
||||
) as NotificationFilter
|
||||
let userIds: number[]
|
||||
|
||||
// 仅向活跃状态的用户发送通知,排除已退学/毕业用户
|
||||
const activeUserCondition = eq(users.status, 'active')
|
||||
|
||||
if (scope === 'ALL') {
|
||||
const allUsers = await db.select({ id: users.id }).from(users).where(activeUserCondition)
|
||||
userIds = allUsers.map((target) => target.id)
|
||||
} else if (scope === 'GRADE') {
|
||||
const grade = typeof filter.grade === 'string' ? filter.grade.trim() : ''
|
||||
if (!grade) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.NOTIFICATION_GRADE_REQUIRED, '年级不能为空')
|
||||
}
|
||||
|
||||
const gradeUsers = await db.select({ id: users.id }).from(users).where(and(eq(users.grade, grade), activeUserCondition))
|
||||
userIds = gradeUsers.map((target) => target.id)
|
||||
} else if (scope === 'CLASS') {
|
||||
const grade = typeof filter.grade === 'string' ? filter.grade.trim() : ''
|
||||
const className = typeof filter.class === 'string' ? filter.class.trim() : ''
|
||||
if (!grade || !className) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_GRADE_CLASS_REQUIRED,
|
||||
'年级和班级不能为空'
|
||||
)
|
||||
}
|
||||
|
||||
const classUsers = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(eq(users.grade, grade), eq(users.class, className), activeUserCondition))
|
||||
userIds = classUsers.map((target) => target.id)
|
||||
} else if (scope === 'MULTI_CLASS') {
|
||||
if (!Array.isArray(filter.classes) || filter.classes.length === 0) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.NOTIFICATION_CLASSES_REQUIRED, '未选择任何班级')
|
||||
}
|
||||
|
||||
const classes = filter.classes.map((entry) => {
|
||||
const candidate = entry && typeof entry === 'object' ? (entry as Record<string, unknown>) : {}
|
||||
return {
|
||||
grade: typeof candidate.grade === 'string' ? candidate.grade.trim() : '',
|
||||
className: typeof candidate.class === 'string' ? candidate.class.trim() : ''
|
||||
}
|
||||
})
|
||||
|
||||
if (classes.some((entry) => !entry.grade || !entry.className)) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.NOTIFICATION_CLASSES_REQUIRED, '班级信息不完整')
|
||||
}
|
||||
|
||||
const whereConditions = classes.map((entry) =>
|
||||
and(eq(users.grade, entry.grade), eq(users.class, entry.className), activeUserCondition)
|
||||
)
|
||||
const multiClassUsers = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(or(...whereConditions))
|
||||
userIds = multiClassUsers.map((target) => target.id)
|
||||
} else {
|
||||
if (!Array.isArray(filter.userIds) || filter.userIds.length === 0) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.NOTIFICATION_USERS_REQUIRED, '未选择任何用户')
|
||||
}
|
||||
|
||||
if (filter.userIds.some((id) => !Number.isInteger(id) || Number(id) <= 0)) {
|
||||
throw createApiError(
|
||||
400,
|
||||
SERVER_ERROR_CODES.NOTIFICATION_USER_ID_INVALID,
|
||||
'用户 ID 列表包含无效值'
|
||||
)
|
||||
}
|
||||
|
||||
userIds = [...new Set(filter.userIds.map(Number))]
|
||||
}
|
||||
|
||||
if (userIds.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
message: '没有找到符合条件的用户',
|
||||
sentCount: 0,
|
||||
totalUsers: 0
|
||||
}
|
||||
}
|
||||
|
||||
const result = await createBatchSystemNotifications(userIds, title, content, important, sender)
|
||||
if (!result) {
|
||||
throw createApiError(500, SERVER_ERROR_CODES.NOTIFICATION_SEND_FAILED, '发送通知失败')
|
||||
}
|
||||
|
||||
const sentCount = Array.isArray(result) ? result.length : result.count
|
||||
const totalUsers = Array.isArray(result) ? userIds.length : result.total || userIds.length
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '通知发送成功',
|
||||
sentCount,
|
||||
totalUsers
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user