diff --git a/README.md b/README.md index 8f9dbd616..bb79ab573 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ Session、VerificationToken、OAuth access/refresh token 和 DeviceCode 记录 - `SKIP_DEPENDENCY_INSTALL=true`(使用项目自身的 Bun lockfile) - `BUN_VERSION=1.3.13`(与 `.bun-version` 保持一致) - `wrangler.jsonc` 为生产配置来源,运行秘密钥通过 Cloudflare Dashboard 设置。 +- 生产配置关闭公开 `workers.dev`、version 和 alias preview URL;非生产分支仍会 + 上传 Worker version 供构建检查,但不会生成可访问的在线预览地址。 开发期建议节奏: - 检查/测试/验证/提交流程以 `$life-ustc-dev-loop` 为准(见 [`.agents/skills/life-ustc-dev-loop/SKILL.md`](./.agents/skills/life-ustc-dev-loop/SKILL.md)) diff --git a/docs/observability.md b/docs/observability.md index 334afac46..c168d3d11 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -10,8 +10,10 @@ high-cardinality resource IDs. - Logs use `logAppEvent` and Cloudflare Workers observability. Production exception fields retain only an allowlisted error class, never the exception message or stack. -- Worker invocation logs use 25% head sampling and traces use 5% head - sampling. Source maps are uploaded with each production Worker deployment. +- Automatic Worker invocation logs are disabled because their raw request + metadata can contain credentials. Production-safe custom logs use 25% head + sampling and traces use 5% head sampling. Source maps are uploaded with each + production Worker deployment. Custom spans identify session lookup, SvelteKit dispatch, MCP authentication, MCP body parsing/SDK dispatch, and GraphQL dispatch without attaching credentials, request bodies, or resource identifiers. diff --git a/src/features/catalog/server/course-page-data.ts b/src/features/catalog/server/course-page-data.ts index a318d4482..394d6f150 100644 --- a/src/features/catalog/server/course-page-data.ts +++ b/src/features/catalog/server/course-page-data.ts @@ -1,4 +1,3 @@ -import { getLatestComments } from "@/features/comments/server/latest-comments"; import { getPrisma } from "@/lib/db/prisma"; import { toLoadData } from "@/lib/load-data-utils"; import { resolveCourseIdByJwId } from "./course-jw-id"; @@ -83,12 +82,5 @@ export async function getCoursePage(jwId: number, locale = "zh-cn") { if (!course) return null; - const [commentCount, latestComments] = await Promise.all([ - prisma.comment.count({ - where: { courseId: course.id, status: { not: "deleted" } }, - }), - getLatestComments({ courseId: course.id }, 5, locale), - ]); - - return toLoadData({ ...course, commentCount, latestComments }); + return toLoadData(course); } diff --git a/src/features/catalog/server/teacher-page-data.ts b/src/features/catalog/server/teacher-page-data.ts index aa05a6c5c..74e0c681b 100644 --- a/src/features/catalog/server/teacher-page-data.ts +++ b/src/features/catalog/server/teacher-page-data.ts @@ -1,4 +1,3 @@ -import { getLatestComments } from "@/features/comments/server/latest-comments"; import { getPrisma } from "@/lib/db/prisma"; import { toLoadData } from "@/lib/load-data-utils"; @@ -60,12 +59,5 @@ export async function getTeacherPage(id: number, locale = "zh-cn") { if (!teacher) return null; - const [commentCount, latestComments] = await Promise.all([ - prisma.comment.count({ - where: { teacherId: teacher.id, status: { not: "deleted" } }, - }), - getLatestComments({ teacherId: teacher.id }, 5, locale), - ]); - - return toLoadData({ ...teacher, commentCount, latestComments }); + return toLoadData(teacher); } diff --git a/src/features/comments/server/latest-comments.ts b/src/features/comments/server/latest-comments.ts deleted file mode 100644 index e4ee03703..000000000 --- a/src/features/comments/server/latest-comments.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Prisma } from "@/generated/prisma/client"; -import { getPrisma } from "@/lib/db/prisma"; -import { toShanghaiIsoString } from "@/lib/time/serialize-date-output"; - -export async function getLatestComments( - where: Prisma.CommentWhereInput, - take = 5, - locale = "zh-cn", -) { - const prisma = getPrisma(locale); - const comments = await prisma.comment.findMany({ - where: { ...where, status: { not: "deleted" } }, - take, - orderBy: { createdAt: "desc" }, - select: { - id: true, - body: true, - authorName: true, - createdAt: true, - user: { select: { name: true, username: true } }, - }, - }); - - return comments.map((comment) => ({ - ...comment, - createdAt: toShanghaiIsoString(comment.createdAt), - })); -} diff --git a/src/features/section-detail/server/section-page-data.ts b/src/features/section-detail/server/section-page-data.ts index b2673ddc2..30f883c4b 100644 --- a/src/features/section-detail/server/section-page-data.ts +++ b/src/features/section-detail/server/section-page-data.ts @@ -15,7 +15,6 @@ export async function getSectionPage(jwId: number, locale = "zh-cn") { if (!section) return null; const relatedData = await getSectionPageRelatedData({ - locale, prisma, section, }); diff --git a/src/features/section-detail/server/section-page-related-data.ts b/src/features/section-detail/server/section-page-related-data.ts index 237dd6407..2a92b7df2 100644 --- a/src/features/section-detail/server/section-page-related-data.ts +++ b/src/features/section-detail/server/section-page-related-data.ts @@ -1,4 +1,3 @@ -import { getLatestComments } from "@/features/comments/server/latest-comments"; import type { getPrisma } from "@/lib/db/prisma"; type PagePrisma = ReturnType; @@ -16,60 +15,37 @@ type OtherSection = { }; export async function getSectionPageRelatedData({ - locale, prisma, section, }: { - locale: string; prisma: PagePrisma; section: SectionPageRelatedSection; }) { const teacherIds = new Set(section.teachers.map((teacher) => teacher.id)); - const [ - sectionCommentCount, - courseCommentCount, - sectionTeacherCommentCount, - latestComments, - otherSections, - ] = await Promise.all([ - prisma.comment.count({ - where: { sectionId: section.id, status: { not: "deleted" } }, - }), - prisma.comment.count({ - where: { courseId: section.courseId, status: { not: "deleted" } }, - }), - prisma.comment.count({ - where: { - sectionTeacher: { sectionId: section.id }, - status: { not: "deleted" }, - }, - }), - getLatestComments({ sectionId: section.id }, 5, locale), - prisma.section.findMany({ - where: { - courseId: section.courseId, - id: { not: section.id }, - retiredAt: null, - }, - orderBy: [{ semester: { jwId: "desc" } }, { code: "asc" }], - select: { - id: true, - jwId: true, - code: true, - semesterId: true, - semester: { select: { endDate: true, nameCn: true, startDate: true } }, - teachers: { - select: { - id: true, - nameCn: true, - nameEn: true, - namePrimary: true, - nameSecondary: true, - }, + const otherSections = await prisma.section.findMany({ + where: { + courseId: section.courseId, + id: { not: section.id }, + retiredAt: null, + }, + orderBy: [{ semester: { jwId: "desc" } }, { code: "asc" }], + select: { + id: true, + jwId: true, + code: true, + semesterId: true, + semester: { select: { endDate: true, nameCn: true, startDate: true } }, + teachers: { + select: { + id: true, + nameCn: true, + nameEn: true, + namePrimary: true, + nameSecondary: true, }, }, - }), - ]); + }, + }); const sameSemesterOtherTeachers = otherSections.filter( (otherSection: OtherSection) => @@ -83,14 +59,8 @@ export async function getSectionPageRelatedData({ ); return { - commentCount: - sectionCommentCount + courseCommentCount + sectionTeacherCommentCount, - courseCommentCount, - latestComments, otherSections, sameSemesterOtherTeachers, sameTeacherOtherSemesters, - sectionCommentCount, - sectionTeacherCommentCount, }; } diff --git a/src/lib/components/DetailSectionNav.svelte b/src/lib/components/DetailSectionNav.svelte index 2e28a407b..4dc55566c 100644 --- a/src/lib/components/DetailSectionNav.svelte +++ b/src/lib/components/DetailSectionNav.svelte @@ -113,6 +113,7 @@ onMount(() => { {#snippet child({ props })} { if ( - CALENDAR_FEED_TOKEN_SEPARATOR.test(segment) && segments[index - 2] === "api" && segments[index - 1] === "calendar-feeds" ) { diff --git a/tests/e2e/src/app/sections/[jwId]/test.ts b/tests/e2e/src/app/sections/[jwId]/test.ts index 3f3b8d7c0..fa2dc2e6a 100644 --- a/tests/e2e/src/app/sections/[jwId]/test.ts +++ b/tests/e2e/src/app/sections/[jwId]/test.ts @@ -419,6 +419,9 @@ test.describe("/catalog/sections/[jwId] 班级详情页", () => { await expect( nav.getByRole("link", { name: /评论|Comments/i }), ).toBeVisible(); + await expect( + nav.getByRole("link", { name: /日历|Calendar/i }), + ).toHaveAttribute("data-sveltekit-preload-data", "off"); await jumpToSection(page, /作业|Homework/i, "#tab-homework"); await expect(page).toHaveURL(/\/catalog\/sections\/\d+\/homework$/); diff --git a/tests/unit/api-observability.test.ts b/tests/unit/api-observability.test.ts index aee004f03..ab613dad2 100644 --- a/tests/unit/api-observability.test.ts +++ b/tests/unit/api-observability.test.ts @@ -42,6 +42,9 @@ describe("API 可观测性", () => { expect(normalized).toBe("/api/calendar-feeds/:credential.ics"); expect(encodedSeparator).toBe("/api/calendar-feeds/:credential.ics"); + expect(normalizeApiRoutePath("/api/calendar-feeds/user-1.ics")).toBe( + "/api/calendar-feeds/:credential.ics", + ); expect(normalized).not.toContain("feed-token-0123456789"); expect(encodedSeparator).not.toContain("feed-token-0123456789"); }); diff --git a/tests/unit/catalog-detail-page-data.test.ts b/tests/unit/catalog-detail-page-data.test.ts new file mode 100644 index 000000000..4ba35ccd7 --- /dev/null +++ b/tests/unit/catalog-detail-page-data.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { courseFindManyMock, courseFindUniqueMock, teacherFindUniqueMock } = + vi.hoisted(() => ({ + courseFindManyMock: vi.fn(), + courseFindUniqueMock: vi.fn(), + teacherFindUniqueMock: vi.fn(), + })); + +vi.mock("@/lib/db/prisma", () => ({ + getPrisma: () => ({ + course: { + findMany: courseFindManyMock, + findUnique: courseFindUniqueMock, + }, + teacher: { findUnique: teacherFindUniqueMock }, + }), +})); + +describe("catalog detail page data", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("loads a course without unused comment queries", async () => { + const course = { code: "MATH1001", id: 11, jwId: 101, sections: [] }; + courseFindManyMock.mockResolvedValue([ + { aliases: [], id: course.id, jwId: course.jwId }, + ]); + courseFindUniqueMock.mockResolvedValue(course); + + const { getCoursePage } = await import( + "@/features/catalog/server/course-page-data" + ); + const result = await getCoursePage(course.jwId); + + expect(result).toEqual(course); + expect(result).not.toHaveProperty("commentCount"); + expect(result).not.toHaveProperty("latestComments"); + }); + + it("loads a teacher without unused comment queries", async () => { + const teacher = { id: 21, namePrimary: "Ada", sections: [] }; + teacherFindUniqueMock.mockResolvedValue(teacher); + + const { getTeacherPage } = await import( + "@/features/catalog/server/teacher-page-data" + ); + const result = await getTeacherPage(teacher.id); + + expect(result).toEqual(teacher); + expect(result).not.toHaveProperty("commentCount"); + expect(result).not.toHaveProperty("latestComments"); + }); +}); diff --git a/tests/unit/retired-section-discovery.test.ts b/tests/unit/retired-section-discovery.test.ts index c62c6b46b..7e3f96e57 100644 --- a/tests/unit/retired-section-discovery.test.ts +++ b/tests/unit/retired-section-discovery.test.ts @@ -2,13 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const { buildSuggestionsMock, - getLatestCommentsMock, sectionFindFirstMock, sectionFindManyMock, semesterMock, } = vi.hoisted(() => ({ buildSuggestionsMock: vi.fn().mockResolvedValue({}), - getLatestCommentsMock: vi.fn().mockResolvedValue([]), sectionFindFirstMock: vi.fn(), sectionFindManyMock: vi.fn().mockResolvedValue([]), semesterMock: { @@ -26,10 +24,6 @@ vi.mock("@/features/catalog/server/section-code-match-suggestions", () => ({ buildSectionCodeSuggestions: buildSuggestionsMock, })); -vi.mock("@/features/comments/server/latest-comments", () => ({ - getLatestComments: getLatestCommentsMock, -})); - vi.mock("@/lib/db/prisma", () => ({ getPrisma: () => ({ section: { findMany: sectionFindManyMock }, @@ -47,7 +41,6 @@ describe("retired Section discovery boundaries", () => { sectionFindFirstMock.mockReset().mockResolvedValue(null); sectionFindManyMock.mockReset().mockResolvedValue([]); buildSuggestionsMock.mockClear(); - getLatestCommentsMock.mockReset().mockResolvedValue([]); }); it("excludes retired rows from public code matching", async () => { @@ -77,15 +70,12 @@ describe("retired Section discovery boundaries", () => { }); it("excludes retired rows from public related-Section discovery", async () => { - const commentCount = vi.fn().mockResolvedValue(0); const { getSectionPageRelatedData } = await import( "@/features/section-detail/server/section-page-related-data" ); await getSectionPageRelatedData({ - locale: "zh-cn", prisma: { - comment: { count: commentCount }, section: { findMany: sectionFindManyMock }, } as never, section: { diff --git a/tests/unit/wrangler-rate-limit-config.test.ts b/tests/unit/wrangler-rate-limit-config.test.ts index b6366945e..d4771f8da 100644 --- a/tests/unit/wrangler-rate-limit-config.test.ts +++ b/tests/unit/wrangler-rate-limit-config.test.ts @@ -17,6 +17,22 @@ async function readRateLimits(fileName: string): Promise { } describe("Wrangler mutation rate-limit bindings", () => { + it("keeps public workers.dev and automatic request metadata logs disabled", async () => { + const source = await readFile( + new URL("../../wrangler.jsonc", import.meta.url), + "utf8", + ); + const config = JSON.parse(source) as { + observability?: { logs?: { invocation_logs?: boolean } }; + preview_urls?: boolean; + workers_dev?: boolean; + }; + + expect(config.preview_urls).toBe(false); + expect(config.workers_dev).toBe(false); + expect(config.observability?.logs?.invocation_logs).toBe(false); + }); + it("uploads production source maps for trace and exception symbolication", async () => { const source = await readFile( new URL("../../wrangler.jsonc", import.meta.url), diff --git a/wrangler.jsonc b/wrangler.jsonc index 18cb8d7dc..d3f11d290 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,7 +1,8 @@ { "$schema": "node_modules/wrangler/config-schema.json", "name": "life-ustc", - "preview_urls": true, + "preview_urls": false, + "workers_dev": false, "main": ".svelte-kit/cloudflare/_worker.js", "upload_source_maps": true, "compatibility_date": "2026-07-23", @@ -19,7 +20,7 @@ "logs": { "enabled": true, "head_sampling_rate": 0.25, - "invocation_logs": true, + "invocation_logs": false, "persist": true }, "traces": {