From 12a766d3ee573e738173b8335408a3920af991e4 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:34:57 +0800 Subject: [PATCH] refactor(logging): replace console.* with module loggers in server modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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)。 --- src/app/api/cron/audit-cleanup/route.ts | 7 +++- src/app/api/proctoring/event/route.ts | 5 ++- src/app/api/web-vitals/route.ts | 39 ++++++++++++------- src/modules/announcements/actions.ts | 7 +++- src/modules/audit/data-access.ts | 21 +++++----- src/modules/audit/retention.ts | 9 +++-- src/modules/auth/actions.ts | 11 +++++- src/modules/classes/data-access-admin.ts | 6 ++- src/modules/classes/data-access-students.ts | 5 ++- src/modules/classes/data-access-teacher.ts | 4 +- src/modules/course-plans/data-access.ts | 6 ++- src/modules/exams/actions-rich-editor.ts | 7 +++- src/modules/exams/actions.ts | 21 +++++----- src/modules/exams/ai-pipeline/auto-mark.ts | 5 ++- src/modules/exams/ai-pipeline/request.ts | 15 ++++--- src/modules/exams/ai-pipeline/structure.ts | 6 ++- src/modules/files/data-access.ts | 26 +++++++------ src/modules/grades/actions-lock.ts | 5 ++- src/modules/grades/actions.ts | 9 +++-- src/modules/grades/lib/notify.ts | 9 +++-- src/modules/homework/actions.ts | 22 +++++++++-- .../lesson-preparation/publish-service.ts | 20 ++++++---- .../services/external-questions-bridge.ts | 5 ++- .../notifications/channels/email-channel.ts | 7 +++- .../notifications/channels/sms-channel.ts | 8 ++-- .../notifications/channels/wechat-channel.ts | 15 ++++--- src/modules/notifications/data-access.ts | 21 ++++++---- src/modules/school/data-access.ts | 26 +++++++------ src/modules/settings/actions-avatar.ts | 5 ++- src/modules/settings/actions.ts | 5 ++- src/shared/lib/cache/redis-store.ts | 21 +++++----- src/shared/lib/rate-limit/redis-limiter.ts | 15 ++++--- src/shared/lib/redis-client.ts | 9 +++-- src/shared/services/exam-homework-port.ts | 5 ++- 34 files changed, 266 insertions(+), 141 deletions(-) diff --git a/src/app/api/cron/audit-cleanup/route.ts b/src/app/api/cron/audit-cleanup/route.ts index f8b592a..fcc77f9 100644 --- a/src/app/api/cron/audit-cleanup/route.ts +++ b/src/app/api/cron/audit-cleanup/route.ts @@ -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 { 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 { 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 }, diff --git a/src/app/api/proctoring/event/route.ts b/src/app/api/proctoring/event/route.ts index d852003..00fb814 100644 --- a/src/app/api/proctoring/event/route.ts +++ b/src/app/api/proctoring/event/route.ts @@ -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 }, diff --git a/src/app/api/web-vitals/route.ts b/src/app/api/web-vitals/route.ts index ce6d080..b191916 100644 --- a/src/app/api/web-vitals/route.ts +++ b/src/app/api/web-vitals/route.ts @@ -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 { // good/info 级别用 info,rating poor 或超预算用 warn if (payload.rating === "poor" || isOverBudget) { - console.warn("[web-vitals] over-budget", { - name: payload.name, - value: payload.value, - rating: payload.rating, - budget, - path: payload.path, - id: payload.id, - }); + 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", { - name: payload.name, - value: payload.value, - rating: payload.rating, - path: payload.path, - }); + log.info( + { + name: payload.name, + value: payload.value, + rating: payload.rating, + path: payload.path, + }, + "reported", + ); } return NextResponse.json({ ok: true }); diff --git a/src/modules/announcements/actions.ts b/src/modules/announcements/actions.ts index c9c19aa..68fa547 100644 --- a/src/modules/announcements/actions.ts +++ b/src/modules/announcements/actions.ts @@ -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> { - 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") } } diff --git a/src/modules/audit/data-access.ts b/src/modules/audit/data-access.ts index bd6bc96..caffb98 100644 --- a/src/modules/audit/data-access.ts +++ b/src/modules/audit/data-access.ts @@ -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 { .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 { .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 { .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 { 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 ({ action: r.action, count: Number(r.count) })) } catch (error) { - console.error("getDataChangeActionStats failed:", error) + log.error({ err: error }, "getDataChangeActionStats failed") throw error } } diff --git a/src/modules/audit/retention.ts b/src/modules/audit/retention.ts index 66b7f9c..a64eada 100644 --- a/src/modules/audit/retention.ts +++ b/src/modules/audit/retention.ts @@ -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 { 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 } } diff --git a/src/modules/auth/actions.ts b/src/modules/auth/actions.ts index 3febc1e..a074143 100644 --- a/src/modules/auth/actions.ts +++ b/src/modules/auth/actions.ts @@ -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({ diff --git a/src/modules/classes/data-access-admin.ts b/src/modules/classes/data-access-admin.ts index 19ca236..2eb8291 100644 --- a/src/modules/classes/data-access-admin.ts +++ b/src/modules/classes/data-access-admin.ts @@ -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 => { 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>> => { @@ -167,7 +170,7 @@ export const getStudentClassesRaw = async (studentId: string): Promise 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") } })() diff --git a/src/modules/course-plans/data-access.ts b/src/modules/course-plans/data-access.ts index 35a2e45..2490f91 100644 --- a/src/modules/course-plans/data-access.ts +++ b/src/modules/course-plans/data-access.ts @@ -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 } } diff --git a/src/modules/exams/actions-rich-editor.ts b/src/modules/exams/actions-rich-editor.ts index 3296a72..453ab53 100644 --- a/src/modules/exams/actions-rich-editor.ts +++ b/src/modules/exams/actions-rich-editor.ts @@ -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(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(error.message) } - console.error("[updateExamFromRichEditorAction]", error instanceof Error ? error.message : String(error)) + log.error({ err: error }, "updateExamFromRichEditorAction failed") return handleActionError(error) } } diff --git a/src/modules/exams/actions.ts b/src/modules/exams/actions.ts index 07cf15c..4dd4cf4 100644 --- a/src/modules/exams/actions.ts +++ b/src/modules/exams/actions.ts @@ -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(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(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(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(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(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(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(t("loadSubjectsFailed")) } } catch (error) { @@ -684,7 +687,7 @@ export async function getGradesAction(): Promise(t("loadGradesFailed")) } } catch (error) { diff --git a/src/modules/exams/ai-pipeline/auto-mark.ts b/src/modules/exams/ai-pipeline/auto-mark.ts index 0fc497d..5a6007e 100644 --- a/src/modules/exams/ai-pipeline/auto-mark.ts +++ b/src/modules/exams/ai-pipeline/auto-mark.ts @@ -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(error.message) } - console.error("[autoMarkExamAction]", error instanceof Error ? error.message : String(error)) + log.error({ err: error }, "autoMarkExamAction failed") return handleActionError(error) } } diff --git a/src/modules/exams/ai-pipeline/request.ts b/src/modules/exams/ai-pipeline/request.ts index 3791203..d08cc6c 100644 --- a/src/modules/exams/ai-pipeline/request.ts +++ b/src/modules/exams/ai-pipeline/request.ts @@ -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> => { const normalizeQuestionCandidate = (value: unknown): unknown => { - if (!value || typeof value !== "object") return value - // 从 unknown 收窄为 Record 以进行字段检查 - const record = value as Record + if (!isRecord(value)) return value + const record = value const contentRaw = record.content - if (!contentRaw || typeof contentRaw !== "object") return value - const content = contentRaw as Record + 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 } } 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 { diff --git a/src/modules/exams/ai-pipeline/structure.ts b/src/modules/exams/ai-pipeline/structure.ts index 45a8d14..3cb20e8 100644 --- a/src/modules/exams/ai-pipeline/structure.ts +++ b/src/modules/exams/ai-pipeline/structure.ts @@ -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 ( } 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") } } } diff --git a/src/modules/files/data-access.ts b/src/modules/files/data-access.ts index 4c61f46..2d915c1 100644 --- a/src/modules/files/data-access.ts +++ b/src/modules/files/data-access.ts @@ -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 { 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 => { 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 { 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", ) } diff --git a/src/modules/lesson-preparation/publish-service.ts b/src/modules/lesson-preparation/publish-service.ts index 68f2248..268a0f5 100644 --- a/src/modules/lesson-preparation/publish-service.ts +++ b/src/modules/lesson-preparation/publish-service.ts @@ -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] 部分失败,可能存在孤儿数据", { - planId: input.planId, - examId, - assignmentId, - createdQuestionIds, - error: e instanceof Error ? e.message : String(e), - }); + log.error( + { + err: e, + planId: input.planId, + examId, + assignmentId, + createdQuestionIds, + }, + "publishLessonPlanHomework 部分失败,可能存在孤儿数据", + ); throw e; } diff --git a/src/modules/lesson-preparation/services/external-questions-bridge.ts b/src/modules/lesson-preparation/services/external-questions-bridge.ts index 598e2f8..814aabf 100644 --- a/src/modules/lesson-preparation/services/external-questions-bridge.ts +++ b/src/modules/lesson-preparation/services/external-questions-bridge.ts @@ -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" } } } diff --git a/src/modules/notifications/channels/email-channel.ts b/src/modules/notifications/channels/email-channel.ts index 27a084f..7e52914 100644 --- a/src/modules/notifications/channels/email-channel.ts +++ b/src/modules/notifications/channels/email-channel.ts @@ -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 { - 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, diff --git a/src/modules/notifications/channels/sms-channel.ts b/src/modules/notifications/channels/sms-channel.ts index 3f6e92f..b06f205 100644 --- a/src/modules/notifications/channels/sms-channel.ts +++ b/src/modules/notifications/channels/sms-channel.ts @@ -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 { 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, diff --git a/src/modules/notifications/channels/wechat-channel.ts b/src/modules/notifications/channels/wechat-channel.ts index f9ca3c5..0dc7a3c 100644 --- a/src/modules/notifications/channels/wechat-channel.ts +++ b/src/modules/notifications/channels/wechat-channel.ts @@ -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 { function buildTemplateData( payload: NotificationPayload ): Record { - // as 断言:metadata 为 Record,此处需按 string 字典取值; - // 运行时若类型不符会被 ?? 兜底为空对象,安全无副作用 - const custom = (payload.metadata?.wechatKeywords as Record | undefined) ?? {} + // metadata 为 Record,用类型守卫收窄为 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 { - 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, diff --git a/src/modules/notifications/data-access.ts b/src/modules/notifications/data-access.ts index 8d9f9c7..38cfcba 100644 --- a/src/modules/notifications/data-access.ts +++ b/src/modules/notifications/data-access.ts @@ -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 { 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") } } } diff --git a/src/modules/school/data-access.ts b/src/modules/school/data-access.ts index aa40033..96fe97b 100644 --- a/src/modules/school/data-access.ts +++ b/src/modules/school/data-access.ts @@ -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 => { @@ -35,7 +37,7 @@ export const getDepartmentsRaw = async (): Promise => { 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 => 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 teacherCount: s.teacherIds.size, })) } catch (error) { - console.error("getGradeOverviewStats failed:", error) + log.error({ err: error }, "getGradeOverviewStats failed") return [] } } diff --git a/src/modules/settings/actions-avatar.ts b/src/modules/settings/actions-avatar.ts index 59989c0..dd8cafd 100644 --- a/src/modules/settings/actions-avatar.ts +++ b/src/modules/settings/actions-avatar.ts @@ -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 { // 删除 DB 记录 await deleteFileAttachment(fileRecord.id) } catch (error) { - console.error("cleanupOldAvatarFile failed:", error) + log.error({ err: error }, "cleanupOldAvatarFile failed") } } diff --git a/src/modules/settings/actions.ts b/src/modules/settings/actions.ts index 2bc2fbf..2fb5d10 100644 --- a/src/modules/settings/actions.ts +++ b/src/modules/settings/actions.ts @@ -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" } } } diff --git a/src/shared/lib/cache/redis-store.ts b/src/shared/lib/cache/redis-store.ts index f2e20fd..1d48f3c 100644 --- a/src/shared/lib/cache/redis-store.ts +++ b/src/shared/lib/cache/redis-store.ts @@ -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", ) } } diff --git a/src/shared/lib/rate-limit/redis-limiter.ts b/src/shared/lib/rate-limit/redis-limiter.ts index f274d3b..8179a36 100644 --- a/src/shared/lib/rate-limit/redis-limiter.ts +++ b/src/shared/lib/rate-limit/redis-limiter.ts @@ -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", ) } } diff --git a/src/shared/lib/redis-client.ts b/src/shared/lib/redis-client.ts index 6e1d902..8a46932 100644 --- a/src/shared/lib/redis-client.ts +++ b/src/shared/lib/redis-client.ts @@ -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 { 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 { diff --git a/src/shared/services/exam-homework-port.ts b/src/shared/services/exam-homework-port.ts index 75e7a4a..fed27b2 100644 --- a/src/shared/services/exam-homework-port.ts +++ b/src/shared/services/exam-homework-port.ts @@ -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 { 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