Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
6 changes: 4 additions & 2 deletions docs/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 1 addition & 9 deletions src/features/catalog/server/course-page-data.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
}
10 changes: 1 addition & 9 deletions src/features/catalog/server/teacher-page-data.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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);
}
28 changes: 0 additions & 28 deletions src/features/comments/server/latest-comments.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/features/section-detail/server/section-page-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ export async function getSectionPage(jwId: number, locale = "zh-cn") {
if (!section) return null;

const relatedData = await getSectionPageRelatedData({
locale,
prisma,
section,
});
Expand Down
74 changes: 22 additions & 52 deletions src/features/section-detail/server/section-page-related-data.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { getLatestComments } from "@/features/comments/server/latest-comments";
import type { getPrisma } from "@/lib/db/prisma";

type PagePrisma = ReturnType<typeof getPrisma>;
Expand All @@ -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) =>
Expand All @@ -83,14 +59,8 @@ export async function getSectionPageRelatedData({
);

return {
commentCount:
sectionCommentCount + courseCommentCount + sectionTeacherCommentCount,
courseCommentCount,
latestComments,
otherSections,
sameSemesterOtherTeachers,
sameTeacherOtherSemesters,
sectionCommentCount,
sectionTeacherCommentCount,
};
}
1 change: 1 addition & 0 deletions src/lib/components/DetailSectionNav.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ onMount(() => {
{#snippet child({ props })}
<a
{...props}
data-sveltekit-preload-data="off"
use:revealActive={active}
href={item.href}
aria-current={active ? "page" : undefined}
Expand Down
2 changes: 0 additions & 2 deletions src/lib/log/api-observability-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,12 @@ const UUID_SEGMENT =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const NUMERIC_SEGMENT = /^\d+$/;
const OPAQUE_ID_SEGMENT = /^(?=.*\d)[A-Za-z0-9_-]{16,}$/;
const CALENDAR_FEED_TOKEN_SEPARATOR = /:|%3a/i;

export function normalizeApiRoutePath(pathname: string) {
return pathname
.split("/")
.map((segment, index, segments) => {
if (
CALENDAR_FEED_TOKEN_SEPARATOR.test(segment) &&
segments[index - 2] === "api" &&
segments[index - 1] === "calendar-feeds"
) {
Expand Down
3 changes: 3 additions & 0 deletions tests/e2e/src/app/sections/[jwId]/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$/);
Expand Down
3 changes: 3 additions & 0 deletions tests/unit/api-observability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand Down
55 changes: 55 additions & 0 deletions tests/unit/catalog-detail-page-data.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
10 changes: 0 additions & 10 deletions tests/unit/retired-section-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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 },
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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: {
Expand Down
16 changes: 16 additions & 0 deletions tests/unit/wrangler-rate-limit-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,22 @@ async function readRateLimits(fileName: string): Promise<RateLimitBinding[]> {
}

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),
Expand Down
5 changes: 3 additions & 2 deletions wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -19,7 +20,7 @@
"logs": {
"enabled": true,
"head_sampling_rate": 0.25,
"invocation_logs": true,
"invocation_logs": false,
"persist": true
},
"traces": {
Expand Down