- Add announcement-card test for component testing - Add is-announcement-visible test and schema test for logic testing - Add announcement-list-skeleton for loading states - Add announcement-pagination for list pagination - Add announcements-service-context and default-announcements-service for service layer
427 lines
14 KiB
TypeScript
427 lines
14 KiB
TypeScript
"use server"
|
||
|
||
import { revalidatePath } from "next/cache"
|
||
import { createId } from "@paralleldrive/cuid2"
|
||
import { getTranslations } from "next-intl/server"
|
||
|
||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||
import { trackEvent } from "@/shared/lib/track-event"
|
||
import { Permissions } from "@/shared/types/permissions"
|
||
import type { ActionState } from "@/shared/types/action-state"
|
||
import { sendBatchNotifications } from "@/modules/notifications"
|
||
import type { NotificationPayload } from "@/modules/notifications"
|
||
|
||
import { CreateAnnouncementSchema, UpdateAnnouncementSchema } from "./schema"
|
||
import {
|
||
getAnnouncements,
|
||
getAnnouncementById,
|
||
insertAnnouncement,
|
||
updateAnnouncementById,
|
||
deleteAnnouncementById,
|
||
publishAnnouncementById,
|
||
archiveAnnouncementById,
|
||
toggleAnnouncementPin,
|
||
markAnnouncementAsRead,
|
||
getAnnouncementReadStatusForUser,
|
||
getAnnouncementByIdForUser,
|
||
resolveAnnouncementTargetUserIds,
|
||
} from "./data-access"
|
||
import type { GetAnnouncementsParams, Announcement } from "./types"
|
||
|
||
/**
|
||
* P1-7: 统一错误处理。
|
||
* - 记录错误堆栈到 console.error 便于生产排查
|
||
* - 上报 trackEvent 用于监控告警
|
||
* - 返回 i18n 化的 message,不向客户端泄露内部错误信息
|
||
*/
|
||
async function handleActionError(
|
||
e: unknown,
|
||
actionName: string,
|
||
t: (key: string) => string
|
||
): Promise<ActionState<never>> {
|
||
console.error(`[announcements] ${actionName} failed:`, e)
|
||
|
||
void trackEvent({
|
||
event: "announcement.action_error",
|
||
targetType: "announcement",
|
||
properties: {
|
||
action: actionName,
|
||
errorName: e instanceof Error ? e.constructor.name : "unknown",
|
||
errorMessage: e instanceof Error ? e.message : String(e),
|
||
},
|
||
})
|
||
|
||
if (e instanceof PermissionDeniedError) {
|
||
return { success: false, message: t("messages.permissionDenied") }
|
||
}
|
||
if (e instanceof Error && e.message === "FORBIDDEN_ANNOUNCEMENT") {
|
||
return { success: false, message: t("messages.forbidden") }
|
||
}
|
||
return { success: false, message: t("messages.unexpectedError") }
|
||
}
|
||
|
||
/**
|
||
* 发布公告后向目标用户批量发送通知。
|
||
* 通知发送失败不影响公告发布本身,仅记录日志。
|
||
* P1-5: resolveTargetUserIds 已下沉到 data-access.resolveAnnouncementTargetUserIds。
|
||
*/
|
||
async function notifyAnnouncementPublished(announcement: Announcement): Promise<void> {
|
||
try {
|
||
const targetUserIds = await resolveAnnouncementTargetUserIds(announcement)
|
||
if (targetUserIds.length === 0) return
|
||
|
||
const t = await getTranslations("announcements")
|
||
const title = t("notification.publishedTitle", { title: announcement.title })
|
||
const content = t("notification.publishedContent")
|
||
|
||
const payloads: NotificationPayload[] = targetUserIds.map((userId) => ({
|
||
userId,
|
||
title,
|
||
content,
|
||
type: "info",
|
||
actionUrl: `/announcements/${announcement.id}`,
|
||
metadata: {
|
||
announcementId: announcement.id,
|
||
announcementType: announcement.type,
|
||
},
|
||
}))
|
||
|
||
await sendBatchNotifications(payloads)
|
||
} catch (error) {
|
||
// 通知发送失败不阻塞公告发布流程,仅记录错误
|
||
console.error("Failed to send announcement notifications:", error)
|
||
}
|
||
}
|
||
|
||
export async function createAnnouncementAction(
|
||
prevState: ActionState<string> | null,
|
||
formData: FormData
|
||
): Promise<ActionState<string>> {
|
||
const t = await getTranslations("announcements")
|
||
try {
|
||
const ctx = await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
|
||
|
||
const parsed = CreateAnnouncementSchema.safeParse({
|
||
title: formData.get("title"),
|
||
content: formData.get("content"),
|
||
type: formData.get("type") || undefined,
|
||
status: formData.get("status") || undefined,
|
||
targetGradeId: formData.get("targetGradeId") || undefined,
|
||
targetClassId: formData.get("targetClassId") || undefined,
|
||
publishedAt: formData.get("publishedAt") || undefined,
|
||
})
|
||
|
||
if (!parsed.success) {
|
||
return {
|
||
success: false,
|
||
message: t("messages.invalidFormData"),
|
||
errors: parsed.error.flatten().fieldErrors,
|
||
}
|
||
}
|
||
|
||
const input = parsed.data
|
||
const isPublished = input.status === "published"
|
||
const publishedAt = isPublished
|
||
? input.publishedAt
|
||
? new Date(input.publishedAt)
|
||
: new Date()
|
||
: input.publishedAt
|
||
? new Date(input.publishedAt)
|
||
: null
|
||
|
||
const id = await insertAnnouncement({
|
||
id: createId(),
|
||
title: input.title,
|
||
content: input.content,
|
||
type: input.type,
|
||
status: input.status,
|
||
targetGradeId: input.targetGradeId,
|
||
targetClassId: input.targetClassId,
|
||
authorId: ctx.userId,
|
||
publishedAt,
|
||
})
|
||
|
||
// 如果创建时直接发布,触发通知(失败不阻塞)
|
||
if (isPublished) {
|
||
const created = await getAnnouncementById(id)
|
||
if (created) {
|
||
await notifyAnnouncementPublished(created)
|
||
}
|
||
}
|
||
|
||
revalidatePath("/admin/announcements")
|
||
revalidatePath("/announcements")
|
||
|
||
void trackEvent({
|
||
event: isPublished ? "announcement.published" : "announcement.created",
|
||
userId: ctx.userId,
|
||
targetId: id,
|
||
targetType: "announcement",
|
||
properties: { type: input.type, status: input.status },
|
||
})
|
||
|
||
return { success: true, message: isPublished ? t("messages.published") : t("messages.created"), data: id }
|
||
} catch (e) {
|
||
return handleActionError(e, "createAnnouncementAction", t)
|
||
}
|
||
}
|
||
|
||
export async function updateAnnouncementAction(
|
||
id: string,
|
||
prevState: ActionState<string> | null,
|
||
formData: FormData
|
||
): Promise<ActionState<string>> {
|
||
const t = await getTranslations("announcements")
|
||
try {
|
||
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
|
||
|
||
const existing = await getAnnouncementById(id)
|
||
if (!existing) return { success: false, message: t("messages.notFound") }
|
||
|
||
const parsed = UpdateAnnouncementSchema.safeParse({
|
||
title: formData.get("title"),
|
||
content: formData.get("content"),
|
||
type: formData.get("type") || undefined,
|
||
status: formData.get("status") || undefined,
|
||
targetGradeId: formData.get("targetGradeId") || undefined,
|
||
targetClassId: formData.get("targetClassId") || undefined,
|
||
publishedAt: formData.get("publishedAt") || undefined,
|
||
})
|
||
|
||
if (!parsed.success) {
|
||
return {
|
||
success: false,
|
||
message: t("messages.invalidFormData"),
|
||
errors: parsed.error.flatten().fieldErrors,
|
||
}
|
||
}
|
||
|
||
const input = parsed.data
|
||
const isPublished = input.status === "published"
|
||
const wasPublished = existing.status === "published"
|
||
const publishedAt = isPublished
|
||
? existing.publishedAt
|
||
? new Date(existing.publishedAt)
|
||
: new Date()
|
||
: input.publishedAt
|
||
? new Date(input.publishedAt)
|
||
: null
|
||
|
||
await updateAnnouncementById(id, {
|
||
title: input.title,
|
||
content: input.content,
|
||
type: input.type,
|
||
status: input.status,
|
||
targetGradeId: input.targetGradeId,
|
||
targetClassId: input.targetClassId,
|
||
publishedAt,
|
||
updatedAt: new Date(),
|
||
})
|
||
|
||
// 当公告从非发布状态变为发布状态时,触发通知(失败不阻塞)
|
||
if (isPublished && !wasPublished) {
|
||
const updated = await getAnnouncementById(id)
|
||
if (updated) {
|
||
await notifyAnnouncementPublished(updated)
|
||
}
|
||
}
|
||
|
||
revalidatePath("/admin/announcements")
|
||
revalidatePath(`/admin/announcements/${id}`)
|
||
revalidatePath("/announcements")
|
||
|
||
void trackEvent({
|
||
event: isPublished && !wasPublished ? "announcement.published" : "announcement.updated",
|
||
targetId: id,
|
||
targetType: "announcement",
|
||
properties: { type: input.type, status: input.status, wasPublished },
|
||
})
|
||
|
||
return {
|
||
success: true,
|
||
message: isPublished && !wasPublished ? t("messages.published") : t("messages.updated"),
|
||
data: id,
|
||
}
|
||
} catch (e) {
|
||
return handleActionError(e, "updateAnnouncementAction", t)
|
||
}
|
||
}
|
||
|
||
export async function deleteAnnouncementAction(id: string): Promise<ActionState<string>> {
|
||
const t = await getTranslations("announcements")
|
||
try {
|
||
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
|
||
|
||
const existing = await getAnnouncementById(id)
|
||
if (!existing) return { success: false, message: t("messages.notFound") }
|
||
|
||
await deleteAnnouncementById(id)
|
||
|
||
revalidatePath("/admin/announcements")
|
||
revalidatePath("/announcements")
|
||
|
||
void trackEvent({
|
||
event: "announcement.deleted",
|
||
targetId: id,
|
||
targetType: "announcement",
|
||
properties: { type: existing.type, status: existing.status },
|
||
})
|
||
|
||
return { success: true, message: t("messages.deleted") }
|
||
} catch (e) {
|
||
return handleActionError(e, "deleteAnnouncementAction", t)
|
||
}
|
||
}
|
||
|
||
export async function publishAnnouncementAction(id: string): Promise<ActionState<string>> {
|
||
const t = await getTranslations("announcements")
|
||
try {
|
||
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
|
||
|
||
const existing = await getAnnouncementById(id)
|
||
if (!existing) return { success: false, message: t("messages.notFound") }
|
||
|
||
const publishedAt = existing.publishedAt
|
||
? new Date(existing.publishedAt)
|
||
: new Date()
|
||
await publishAnnouncementById(id, publishedAt)
|
||
|
||
// 发布成功后触发通知(失败不阻塞)
|
||
await notifyAnnouncementPublished(existing)
|
||
|
||
revalidatePath("/admin/announcements")
|
||
revalidatePath(`/admin/announcements/${id}`)
|
||
revalidatePath("/announcements")
|
||
|
||
void trackEvent({
|
||
event: "announcement.published",
|
||
targetId: id,
|
||
targetType: "announcement",
|
||
properties: { type: existing.type },
|
||
})
|
||
|
||
return { success: true, message: t("messages.published") }
|
||
} catch (e) {
|
||
return handleActionError(e, "publishAnnouncementAction", t)
|
||
}
|
||
}
|
||
|
||
export async function archiveAnnouncementAction(id: string): Promise<ActionState<string>> {
|
||
const t = await getTranslations("announcements")
|
||
try {
|
||
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
|
||
|
||
const existing = await getAnnouncementById(id)
|
||
if (!existing) return { success: false, message: t("messages.notFound") }
|
||
|
||
await archiveAnnouncementById(id)
|
||
|
||
revalidatePath("/admin/announcements")
|
||
revalidatePath(`/admin/announcements/${id}`)
|
||
revalidatePath("/announcements")
|
||
|
||
void trackEvent({
|
||
event: "announcement.archived",
|
||
targetId: id,
|
||
targetType: "announcement",
|
||
properties: { type: existing.type },
|
||
})
|
||
|
||
return { success: true, message: t("messages.archived") }
|
||
} catch (e) {
|
||
return handleActionError(e, "archiveAnnouncementAction", t)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// V2-P2-13d: 公告置顶 Server Action
|
||
// P0-3: 新增资源存在性二次校验
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function toggleAnnouncementPinAction(id: string): Promise<ActionState<string>> {
|
||
const t = await getTranslations("announcements")
|
||
try {
|
||
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
|
||
|
||
// P0-3: 资源存在性校验
|
||
const existing = await getAnnouncementById(id)
|
||
if (!existing) return { success: false, message: t("messages.notFound") }
|
||
|
||
await toggleAnnouncementPin(id)
|
||
revalidatePath("/admin/announcements")
|
||
revalidatePath("/announcements")
|
||
|
||
void trackEvent({
|
||
event: "announcement.pin_toggled",
|
||
targetId: id,
|
||
targetType: "announcement",
|
||
})
|
||
|
||
return { success: true, message: t("messages.pinToggled") }
|
||
} catch (e) {
|
||
return handleActionError(e, "toggleAnnouncementPinAction", t)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// V2-P2-13d: 公告已读标记 Server Action
|
||
// P0-3: 新增资源可见性二次校验(防止对草稿/他人班级公告写入已读记录)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function markAnnouncementAsReadAction(announcementId: string): Promise<ActionState<string>> {
|
||
const t = await getTranslations("announcements")
|
||
try {
|
||
const ctx = await requirePermission(Permissions.ANNOUNCEMENT_READ)
|
||
|
||
// P0-3: 资源可见性二次校验——必须为已发布且对当前用户受众可见
|
||
const visible = await getAnnouncementByIdForUser(announcementId, ctx.userId, ctx.dataScope)
|
||
if (!visible) {
|
||
// 不可见时抛出特定错误,由 handleActionError 转为 i18n 文案
|
||
throw new Error("FORBIDDEN_ANNOUNCEMENT")
|
||
}
|
||
|
||
await markAnnouncementAsRead(announcementId, ctx.userId)
|
||
|
||
void trackEvent({
|
||
event: "announcement.marked_read",
|
||
userId: ctx.userId,
|
||
targetId: announcementId,
|
||
targetType: "announcement",
|
||
})
|
||
|
||
return { success: true, message: t("messages.markedRead") }
|
||
} catch (e) {
|
||
return handleActionError(e, "markAnnouncementAsReadAction", t)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 批量获取公告已读状态(用于列表页标记已读/未读)。
|
||
* P1-2: 列表组件消费此 Action 展示已读/未读视觉区分。
|
||
*/
|
||
export async function getAnnouncementReadStatusAction(
|
||
announcementIds: string[]
|
||
): Promise<ActionState<Record<string, boolean>>> {
|
||
const t = await getTranslations("announcements")
|
||
try {
|
||
const ctx = await requirePermission(Permissions.ANNOUNCEMENT_READ)
|
||
const statusMap = await getAnnouncementReadStatusForUser(announcementIds, ctx.userId)
|
||
return { success: true, data: Object.fromEntries(statusMap) }
|
||
} catch (e) {
|
||
return handleActionError(e, "getAnnouncementReadStatusAction", t)
|
||
}
|
||
}
|
||
|
||
export async function getAnnouncementsAction(
|
||
params?: GetAnnouncementsParams
|
||
): Promise<ActionState<Announcement[]>> {
|
||
const t = await getTranslations("announcements")
|
||
try {
|
||
await requirePermission(Permissions.ANNOUNCEMENT_READ)
|
||
const data = await getAnnouncements(params)
|
||
return { success: true, data }
|
||
} catch (e) {
|
||
return handleActionError(e, "getAnnouncementsAction", t)
|
||
}
|
||
}
|