refactor(logging): replace console.* with module loggers in server modules
34 个服务端文件替换 86 处 console.* 调用为 createModuleLogger。模块覆盖: exams/audit/school/files/classes/notifications/grades/auth/homework/announcements/settings/lesson-preparation。shared lib: cache/redis-store, rate-limit/redis-limiter, redis-client, exam-homework-port。API routes: web-vitals, cron/audit-cleanup, proctoring/event。 web-vitals/route.ts 从 edge runtime 改为 nodejs runtime,因 pino 依赖 Node.js stream 内置模块,不兼容 Edge Runtime (V8 Isolate)。
This commit is contained in:
@@ -2,6 +2,9 @@ import { NextResponse } from "next/server"
|
||||
|
||||
import { getAuditRetentionConfig, purgeExpiredAuditLogs } from "@/modules/audit/retention"
|
||||
import { trackEvent } from "@/shared/lib/track-event"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
|
||||
const log = createModuleLogger("audit-cleanup")
|
||||
|
||||
/**
|
||||
* 审计日志定时清理 Cron 端点
|
||||
@@ -27,7 +30,7 @@ export async function GET(request: Request): Promise<NextResponse> {
|
||||
const authHeader = request.headers.get("authorization")
|
||||
const secret = process.env.CRON_SECRET
|
||||
if (!secret) {
|
||||
console.error("[audit-cleanup] CRON_SECRET is not configured")
|
||||
log.error("CRON_SECRET is not configured")
|
||||
return NextResponse.json({ error: "Cron secret not configured" }, { status: 500 })
|
||||
}
|
||||
if (authHeader !== `Bearer ${secret}`) {
|
||||
@@ -69,7 +72,7 @@ export async function GET(request: Request): Promise<NextResponse> {
|
||||
result,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[audit-cleanup] failed:", error)
|
||||
log.error({ err: error }, "audit-cleanup failed")
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||
{ status: 500 },
|
||||
|
||||
@@ -6,6 +6,9 @@ import { examSubmissions } from "@/shared/db/schema"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { recordProctoringEvent } from "@/modules/proctoring/data-access"
|
||||
import type { ProctoringEventType } from "@/modules/proctoring/types"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
|
||||
const log = createModuleLogger("proctoring")
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -82,7 +85,7 @@ export async function POST(req: Request) {
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
console.error("POST /api/proctoring/event error:", error)
|
||||
log.error({ err: error }, "POST /api/proctoring/event error")
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Failed to record proctoring event" },
|
||||
{ status: 500 },
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createModuleLogger } from "@/shared/lib/logger";
|
||||
|
||||
const log = createModuleLogger("web-vitals");
|
||||
|
||||
/**
|
||||
* Web Vitals 上报端点
|
||||
@@ -10,7 +13,7 @@ import { NextResponse } from "next/server";
|
||||
* - LCP ≤ 2.5s,INP ≤ 200ms,CLS ≤ 0.05,TTFB ≤ 800ms,FCP ≤ 1.8s
|
||||
* - 超出预算的 "poor" 级别会以 warn 级别日志记录便于排查
|
||||
*/
|
||||
export const runtime = "edge";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type WebVitalEntry = {
|
||||
name: string;
|
||||
@@ -52,21 +55,27 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
|
||||
// good/info 级别用 info,rating poor 或超预算用 warn
|
||||
if (payload.rating === "poor" || isOverBudget) {
|
||||
console.warn("[web-vitals] over-budget", {
|
||||
log.warn(
|
||||
{
|
||||
name: payload.name,
|
||||
value: payload.value,
|
||||
rating: payload.rating,
|
||||
budget,
|
||||
path: payload.path,
|
||||
id: payload.id,
|
||||
});
|
||||
},
|
||||
"over-budget",
|
||||
);
|
||||
} else {
|
||||
console.info("[web-vitals] reported", {
|
||||
log.info(
|
||||
{
|
||||
name: payload.name,
|
||||
value: payload.value,
|
||||
rating: payload.rating,
|
||||
path: payload.path,
|
||||
});
|
||||
},
|
||||
"reported",
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
|
||||
@@ -27,6 +27,9 @@ import {
|
||||
resolveAnnouncementTargetUserIds,
|
||||
} from "./data-access"
|
||||
import type { GetAnnouncementsParams, Announcement } from "./types"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
|
||||
const log = createModuleLogger("announcements")
|
||||
|
||||
/**
|
||||
* P1-7: 统一错误处理。
|
||||
@@ -39,7 +42,7 @@ async function handleActionError(
|
||||
actionName: string,
|
||||
t: (key: string) => string
|
||||
): Promise<ActionState<never>> {
|
||||
console.error(`[announcements] ${actionName} failed:`, e)
|
||||
log.error({ err: e, action: actionName }, `${actionName} failed`)
|
||||
|
||||
void trackEvent({
|
||||
event: "announcement.action_error",
|
||||
@@ -89,7 +92,7 @@ async function notifyAnnouncementPublished(announcement: Announcement): Promise<
|
||||
await sendBatchNotifications(payloads)
|
||||
} catch (error) {
|
||||
// 通知发送失败不阻塞公告发布流程,仅记录错误
|
||||
console.error("Failed to send announcement notifications:", error)
|
||||
log.error({ err: error }, "Failed to send announcement notifications")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { and, asc, desc, eq, gte, lte, count, like, type SQL, sql } from "drizzl
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { cacheFn } from "@/shared/lib/cache"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
import { auditLogs, loginLogs, dataChangeLogs } from "@/shared/db/schema"
|
||||
import type {
|
||||
AuditLog,
|
||||
@@ -19,6 +20,8 @@ import type {
|
||||
PaginatedResult,
|
||||
} from "./types"
|
||||
|
||||
const log = createModuleLogger("audit")
|
||||
|
||||
const toIso = (d: Date): string => d.toISOString()
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20
|
||||
@@ -85,7 +88,7 @@ export async function getAuditLogsRaw(
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("getAuditLogs failed:", error)
|
||||
log.error({ err: error }, "getAuditLogs failed")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -143,7 +146,7 @@ export async function getLoginLogsRaw(
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("getLoginLogs failed:", error)
|
||||
log.error({ err: error }, "getLoginLogs failed")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -162,7 +165,7 @@ export async function getAuditModuleOptionsRaw(): Promise<string[]> {
|
||||
.orderBy(asc(auditLogs.module))
|
||||
return rows.map((r) => r.module)
|
||||
} catch (error) {
|
||||
console.error("getAuditModuleOptions failed:", error)
|
||||
log.error({ err: error }, "getAuditModuleOptions failed")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -222,7 +225,7 @@ export async function getDataChangeLogsRaw(
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("getDataChangeLogs failed:", error)
|
||||
log.error({ err: error }, "getDataChangeLogs failed")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -245,7 +248,7 @@ export async function getDataChangeStatsRaw(): Promise<DataChangeStat[]> {
|
||||
.orderBy(desc(count()))
|
||||
return rows.map((r) => ({ tableName: r.tableName, count: Number(r.count) }))
|
||||
} catch (error) {
|
||||
console.error("getDataChangeStats failed:", error)
|
||||
log.error({ err: error }, "getDataChangeStats failed")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -264,7 +267,7 @@ export async function getDataChangeTableOptionsRaw(): Promise<string[]> {
|
||||
.orderBy(asc(dataChangeLogs.tableName))
|
||||
return rows.map((r) => r.tableName)
|
||||
} catch (error) {
|
||||
console.error("getDataChangeTableOptions failed:", error)
|
||||
log.error({ err: error }, "getDataChangeTableOptions failed")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -377,7 +380,7 @@ export async function getAuditOverviewStatsRaw(): Promise<AuditOverviewStats> {
|
||||
totalAuditLogs: Number(totalAudit[0]?.value ?? 0),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("getAuditOverviewStats failed:", error)
|
||||
log.error({ err: error }, "getAuditOverviewStats failed")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -442,7 +445,7 @@ export async function getAuditTrendRaw(days: number = 7): Promise<AuditTrendPoin
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error("getAuditTrend failed:", error)
|
||||
log.error({ err: error }, "getAuditTrend failed")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -467,7 +470,7 @@ export async function getDataChangeActionStatsRaw(): Promise<DataChangeActionSta
|
||||
.groupBy(dataChangeLogs.action)
|
||||
return rows.map((r) => ({ action: r.action, count: Number(r.count) }))
|
||||
} catch (error) {
|
||||
console.error("getDataChangeActionStats failed:", error)
|
||||
log.error({ err: error }, "getDataChangeActionStats failed")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getSystemSetting,
|
||||
upsertSystemSetting,
|
||||
} from "@/modules/settings/data-access-system-settings"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
import {
|
||||
DEFAULT_RETENTION_DAYS,
|
||||
DEFAULT_LOGIN_LOG_RETENTION_DAYS,
|
||||
@@ -16,6 +17,8 @@ import {
|
||||
} from "./types"
|
||||
import type { AuditRetentionConfig, PurgeResult } from "./types"
|
||||
|
||||
const log = createModuleLogger("audit")
|
||||
|
||||
// 保留天数常量从 types.ts 重新导出,保持现有 import 路径兼容
|
||||
export {
|
||||
DEFAULT_RETENTION_DAYS,
|
||||
@@ -80,7 +83,7 @@ export async function getAuditRetentionConfig(): Promise<AuditRetentionConfig> {
|
||||
autoCleanupEnabled,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("getAuditRetentionConfig failed:", error)
|
||||
log.error({ err: error }, "getAuditRetentionConfig failed")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -130,7 +133,7 @@ export async function saveAuditRetentionConfig(
|
||||
}),
|
||||
])
|
||||
} catch (error) {
|
||||
console.error("saveAuditRetentionConfig failed:", error)
|
||||
log.error({ err: error }, "saveAuditRetentionConfig failed")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -177,7 +180,7 @@ export async function purgeExpiredAuditLogs(
|
||||
dataChangeLogsDeleted: dataChangeResult[0].affectedRows,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("purgeExpiredAuditLogs failed:", error)
|
||||
log.error({ err: error }, "purgeExpiredAuditLogs failed")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ import {
|
||||
type RegisterErrorCode,
|
||||
type RegisterResult,
|
||||
} from "./types"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
|
||||
const log = createModuleLogger("auth")
|
||||
|
||||
/**
|
||||
* 注册速率限制规则。
|
||||
@@ -245,8 +248,12 @@ export async function registerAction(
|
||||
|
||||
if (!consumed) {
|
||||
// 邀请码已被他人使用(极端并发场景)
|
||||
console.warn(
|
||||
`[register] Invitation code ${invitationCodeForConsume} was already consumed by another user (email=${normalizedEmail})`,
|
||||
log.warn(
|
||||
{
|
||||
invitationCode: invitationCodeForConsume,
|
||||
email: normalizedEmail,
|
||||
},
|
||||
"Invitation code was already consumed by another user",
|
||||
)
|
||||
} else {
|
||||
void trackEvent({
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
usersToRoles,
|
||||
} from "@/shared/db/schema"
|
||||
import { ROLE_NAMES } from "@/shared/types/permissions"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
||||
import type {
|
||||
AdminClassListItem,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
isDuplicateInvitationCodeError,
|
||||
} from "./data-access"
|
||||
|
||||
const log = createModuleLogger("classes")
|
||||
const isClassSubject = (v: unknown): v is ClassSubject =>
|
||||
typeof v === "string" && (DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(v)
|
||||
|
||||
@@ -88,7 +90,7 @@ export const getAdminClassesRaw = async (): Promise<AdminClassListItem[]> => {
|
||||
asc(classes.room)
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("getAdminClasses primary query failed, falling back:", error)
|
||||
log.error({ err: error }, "getAdminClasses primary query failed, falling back")
|
||||
return await db
|
||||
.select({
|
||||
id: classes.id,
|
||||
@@ -249,7 +251,7 @@ export const getGradeManagedClassesRaw = async (userId: string): Promise<AdminCl
|
||||
asc(classes.room)
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("getGradeManagedClasses primary query failed:", error)
|
||||
log.error({ err: error }, "getGradeManagedClasses primary query failed")
|
||||
return []
|
||||
}
|
||||
})(),
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getHomeworkSubmissionsForAssignments,
|
||||
getPublishedHomeworkAssignmentsWithSubject,
|
||||
} from "@/modules/homework/data-access-classes"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
import type {
|
||||
ClassStudent,
|
||||
StudentEnrolledClass,
|
||||
@@ -26,6 +27,8 @@ import {
|
||||
getTeacherSubjectIdsForClass,
|
||||
} from "./data-access"
|
||||
|
||||
const log = createModuleLogger("classes")
|
||||
|
||||
export const getStudentsSubjectScoresRaw = async (
|
||||
studentIds: string[]
|
||||
): Promise<Map<string, Record<string, number | null>>> => {
|
||||
@@ -167,7 +170,7 @@ export const getStudentClassesRaw = async (studentId: string): Promise<StudentEn
|
||||
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
|
||||
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
||||
} catch (error) {
|
||||
console.error("getStudentClasses primary query failed, falling back:", error)
|
||||
log.error({ err: error }, "getStudentClasses primary query failed, falling back")
|
||||
return await db
|
||||
.select({
|
||||
id: classes.id,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
usersToRoles,
|
||||
} from "@/shared/db/schema"
|
||||
import { ROLE_NAMES } from "@/shared/types/permissions"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
import { DEFAULT_CLASS_SUBJECTS } from "./types"
|
||||
import type {
|
||||
ClassSubject,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
isDuplicateInvitationCodeError,
|
||||
} from "./data-access-invitations"
|
||||
|
||||
const log = createModuleLogger("classes")
|
||||
const isClassSubject = (v: unknown): v is ClassSubject =>
|
||||
typeof v === "string" && (DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(v)
|
||||
|
||||
@@ -70,7 +72,7 @@ export const getTeacherClassesRaw = async (params?: { teacherId?: string }): Pro
|
||||
.groupBy(classes.id, classes.schoolName, classes.name, classes.grade, classes.homeroom, classes.room, classes.invitationCode)
|
||||
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
||||
} catch (error) {
|
||||
console.error("getTeacherClasses query failed:", error)
|
||||
log.error({ err: error }, "getTeacherClasses query failed")
|
||||
throw new Error("Failed to load teacher classes")
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -7,6 +7,7 @@ import { and, asc, desc, eq, inArray, type SQL } from "drizzle-orm"
|
||||
import { db } from "@/shared/db"
|
||||
import { coursePlanItems, coursePlans } from "@/shared/db/schema"
|
||||
import { safeParseDate } from "@/shared/lib/action-utils"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
import type {
|
||||
CoursePlan,
|
||||
CoursePlanItem,
|
||||
@@ -25,6 +26,7 @@ import type {
|
||||
UpdateCoursePlanItemInput,
|
||||
} from "./schema"
|
||||
|
||||
const log = createModuleLogger("course-plans")
|
||||
const toIso = (d: Date | null | undefined): string | null =>
|
||||
d ? d.toISOString() : null
|
||||
|
||||
@@ -164,7 +166,7 @@ export const getCoursePlansRaw = async (params?: GetCoursePlansParams, scope?: C
|
||||
|
||||
return await enrichPlanRows(rows)
|
||||
} catch (error) {
|
||||
console.error("getCoursePlans failed:", error)
|
||||
log.error({ err: error }, "getCoursePlans failed")
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -199,7 +201,7 @@ export const getCoursePlanByIdRaw = async (id: string, scope?: CoursePlanQuerySc
|
||||
items: itemRows.map(mapItemRow),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("getCoursePlanById failed:", error)
|
||||
log.error({ err: error }, "getCoursePlanById failed")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guar
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { z } from "zod"
|
||||
import { handleActionError, safeJsonParse } from "@/shared/lib/action-utils"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
import { createQuestionWithRelations } from "@/modules/questions/data-access"
|
||||
import {
|
||||
getExamCreatorId,
|
||||
@@ -35,6 +36,8 @@ import {
|
||||
successState,
|
||||
} from "./actions-helpers"
|
||||
|
||||
const log = createModuleLogger("exams")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -175,7 +178,7 @@ export async function createExamFromRichEditorAction(
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<string>(error.message)
|
||||
}
|
||||
console.error("[createExamFromRichEditorAction]", error instanceof Error ? error.message : String(error))
|
||||
log.error({ err: error }, "createExamFromRichEditorAction failed")
|
||||
return handleActionError(error)
|
||||
}
|
||||
}
|
||||
@@ -285,7 +288,7 @@ export async function updateExamFromRichEditorAction(
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<string>(error.message)
|
||||
}
|
||||
console.error("[updateExamFromRichEditorAction]", error instanceof Error ? error.message : String(error))
|
||||
log.error({ err: error }, "updateExamFromRichEditorAction failed")
|
||||
return handleActionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
safeJsonParse,
|
||||
} from "@/shared/lib/action-utils"
|
||||
import { trackExamEvent } from "@/shared/lib/track-event"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
import {
|
||||
deleteExamById,
|
||||
duplicateExam,
|
||||
@@ -44,6 +45,8 @@ import {
|
||||
successState,
|
||||
} from "./actions-helpers"
|
||||
|
||||
const log = createModuleLogger("exams")
|
||||
|
||||
// Re-export 从拆分文件迁移的类型导出,保持外部 import 路径不变
|
||||
// 注意:Next.js 16 禁止在 "use server" 文件中 re-export 值(仅允许 async 函数导出),
|
||||
// autoMarkExamAction / createExamFromRichEditorAction / updateExamFromRichEditorAction
|
||||
@@ -189,7 +192,7 @@ export async function createExamAction(
|
||||
examModeConfig: parseExamModeConfig(formData),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||||
log.error({ err: error }, "ExamAction failed")
|
||||
return failState<string>(t("dbCreateFailed"))
|
||||
}
|
||||
|
||||
@@ -310,7 +313,7 @@ export async function createAiExamAction(
|
||||
examModeConfig: parseExamModeConfig(formData),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||||
log.error({ err: error }, "ExamAction failed")
|
||||
return failState<string>(t("dbCreateFailed"))
|
||||
}
|
||||
|
||||
@@ -423,7 +426,7 @@ export async function regenerateAiQuestionAction(
|
||||
content: result.data.content,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||||
log.error({ err: error }, "ExamAction failed")
|
||||
return failState<AiRewriteQuestionData>(t("aiQuestionFormatInvalid"))
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -492,7 +495,7 @@ export async function updateExamAction(
|
||||
status,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||||
log.error({ err: error }, "ExamAction failed")
|
||||
return failState<string>(t("dbUpdateFailed"))
|
||||
}
|
||||
|
||||
@@ -550,7 +553,7 @@ export async function deleteExamAction(
|
||||
try {
|
||||
await deleteExamById(examId)
|
||||
} catch (error) {
|
||||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||||
log.error({ err: error }, "ExamAction failed")
|
||||
return failState<string>(t("dbDeleteFailed"))
|
||||
}
|
||||
|
||||
@@ -604,7 +607,7 @@ export async function duplicateExamAction(
|
||||
}
|
||||
newExamId = duplicatedId
|
||||
} catch (error) {
|
||||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||||
log.error({ err: error }, "ExamAction failed")
|
||||
return failState<string>(t("dbDuplicateFailed"))
|
||||
}
|
||||
|
||||
@@ -644,7 +647,7 @@ export async function getExamPreviewAction(
|
||||
questions: exam.questions,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||||
log.error({ err: error }, "ExamAction failed")
|
||||
return failState<{ structure: unknown; questions: Array<{ id: string }> }>(t("loadPreviewFailed"))
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -664,7 +667,7 @@ export async function getSubjectsAction(): Promise<ActionState<{ id: string; nam
|
||||
const allSubjects = await getExamSubjects()
|
||||
return successState(allSubjects)
|
||||
} catch (error) {
|
||||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||||
log.error({ err: error }, "ExamAction failed")
|
||||
return failState<{ id: string; name: string }[]>(t("loadSubjectsFailed"))
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -684,7 +687,7 @@ export async function getGradesAction(): Promise<ActionState<{ id: string; name:
|
||||
const allGrades = await getExamGrades()
|
||||
return successState(allGrades)
|
||||
} catch (error) {
|
||||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||||
log.error({ err: error }, "ExamAction failed")
|
||||
return failState<{ id: string; name: string }[]>(t("loadGradesFailed"))
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Permissions } from "@/shared/types/permissions"
|
||||
import { z } from "zod"
|
||||
import { handleActionError } from "@/shared/lib/action-utils"
|
||||
import { isRecord } from "@/shared/lib/type-guards"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
import {
|
||||
failState,
|
||||
successState,
|
||||
@@ -23,6 +24,8 @@ import {
|
||||
getStringValue,
|
||||
} from "../actions-helpers"
|
||||
|
||||
const log = createModuleLogger("exams")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema & 类型
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -390,7 +393,7 @@ export async function autoMarkExamAction(
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<AutoMarkResult>(error.message)
|
||||
}
|
||||
console.error("[autoMarkExamAction]", error instanceof Error ? error.message : String(error))
|
||||
log.error({ err: error }, "autoMarkExamAction failed")
|
||||
return handleActionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
import { z } from "zod"
|
||||
import { createAiChatCompletion, getAiErrorMessage } from "@/shared/lib/ai"
|
||||
import { env } from "@/env.mjs"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
import { isRecord } from "../lib/type-guards"
|
||||
|
||||
import {
|
||||
AI_EXAM_SOURCE_VALIDATION_SYSTEM_PROMPT,
|
||||
@@ -25,6 +27,8 @@ import {
|
||||
parseAiResponse,
|
||||
} from "./parse"
|
||||
|
||||
const log = createModuleLogger("exams")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -191,12 +195,11 @@ export const parseQuestionDetail = async (input: {
|
||||
aiProviderId?: string
|
||||
}): Promise<z.infer<typeof AiQuestionSchema>> => {
|
||||
const normalizeQuestionCandidate = (value: unknown): unknown => {
|
||||
if (!value || typeof value !== "object") return value
|
||||
// 从 unknown 收窄为 Record<string, unknown> 以进行字段检查
|
||||
const record = value as Record<string, unknown>
|
||||
if (!isRecord(value)) return value
|
||||
const record = value
|
||||
const contentRaw = record.content
|
||||
if (!contentRaw || typeof contentRaw !== "object") return value
|
||||
const content = contentRaw as Record<string, unknown>
|
||||
if (!isRecord(contentRaw)) return value
|
||||
const content = contentRaw
|
||||
const normalizedSubQuestions = Array.isArray(content.subQuestions)
|
||||
? content.subQuestions
|
||||
: Array.isArray(content.subquestions)
|
||||
@@ -246,7 +249,7 @@ export const parseQuestionDetail = async (input: {
|
||||
} satisfies z.infer<typeof AiQuestionSchema>
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[parseQuestionDetail] Falling back to text question:", error instanceof Error ? error.message : String(error))
|
||||
log.warn({ err: error }, "parseQuestionDetail falling back to text question")
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { z } from "zod"
|
||||
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
|
||||
import {
|
||||
AiExamResponseSchema,
|
||||
AiQuestionSchema,
|
||||
@@ -25,6 +27,8 @@ import {
|
||||
} from "./parse"
|
||||
import type { SplitQuestionItem } from "./request"
|
||||
|
||||
const log = createModuleLogger("exams")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structure splitting
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -78,7 +82,7 @@ export const mapWithConcurrency = async <T, R>(
|
||||
} catch (error) {
|
||||
// Catch per-item errors so a single failure doesn't reject the whole batch.
|
||||
// The result slot stays undefined; callers should handle missing entries.
|
||||
console.error("[mapWithConcurrency] worker error at index", index, error instanceof Error ? error.message : String(error))
|
||||
log.error({ err: error, index }, "mapWithConcurrency worker error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { and, count, desc, eq, inArray, like, or, sql, type SQL } from "drizzle-
|
||||
import { cacheFn } from "@/shared/lib/cache"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
import { fileAttachments } from "@/shared/db/schema"
|
||||
import type {
|
||||
BatchDeleteResult,
|
||||
@@ -13,6 +14,7 @@ import type {
|
||||
FileStats,
|
||||
} from "./types"
|
||||
|
||||
const log = createModuleLogger("files")
|
||||
const toIso = (d: Date): string => d.toISOString()
|
||||
|
||||
const mapRow = (row: typeof fileAttachments.$inferSelect): FileAttachment => ({
|
||||
@@ -52,7 +54,7 @@ export async function createFileAttachment(
|
||||
const created = await getFileAttachment(data.id)
|
||||
return created
|
||||
} catch (error) {
|
||||
console.error("createFileAttachment failed:", error)
|
||||
log.error({ err: error }, "createFileAttachment failed")
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -70,7 +72,7 @@ export const getFileAttachmentRaw = async (id: string): Promise<FileAttachment |
|
||||
|
||||
return row ? mapRow(row) : null
|
||||
} catch (error) {
|
||||
console.error("getFileAttachment failed:", error)
|
||||
log.error({ err: error }, "getFileAttachment failed")
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -99,7 +101,7 @@ export const getFileAttachmentsByTargetRaw = async (targetType: string, targetId
|
||||
|
||||
return rows.map(mapRow)
|
||||
} catch (error) {
|
||||
console.error("getFileAttachmentsByTarget failed:", error)
|
||||
log.error({ err: error }, "getFileAttachmentsByTarget failed")
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -123,7 +125,7 @@ export const getFileAttachmentsByUploaderRaw = async (uploaderId: string): Promi
|
||||
|
||||
return rows.map(mapRow)
|
||||
} catch (error) {
|
||||
console.error("getFileAttachmentsByUploader failed:", error)
|
||||
log.error({ err: error }, "getFileAttachmentsByUploader failed")
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -147,7 +149,7 @@ export const getAllFileAttachmentsRaw = async (limit = 100): Promise<FileAttachm
|
||||
|
||||
return rows.map(mapRow)
|
||||
} catch (error) {
|
||||
console.error("getAllFileAttachments failed:", error)
|
||||
log.error({ err: error }, "getAllFileAttachments failed")
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -166,7 +168,7 @@ export async function deleteFileAttachment(id: string): Promise<boolean> {
|
||||
await db.delete(fileAttachments).where(eq(fileAttachments.id, id))
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("deleteFileAttachment failed:", error)
|
||||
log.error({ err: error }, "deleteFileAttachment failed")
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -183,7 +185,7 @@ export async function deleteFileAttachments(ids: string[]): Promise<BatchDeleteR
|
||||
await db.delete(fileAttachments).where(inArray(fileAttachments.id, ids))
|
||||
return { success: true, deletedCount: ids.length, failedIds: [] }
|
||||
} catch (error) {
|
||||
console.error("deleteFileAttachments batch failed:", error)
|
||||
log.error({ err: error }, "deleteFileAttachments batch failed")
|
||||
// 失败时回退到逐条删除,尽量多删
|
||||
const failedIds: string[] = []
|
||||
let deletedCount = 0
|
||||
@@ -192,7 +194,7 @@ export async function deleteFileAttachments(ids: string[]): Promise<BatchDeleteR
|
||||
await db.delete(fileAttachments).where(eq(fileAttachments.id, id))
|
||||
deletedCount += 1
|
||||
} catch (err) {
|
||||
console.error("deleteFileAttachments single failed:", err)
|
||||
log.error({ err }, "deleteFileAttachments single failed")
|
||||
failedIds.push(id)
|
||||
}
|
||||
}
|
||||
@@ -242,7 +244,7 @@ export const getFileAttachmentsWithFiltersRaw = async (params: FileAttachmentQue
|
||||
|
||||
return rows.map(mapRow)
|
||||
} catch (error) {
|
||||
console.error("getFileAttachmentsWithFilters failed:", error)
|
||||
log.error({ err: error }, "getFileAttachmentsWithFilters failed")
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -278,7 +280,7 @@ export const getFileStatsRaw = async (): Promise<FileStats> => {
|
||||
|
||||
return { totalCount, totalSize, byType }
|
||||
} catch (error) {
|
||||
console.error("getFileStats failed:", error)
|
||||
log.error({ err: error }, "getFileStats failed")
|
||||
return { totalCount: 0, totalSize: 0, byType: [] }
|
||||
}
|
||||
}
|
||||
@@ -302,7 +304,7 @@ export const getFileByUrlRaw = async (url: string): Promise<FileAttachment | nul
|
||||
|
||||
return row ? mapRow(row) : null
|
||||
} catch (error) {
|
||||
console.error("getFileByUrl failed:", error)
|
||||
log.error({ err: error }, "getFileByUrl failed")
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -325,7 +327,7 @@ export const getFileAttachmentsByIdsRaw = async (ids: string[]): Promise<FileAtt
|
||||
.where(inArray(fileAttachments.id, ids))
|
||||
return rows.map(mapRow)
|
||||
} catch (error) {
|
||||
console.error("getFileAttachmentsByIds failed:", error)
|
||||
log.error({ err: error }, "getFileAttachmentsByIds failed")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
} from "./data-access-drafts"
|
||||
import { assertClassInScope } from "./lib/scope-check"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
|
||||
const log = createModuleLogger("grades")
|
||||
|
||||
/**
|
||||
* P3-6 协同录入锁 Server Actions。
|
||||
@@ -199,7 +202,7 @@ export async function releaseDraftLockAction(params: {
|
||||
return { success: true, data: { released: true } }
|
||||
} catch (e) {
|
||||
// 释放失败不应阻断主流程,降级为日志
|
||||
console.error("[grades] releaseDraftLockAction failed:", e)
|
||||
log.error({ err: e }, "releaseDraftLockAction failed")
|
||||
return { success: true, data: { released: true } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ import type {
|
||||
ClassRankingItem,
|
||||
GradeRecord,
|
||||
} from "./types"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
|
||||
const log = createModuleLogger("grades")
|
||||
|
||||
/**
|
||||
* grades 模块核心 Server Actions(编排层)。
|
||||
@@ -306,9 +309,9 @@ export async function batchCreateGradeRecordsByExamAction(
|
||||
])
|
||||
|
||||
if (errorBookResult.status === "rejected") {
|
||||
console.error(
|
||||
`[grade-entry] 考试错题采集失败 submission=${r.submissionId}:`,
|
||||
errorBookResult.reason,
|
||||
log.error(
|
||||
{ err: errorBookResult.reason, submissionId: r.submissionId },
|
||||
"考试错题采集失败",
|
||||
)
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -9,6 +9,9 @@ import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
import { getGradeTrend } from "../data-access-analytics"
|
||||
import { detectScoreAnomaly, type ScoreAnomalyResult } from "../stats-service"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
|
||||
const log = createModuleLogger("grades")
|
||||
|
||||
/**
|
||||
* v4-P1-6: 成绩录入后通知学生和家长。
|
||||
@@ -117,9 +120,9 @@ export async function notifyScoreAnomalyIfNeeded(params: {
|
||||
subjectId: params.subjectId,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[grade-anomaly] 检测学生 ${studentId} 异常失败:`,
|
||||
error,
|
||||
log.error(
|
||||
{ err: error, studentId },
|
||||
"检测学生异常失败",
|
||||
)
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -30,6 +30,9 @@ import {
|
||||
import { getScansBySubmissionId } from "./data-access-scans"
|
||||
import { getExcellentSubmissions, getUnsubmittedStudents, getHomeworkAssignmentById } from "./data-access"
|
||||
import type { ExcellentSubmissionItem, ScanAttachment } from "./types"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
|
||||
const log = createModuleLogger("homework")
|
||||
|
||||
const parseStudentIds = (raw: string): string[] => {
|
||||
return raw
|
||||
@@ -83,10 +86,16 @@ async function runPostGradingHooks(submissionId: string, studentId: string): Pro
|
||||
])
|
||||
|
||||
if (errorBookResult.status === "rejected") {
|
||||
console.error(`[post-grading] 错题采集失败 submission=${submissionId}:`, errorBookResult.reason)
|
||||
log.error(
|
||||
{ err: errorBookResult.reason, submissionId },
|
||||
"错题采集失败",
|
||||
)
|
||||
}
|
||||
if (masteryResult.status === "rejected") {
|
||||
console.error(`[post-grading] 掌握度更新失败 submission=${submissionId}:`, masteryResult.reason)
|
||||
log.error(
|
||||
{ err: masteryResult.reason, submissionId },
|
||||
"掌握度更新失败",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,8 +691,13 @@ export async function remindUnsubmittedAction(params: {
|
||||
const failedCount = results.length - successCount
|
||||
|
||||
if (failedCount > 0) {
|
||||
console.error(
|
||||
`[remindUnsubmitted] ${failedCount}/${results.length} notifications failed for assignment ${params.assignmentId}`
|
||||
log.error(
|
||||
{
|
||||
failedCount,
|
||||
total: results.length,
|
||||
assignmentId: params.assignmentId,
|
||||
},
|
||||
"Notifications failed for assignment",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ import { getStudentIdsByClassIds } from "@/modules/classes/data-access";
|
||||
import { normalizeDocument } from "./data-access";
|
||||
import { isExerciseBlockData, isLessonPlanStatus, isValidQuestionType } from "./lib/type-guards";
|
||||
import type { LessonPlanDocument, LessonPlan, LessonPlanStatus } from "./types";
|
||||
import { createModuleLogger } from "@/shared/lib/logger";
|
||||
|
||||
const log = createModuleLogger("lesson-preparation");
|
||||
|
||||
interface PublishInput {
|
||||
planId: string;
|
||||
@@ -208,13 +211,16 @@ export async function publishLessonPlanHomework(
|
||||
} catch (e) {
|
||||
// 补偿错误处理:记录已创建的资源 ID 便于排查孤儿数据
|
||||
if (e instanceof PublishServiceError) throw e;
|
||||
console.error("[publishLessonPlanHomework] 部分失败,可能存在孤儿数据", {
|
||||
log.error(
|
||||
{
|
||||
err: e,
|
||||
planId: input.planId,
|
||||
examId,
|
||||
assignmentId,
|
||||
createdQuestionIds,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
},
|
||||
"publishLessonPlanHomework 部分失败,可能存在孤儿数据",
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ import "server-only"
|
||||
|
||||
import { getQuestions } from "@/modules/questions/data-access"
|
||||
import type { QuestionPickerItem, QuestionPickerParams } from "../providers/lesson-plan-provider"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
|
||||
const log = createModuleLogger("lesson-preparation")
|
||||
|
||||
/**
|
||||
* 跨模块题目查询桥接器(V4 P0-4 修复)。
|
||||
@@ -38,7 +41,7 @@ export async function fetchExternalQuestions(
|
||||
|
||||
return { success: true, data: { data: items } }
|
||||
} catch (e) {
|
||||
console.error("[external-questions-bridge] fetchExternalQuestions failed", e)
|
||||
log.error({ err: e }, "fetchExternalQuestions failed")
|
||||
return { success: false, message: "external_questions_unavailable" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ import type {
|
||||
NotificationChannel,
|
||||
} from "../types"
|
||||
import type { NotificationChannelSender, ChannelRecipient } from "./types"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
|
||||
const log = createModuleLogger("notifications")
|
||||
const channel: NotificationChannel = "email"
|
||||
|
||||
/** 从环境变量读取邮件配置 */
|
||||
@@ -111,8 +113,9 @@ class MockEmailSender implements NotificationChannelSender {
|
||||
payload: NotificationPayload,
|
||||
recipient: ChannelRecipient
|
||||
): Promise<ChannelSendResult> {
|
||||
console.info(
|
||||
`[MockEmail] send to ${recipient.email ?? "(no email)"}: subject="${payload.title}"`
|
||||
log.info(
|
||||
{ email: recipient.email ?? null, subject: payload.title },
|
||||
"MockEmail send"
|
||||
)
|
||||
return {
|
||||
channel,
|
||||
|
||||
@@ -23,7 +23,9 @@ import type {
|
||||
NotificationChannel,
|
||||
} from "../types"
|
||||
import type { NotificationChannelSender, ChannelRecipient } from "./types"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
|
||||
const log = createModuleLogger("notifications")
|
||||
const channel: NotificationChannel = "sms"
|
||||
|
||||
type SmsProvider = "aliyun" | "tencent" | "mock"
|
||||
@@ -73,9 +75,9 @@ class MockSmsSender implements NotificationChannelSender {
|
||||
): Promise<ChannelSendResult> {
|
||||
const params = buildTemplateParams(payload)
|
||||
// 开发环境仅记录日志,不实际发送
|
||||
console.info(
|
||||
`[MockSms] send to ${recipient.phone ?? "(no phone)"}: title="${payload.title}" params=`,
|
||||
params
|
||||
log.info(
|
||||
{ phone: recipient.phone ?? null, title: payload.title, params },
|
||||
"MockSms send"
|
||||
)
|
||||
return {
|
||||
channel,
|
||||
|
||||
@@ -24,7 +24,10 @@ import type {
|
||||
NotificationChannel,
|
||||
} from "../types"
|
||||
import type { NotificationChannelSender, ChannelRecipient } from "./types"
|
||||
import { isStringRecord } from "../lib/type-guards"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
|
||||
const log = createModuleLogger("notifications")
|
||||
const channel: NotificationChannel = "wechat"
|
||||
const WECHAT_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token"
|
||||
const WECHAT_SEND_URL = "https://api.weixin.qq.com/cgi-bin/message/template/send"
|
||||
@@ -103,9 +106,10 @@ async function getAccessToken(): Promise<string> {
|
||||
function buildTemplateData(
|
||||
payload: NotificationPayload
|
||||
): Record<string, { value: string }> {
|
||||
// as 断言:metadata 为 Record<string, unknown>,此处需按 string 字典取值;
|
||||
// 运行时若类型不符会被 ?? 兜底为空对象,安全无副作用
|
||||
const custom = (payload.metadata?.wechatKeywords as Record<string, string> | undefined) ?? {}
|
||||
// metadata 为 Record<string, unknown>,用类型守卫收窄为 string 字典;
|
||||
// 不符合时 ?? 兜底为空对象
|
||||
const rawKeywords = payload.metadata?.wechatKeywords
|
||||
const custom = isStringRecord(rawKeywords) ? rawKeywords : {}
|
||||
return {
|
||||
keyword1: { value: custom.keyword1 ?? payload.title },
|
||||
keyword2: { value: custom.keyword2 ?? payload.content },
|
||||
@@ -121,8 +125,9 @@ class MockWechatSender implements NotificationChannelSender {
|
||||
payload: NotificationPayload,
|
||||
recipient: ChannelRecipient
|
||||
): Promise<ChannelSendResult> {
|
||||
console.info(
|
||||
`[MockWechat] send to openId=${recipient.wechatOpenId ?? "(no openId)"}: title="${payload.title}"`
|
||||
log.info(
|
||||
{ openId: recipient.wechatOpenId ?? null, title: payload.title },
|
||||
"MockWechat send"
|
||||
)
|
||||
return {
|
||||
channel,
|
||||
|
||||
@@ -23,6 +23,7 @@ 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 { createModuleLogger } from "@/shared/lib/logger"
|
||||
import type { ChannelRecipient } from "./channels/types"
|
||||
import type {
|
||||
ChannelSendResult,
|
||||
@@ -34,6 +35,7 @@ import type {
|
||||
PaginatedResult,
|
||||
} from "./types"
|
||||
|
||||
const log = createModuleLogger("notifications")
|
||||
const toIsoRequired = (d: Date): string => d.toISOString()
|
||||
|
||||
const isNotificationType = (v: unknown): v is NotificationType =>
|
||||
@@ -247,19 +249,23 @@ export const getUserContactInfo = cacheFn(getUserContactInfoRaw, {
|
||||
* - 通知送达率统计
|
||||
* - 渠道健康度监控
|
||||
*
|
||||
* DB 写入失败时降级为 console.info,不阻塞通知发送流程。
|
||||
* DB 写入失败时降级为 logger.info,不阻塞通知发送流程。
|
||||
*/
|
||||
export async function logNotificationSend(
|
||||
result: ChannelSendResult,
|
||||
payload?: { userId: string; title: string }
|
||||
): Promise<void> {
|
||||
const status = result.success ? "success" : "failure"
|
||||
const errorPart = result.error ? ` error="${result.error}"` : ""
|
||||
|
||||
// 始终输出 console 日志(便于开发调试)
|
||||
// TODO V3-P2-8: 接入统一日志服务(shared/lib/logger),替换 console.info
|
||||
console.info(
|
||||
`[NotificationLog] ${result.success ? "OK" : "FAIL"} channel=${result.channel} messageId=${result.messageId ?? "-"}${errorPart}`
|
||||
// 始终输出结构化日志(便于开发调试与监控)
|
||||
log.info(
|
||||
{
|
||||
channel: result.channel,
|
||||
messageId: result.messageId ?? null,
|
||||
success: result.success,
|
||||
error: result.error ?? null,
|
||||
},
|
||||
result.success ? "NotificationLog OK" : "NotificationLog FAIL"
|
||||
)
|
||||
|
||||
// 持久化到 DB(需要 payload 提供 userId 和 title)
|
||||
@@ -278,8 +284,7 @@ export async function logNotificationSend(
|
||||
})
|
||||
} catch (dbError) {
|
||||
// DB 写入失败不阻塞通知流程,仅记录错误
|
||||
// TODO V3-P2-8: 接入统一日志服务(shared/lib/logger),替换 console.error
|
||||
console.error("[NotificationLog] Failed to persist log:", dbError)
|
||||
log.error({ err: dbError }, "NotificationLog failed to persist log")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { and, asc, desc, eq, inArray, or, sql } from "drizzle-orm"
|
||||
|
||||
import { cacheFn } from "@/shared/lib/cache"
|
||||
import { db } from "@/shared/db"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
import { academicYears, departments, grades, roles, schools, subjects, users, usersToRoles } from "@/shared/db/schema"
|
||||
import type {
|
||||
AcademicYearInsertData,
|
||||
@@ -22,6 +23,7 @@ import type {
|
||||
StaffOption,
|
||||
} from "./types"
|
||||
|
||||
const log = createModuleLogger("school")
|
||||
const toIso = (d: Date): string => d.toISOString()
|
||||
|
||||
export const getDepartmentsRaw = async (): Promise<DepartmentListItem[]> => {
|
||||
@@ -35,7 +37,7 @@ export const getDepartmentsRaw = async (): Promise<DepartmentListItem[]> => {
|
||||
updatedAt: toIso(r.updatedAt),
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("getDepartments failed:", error)
|
||||
log.error({ err: error }, "getDepartments failed")
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -58,7 +60,7 @@ export const getAcademicYearsRaw = async (): Promise<AcademicYearListItem[]> =>
|
||||
updatedAt: toIso(r.updatedAt),
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("getAcademicYears failed:", error)
|
||||
log.error({ err: error }, "getAcademicYears failed")
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -79,7 +81,7 @@ export const getSchoolsRaw = async (): Promise<SchoolListItem[]> => {
|
||||
updatedAt: toIso(r.updatedAt),
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("getSchools failed:", error)
|
||||
log.error({ err: error }, "getSchools failed")
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -138,7 +140,7 @@ export const getGradesRaw = async (): Promise<GradeListItem[]> => {
|
||||
updatedAt: toIso(r.updatedAt),
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("getGrades failed:", error)
|
||||
log.error({ err: error }, "getGrades failed")
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -165,7 +167,7 @@ export const getStaffOptionsRaw = async (): Promise<StaffOption[]> => {
|
||||
email: r.email,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("getStaffOptions failed:", error)
|
||||
log.error({ err: error }, "getStaffOptions failed")
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -226,7 +228,7 @@ export const getGradesForStaffRaw = async (staffId: string): Promise<GradeListIt
|
||||
updatedAt: toIso(r.updatedAt),
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("getGradesForStaff failed:", error)
|
||||
log.error({ err: error }, "getGradesForStaff failed")
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -304,7 +306,7 @@ export const getSchoolsForUserRaw = async (userId: string): Promise<SchoolListIt
|
||||
updatedAt: toIso(r.updatedAt),
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("getSchoolsForUser failed:", error)
|
||||
log.error({ err: error }, "getSchoolsForUser failed")
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -402,7 +404,7 @@ export const getGradesForUserRaw = async (userId: string): Promise<GradeListItem
|
||||
|
||||
return []
|
||||
} catch (error) {
|
||||
console.error("getGradesForUser failed:", error)
|
||||
log.error({ err: error }, "getGradesForUser failed")
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -570,7 +572,7 @@ export const getSubjectOptionsRaw = async (): Promise<SubjectOption[]> => {
|
||||
order: Number(r.order ?? 0),
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("getSubjectOptions failed:", error)
|
||||
log.error({ err: error }, "getSubjectOptions failed")
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -602,7 +604,7 @@ export const getGradeOptionsRaw = async (): Promise<GradeOption[]> => {
|
||||
order: Number(r.order ?? 0),
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("getGradeOptions failed:", error)
|
||||
log.error({ err: error }, "getGradeOptions failed")
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -874,7 +876,7 @@ export const getOrgTreeRaw = async (): Promise<OrgTreeNode[]> => {
|
||||
children: gradesBySchoolId.get(school.id) ?? [],
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("getOrgTree failed:", error)
|
||||
log.error({ err: error }, "getOrgTree failed")
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -927,7 +929,7 @@ export const getGradeOverviewStatsRaw = async (): Promise<GradeOverviewStats[]>
|
||||
teacherCount: s.teacherIds.size,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("getGradeOverviewStats failed:", error)
|
||||
log.error({ err: error }, "getGradeOverviewStats failed")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
getFileByUrl,
|
||||
} from "@/modules/files/data-access"
|
||||
import { invalidateFor } from "@/shared/lib/cache"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
|
||||
const log = createModuleLogger("settings")
|
||||
|
||||
/**
|
||||
* 清理旧头像文件(磁盘 + DB 记录)
|
||||
@@ -30,7 +33,7 @@ async function cleanupOldAvatarFile(oldImageUrl: string | null): Promise<void> {
|
||||
// 删除 DB 记录
|
||||
await deleteFileAttachment(fileRecord.id)
|
||||
} catch (error) {
|
||||
console.error("cleanupOldAvatarFile failed:", error)
|
||||
log.error({ err: error }, "cleanupOldAvatarFile failed")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,9 +23,12 @@ import {
|
||||
updateAiProvider,
|
||||
} from "./data-access"
|
||||
import type { AiProviderSummary, AiProviderVisibility } from "./types"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
|
||||
export type { AiProviderSummary } from "./types"
|
||||
|
||||
const log = createModuleLogger("settings")
|
||||
|
||||
const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom", "ollama"])
|
||||
const VisibilitySchema = z.enum(["public", "private"])
|
||||
|
||||
@@ -217,7 +220,7 @@ export async function upsertAiProviderAction(
|
||||
return { success: true, message: "AI provider created", data: id }
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) return { success: false, message: error.message }
|
||||
console.error("[upsertAiProviderAction] Failed to save AI provider:", error)
|
||||
log.error({ err: error }, "Failed to save AI provider")
|
||||
return { success: false, message: "Failed to save AI provider" }
|
||||
}
|
||||
}
|
||||
|
||||
21
src/shared/lib/cache/redis-store.ts
vendored
21
src/shared/lib/cache/redis-store.ts
vendored
@@ -2,6 +2,9 @@ import "server-only"
|
||||
|
||||
import { getRedisClient } from "@/shared/lib/redis-client"
|
||||
import type { CacheStore } from "./types"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
|
||||
const log = createModuleLogger("cache")
|
||||
|
||||
const KEY_PREFIX = "next-edu:cache:"
|
||||
const TAG_PREFIX = `${KEY_PREFIX}tag:`
|
||||
@@ -33,9 +36,9 @@ export class RedisCacheStore implements CacheStore {
|
||||
return JSON.parse(cached) as T
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[cache] Redis get failure, falling back to producer:",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
log.error(
|
||||
{ err: error },
|
||||
"Redis get failure, falling back to producer",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -58,9 +61,9 @@ export class RedisCacheStore implements CacheStore {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[cache] Redis set failure, ignoring:",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
log.error(
|
||||
{ err: error },
|
||||
"Redis set failure, ignoring",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -88,9 +91,9 @@ export class RedisCacheStore implements CacheStore {
|
||||
await redis.del(...tagKeysToDelete)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[cache] Redis invalidateTags failure, ignoring:",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
log.error(
|
||||
{ err: error },
|
||||
"Redis invalidateTags failure, ignoring",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,12 @@
|
||||
|
||||
import { env } from "@/env.mjs"
|
||||
import { getRedisClient } from "@/shared/lib/redis-client"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
|
||||
import type { RateLimiter, RateLimitParams, RateLimitResult } from "./types"
|
||||
|
||||
const log = createModuleLogger("rate-limit")
|
||||
|
||||
/** 毫秒转 upstash `Ratelimit.slidingWindow` 接受的字符串时间单位 */
|
||||
function msToSlidingWindowArg(ms: number): string {
|
||||
if (ms % (60 * 1000) === 0) {
|
||||
@@ -148,9 +151,9 @@ export class RedisRateLimiter implements RateLimiter {
|
||||
}
|
||||
} catch (error) {
|
||||
// 限流后端故障时降级为放行,避免阻断主流程
|
||||
console.error(
|
||||
"[rate-limit] Redis backend failure, falling back to allow:",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
log.error(
|
||||
{ err: error },
|
||||
"Redis backend failure, falling back to allow",
|
||||
)
|
||||
const resetTime = Date.now() + params.windowMs
|
||||
return {
|
||||
@@ -173,9 +176,9 @@ export class RedisRateLimiter implements RateLimiter {
|
||||
}
|
||||
} catch (error) {
|
||||
// reset 失败不阻断主流程
|
||||
console.error(
|
||||
"[rate-limit] Redis reset failure, ignoring:",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
log.error(
|
||||
{ err: error },
|
||||
"Redis reset failure, ignoring",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import "server-only"
|
||||
|
||||
import { env } from "@/env.mjs"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
|
||||
const log = createModuleLogger("redis")
|
||||
|
||||
/**
|
||||
* 共享 Redis 客户端单例(rate-limit + cache 共用)。
|
||||
@@ -42,9 +45,9 @@ export async function getRedisClient(): Promise<unknown | null> {
|
||||
singleton = client
|
||||
return client
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[redis-client] Failed to load @upstash/redis:",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
log.error(
|
||||
{ err: error },
|
||||
"Failed to load @upstash/redis",
|
||||
)
|
||||
return null
|
||||
} finally {
|
||||
|
||||
@@ -19,6 +19,9 @@ import type { DataScope } from "@/shared/types/permissions"
|
||||
import type { Exam } from "@/modules/exams/types"
|
||||
import type { HomeworkAssignmentListItem } from "@/modules/homework/types"
|
||||
import type { ExamWithQuestionsForHomework } from "@/modules/exams/data-access"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
|
||||
const log = createModuleLogger("exam-homework-port")
|
||||
|
||||
/**
|
||||
* 考试/作业模块对外暴露的服务契约。
|
||||
@@ -72,7 +75,7 @@ class ServiceProvider<T> {
|
||||
if (this.impl !== null) {
|
||||
// 允许重复注册(HMR 场景),覆盖旧实现
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.warn("[ServiceProvider] Re-registering service implementation (HMR?)")
|
||||
log.warn("Re-registering service implementation (HMR?)")
|
||||
}
|
||||
}
|
||||
this.impl = impl
|
||||
|
||||
Reference in New Issue
Block a user