"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> { 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 { 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 | null, formData: FormData ): Promise> { 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 | null, formData: FormData ): Promise> { 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> { 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> { 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> { 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> { 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> { 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>> { 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> { 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) } }