From 176bbe3ce2d470589d82ce9699c683ae1a56f11d Mon Sep 17 00:00:00 2001 From: TianKai Ma Date: Wed, 2 Sep 2026 01:06:32 +0800 Subject: [PATCH 1/3] feat(young): expose second-classroom events across all interfaces Import young.ustc.edu.cn signup events from the static snapshot into a new YoungEvent model, then surface them through REST (/api/catalog/young-events), GraphQL (catalog.youngEvents/youngEvent), MCP (catalog_young_event_*), and the web catalog (/catalog/young-events) with zh/en i18n, navigation, and sitemap entries. --- docs/contracts/graphql.json | 1 + docs/contracts/mcp.json | 4 + docs/contracts/young-event.json | 96 ++++ docs/graphql/schema.graphql | 33 ++ docs/interface-hierarchy.md | 3 + messages/en-us.json | 30 ++ messages/zh-cn.json | 30 ++ .../migration.sql | 33 ++ prisma/schema.prisma | 27 ++ prisma/seed.sql | 8 + public/openapi.generated.json | 413 +++++++++++++++++- .../dashboard/server/dashboard-page-copy.ts | 1 + .../components/YoungEventDetailPage.svelte | 90 ++++ .../young/components/YoungEventsPage.svelte | 213 +++++++++ .../young/server/young-event-service.ts | 149 +++++++ src/features/young/server/young-page-load.ts | 70 +++ src/lib/api/routes/young-event-routes.ts | 59 +++ src/lib/api/schemas/request-path-schemas.ts | 8 + src/lib/api/schemas/request-query-schemas.ts | 1 + src/lib/api/schemas/response-schemas.ts | 1 + src/lib/api/schemas/young-event-schemas.ts | 74 ++++ src/lib/components/shell/AppShell.svelte | 11 + src/lib/graphql/input-boundaries.ts | 12 + src/lib/graphql/operation-analysis.ts | 1 + src/lib/graphql/operation-definitions.ts | 69 +++ src/lib/graphql/schema.ts | 62 +++ src/lib/mcp/compact-dispatch.ts | 5 + src/lib/mcp/compact-entities.ts | 20 + src/lib/mcp/server.ts | 2 + src/lib/mcp/tool-output-schemas.ts | 52 +++ src/lib/mcp/tool-scopes.ts | 4 + .../mcp/tools/catalog/young-event-tools.ts | 109 +++++ src/lib/shell/layout-server-data.ts | 3 + .../api/catalog/young-events/+server.ts | 11 + .../catalog/young-events/[youngId]/+server.ts | 15 + .../catalog/young-events/+page.server.ts | 28 ++ src/routes/catalog/young-events/+page.svelte | 18 + .../young-events/[youngId]/+page.server.ts | 34 ++ .../young-events/[youngId]/+page.svelte | 14 + src/routes/sitemap.xml/+server.ts | 13 +- src/static-loader/import-state.ts | 2 +- src/static-loader/import.ts | 97 +++- src/static-loader/snapshot.ts | 10 + src/static-loader/young-plan.ts | 111 +++++ tests/e2e/fixtures/scenario.json | 8 + tests/e2e/src/app/_shared/page-contract.ts | 38 ++ tests/e2e/src/app/_shared/page-inventory.ts | 34 ++ .../src/app/young-events/[youngId]/test.ts | 73 ++++ tests/e2e/src/app/young-events/test.ts | 99 +++++ tests/fixtures/dev-seed.ts | 8 + .../mcp/catalog/young-events.test.ts | 126 ++++++ .../integration/rest/_shared/api-contract.ts | 47 ++ tests/integration/rest/young-events/test.ts | 101 +++++ tests/unit/graphql-operations.test.ts | 4 +- tests/unit/seo-discovery-routes.test.ts | 3 + tests/unit/sitemap-route.test.ts | 25 +- tests/unit/static-import-state.test.ts | 4 +- tests/unit/static-loader-young-plan.test.ts | 130 ++++++ tests/unit/young-event-service.test.ts | 124 ++++++ 59 files changed, 2857 insertions(+), 14 deletions(-) create mode 100644 docs/contracts/young-event.json create mode 100644 prisma/migrations/20260901210000_add_young_events/migration.sql create mode 100644 src/features/young/components/YoungEventDetailPage.svelte create mode 100644 src/features/young/components/YoungEventsPage.svelte create mode 100644 src/features/young/server/young-event-service.ts create mode 100644 src/features/young/server/young-page-load.ts create mode 100644 src/lib/api/routes/young-event-routes.ts create mode 100644 src/lib/api/schemas/young-event-schemas.ts create mode 100644 src/lib/mcp/tools/catalog/young-event-tools.ts create mode 100644 src/routes/api/catalog/young-events/+server.ts create mode 100644 src/routes/api/catalog/young-events/[youngId]/+server.ts create mode 100644 src/routes/catalog/young-events/+page.server.ts create mode 100644 src/routes/catalog/young-events/+page.svelte create mode 100644 src/routes/catalog/young-events/[youngId]/+page.server.ts create mode 100644 src/routes/catalog/young-events/[youngId]/+page.svelte create mode 100644 src/static-loader/young-plan.ts create mode 100644 tests/e2e/src/app/young-events/[youngId]/test.ts create mode 100644 tests/e2e/src/app/young-events/test.ts create mode 100644 tests/integration/mcp/catalog/young-events.test.ts create mode 100644 tests/integration/rest/young-events/test.ts create mode 100644 tests/unit/static-loader-young-plan.test.ts create mode 100644 tests/unit/young-event-service.test.ts diff --git a/docs/contracts/graphql.json b/docs/contracts/graphql.json index aaaca1e44..e71affffb 100644 --- a/docs/contracts/graphql.json +++ b/docs/contracts/graphql.json @@ -73,6 +73,7 @@ "account.profile / account.clientActivity", "catalog.courses / catalog.sections / catalog.teachers", "catalog.semesters / catalog.currentSemester / catalog.bus", + "catalog.youngEvents / catalog.youngEvent", "community.user / community.comments / community.description", "workspace.overview / workspace.todos", "workspace.subscriptions / workspace.homeworks", diff --git a/docs/contracts/mcp.json b/docs/contracts/mcp.json index f6da90525..377177575 100644 --- a/docs/contracts/mcp.json +++ b/docs/contracts/mcp.json @@ -197,6 +197,10 @@ { "name": "Weather", "tools": ["catalog_weather_get"] + }, + { + "name": "Young Events", + "tools": ["catalog_young_event_list", "catalog_young_event_get"] } ] }, diff --git a/docs/contracts/young-event.json b/docs/contracts/young-event.json new file mode 100644 index 000000000..a1beac293 --- /dev/null +++ b/docs/contracts/young-event.json @@ -0,0 +1,96 @@ +{ + "name": "Young Events", + "access": { + "anon": "Can browse second-classroom (第二课堂) signup events from young.ustc.edu.cn.", + "user": "Can browse second-classroom (第二课堂) signup events from young.ustc.edu.cn.", + "admin": "Can browse second-classroom (第二课堂) signup events from young.ustc.edu.cn.", + "agent": "Can read second-classroom (第二课堂) signup events from young.ustc.edu.cn." + }, + "rules": { + "public-no-signin": "Young events are public campus activity information; sign-up itself still happens on young.ustc.edu.cn, so sign-in is not a prerequisite for browsing.", + "static-snapshot-sourced": "Events are scraped by the static repo (authenticated young.ustc.edu.cn enrolmentList/endList APIs) into the published SQLite snapshot and imported into Postgres by the static loader; the server never calls young.ustc.edu.cn at request time.", + "snapshot-authoritative": "The static snapshot is authoritative; the loader upserts by upstream youngId and deletes events absent from the snapshot. An empty snapshot never wipes existing rows (treated as upstream breakage).", + "raw-payload-preserved": "Curated columns cover the common fields; the complete upstream record stays available under rawJson for REST and MCP consumers.", + "shanghai-local-times": "Upstream datetimes are Asia/Shanghai local times; startAt/endAt/applyStartAt/applyEndAt are stored and returned as instants.", + "read-only": "Events are imported facts; no transport exposes writes." + }, + "capabilities": { + "young-event": { + "title": "Second-Classroom Events", + "auth": "anon", + "web": { + "pages": ["/catalog/young-events", "/catalog/young-events/[youngId]"] + }, + "rest": { + "routes": [ + { + "path": "/api/catalog/young-events", + "returns": "paginated { youngId, name, category, department, organizer, status, location, imageUrl, hours, capacity, appliedCount, startAt, endAt, applyStartAt, applyEndAt, isActive }", + "notes": [ + "Optional query parameters: active (true/false), category, search (name), page, pageSize.", + "Default order: signup-open (active) events first, then by startAt descending." + ] + }, + { + "path": "/api/catalog/young-events/[youngId]", + "returns": "single event including rawJson", + "notes": ["404 when the youngId is unknown."] + } + ] + }, + "graphql": { + "queries": [ + { + "name": "youngEvents", + "parent": "Catalog", + "arguments": { + "page": "PageInput", + "filter": "YoungEventFilter" + }, + "returns": "YoungEventPage!", + "rest_equivalent": "GET /api/catalog/young-events", + "mcp_equivalent": "catalog_young_event_list" + }, + { + "name": "youngEvent", + "parent": "Catalog", + "arguments": { "youngId": "String!" }, + "returns": "YoungEvent", + "rest_equivalent": "GET /api/catalog/young-events/[youngId]", + "mcp_equivalent": "catalog_young_event_get", + "notes": ["Returns null when the youngId is unknown."] + } + ] + }, + "mcp": { + "tools": [ + { + "name": "catalog_young_event_list", + "returns": "paginated event summaries", + "rest_equivalent": "GET /api/catalog/young-events", + "notes": [ + "Accepts active, category, search, page, pageSize, and mode inputs.", + "Default mode returns summaries without rawJson; full mode includes the raw upstream payload." + ] + }, + { + "name": "catalog_young_event_get", + "returns": "single event including rawJson", + "rest_equivalent": "GET /api/catalog/young-events/[youngId]", + "notes": ["Accepts youngId and mode inputs."] + } + ] + }, + "display": { + "fields": [ + "event name", + "category (单次项目 / 系列项目)", + "signup window and event time", + "capacity and applied count", + "status", + "organizer and department" + ] + } + } + } +} diff --git a/docs/graphql/schema.graphql b/docs/graphql/schema.graphql index a5247472d..c4984ffe8 100644 --- a/docs/graphql/schema.graphql +++ b/docs/graphql/schema.graphql @@ -114,6 +114,8 @@ type Catalog { teacher(id: Int!): Teacher teachers(filter: TeacherFilter, page: PageInput): TeacherPage! weather(locationKey: String!): WeatherSnapshot + youngEvent(youngId: String!): YoungEvent + youngEvents(filter: YoungEventFilter, page: PageInput): YoungEventPage! } type CatalogLink { @@ -824,3 +826,34 @@ type WorkspaceOverview { todaySchedules: Int! upcomingExams: Int! } + +type YoungEvent { + appliedCount: Int + applyEndAt: DateTime + applyStartAt: DateTime + capacity: Int + category: String + department: String + endAt: DateTime + hours: Float + imageUrl: String + isActive: Boolean! + location: String + name: String! + organizer: String + registrationStatus: String + startAt: DateTime + status: String + youngId: String! +} + +input YoungEventFilter { + active: Boolean + category: String + search: String +} + +type YoungEventPage { + items: [YoungEvent!]! + pageInfo: PageInfo! +} diff --git a/docs/interface-hierarchy.md b/docs/interface-hierarchy.md index 83ea694b5..aeff3827f 100644 --- a/docs/interface-hierarchy.md +++ b/docs/interface-hierarchy.md @@ -35,6 +35,9 @@ Life@USTC │ │ ├── timetable │ │ ├── next-departures │ │ └── map +│ ├── young-events +│ │ ├── list +│ │ └── get │ └── links ├── workspace current user's campus work │ ├── overview diff --git a/messages/en-us.json b/messages/en-us.json index 07275d498..0bc82b36a 100644 --- a/messages/en-us.json +++ b/messages/en-us.json @@ -1054,6 +1054,36 @@ "humidityLabel": "Humidity", "windLabel": "Wind" }, + "youngEvents": { + "title": "Second Classroom", + "description": "Signup events from the USTC second classroom platform (young.ustc.edu.cn).", + "searchPlaceholder": "Search by event name...", + "showing": "Showing {count} of {total} events", + "searchFor": "for \"{query}\"", + "noEventsFound": "No events found", + "signupStatus": "Signup status", + "statusAll": "All events", + "statusActive": "Signup open", + "statusEnded": "Ended", + "category": "Category", + "allCategories": "All categories", + "eventName": "Event", + "eventTime": "Event time", + "signupWindow": "Signup window", + "location": "Location", + "organizer": "Organizer", + "department": "Department", + "hours": "Hours", + "capacity": "Capacity", + "appliedCount": "Applied", + "status": "Status", + "registrationStatus": "Registration", + "signupCta": "Sign up on young.ustc.edu.cn", + "signupHint": "Sign-up happens on the second classroom platform; this site is read-only.", + "notFound": "Event not found", + "notFoundDescription": "This event does not exist or has been removed.", + "backToList": "Back to all events" + }, "publications": { "title": "News & Notices", "pageTitle": "Campus News & Notices", diff --git a/messages/zh-cn.json b/messages/zh-cn.json index 0323ab829..5e086d20b 100644 --- a/messages/zh-cn.json +++ b/messages/zh-cn.json @@ -2680,6 +2680,36 @@ "humidityLabel": "湿度", "windLabel": "风" }, + "youngEvents": { + "title": "第二课堂", + "description": "来自第二课堂平台(young.ustc.edu.cn)的报名活动。", + "searchPlaceholder": "按活动名称搜索...", + "showing": "显示 {total} 个活动中的 {count} 个", + "searchFor": "关于 \"{query}\"", + "noEventsFound": "未找到活动", + "signupStatus": "报名状态", + "statusAll": "全部活动", + "statusActive": "报名中", + "statusEnded": "已结束", + "category": "类别", + "allCategories": "全部类别", + "eventName": "活动", + "eventTime": "活动时间", + "signupWindow": "报名时间", + "location": "地点", + "organizer": "主办方", + "department": "承办部门", + "hours": "学时", + "capacity": "名额", + "appliedCount": "已报名", + "status": "状态", + "registrationStatus": "报名状态", + "signupCta": "前往 young.ustc.edu.cn 报名", + "signupHint": "报名需在第二课堂平台完成,本站仅提供浏览。", + "notFound": "活动不存在", + "notFoundDescription": "该活动不存在或已被移除。", + "backToList": "返回活动列表" + }, "publications": { "title": "新闻与通知", "pageTitle": "校园新闻与通知", diff --git a/prisma/migrations/20260901210000_add_young_events/migration.sql b/prisma/migrations/20260901210000_add_young_events/migration.sql new file mode 100644 index 000000000..2f587c0f8 --- /dev/null +++ b/prisma/migrations/20260901210000_add_young_events/migration.sql @@ -0,0 +1,33 @@ +-- CreateTable +CREATE TABLE "YoungEvent" ( + "id" SERIAL NOT NULL, + "youngId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "category" TEXT, + "department" TEXT, + "organizer" TEXT, + "status" TEXT, + "registrationStatus" TEXT, + "location" TEXT, + "imageUrl" TEXT, + "hours" DOUBLE PRECISION, + "capacity" INTEGER, + "appliedCount" INTEGER, + "startAt" TIMESTAMP(0), + "endAt" TIMESTAMP(0), + "applyStartAt" TIMESTAMP(0), + "applyEndAt" TIMESTAMP(0), + "isActive" BOOLEAN NOT NULL, + "rawJson" JSONB NOT NULL, + + CONSTRAINT "YoungEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "YoungEvent_youngId_key" ON "YoungEvent"("youngId"); + +-- CreateIndex +CREATE INDEX "YoungEvent_isActive_startAt_idx" ON "YoungEvent"("isActive", "startAt"); + +-- CreateIndex +CREATE INDEX "YoungEvent_category_idx" ON "YoungEvent"("category"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e32330492..e5a5b0e89 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1865,3 +1865,30 @@ model WeatherObservation { @@unique([locationKey, observedAt]) @@index([locationKey, observedAt]) } + +model YoungEvent { + id Int @id @default(autoincrement()) + + youngId String @unique + + name String + category String? + department String? + organizer String? + status String? + registrationStatus String? + location String? + imageUrl String? + hours Float? + capacity Int? + appliedCount Int? + startAt DateTime? @db.Timestamp(0) + endAt DateTime? @db.Timestamp(0) + applyStartAt DateTime? @db.Timestamp(0) + applyEndAt DateTime? @db.Timestamp(0) + isActive Boolean + rawJson Json + + @@index([isActive, startAt]) + @@index([category]) +} diff --git a/prisma/seed.sql b/prisma/seed.sql index edbe679be..4a0772153 100644 --- a/prisma/seed.sql +++ b/prisma/seed.sql @@ -644,6 +644,14 @@ INSERT INTO public."UserSectionSubscription" ("userId", "sectionId") VALUES ('cm INSERT INTO public."UserSectionSubscription" ("userId", "sectionId") VALUES ('cmqw1sr9g0001bqt44c3s0kqa', 3) ON CONFLICT DO NOTHING; INSERT INTO public."UserSectionSubscription" ("userId", "sectionId") VALUES ('cmqw1sr9g0001bqt44c3s0kqa', 4) ON CONFLICT DO NOTHING; + +-- +-- Data for Name: YoungEvent; Type: TABLE DATA; Schema: public; Owner: - +-- + +INSERT INTO public."YoungEvent" ("youngId", name, category, department, organizer, status, "registrationStatus", location, "imageUrl", hours, capacity, "appliedCount", "startAt", "endAt", "applyStartAt", "applyEndAt", "isActive", "rawJson") VALUES ('dev-scenario-young-event', '第二课堂示例活动', '单次项目', '校团委', '学生会', '进行中', '报名中', '东区图书馆', NULL, 2, 30, 5, '2026-05-10 06:00:00+00', '2026-05-10 08:00:00+00', '2026-04-28 16:00:00+00', '2026-05-09 15:59:59+00', true, '{"id": "dev-scenario-young-event", "itemName": "第二课堂示例活动"}'::jsonb) ON CONFLICT DO NOTHING; +INSERT INTO public."YoungEvent" ("youngId", name, category, "isActive", "rawJson") VALUES ('dev-scenario-young-event-ended', '第二课堂已结束活动', '单次项目', false, '{"id": "dev-scenario-young-event-ended"}'::jsonb) ON CONFLICT DO NOTHING; + -- Account-deletion tests intentionally exercise ON DELETE SET NULL. Restore -- ownership on the named scenario rows so reseeding is a complete reset. UPDATE public."Homework" diff --git a/public/openapi.generated.json b/public/openapi.generated.json index 502b2292d..474711ded 100644 --- a/public/openapi.generated.json +++ b/public/openapi.generated.json @@ -88,6 +88,10 @@ "name": "catalog.weather", "description": "catalog.weather operations" }, + { + "name": "catalog.young-events", + "description": "catalog.young-events operations" + }, { "name": "community.comments", "description": "community.comments operations" @@ -153,7 +157,8 @@ "catalog.section", "catalog.semesters", "catalog.teachers", - "catalog.weather" + "catalog.weather", + "catalog.young-events" ] }, { @@ -2184,6 +2189,107 @@ } } }, + "/api/catalog/young-events": { + "get": { + "operationId": "get-api-catalog-young-events", + "summary": "List second-classroom (Young) signup events", + "tags": [ + "catalog.young-events" + ], + "parameters": [ + { + "in": "query", + "name": "active", + "schema": { + "description": "Filter by signup-open (active) events.", + "type": "string", + "enum": [ + "true", + "false" + ] + }, + "description": "Filter by signup-open (active) events." + }, + { + "in": "query", + "name": "category", + "schema": { + "description": "Exact category filter, e.g. 单次项目 or 系列项目.", + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "description": "Exact category filter, e.g. 单次项目 or 系列项目." + }, + { + "in": "query", + "name": "search", + "schema": { + "description": "Case-insensitive substring match on the event name.", + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "description": "Case-insensitive substring match on the event name." + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1, + "maximum": 1000 + } + }, + { + "in": "query", + "name": "pageSize", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1, + "maximum": 100 + }, + "description": "Number of items per page." + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1, + "maximum": 100 + }, + "deprecated": true, + "description": "Deprecated alias for pageSize. pageSize takes precedence when both are supplied." + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/paginatedYoungEventResponseSchema" + } + } + } + }, + "400": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + } + } + } + }, "/api/community/comments": { "get": { "operationId": "listComments", @@ -5723,6 +5829,60 @@ } } }, + "/api/catalog/young-events/{youngId}": { + "get": { + "operationId": "get-api-catalog-young-events-youngId", + "summary": "Get a second-classroom (Young) signup event", + "tags": [ + "catalog.young-events" + ], + "parameters": [ + { + "in": "path", + "name": "youngId", + "schema": { + "type": "string", + "minLength": 1, + "description": "Upstream young.ustc.edu.cn event identifier." + }, + "required": true, + "description": "Upstream young.ustc.edu.cn event identifier." + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/youngEventDetailSchema" + } + } + } + }, + "400": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "404": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + } + } + } + }, "/api/community/comments/{id}": { "get": { "operationId": "getComment", @@ -14043,6 +14203,153 @@ ], "additionalProperties": false }, + "paginatedYoungEventResponseSchema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "youngId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "category": { + "nullable": true, + "type": "string" + }, + "department": { + "nullable": true, + "type": "string" + }, + "organizer": { + "nullable": true, + "type": "string" + }, + "status": { + "nullable": true, + "type": "string" + }, + "registrationStatus": { + "nullable": true, + "type": "string" + }, + "location": { + "nullable": true, + "type": "string" + }, + "imageUrl": { + "nullable": true, + "type": "string" + }, + "hours": { + "nullable": true, + "type": "number" + }, + "capacity": { + "nullable": true, + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "appliedCount": { + "nullable": true, + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "startAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "endAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "applyStartAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "applyEndAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "isActive": { + "type": "boolean" + } + }, + "required": [ + "youngId", + "name", + "category", + "department", + "organizer", + "status", + "registrationStatus", + "location", + "imageUrl", + "hours", + "capacity", + "appliedCount", + "startAt", + "endAt", + "applyStartAt", + "applyEndAt", + "isActive" + ], + "additionalProperties": false + } + }, + "pagination": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "pageSize": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "total": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "totalPages": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "page", + "pageSize", + "total", + "totalPages" + ], + "additionalProperties": false + } + }, + "required": [ + "data", + "pagination" + ], + "additionalProperties": false + }, "commentsListResponseSchema": { "type": "object", "properties": { @@ -26992,6 +27299,110 @@ ], "additionalProperties": false }, + "youngEventDetailSchema": { + "type": "object", + "properties": { + "youngId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "category": { + "nullable": true, + "type": "string" + }, + "department": { + "nullable": true, + "type": "string" + }, + "organizer": { + "nullable": true, + "type": "string" + }, + "status": { + "nullable": true, + "type": "string" + }, + "registrationStatus": { + "nullable": true, + "type": "string" + }, + "location": { + "nullable": true, + "type": "string" + }, + "imageUrl": { + "nullable": true, + "type": "string" + }, + "hours": { + "nullable": true, + "type": "number" + }, + "capacity": { + "nullable": true, + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "appliedCount": { + "nullable": true, + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "startAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "endAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "applyStartAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "applyEndAt": { + "nullable": true, + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "isActive": { + "type": "boolean" + }, + "rawJson": {} + }, + "required": [ + "youngId", + "name", + "category", + "department", + "organizer", + "status", + "registrationStatus", + "location", + "imageUrl", + "hours", + "capacity", + "appliedCount", + "startAt", + "endAt", + "applyStartAt", + "applyEndAt", + "isActive", + "rawJson" + ], + "additionalProperties": false + }, "commentThreadResponseSchema": { "type": "object", "properties": { diff --git a/src/features/dashboard/server/dashboard-page-copy.ts b/src/features/dashboard/server/dashboard-page-copy.ts index 7e158e3ea..41d4bf6fd 100644 --- a/src/features/dashboard/server/dashboard-page-copy.ts +++ b/src/features/dashboard/server/dashboard-page-copy.ts @@ -24,6 +24,7 @@ export function getDashboardPageCopy(locale: AppLocale) { subscriptions: copy.subscriptions, todos: copy.todos, weather: copy.weather, + youngEvents: copy.youngEvents, }; } diff --git a/src/features/young/components/YoungEventDetailPage.svelte b/src/features/young/components/YoungEventDetailPage.svelte new file mode 100644 index 000000000..6a682b84c --- /dev/null +++ b/src/features/young/components/YoungEventDetailPage.svelte @@ -0,0 +1,90 @@ + + + +
+ {#if event.imageUrl} + {event.name} + {/if} + + +
+ {#each fields as field (field.label)} +
+
{field.label}
+
{field.value}
+
+ {/each} +
+
+ +

{youngCopy.signupHint}

+ +
+ + +
+
+
diff --git a/src/features/young/components/YoungEventsPage.svelte b/src/features/young/components/YoungEventsPage.svelte new file mode 100644 index 000000000..737a4c6e5 --- /dev/null +++ b/src/features/young/components/YoungEventsPage.svelte @@ -0,0 +1,213 @@ + + +{#snippet paginationFooter()} + +{/snippet} + + + 1 ? paginationFooter : undefined}> + {#snippet header()} +
+
+ + +
+
+ + + {youngCopy.statusAll} + {youngCopy.statusActive} + {youngCopy.statusEnded} + +
+
+ + + {youngCopy.allCategories} + {#each categories as category (category)} + {category} + {/each} + +
+ + +
+ {/snippet} + +
+ + {#if data.length > 0} + + {#snippet mobile()} + + {#each data as event, index (event.youngId)} +
+ + {#snippet child({ props })} + + + {event.name} + + + {formatDateTime(event.startAt)} + + + {event.category ?? "-"} + {event.registrationStatus ?? "-"} + + + {/snippet} + + {#if index < data.length - 1} + + {/if} +
+ {/each} +
+ {/snippet} + {#snippet desktop()} + + + + {youngCopy.eventName} + {youngCopy.category} + {youngCopy.eventTime} + {youngCopy.signupWindow} + {youngCopy.capacity} + {youngCopy.registrationStatus} + + + + {#each data as event (event.youngId)} + + + + + + + {event.category ?? "-"} + + {formatDateTime(event.startAt)} + + + {formatDateTime(event.applyStartAt)} ~ {formatDateTime(event.applyEndAt)} + + + {event.appliedCount ?? 0}{event.capacity != null ? ` / ${event.capacity}` : ""} + + {event.registrationStatus ?? "-"} + + {/each} + + + {/snippet} +
+ {:else} +
+ +
+ {/if} +
+
+
diff --git a/src/features/young/server/young-event-service.ts b/src/features/young/server/young-event-service.ts new file mode 100644 index 000000000..18453a46d --- /dev/null +++ b/src/features/young/server/young-event-service.ts @@ -0,0 +1,149 @@ +import type { Prisma } from "@/generated/prisma/client"; +import { + buildPaginatedResponse, + normalizePagination, + type PaginatedResponse, + type PaginationInput, +} from "@/lib/api/pagination"; +import { prisma } from "@/lib/db/prisma"; +import { formatShanghaiTimestamp } from "@/lib/time/shanghai-format"; + +export type YoungEventSummary = { + youngId: string; + name: string; + category: string | null; + department: string | null; + organizer: string | null; + status: string | null; + registrationStatus: string | null; + location: string | null; + imageUrl: string | null; + hours: number | null; + capacity: number | null; + appliedCount: number | null; + startAt: string | null; + endAt: string | null; + applyStartAt: string | null; + applyEndAt: string | null; + isActive: boolean; +}; + +export type YoungEventDetail = YoungEventSummary & { + rawJson: Prisma.JsonValue; +}; + +export type YoungEventListInput = PaginationInput & { + active?: boolean | null; + category?: string | null; + search?: string | null; +}; + +const YOUNG_EVENT_SELECT = { + youngId: true, + name: true, + category: true, + department: true, + organizer: true, + status: true, + registrationStatus: true, + location: true, + imageUrl: true, + hours: true, + capacity: true, + appliedCount: true, + startAt: true, + endAt: true, + applyStartAt: true, + applyEndAt: true, + isActive: true, +} satisfies Prisma.YoungEventSelect; + +type YoungEventRecord = Prisma.YoungEventGetPayload<{ + select: typeof YOUNG_EVENT_SELECT; +}>; + +function toShanghaiIso(date: Date | null): string | null { + return date == null ? null : formatShanghaiTimestamp(date); +} + +function toYoungEventSummary(record: YoungEventRecord): YoungEventSummary { + return { + youngId: record.youngId, + name: record.name, + category: record.category, + department: record.department, + organizer: record.organizer, + status: record.status, + registrationStatus: record.registrationStatus, + location: record.location, + imageUrl: record.imageUrl, + hours: record.hours, + capacity: record.capacity, + appliedCount: record.appliedCount, + startAt: toShanghaiIso(record.startAt), + endAt: toShanghaiIso(record.endAt), + applyStartAt: toShanghaiIso(record.applyStartAt), + applyEndAt: toShanghaiIso(record.applyEndAt), + isActive: record.isActive, + }; +} + +export async function listYoungEvents( + input: YoungEventListInput = {}, +): Promise> { + const { page, pageSize, skip } = normalizePagination(input); + + const where: Prisma.YoungEventWhereInput = {}; + if (input.active != null) where.isActive = input.active; + const category = input.category?.trim(); + if (category) where.category = category; + const search = input.search?.trim(); + if (search) where.name = { contains: search, mode: "insensitive" }; + + const [total, records] = await Promise.all([ + prisma.youngEvent.count({ where }), + prisma.youngEvent.findMany({ + where, + select: YOUNG_EVENT_SELECT, + // Signup-open events first, then most recent start time. + orderBy: [ + { isActive: "desc" }, + { startAt: { sort: "desc", nulls: "last" } }, + { youngId: "asc" }, + ], + skip, + take: pageSize, + }), + ]); + + return buildPaginatedResponse( + records.map(toYoungEventSummary), + page, + pageSize, + total, + ); +} + +export async function getYoungEvent( + youngId: string, +): Promise { + const record = await prisma.youngEvent.findUnique({ + where: { youngId }, + select: { ...YOUNG_EVENT_SELECT, rawJson: true }, + }); + if (record == null) return null; + const { rawJson, ...summaryRecord } = record; + return { ...toYoungEventSummary(summaryRecord), rawJson }; +} + +export async function listYoungEventCategories(): Promise { + const rows = await prisma.youngEvent.findMany({ + where: { category: { not: null } }, + select: { category: true }, + distinct: ["category"], + orderBy: { category: "asc" }, + }); + return rows + .map((row) => row.category) + .filter((category): category is string => category != null); +} diff --git a/src/features/young/server/young-page-load.ts b/src/features/young/server/young-page-load.ts new file mode 100644 index 000000000..9805a72ac --- /dev/null +++ b/src/features/young/server/young-page-load.ts @@ -0,0 +1,70 @@ +import { CATALOG_PAGE_SIZE } from "@/features/catalog/server/catalog-page-constants"; +import { getDashboardPageCopy } from "@/features/dashboard/server/dashboard-page-copy"; +import type { DashboardPageLoadEvent } from "@/features/dashboard/server/dashboard-page-load-types"; +import { + getYoungEvent, + listYoungEventCategories, + listYoungEvents, +} from "@/features/young/server/young-event-service"; +import { + optionalValue, + parsePositivePage, + toLoadData, +} from "@/lib/load-data-utils"; + +export type YoungEventsPageFilters = { + active?: boolean; + category?: string; + search?: string; +}; + +function parseActiveParam(value: string | null): boolean | undefined { + if (value === "true") return true; + if (value === "false") return false; + return undefined; +} + +export async function loadYoungEventsPage({ + locals, + url, +}: DashboardPageLoadEvent) { + const filters: YoungEventsPageFilters = { + active: parseActiveParam(url.searchParams.get("active")), + category: optionalValue(url.searchParams.get("category")), + search: optionalValue(url.searchParams.get("search")), + }; + const page = parsePositivePage(url.searchParams.get("page")); + + const [result, categories] = await Promise.all([ + listYoungEvents({ + active: filters.active, + category: filters.category, + search: filters.search, + page, + pageSize: CATALOG_PAGE_SIZE, + }), + listYoungEventCategories(), + ]); + + return toLoadData({ + copy: getDashboardPageCopy(locals.locale), + locale: locals.locale, + data: result.data, + pagination: result.pagination, + filters, + categories, + }); +} + +export async function loadYoungEventDetailPage({ + locals, + youngId, +}: DashboardPageLoadEvent & { youngId: string }) { + const event = await getYoungEvent(youngId); + + return toLoadData({ + copy: getDashboardPageCopy(locals.locale), + locale: locals.locale, + event, + }); +} diff --git a/src/lib/api/routes/young-event-routes.ts b/src/lib/api/routes/young-event-routes.ts new file mode 100644 index 000000000..18a3c74e7 --- /dev/null +++ b/src/lib/api/routes/young-event-routes.ts @@ -0,0 +1,59 @@ +import { + getYoungEvent, + listYoungEvents, +} from "@/features/young/server/young-event-service"; +import { + handleRouteError, + notFound, + parseRouteQuery, + schemaJsonResponse, +} from "@/lib/api/helpers"; +import { + paginatedYoungEventResponseSchema, + youngEventDetailSchema, + youngEventsQuerySchema, +} from "@/lib/api/schemas/young-event-schemas"; +import { PUBLIC_CATALOG_HEADERS } from "@/lib/public-cache-control"; + +export async function getYoungEventsRoute(request: Request) { + const parsed = parseRouteQuery( + new URL(request.url).searchParams, + youngEventsQuerySchema, + "Invalid young events query", + { logErrors: true }, + ); + if (parsed instanceof Response) return parsed; + + const { query, pagination } = parsed; + try { + const result = await listYoungEvents({ + active: query.active, + category: query.category, + search: query.search, + page: pagination.page, + pageSize: pagination.pageSize, + }); + return schemaJsonResponse(paginatedYoungEventResponseSchema, result, { + headers: PUBLIC_CATALOG_HEADERS, + }); + } catch (error) { + return handleRouteError("Failed to fetch young events", error); + } +} + +export async function getYoungEventDetailRoute( + _request: Request, + params: { youngId: string }, +) { + try { + const event = await getYoungEvent(params.youngId); + if (event == null) { + return notFound("Young event not found"); + } + return schemaJsonResponse(youngEventDetailSchema, event, { + headers: PUBLIC_CATALOG_HEADERS, + }); + } catch (error) { + return handleRouteError("Failed to fetch young event", error); + } +} diff --git a/src/lib/api/schemas/request-path-schemas.ts b/src/lib/api/schemas/request-path-schemas.ts index fa3883504..622c468c1 100644 --- a/src/lib/api/schemas/request-path-schemas.ts +++ b/src/lib/api/schemas/request-path-schemas.ts @@ -17,6 +17,14 @@ export const jwIdPathParamsSchema = z.object({ jwId: integerStringSchema, }); +export const youngEventYoungIdPathParamsSchema = z.object({ + youngId: z + .string() + .trim() + .min(1) + .describe("Upstream young.ustc.edu.cn event identifier."), +}); + export const userCalendarPathParamsSchema = z.object({ userId: z .string() diff --git a/src/lib/api/schemas/request-query-schemas.ts b/src/lib/api/schemas/request-query-schemas.ts index b0122562d..d0e8d8fcf 100644 --- a/src/lib/api/schemas/request-query-schemas.ts +++ b/src/lib/api/schemas/request-query-schemas.ts @@ -39,3 +39,4 @@ export { uploadObjectQuerySchema, } from "./misc-query-schemas"; export { weatherQuerySchema } from "./weather-schemas"; +export { youngEventsQuerySchema } from "./young-event-schemas"; diff --git a/src/lib/api/schemas/response-schemas.ts b/src/lib/api/schemas/response-schemas.ts index 3a1d0190d..f49cf05f5 100644 --- a/src/lib/api/schemas/response-schemas.ts +++ b/src/lib/api/schemas/response-schemas.ts @@ -13,3 +13,4 @@ export * from "./response-schema-primitives"; export * from "./schedule-response-schema-core"; export * from "./uploads-response-schemas"; export * from "./weather-response-schemas"; +export * from "./young-event-schemas"; diff --git a/src/lib/api/schemas/young-event-schemas.ts b/src/lib/api/schemas/young-event-schemas.ts new file mode 100644 index 000000000..c86ff4448 --- /dev/null +++ b/src/lib/api/schemas/young-event-schemas.ts @@ -0,0 +1,74 @@ +import * as z from "zod"; +import { + booleanQuerySchema, + deprecatedPaginationLimitParam, + integerStringRangeSchema, + paginationPageSizeParam, +} from "./request-schema-primitives"; +import { + createPaginatedSchema, + dateTimeSchema, +} from "./response-schema-primitives"; + +const youngEventPageSizeSchema = integerStringRangeSchema({ + minimum: 1, + maximum: 100, + message: "pageSize must be between 1 and 100", +}); + +export const youngEventsQuerySchema = z.object({ + active: booleanQuerySchema + .optional() + .describe("Filter by signup-open (active) events."), + category: z + .string() + .trim() + .min(1) + .max(100) + .optional() + .describe("Exact category filter, e.g. 单次项目 or 系列项目."), + search: z + .string() + .trim() + .min(1) + .max(100) + .optional() + .describe("Case-insensitive substring match on the event name."), + page: integerStringRangeSchema({ + minimum: 1, + maximum: 1000, + message: "page must be between 1 and 1000", + }).optional(), + pageSize: paginationPageSizeParam(youngEventPageSizeSchema), + limit: deprecatedPaginationLimitParam(youngEventPageSizeSchema), +}); + +export type YoungEventsQuery = z.output; + +export const youngEventSummarySchema = z.strictObject({ + youngId: z.string(), + name: z.string(), + category: z.string().nullable(), + department: z.string().nullable(), + organizer: z.string().nullable(), + status: z.string().nullable(), + registrationStatus: z.string().nullable(), + location: z.string().nullable(), + imageUrl: z.string().nullable(), + hours: z.number().nullable(), + capacity: z.number().int().nullable(), + appliedCount: z.number().int().nullable(), + startAt: dateTimeSchema.nullable(), + endAt: dateTimeSchema.nullable(), + applyStartAt: dateTimeSchema.nullable(), + applyEndAt: dateTimeSchema.nullable(), + isActive: z.boolean(), +}); + +export const youngEventDetailSchema = youngEventSummarySchema.extend({ + rawJson: z.unknown(), +}); + +export const paginatedYoungEventResponseSchema = createPaginatedSchema( + youngEventSummarySchema, +); diff --git a/src/lib/components/shell/AppShell.svelte b/src/lib/components/shell/AppShell.svelte index 19293504f..00bfa6e23 100644 --- a/src/lib/components/shell/AppShell.svelte +++ b/src/lib/components/shell/AppShell.svelte @@ -17,6 +17,7 @@ import MapIcon from "@lucide/svelte/icons/map"; import RouteIcon from "@lucide/svelte/icons/route"; import ScrollTextIcon from "@lucide/svelte/icons/scroll-text"; import SmartphoneIcon from "@lucide/svelte/icons/smartphone"; +import SparklesIcon from "@lucide/svelte/icons/sparkles"; import TerminalIcon from "@lucide/svelte/icons/terminal"; import UsersIcon from "@lucide/svelte/icons/users"; import { onMount } from "svelte"; @@ -210,6 +211,11 @@ function buildShellNavGroups( ], }, { href: "/catalog/links", icon: LinkIcon, label: copy.nav.links }, + { + href: "/catalog/young-events", + icon: SparklesIcon, + label: copy.nav.youngEvents, + }, { href: "/news", icon: ScrollTextIcon, label: copy.nav.news }, ]; const usageLinks: ShellLink[] = [ @@ -391,6 +397,11 @@ function buildMobileSecondaryNavGroups( icon: LinkIcon, label: copy.nav.links, }, + { + href: "/catalog/young-events", + icon: SparklesIcon, + label: copy.nav.youngEvents, + }, { href: "/news", icon: ScrollTextIcon, diff --git a/src/lib/graphql/input-boundaries.ts b/src/lib/graphql/input-boundaries.ts index a7c0b7ba5..454e12ba8 100644 --- a/src/lib/graphql/input-boundaries.ts +++ b/src/lib/graphql/input-boundaries.ts @@ -97,6 +97,18 @@ export function validateGraphqlVersionKey(value: string | null | undefined) { return versionKey; } +export function requireGraphqlYoungEventId(value: string): string { + const youngId = validateOptionalText( + value.trim(), + "youngId", + GRAPHQL_LIMITS.versionKeyChars, + ); + if (!youngId) { + badUserInput("youngId must be a non-empty string."); + } + return youngId; +} + const WEATHER_LOCATION_KEYS = new Set( WEATHER_LOCATIONS.map((location) => location.key), ); diff --git a/src/lib/graphql/operation-analysis.ts b/src/lib/graphql/operation-analysis.ts index 454beb982..800009874 100644 --- a/src/lib/graphql/operation-analysis.ts +++ b/src/lib/graphql/operation-analysis.ts @@ -43,6 +43,7 @@ export const PAGINATED_FIELD_COORDINATES = new Set([ "Catalog.sections", "Catalog.semesters", "Catalog.teachers", + "Catalog.youngEvents", "Exam.examRooms", "Schedule.teachers", "Workspace.exams", diff --git a/src/lib/graphql/operation-definitions.ts b/src/lib/graphql/operation-definitions.ts index 2c2ea2647..c2069d28e 100644 --- a/src/lib/graphql/operation-definitions.ts +++ b/src/lib/graphql/operation-definitions.ts @@ -449,6 +449,75 @@ export const persistedGraphqlOperationDefinitions = [ `, scopes: [], }), + query({ + id: "catalog.young_event.list.v1", + title: "List Young events", + description: + "Lists second-classroom signup events from young.ustc.edu.cn with filters and bounded pagination.", + document: /* GraphQL */ ` + query CatalogYoungEvents($page: PageInput, $filter: YoungEventFilter) { + catalog { + youngEvents(page: $page, filter: $filter) { + items { + youngId + name + category + department + organizer + status + registrationStatus + location + imageUrl + hours + capacity + appliedCount + startAt + endAt + applyStartAt + applyEndAt + isActive + } + pageInfo { + ${pageInfoFields} + } + } + } + } + `, + scopes: [], + }), + query({ + id: "catalog.young_event.get.v1", + title: "Get Young event", + description: + "Returns one second-classroom signup event by its young.ustc.edu.cn identifier.", + document: /* GraphQL */ ` + query CatalogYoungEvent($youngId: String!) { + catalog { + youngEvent(youngId: $youngId) { + youngId + name + category + department + organizer + status + registrationStatus + location + imageUrl + hours + capacity + appliedCount + startAt + endAt + applyStartAt + applyEndAt + isActive + } + } + } + `, + scopes: [], + }), query({ id: "account.profile.get.v1", title: "Get account profile", diff --git a/src/lib/graphql/schema.ts b/src/lib/graphql/schema.ts index 0e177757c..d98ad8937 100644 --- a/src/lib/graphql/schema.ts +++ b/src/lib/graphql/schema.ts @@ -17,6 +17,10 @@ import { import { getPublicDashboardLinksData } from "@/features/dashboard-links/server/dashboard-link-data"; import { getPublicUserIdentityByIdentifier } from "@/features/profile/server/user-profile-page-data"; import { getWeatherSnapshot } from "@/features/weather/server/weather-service"; +import { + getYoungEvent, + listYoungEvents, +} from "@/features/young/server/young-event-service"; import { capGraphqlAlternateRoutes, capGraphqlBusCampuses, @@ -27,6 +31,7 @@ import type { GraphqlContext, GraphqlServerContext } from "./context"; import { graphqlDateScalar, graphqlDateTimeScalar } from "./date-scalar"; import { requireGraphqlId, + requireGraphqlYoungEventId, validateGraphqlIdList, validateGraphqlSearch, validateGraphqlTeacherCode, @@ -193,6 +198,37 @@ export const graphqlTypeDefs = /* GraphQL */ ` pageInfo: PageInfo! } + input YoungEventFilter { + active: Boolean + category: String + search: String + } + + type YoungEvent { + youngId: String! + name: String! + category: String + department: String + organizer: String + status: String + registrationStatus: String + location: String + imageUrl: String + hours: Float + capacity: Int + appliedCount: Int + startAt: DateTime + endAt: DateTime + applyStartAt: DateTime + applyEndAt: DateTime + isActive: Boolean! + } + + type YoungEventPage { + items: [YoungEvent!]! + pageInfo: PageInfo! + } + type BusCampus { id: Int! nameCn: String! @@ -318,6 +354,8 @@ export const graphqlTypeDefs = /* GraphQL */ ` ): BusRouteTimetable links(query: String): [CatalogLink!]! weather(locationKey: String!): WeatherSnapshot + youngEvents(page: PageInput, filter: YoungEventFilter): YoungEventPage! + youngEvent(youngId: String!): YoungEvent } type CatalogLink { @@ -363,6 +401,7 @@ export const graphqlSchema = createSchema< SectionPage: graphqlPageResolvers, TeacherPage: graphqlPageResolvers, BusRoutePage: graphqlPageResolvers, + YoungEventPage: graphqlPageResolvers, ...graphqlScopeResolvers, ...graphqlMutationResolvers, Teacher: { @@ -576,6 +615,29 @@ export const graphqlSchema = createSchema< validateGraphqlWeatherLocationKey(args.locationKey), ); }, + youngEvents( + _parent, + args: { + filter?: { + active?: boolean | null; + category?: string | null; + search?: string | null; + } | null; + page?: GraphqlPageInput | null; + }, + ) { + const pagination = normalizeGraphqlPage(args.page); + return listYoungEvents({ + active: args.filter?.active ?? undefined, + category: validateGraphqlSearch(args.filter?.category), + search: validateGraphqlSearch(args.filter?.search), + page: pagination.page, + pageSize: pagination.pageSize, + }); + }, + async youngEvent(_parent, args: { youngId: string }) { + return getYoungEvent(requireGraphqlYoungEventId(args.youngId)); + }, }, }, }); diff --git a/src/lib/mcp/compact-dispatch.ts b/src/lib/mcp/compact-dispatch.ts index 3feb8b280..61f78e59f 100644 --- a/src/lib/mcp/compact-dispatch.ts +++ b/src/lib/mcp/compact-dispatch.ts @@ -16,6 +16,7 @@ import { compactTeacherTitle, compactTodo, compactUser, + compactYoungEvent, } from "./compact-entities"; import { asRecordArray, @@ -64,6 +65,10 @@ export function compactBusArrayItem( export function compactEntityArrayItem( value: Record, ): CompactArrayMatch { + if (Object.hasOwn(value, "youngId")) { + return { matched: true, value: compactYoungEvent(value) }; + } + if ( Object.hasOwn(value, "latitude") && Object.hasOwn(value, "longitude") && diff --git a/src/lib/mcp/compact-entities.ts b/src/lib/mcp/compact-entities.ts index 9023fa404..b9594340f 100644 --- a/src/lib/mcp/compact-entities.ts +++ b/src/lib/mcp/compact-entities.ts @@ -34,6 +34,26 @@ export function compactTeacherTitle(value: unknown) { ]); } +export function compactYoungEvent(value: unknown) { + if (!isRecord(value)) return value; + return pick(value, [ + "youngId", + "name", + "category", + "status", + "registrationStatus", + "location", + "hours", + "capacity", + "appliedCount", + "startAt", + "endAt", + "applyStartAt", + "applyEndAt", + "isActive", + ]); +} + export function compactCourse(value: unknown) { if (!isRecord(value)) return value; return pick(value, [ diff --git a/src/lib/mcp/server.ts b/src/lib/mcp/server.ts index fa5bcff4c..3dcf045c7 100644 --- a/src/lib/mcp/server.ts +++ b/src/lib/mcp/server.ts @@ -8,6 +8,7 @@ import { registerBusTools } from "@/lib/mcp/tools/bus/bus-tools"; import { registerCourseTools } from "@/lib/mcp/tools/catalog/course-tools"; import { registerSectionDataTools } from "@/lib/mcp/tools/catalog/section-data-tools"; import { registerWeatherTools } from "@/lib/mcp/tools/catalog/weather-tools"; +import { registerYoungEventTools } from "@/lib/mcp/tools/catalog/young-event-tools"; import { registerCommentTools } from "@/lib/mcp/tools/community/comment-tools"; import { registerDescriptionTools } from "@/lib/mcp/tools/community/description-tools"; import { registerGraphqlOperationTool } from "@/lib/mcp/tools/graphql/graphql-operation-tool"; @@ -52,6 +53,7 @@ export function createMcpServer() { registerDashboardTools(server); registerSectionDataTools(server); registerWeatherTools(server); + registerYoungEventTools(server); registerMyDataTools(server); registerCalendarTools(server); registerGraphqlOperationTool(server); diff --git a/src/lib/mcp/tool-output-schemas.ts b/src/lib/mcp/tool-output-schemas.ts index 20401e977..5b582acc9 100644 --- a/src/lib/mcp/tool-output-schemas.ts +++ b/src/lib/mcp/tool-output-schemas.ts @@ -77,6 +77,11 @@ import { uploadsListResponseSchema, } from "@/lib/api/schemas/uploads-response-schemas"; import { weatherSnapshotResponseSchema } from "@/lib/api/schemas/weather-response-schemas"; +import { + paginatedYoungEventResponseSchema, + youngEventDetailSchema, + youngEventSummarySchema, +} from "@/lib/api/schemas/young-event-schemas"; type OutputShape = Record; @@ -732,6 +737,35 @@ const weatherFullSchema = z.union([ weatherNoDataSchema, ]); +const compactYoungEventSchema = youngEventSummarySchema.omit({ + department: true, + organizer: true, + imageUrl: true, +}); + +const youngEventPaginationSchema = + paginatedYoungEventResponseSchema.shape.pagination; + +const youngEventListDefaultSchema = objectOutputSchema({ + data: z.array(compactYoungEventSchema), + pagination: youngEventPaginationSchema, +}); + +const youngEventListFullSchema = objectOutputSchema({ + data: z.array(youngEventSummarySchema), + pagination: youngEventPaginationSchema, +}); + +const youngEventGetDefaultSchema = objectOutputSchema({ + youngId: z.string(), + event: youngEventSummarySchema.nullable(), +}); + +const youngEventGetFullSchema = objectOutputSchema({ + youngId: z.string(), + event: youngEventDetailSchema.nullable(), +}); + const compactSectionSummarySchema = z.strictObject({ id: z.number().int(), jwId: z.number().int(), @@ -1174,6 +1208,14 @@ const nonAcademicModeOutputSchemas = { default: weatherDefaultSchema, full: weatherFullSchema, }, + catalog_young_event_list: { + default: youngEventListDefaultSchema, + full: youngEventListFullSchema, + }, + catalog_young_event_get: { + default: youngEventGetDefaultSchema, + full: youngEventGetFullSchema, + }, } satisfies Record>; type NonAcademicModeToolName = keyof typeof nonAcademicModeOutputSchemas; @@ -1668,6 +1710,16 @@ const TOOL_OUTPUT_SCHEMAS: Record = { locationKey: z.enum(["ustc-main", "ustc-gaoxin"]), hasData: z.boolean(), }), + catalog_young_event_list: objectOutputSchema({ + data: z.array(z.union([compactYoungEventSchema, youngEventSummarySchema])), + pagination: youngEventPaginationSchema, + }), + catalog_young_event_get: objectOutputSchema({ + youngId: z.string(), + event: z + .union([youngEventSummarySchema, youngEventDetailSchema]) + .nullable(), + }), catalog_bus_route_list: objectOutputSchema({ locale: z.string(), version: z.unknown(), diff --git a/src/lib/mcp/tool-scopes.ts b/src/lib/mcp/tool-scopes.ts index 535e96941..fcabd9a93 100644 --- a/src/lib/mcp/tool-scopes.ts +++ b/src/lib/mcp/tool-scopes.ts @@ -136,6 +136,10 @@ const TOOL_SCOPE_MAP: Record = { // Weather catalog_weather_get: [], + // Young events + catalog_young_event_list: [], + catalog_young_event_get: [], + // Course catalog catalog_course_search: [], catalog_course_get: [], diff --git a/src/lib/mcp/tools/catalog/young-event-tools.ts b/src/lib/mcp/tools/catalog/young-event-tools.ts new file mode 100644 index 000000000..507342362 --- /dev/null +++ b/src/lib/mcp/tools/catalog/young-event-tools.ts @@ -0,0 +1,109 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import * as z from "zod"; +import { + getYoungEvent, + listYoungEvents, +} from "@/features/young/server/young-event-service"; +import { + jsonToolResult, + type McpModeInput, + mcpModeInputSchema, + resolveMcpMode, +} from "@/lib/mcp/tools/_shared/helpers"; + +async function listYoungEventsTool({ + active, + category, + search, + page, + limit, + mode, +}: { + active?: boolean; + category?: string; + search?: string; + page: number; + limit: number; + mode?: McpModeInput; +}) { + const result = await listYoungEvents({ + active, + category, + search, + page, + pageSize: limit, + }); + return jsonToolResult(result, { mode: resolveMcpMode(mode) }); +} + +async function getYoungEventTool({ + youngId, + mode, +}: { + youngId: string; + mode?: McpModeInput; +}) { + const resolvedMode = resolveMcpMode(mode); + const event = await getYoungEvent(youngId); + + if (event == null) { + return jsonToolResult( + { found: false, youngId, event: null }, + { mode: resolvedMode }, + ); + } + + if (resolvedMode === "full") { + return jsonToolResult({ found: true, event }, { mode: "full" }); + } + + const { rawJson: _rawJson, ...summary } = event; + return jsonToolResult({ found: true, event: summary }, { mode: "default" }); +} + +export function registerYoungEventTools(server: McpServer) { + server.registerTool( + "catalog_young_event_list", + { + description: + "List second-classroom (第二课堂) signup events from young.ustc.edu.cn: name, category, signup window, event time, capacity, applied count, and status. Sign-up itself happens on young.ustc.edu.cn.", + inputSchema: { + active: z + .boolean() + .optional() + .describe("Filter by signup-open (active) events."), + category: z + .string() + .trim() + .min(1) + .max(100) + .optional() + .describe("Exact category filter, e.g. 单次项目 or 系列项目."), + search: z + .string() + .trim() + .min(1) + .max(100) + .optional() + .describe("Case-insensitive substring match on the event name."), + page: z.number().int().min(1).max(1000).default(1), + limit: z.number().int().min(1).max(100).default(20), + mode: mcpModeInputSchema, + }, + }, + listYoungEventsTool, + ); + + server.registerTool( + "catalog_young_event_get", + { + description: + "Fetch one second-classroom (第二课堂) signup event by its young.ustc.edu.cn identifier. Full mode includes the raw upstream payload.", + inputSchema: { + youngId: z.string().trim().min(1), + mode: mcpModeInputSchema, + }, + }, + getYoungEventTool, + ); +} diff --git a/src/lib/shell/layout-server-data.ts b/src/lib/shell/layout-server-data.ts index 966001123..be440c3b2 100644 --- a/src/lib/shell/layout-server-data.ts +++ b/src/lib/shell/layout-server-data.ts @@ -15,6 +15,7 @@ const layoutMessages = { publications: enUsMessages.publications, profile: enUsMessages.profile, theme: enUsMessages.theme, + youngEvents: enUsMessages.youngEvents, }, "zh-cn": { accessibility: zhCnMessages.accessibility, @@ -29,6 +30,7 @@ const layoutMessages = { publications: zhCnMessages.publications, profile: zhCnMessages.profile, theme: zhCnMessages.theme, + youngEvents: zhCnMessages.youngEvents, }, }; @@ -68,6 +70,7 @@ export function buildLayoutCopy(locale: LayoutLocale) { news: messages.publications.title, todos: messages.meDashboard.nav.todos.title, transitMap: messages.metadata.pages.busMap, + youngEvents: messages.youngEvents.title, mobileApp: messages.metadata.pages.mobileApp, prestoBot: messages.metadata.pages.prestoBot, mcp: messages.metadata.pages.mcp, diff --git a/src/routes/api/catalog/young-events/+server.ts b/src/routes/api/catalog/young-events/+server.ts new file mode 100644 index 000000000..46c263a8b --- /dev/null +++ b/src/routes/api/catalog/young-events/+server.ts @@ -0,0 +1,11 @@ +import { getYoungEventsRoute } from "@/lib/api/routes/young-event-routes"; +import { svelteRequestHandler } from "@/lib/api/svelte-route"; +import { observedApiRoute } from "@/lib/log/api-observability"; + +/** + * List second-classroom (Young) signup events. + * @params youngEventsQuerySchema + * @response paginatedYoungEventResponseSchema + * @response 400:openApiErrorSchema + */ +export const GET = svelteRequestHandler(observedApiRoute(getYoungEventsRoute)); diff --git a/src/routes/api/catalog/young-events/[youngId]/+server.ts b/src/routes/api/catalog/young-events/[youngId]/+server.ts new file mode 100644 index 000000000..8b0c3cf17 --- /dev/null +++ b/src/routes/api/catalog/young-events/[youngId]/+server.ts @@ -0,0 +1,15 @@ +import type { RequestHandler } from "@sveltejs/kit"; +import { getYoungEventDetailRoute } from "@/lib/api/routes/young-event-routes"; +import { observedApiRoute } from "@/lib/log/api-observability"; + +/** + * Get a second-classroom (Young) signup event. + * @pathParams youngEventYoungIdPathParamsSchema + * @response youngEventDetailSchema + * @response 400:openApiErrorSchema + * @response 404:openApiErrorSchema + */ +export const GET: RequestHandler = ({ request, params }) => + observedApiRoute(() => + getYoungEventDetailRoute(request, { youngId: params.youngId }), + )(request); diff --git a/src/routes/catalog/young-events/+page.server.ts b/src/routes/catalog/young-events/+page.server.ts new file mode 100644 index 000000000..8ce0fe6d6 --- /dev/null +++ b/src/routes/catalog/young-events/+page.server.ts @@ -0,0 +1,28 @@ +import { loadYoungEventsPage } from "@/features/young/server/young-page-load"; +import { updateSocialMetadata } from "@/lib/social-metadata"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async (event) => { + const [data, layoutData] = await Promise.all([ + loadYoungEventsPage({ + locals: event.locals, + request: event.request, + url: event.url, + }), + event.parent(), + ]); + + return { + ...data, + socialMetadata: updateSocialMetadata(layoutData.socialMetadata, { + card: { + label: + event.locals.locale === "zh-cn" + ? "CAMPUS · 第二课堂" + : "SECOND CLASSROOM", + }, + description: data.copy.youngEvents.description, + title: `${data.copy.youngEvents.title} - Life@USTC`, + }), + }; +}; diff --git a/src/routes/catalog/young-events/+page.svelte b/src/routes/catalog/young-events/+page.svelte new file mode 100644 index 000000000..9566a335d --- /dev/null +++ b/src/routes/catalog/young-events/+page.svelte @@ -0,0 +1,18 @@ + + + + {data.copy.youngEvents.title} - Life@USTC + + + diff --git a/src/routes/catalog/young-events/[youngId]/+page.server.ts b/src/routes/catalog/young-events/[youngId]/+page.server.ts new file mode 100644 index 000000000..c38a2caa3 --- /dev/null +++ b/src/routes/catalog/young-events/[youngId]/+page.server.ts @@ -0,0 +1,34 @@ +import { error } from "@sveltejs/kit"; +import { loadYoungEventDetailPage } from "@/features/young/server/young-page-load"; +import { updateSocialMetadata } from "@/lib/social-metadata"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async (event) => { + const [data, layoutData] = await Promise.all([ + loadYoungEventDetailPage({ + locals: event.locals, + request: event.request, + url: event.url, + youngId: event.params.youngId, + }), + event.parent(), + ]); + + if (data.event == null) { + error(404, data.copy.youngEvents.notFound); + } + + return { + ...data, + socialMetadata: updateSocialMetadata(layoutData.socialMetadata, { + card: { + label: + event.locals.locale === "zh-cn" + ? "CAMPUS · 第二课堂" + : "SECOND CLASSROOM", + }, + description: data.copy.youngEvents.description, + title: `${data.event.name} - Life@USTC`, + }), + }; +}; diff --git a/src/routes/catalog/young-events/[youngId]/+page.svelte b/src/routes/catalog/young-events/[youngId]/+page.svelte new file mode 100644 index 000000000..0bb3ede54 --- /dev/null +++ b/src/routes/catalog/young-events/[youngId]/+page.svelte @@ -0,0 +1,14 @@ + + + + {data.event?.name ?? data.copy.youngEvents.notFound} - Life@USTC + + +{#if data.event} + +{/if} diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts index e9595d745..8d2563187 100644 --- a/src/routes/sitemap.xml/+server.ts +++ b/src/routes/sitemap.xml/+server.ts @@ -13,6 +13,7 @@ const STATIC_ROUTES = [ "/catalog/links", "/catalog/bus/map", "/catalog/weather", + "/catalog/young-events", "/api/docs/tag/catalog-section", "/usage/mobile", "/usage/bot", @@ -23,13 +24,18 @@ const STATIC_ROUTES = [ ]; async function getEntityUrls(origin: string) { - const [courses, sections, teachers] = await Promise.all([ + const [courses, sections, teachers, youngEvents] = await Promise.all([ prisma.course.findMany({ select: { jwId: true } }), prisma.section.findMany({ where: { retiredAt: null }, select: { jwId: true }, }), prisma.teacher.findMany({ select: { id: true } }), + // Only signup-open events are worth indexing; ended events churn quickly. + prisma.youngEvent.findMany({ + where: { isActive: true }, + select: { youngId: true }, + }), ]); const courseUrls = courses.map( @@ -41,8 +47,11 @@ async function getEntityUrls(origin: string) { const teacherUrls = teachers.map( ({ id }) => `${origin}/catalog/teachers/${id}`, ); + const youngEventUrls = youngEvents.map( + ({ youngId }) => `${origin}/catalog/young-events/${youngId}`, + ); - return [...courseUrls, ...sectionUrls, ...teacherUrls]; + return [...courseUrls, ...sectionUrls, ...teacherUrls, ...youngEventUrls]; } async function loadSitemapUrls() { diff --git a/src/static-loader/import-state.ts b/src/static-loader/import-state.ts index 33dcf43fe..16f3b19e9 100644 --- a/src/static-loader/import-state.ts +++ b/src/static-loader/import-state.ts @@ -5,7 +5,7 @@ const SHA256_PATTERN = /^[a-f0-9]{64}$/; // Increment whenever mapper semantics change and existing imported rows must be // rebuilt even when the source snapshot itself is unchanged. -export const STATIC_IMPORT_TRANSFORM_REVISION = 1; +export const STATIC_IMPORT_TRANSFORM_REVISION = 2; type StaticImportStateTransaction = { staticImportState: Pick< diff --git a/src/static-loader/import.ts b/src/static-loader/import.ts index 52fad626e..f46f42049 100644 --- a/src/static-loader/import.ts +++ b/src/static-loader/import.ts @@ -59,6 +59,7 @@ import { validateMappedSectionJwIds, validateSnapshotCompleteness, } from "./validation"; +import { loadYoungEvents, type YoungEventBuild } from "./young-plan"; export type ImportConfig = { snapshotPath: string; @@ -80,6 +81,7 @@ export type ImportRecordCounts = { buildings: number; campuses: number; adminClasses: number; + youngEvents: number; }; export type ImportReport = { @@ -220,6 +222,7 @@ export async function runImport( } const exams = loadExams(snapshot, allSectionJwIds); + const youngEvents = loadYoungEvents(snapshot); const plannedRecordCounts: ImportRecordCounts = { semesters: semesters.length, departments: departments.length + departmentPlaceholders.length, @@ -233,6 +236,7 @@ export async function runImport( buildings: buildings.length, campuses: campuses.length, adminClasses: adminClasses.length, + youngEvents: youngEvents?.length ?? 0, }; let sectionPresenceStats: | SectionPresenceStats @@ -423,7 +427,12 @@ export async function runImport( ); }, ); - const databaseRecordCounts = await logStep("countDatabaseRecords", 12, () => + if (youngEvents != null) { + await logStep("syncYoungEvents", youngEvents.length, () => + syncYoungEvents(tx, youngEvents), + ); + } + const databaseRecordCounts = await logStep("countDatabaseRecords", 13, () => countStats(tx), ); sectionPresenceStats = await logStep( @@ -1309,6 +1318,89 @@ async function writeExamRooms( } } +async function syncYoungEvents( + tx: Prisma.TransactionClient, + builds: YoungEventBuild[], +): Promise { + const columns = [ + "name", + "category", + "department", + "organizer", + "status", + "registrationStatus", + "location", + "imageUrl", + "hours", + "capacity", + "appliedCount", + "startAt", + "endAt", + "applyStartAt", + "applyEndAt", + "isActive", + "rawJson", + ]; + await bulkUpsert( + tx, + "YoungEvent", + "youngId", + "text", + columns, + [ + "text", + "text", + "text", + "text", + "text", + "text", + "text", + "text", + "float8", + "int", + "int", + "timestamp", + "timestamp", + "timestamp", + "timestamp", + "boolean", + "jsonb", + ], + builds.map((build) => ({ + key: build.youngId, + values: [ + build.name, + build.category, + build.department, + build.organizer, + build.status, + build.registrationStatus, + build.location, + build.imageUrl, + build.hours, + build.capacity, + build.appliedCount, + build.startAt, + build.endAt, + build.applyStartAt, + build.applyEndAt, + build.isActive, + build.rawJson, + ] satisfies ColumnValue[], + })), + ); + + // The snapshot is authoritative for both lists; drop events that disappeared. + // An empty snapshot means the upstream fetch broke (the ended list alone + // carries thousands of historical events), so keep existing rows instead of + // wiping the table. + if (builds.length === 0) return; + const keepYoungIds = builds.map((build) => build.youngId); + await tx.youngEvent.deleteMany({ + where: { youngId: { notIn: keepYoungIds } }, + }); +} + async function countStats( prisma: Prisma.TransactionClient, ): Promise { @@ -1325,6 +1417,7 @@ async function countStats( buildings, campuses, adminClasses, + youngEvents, ] = await Promise.all([ prisma.semester.count(), prisma.department.count(), @@ -1338,6 +1431,7 @@ async function countStats( prisma.building.count(), prisma.campus.count(), prisma.adminClass.count(), + prisma.youngEvent.count(), ]); return { @@ -1353,5 +1447,6 @@ async function countStats( buildings, campuses, adminClasses, + youngEvents, }; } diff --git a/src/static-loader/snapshot.ts b/src/static-loader/snapshot.ts index ab6d4ac14..16485ac89 100644 --- a/src/static-loader/snapshot.ts +++ b/src/static-loader/snapshot.ts @@ -45,6 +45,16 @@ export class Snapshot { return rows; } + hasTable(tableName: string): boolean { + assertIdentifier(tableName, "table"); + const row = this.db + .query( + `SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`, + ) + .get(tableName); + return row != null; + } + groupByParent( rows: SnapshotRow[], parentColumn = "parent_store_id", diff --git a/src/static-loader/young-plan.ts b/src/static-loader/young-plan.ts new file mode 100644 index 000000000..d2f6f50f0 --- /dev/null +++ b/src/static-loader/young-plan.ts @@ -0,0 +1,111 @@ +import type { Snapshot } from "./snapshot"; +import { asFloat, asInt, asString, type SnapshotRow } from "./snapshot-values"; + +const ACTIVE_TABLE = "young_mobile_item_enrolment_list_result_records"; +const ENDED_TABLE = "young_mobile_item_end_list_result_records"; + +// Internal bookkeeping columns added by the snapshot store, not upstream data. +const INTERNAL_COLUMNS = new Set([ + "store_id", + "fetch_id", + "parent_store_id", + "position", + "value", + "list_type", +]); + +// Upstream datetimes look like "2026-08-08 23:13:00" in Asia/Shanghai. +// Shanghai has observed a fixed UTC+08:00 offset since 1991 (no DST), so +// appending the offset is an exact parse. +function asShanghaiDateTime(value: unknown): Date | undefined { + const str = asString(value); + if (str == null) return undefined; + const normalized = str.replace(" ", "T"); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/.test(normalized)) { + return undefined; + } + const date = new Date(`${normalized}+08:00`); + return Number.isNaN(date.getTime()) ? undefined : date; +} + +export type YoungEventBuild = { + youngId: string; + name: string; + category?: string; + department?: string; + organizer?: string; + status?: string; + registrationStatus?: string; + location?: string; + imageUrl?: string; + hours?: number; + capacity?: number; + appliedCount?: number; + startAt?: Date; + endAt?: Date; + applyStartAt?: Date; + applyEndAt?: Date; + isActive: boolean; + rawJson: string; +}; + +function mapYoungEventRow( + row: SnapshotRow, + isActive: boolean, +): YoungEventBuild | null { + const youngId = asString(row.id); + if (youngId == null) return null; + + const raw: Record = {}; + for (const [key, value] of Object.entries(row)) { + if (!INTERNAL_COLUMNS.has(key)) raw[key] = value; + } + + return { + youngId, + name: asString(row.itemName) ?? youngId, + category: asString(row.itemCategory_dictText), + department: asString(row.businessDeptName), + organizer: + asString(row.organizer_dictText) ?? asString(row.sponsor_dictText), + status: asString(row.itemStatus_dictText), + registrationStatus: asString(row.registrationStatus), + location: asString(row.placeInfo), + imageUrl: asString(row.pic), + hours: asFloat(row.validHour) ?? asFloat(row.hours), + capacity: asInt(row.peopleNum), + appliedCount: asInt(row.applyNum), + startAt: asShanghaiDateTime(row.st), + endAt: asShanghaiDateTime(row.et), + applyStartAt: asShanghaiDateTime(row.applySt), + applyEndAt: asShanghaiDateTime(row.applyEt), + isActive, + rawJson: JSON.stringify(raw), + }; +} + +/** + * Load Young signup events from the static snapshot. Returns null when the + * snapshot predates the young builder (tables absent), so callers can skip + * the import without wiping previously imported rows. + */ +export function loadYoungEvents(snapshot: Snapshot): YoungEventBuild[] | null { + const metadata = snapshot.metadata(); + if (metadata.young_events_mode == null) return null; + + const merged = new Map(); + // Ended first so active rows win on a youngId conflict. + if (snapshot.hasTable(ENDED_TABLE)) { + for (const row of snapshot.queryAll(ENDED_TABLE)) { + const build = mapYoungEventRow(row, false); + if (build != null) merged.set(build.youngId, build); + } + } + if (snapshot.hasTable(ACTIVE_TABLE)) { + for (const row of snapshot.queryAll(ACTIVE_TABLE)) { + const build = mapYoungEventRow(row, true); + if (build != null) merged.set(build.youngId, build); + } + } + return [...merged.values()]; +} diff --git a/tests/e2e/fixtures/scenario.json b/tests/e2e/fixtures/scenario.json index 39b6f94bc..476dd0b99 100644 --- a/tests/e2e/fixtures/scenario.json +++ b/tests/e2e/fixtures/scenario.json @@ -240,5 +240,13 @@ }, "suspensions": { "reasonKeyword": "账号异常临时限制" + }, + "youngEvent": { + "youngId": "dev-scenario-young-event", + "name": "第二课堂示例活动", + "category": "单次项目", + "department": "校团委", + "organizer": "学生会", + "location": "东区图书馆" } } diff --git a/tests/e2e/src/app/_shared/page-contract.ts b/tests/e2e/src/app/_shared/page-contract.ts index 7b1604bce..1e1f4ad0f 100644 --- a/tests/e2e/src/app/_shared/page-contract.ts +++ b/tests/e2e/src/app/_shared/page-contract.ts @@ -433,6 +433,44 @@ export async function assertPageContract( return; } + case "/catalog/young-events": { + await gotoContractPage( + page, + `/catalog/young-events?search=${encodeURIComponent(DEV_SEED.youngEvent.name)}`, + testInfo, + ); + await expectMainContent(page); + await expect( + page.getByRole("heading", { + level: 1, + name: /第二课堂|Second Classroom/i, + }), + ).toBeVisible(); + await expect(visibleText(page, DEV_SEED.youngEvent.name)).toBeVisible(); + await maybeCapture(page, testInfo, "young-events"); + return; + } + + case "/catalog/young-events/[youngId]": { + await gotoContractPage( + page, + `/catalog/young-events/${DEV_SEED.youngEvent.youngId}`, + testInfo, + ); + await expectMainContent(page); + await expect( + page.getByRole("heading", { + level: 1, + name: DEV_SEED.youngEvent.name, + }), + ).toBeVisible(); + await expect( + visibleText(page, DEV_SEED.youngEvent.location), + ).toBeVisible(); + await maybeCapture(page, testInfo, "young-events-youngId"); + return; + } + case "/catalog/bus/map": { await gotoContractPage(page, routePath, testInfo); await expectMainContent(page); diff --git a/tests/e2e/src/app/_shared/page-inventory.ts b/tests/e2e/src/app/_shared/page-inventory.ts index b1c5a870b..038f2dec1 100644 --- a/tests/e2e/src/app/_shared/page-inventory.ts +++ b/tests/e2e/src/app/_shared/page-inventory.ts @@ -90,6 +90,8 @@ const E2E = { guidesMarkdown: "src/app/guides/markdown-support/test.ts", usage: "src/app/usage/test.ts", weather: "src/app/weather/test.ts", + youngEvents: "src/app/young-events/test.ts", + youngEventsYoungId: "src/app/young-events/[youngId]/test.ts", oauthAuthorize: "src/app/oauth/authorize/test.ts", oauthDevice: "src/app/oauth/device/test.ts", privacy: "src/app/privacy/test.ts", @@ -501,6 +503,38 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ }, ], }, + { + routeId: "/catalog/young-events", + samplePath: "/catalog/young-events", + kind: "page", + auth: "public", + contractPath: "/catalog/young-events", + e2eSpec: E2E.youngEvents, + mobileScreenshots: ["public"], + primaryActions: [ + { + id: "search-young-events", + e2eSpec: E2E.youngEvents, + evidence: "搜索、报名状态筛选与清除按钮", + }, + ], + }, + { + routeId: "/catalog/young-events/[youngId]", + samplePath: `/catalog/young-events/${DEV_SEED.youngEvent.youngId}`, + kind: "page", + auth: "public", + contractPath: "/catalog/young-events/[youngId]", + e2eSpec: E2E.youngEventsYoungId, + mobileScreenshots: ["public"], + primaryActions: [ + { + id: "back-to-young-events", + role: "link", + e2eSpec: E2E.youngEventsYoungId, + }, + ], + }, { routeId: "/catalog/courses/[jwId]", samplePath: `/catalog/courses/${DEV_SEED.course.jwId}`, diff --git a/tests/e2e/src/app/young-events/[youngId]/test.ts b/tests/e2e/src/app/young-events/[youngId]/test.ts new file mode 100644 index 000000000..665524abb --- /dev/null +++ b/tests/e2e/src/app/young-events/[youngId]/test.ts @@ -0,0 +1,73 @@ +/** + * E2E tests for /catalog/young-events/[youngId] — 第二课堂活动详情 + * + * ## Data Represented + * - One signup event: name, category, status, event time, signup window, + * location, organizer, department, hours, capacity + * - Seed event: DEV_SEED.youngEvent (youngId dev-scenario-young-event) + * + * ## UI/UX Elements + * - Field grid with event metadata + * - External signup link to young.ustc.edu.cn + * - Back link to the event list + * + * ## Edge Cases + * - Unknown youngId renders the 404 error page + */ +import { expect, test } from "@playwright/test"; +import { DEV_SEED } from "../../../../utils/dev-seed"; +import { visibleText } from "../../../../utils/locators"; +import { gotoAndWaitForReady } from "../../../../utils/page-ready"; +import { assertPageContract } from "../../_shared/page-contract"; + +const DETAIL_PATH = `/catalog/young-events/${DEV_SEED.youngEvent.youngId}`; + +test.describe("/catalog/young-events/[youngId] 第二课堂活动详情", () => { + test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { + routePath: "/catalog/young-events/[youngId]", + testInfo, + }); + }); + + test("渲染活动字段与返回链接", async ({ page }) => { + await gotoAndWaitForReady(page, DETAIL_PATH); + + await expect( + page.getByRole("heading", { level: 1, name: DEV_SEED.youngEvent.name }), + ).toBeVisible(); + await expect(visibleText(page, DEV_SEED.youngEvent.location)).toBeVisible(); + await expect( + visibleText(page, DEV_SEED.youngEvent.organizer), + ).toBeVisible(); + + const signupLink = page.getByRole("link", { + name: /young\.ustc\.edu\.cn/i, + }); + await expect(signupLink).toBeVisible(); + await expect(signupLink).toHaveAttribute( + "href", + "https://young.ustc.edu.cn", + ); + + const backLink = page.getByRole("link", { + name: /返回活动列表|Back to all events/i, + }); + await expect(backLink).toBeVisible(); + await backLink.click(); + await page.waitForURL(/\/catalog\/young-events$/); + await expect( + page.getByRole("heading", { + level: 1, + name: /第二课堂|Second Classroom/i, + }), + ).toBeVisible(); + }); + + test("未知 youngId 显示 404", async ({ page }) => { + const response = await page.goto( + "/catalog/young-events/e2e-unknown-young-id", + ); + expect(response?.status()).toBe(404); + }); +}); diff --git a/tests/e2e/src/app/young-events/test.ts b/tests/e2e/src/app/young-events/test.ts new file mode 100644 index 000000000..86bacfd66 --- /dev/null +++ b/tests/e2e/src/app/young-events/test.ts @@ -0,0 +1,99 @@ +/** + * E2E tests for /catalog/young-events — 第二课堂活动列表 + * + * ## Data Represented + * - Signup events from young.ustc.edu.cn with name, category, event time, + * signup window, capacity, and registration status + * - Seed events: DEV_SEED.youngEvent (active) + dev-scenario-young-event-ended + * + * ## UI/UX Elements + * - Search input (searchbox) with submit and clear buttons + * - Signup status and category selects (native comboboxes) + * - Desktop table / mobile item list with links to /catalog/young-events/{youngId} + * - URL-driven pagination + * - Empty state when no events match + * + * ## Edge Cases + * - SSR output contains the search query for SEO + * - Non-matching search shows the empty state instead of an error + */ +import { expect, test } from "@playwright/test"; +import { DEV_SEED } from "../../../utils/dev-seed"; +import { visibleText } from "../../../utils/locators"; +import { gotoAndWaitForReady } from "../../../utils/page-ready"; +import { absoluteTestUrl } from "../../../utils/request-url"; +import { assertPageContract } from "../_shared/page-contract"; + +test.describe("/catalog/young-events 第二课堂活动", () => { + test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { + routePath: "/catalog/young-events", + testInfo, + }); + }); + + test("SSR 输出包含搜索查询", async ({ baseURL }) => { + const response = await fetch( + absoluteTestUrl( + `/catalog/young-events?search=${encodeURIComponent(DEV_SEED.youngEvent.name)}`, + baseURL, + ), + ); + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain('id="main-content"'); + expect(html).toContain(DEV_SEED.youngEvent.name); + }); + + test("搜索、报名状态筛选与清除按钮", async ({ page }) => { + await gotoAndWaitForReady(page, "/catalog/young-events"); + await expect( + page + .getByRole("link", { name: new RegExp(DEV_SEED.youngEvent.name) }) + .first(), + ).toBeVisible(); + + const searchbox = page.getByRole("searchbox"); + await searchbox.fill(DEV_SEED.youngEvent.name); + await page.getByRole("button", { name: /^(?:搜索|Search)$/i }).click(); + await page.waitForURL(/[?&]search=/); + await expect( + visibleText(page, DEV_SEED.youngEvent.name).first(), + ).toBeVisible(); + + await searchbox.fill(""); + await page + .getByRole("combobox", { name: /报名状态|Signup status/i }) + .selectOption("false"); + await page.getByRole("button", { name: /^(?:搜索|Search)$/i }).click(); + await page.waitForURL(/active=false/); + await expect( + page.getByRole("link", { name: /已结束活动/ }).first(), + ).toBeVisible(); + await expect( + page.getByRole("link", { + name: new RegExp(DEV_SEED.youngEvent.name), + }), + ).toHaveCount(0); + + await page.getByRole("link", { name: /^(?:清除|Clear)$/i }).click(); + await page.waitForURL(/\/catalog\/young-events$/); + await expect( + page + .getByRole("link", { name: new RegExp(DEV_SEED.youngEvent.name) }) + .first(), + ).toBeVisible(); + }); + + test("无匹配活动时显示明确空状态", async ({ page }) => { + await gotoAndWaitForReady( + page, + "/catalog/young-events?search=e2e-no-matching-young-event-7f3c9a", + ); + + await expect(page.getByText(/未找到活动|No events found/i)).toBeVisible(); + await expect( + page.locator("#main-content a[href^='/catalog/young-events/']"), + ).toHaveCount(0); + }); +}); diff --git a/tests/fixtures/dev-seed.ts b/tests/fixtures/dev-seed.ts index 5b4eea51a..098e945f6 100644 --- a/tests/fixtures/dev-seed.ts +++ b/tests/fixtures/dev-seed.ts @@ -121,6 +121,14 @@ export const DEV_SEED = { recommendedDeparture: s.bus.recommendedDeparture, }, suspensions: { reasonKeyword: s.suspensions.reasonKeyword }, + youngEvent: { + youngId: s.youngEvent.youngId, + name: s.youngEvent.name, + category: s.youngEvent.category, + department: s.youngEvent.department, + organizer: s.youngEvent.organizer, + location: s.youngEvent.location, + }, } as const; export const DEV_SCENARIO_MARKER = "[DEV-SCENARIO]"; diff --git a/tests/integration/mcp/catalog/young-events.test.ts b/tests/integration/mcp/catalog/young-events.test.ts new file mode 100644 index 000000000..63967f875 --- /dev/null +++ b/tests/integration/mcp/catalog/young-events.test.ts @@ -0,0 +1,126 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import * as fixtures from "../_harness"; + +const context = fixtures.createMcpToolTestContext(); + +const ACTIVE_ID = "[integration-test]-young-active"; +const ENDED_ID = "[integration-test]-young-ended"; + +beforeAll(async () => { + await fixtures.prisma.youngEvent.createMany({ + data: [ + { + youngId: ACTIVE_ID, + name: "[integration-test] 进行中的活动", + category: "单次项目", + department: "校团委", + organizer: "学生会", + status: "进行中", + registrationStatus: "报名中", + location: "东区图书馆", + imageUrl: "https://example.com/pic.jpg", + hours: 2, + capacity: 30, + appliedCount: 5, + startAt: new Date("2026-09-10T06:00:00.000Z"), + endAt: new Date("2026-09-10T08:00:00.000Z"), + applyStartAt: new Date("2026-09-01T00:00:00.000Z"), + applyEndAt: new Date("2026-09-09T15:59:59.000Z"), + isActive: true, + rawJson: { id: ACTIVE_ID, itemName: "[integration-test] 进行中的活动" }, + }, + { + youngId: ENDED_ID, + name: "[integration-test] 已结束的活动", + isActive: false, + rawJson: { id: ENDED_ID }, + }, + ], + }); +}); + +afterAll(async () => { + await fixtures.prisma.youngEvent.deleteMany({ + where: { youngId: { in: [ACTIVE_ID, ENDED_ID] } }, + }); +}); + +describe("第二课堂活动", () => { + it("catalog_young_event_list 返回分页结构并可按报名状态筛选", async () => { + const result = await context.client.call<{ + data?: Array<{ youngId?: string; isActive?: boolean; name?: string }>; + pagination?: { page?: number; pageSize?: number; total?: number }; + }>("catalog_young_event_list", { + active: true, + search: "integration-test", + limit: 10, + page: 1, + }); + + expect(result.pagination?.page).toBe(1); + expect(result.pagination?.total).toBeGreaterThanOrEqual(1); + const event = result.data?.find((item) => item.youngId === ACTIVE_ID); + expect(event?.isActive).toBe(true); + expect(event?.name).toBe("[integration-test] 进行中的活动"); + expect(result.data?.every((item) => item.youngId !== ENDED_ID)).toBe(true); + }); + + it("catalog_young_event_list 默认模式省略详情字段,full 模式保留", async () => { + const compact = await context.client.call<{ + data?: Array<{ + youngId?: string; + imageUrl?: unknown; + department?: unknown; + }>; + }>("catalog_young_event_list", { + search: "[integration-test]", + limit: 5, + page: 1, + }); + const compactEvent = compact.data?.find( + (item) => item.youngId === ACTIVE_ID, + ); + expect(compactEvent).toBeDefined(); + expect(compactEvent?.imageUrl).toBeUndefined(); + expect(compactEvent?.department).toBeUndefined(); + + const full = await context.client.call<{ + data?: Array<{ + youngId?: string; + imageUrl?: string | null; + department?: string | null; + }>; + }>("catalog_young_event_list", { + mode: "full", + search: "[integration-test]", + limit: 5, + page: 1, + }); + const fullEvent = full.data?.find((item) => item.youngId === ACTIVE_ID); + expect(fullEvent?.imageUrl).toBe("https://example.com/pic.jpg"); + expect(fullEvent?.department).toBe("校团委"); + }); + + it("catalog_young_event_get 返回详情,full 模式包含 rawJson", async () => { + const result = await context.client.call<{ + found?: boolean; + event?: { youngId?: string; rawJson?: unknown } | null; + }>("catalog_young_event_get", { mode: "full", youngId: ACTIVE_ID }); + + expect(result.found).toBe(true); + expect(result.event?.youngId).toBe(ACTIVE_ID); + expect(result.event?.rawJson).toMatchObject({ id: ACTIVE_ID }); + }); + + it("catalog_young_event_get 对未知 id 返回 found: false", async () => { + const result = await context.client.call<{ + found?: boolean; + youngId?: string; + event?: unknown; + }>("catalog_young_event_get", { youngId: "[integration-test]-missing" }); + + expect(result.found).toBe(false); + expect(result.youngId).toBe("[integration-test]-missing"); + expect(result.event).toBeNull(); + }); +}); diff --git a/tests/integration/rest/_shared/api-contract.ts b/tests/integration/rest/_shared/api-contract.ts index 9829bdb6e..169f08ad6 100644 --- a/tests/integration/rest/_shared/api-contract.ts +++ b/tests/integration/rest/_shared/api-contract.ts @@ -399,6 +399,53 @@ export async function assertApiContract( return; } + case "/api/catalog/young-events": { + const response = await request.get( + `/api/catalog/young-events?search=${encodeURIComponent(DEV_SEED.youngEvent.name)}`, + ); + expect(response.status()).toBe(200); + const body = (await response.json()) as { + data?: Array<{ + youngId?: string; + name?: string; + category?: string | null; + isActive?: boolean; + }>; + pagination?: { page?: number; total?: number; totalPages?: number }; + }; + expect(typeof body.pagination?.page).toBe("number"); + expect(typeof body.pagination?.total).toBe("number"); + expect(body.pagination?.totalPages).toBeGreaterThanOrEqual(1); + const event = body.data?.find( + (entry) => entry.youngId === DEV_SEED.youngEvent.youngId, + ); + expect(event).toBeDefined(); + expect(event?.name).toBe(DEV_SEED.youngEvent.name); + expect(event?.category).toBe(DEV_SEED.youngEvent.category); + expect(event?.isActive).toBe(true); + return; + } + + case "/api/catalog/young-events/[youngId]": { + const response = await request.get( + `/api/catalog/young-events/${DEV_SEED.youngEvent.youngId}`, + ); + expect(response.status()).toBe(200); + const body = (await response.json()) as { + youngId?: string; + name?: string; + rawJson?: unknown; + }; + expect(body.youngId).toBe(DEV_SEED.youngEvent.youngId); + expect(body.name).toBe(DEV_SEED.youngEvent.name); + expect(body.rawJson).toBeDefined(); + const missing = await request.get( + "/api/catalog/young-events/invalid-e2e", + ); + expect(missing.status()).toBe(404); + return; + } + case "/api/community/comments": { const section = await resolveSeedSectionMatch(request); const response = await request.get( diff --git a/tests/integration/rest/young-events/test.ts b/tests/integration/rest/young-events/test.ts new file mode 100644 index 000000000..9687f0eab --- /dev/null +++ b/tests/integration/rest/young-events/test.ts @@ -0,0 +1,101 @@ +/** + * E2E tests for GET /api/catalog/young-events + * + * ## Endpoints + * - `GET /api/catalog/young-events` — List second-classroom signup events with + * optional `active`, `category`, `search`, `page`/`pageSize` (and deprecated + * `limit` alias) filters. + * - `GET /api/catalog/young-events/[youngId]` — Fetch one event by its + * young.ustc.edu.cn identifier. + * + * ## Response + * - 200 list: `{ data: YoungEventSummary[], pagination: { page, pageSize, total, totalPages } }` + * - 200 detail: `YoungEventDetail` (summary fields + `rawJson`) + * - 400: `{ error: string }` on invalid query + * - 404: `{ error: string }` on unknown youngId + * + * ## Auth Requirements + * - Public (no authentication required) + * + * ## Edge Cases + * - The dev seed includes one active and one ended event + * (`DEV_SEED.youngEvent`), so search and filter assertions are deterministic. + * - `totalPages` is always >= 1, even when total is 0 + */ +import { expect, test } from "@playwright/test"; +import { assertApiContract } from "../_shared/api-contract"; + +test.describe("GET /api/catalog/young-events 接口", () => { + test("接口契约", async ({ request }) => { + await assertApiContract(request, { + routePath: "/api/catalog/young-events", + }); + }); + + test("详情接口契约", async ({ request }) => { + await assertApiContract(request, { + routePath: "/api/catalog/young-events/[youngId]", + }); + }); + + test("返回分页响应结构与公开缓存头", async ({ request }) => { + const response = await request.get("/api/catalog/young-events"); + expect(response.status()).toBe(200); + expect(response.headers()["cache-control"]).toBe( + "public, max-age=0, stale-while-revalidate=300", + ); + expect(response.headers()["cloudflare-cdn-cache-control"]).toBe( + "public, max-age=86400, stale-while-revalidate=300", + ); + const body = (await response.json()) as { + data?: unknown[]; + pagination?: { + page?: number; + pageSize?: number; + total?: number; + totalPages?: number; + }; + }; + expect(Array.isArray(body.data)).toBe(true); + expect(typeof body.pagination?.page).toBe("number"); + expect(typeof body.pagination?.pageSize).toBe("number"); + expect(typeof body.pagination?.total).toBe("number"); + expect(body.pagination?.totalPages).toBeGreaterThanOrEqual(1); + }); + + test("active 筛选只返回报名中的活动", async ({ request }) => { + const response = await request.get("/api/catalog/young-events?active=true"); + expect(response.status()).toBe(200); + const body = (await response.json()) as { + data?: Array<{ isActive?: boolean }>; + }; + expect(body.data?.every((event) => event.isActive === true)).toBe(true); + }); + + test("不匹配的搜索返回空数组而不是错误", async ({ request }) => { + const response = await request.get( + `/api/catalog/young-events?search=${encodeURIComponent("e2e-不可能存在的活动名称")}`, + ); + expect(response.status()).toBe(200); + const body = (await response.json()) as { data?: unknown[] }; + expect(body.data).toEqual([]); + }); + + test("非法 active 参数返回 400", async ({ request }) => { + const response = await request.get( + "/api/catalog/young-events?active=maybe", + ); + expect(response.status()).toBe(400); + const body = (await response.json()) as { error?: string }; + expect(typeof body.error).toBe("string"); + }); + + test("未知 youngId 返回 404", async ({ request }) => { + const response = await request.get( + "/api/catalog/young-events/e2e-unknown-young-id", + ); + expect(response.status()).toBe(404); + const body = (await response.json()) as { error?: string }; + expect(typeof body.error).toBe("string"); + }); +}); diff --git a/tests/unit/graphql-operations.test.ts b/tests/unit/graphql-operations.test.ts index 6c2a2bf49..0cd436543 100644 --- a/tests/unit/graphql-operations.test.ts +++ b/tests/unit/graphql-operations.test.ts @@ -32,7 +32,7 @@ describe("persisted GraphQL operation registry", () => { expect(registeredQueryFields).toEqual(queryFields); expect(registeredMutationFields).toEqual(mutationFields); - expect(graphqlPersistedOperationRegistry).toHaveLength(47); + expect(graphqlPersistedOperationRegistry).toHaveLength(49); expect( graphqlPersistedOperationRegistry.map((operation) => operation.id), ).toEqual( @@ -84,7 +84,7 @@ describe("persisted GraphQL operation registry", () => { it("publishes frozen safety metadata without operation documents", () => { expect(publicGraphqlOperationsManifest.schemaVersion).toBe(1); - expect(publicGraphqlOperationsManifest.operations).toHaveLength(47); + expect(publicGraphqlOperationsManifest.operations).toHaveLength(49); expect(Object.isFrozen(publicGraphqlOperationsManifest)).toBe(true); expect(Object.isFrozen(publicGraphqlOperationsManifest.operations)).toBe( true, diff --git a/tests/unit/seo-discovery-routes.test.ts b/tests/unit/seo-discovery-routes.test.ts index 69d553605..43714f6d1 100644 --- a/tests/unit/seo-discovery-routes.test.ts +++ b/tests/unit/seo-discovery-routes.test.ts @@ -23,6 +23,9 @@ vi.mock("@/lib/db/prisma", () => ({ teacher: { findMany: vi.fn(async () => [{ id: 1 }]), }, + youngEvent: { + findMany: vi.fn(async () => [{ youngId: "young-1" }]), + }, }, })); diff --git a/tests/unit/sitemap-route.test.ts b/tests/unit/sitemap-route.test.ts index 0a07c6eff..741ec2211 100644 --- a/tests/unit/sitemap-route.test.ts +++ b/tests/unit/sitemap-route.test.ts @@ -4,18 +4,24 @@ import { SITEMAP_RUNTIME_CACHE_TTL_MS, } from "@/features/catalog/server/sitemap-cache"; -const { courseFindManyMock, sectionFindManyMock, teacherFindManyMock } = - vi.hoisted(() => ({ - courseFindManyMock: vi.fn(), - sectionFindManyMock: vi.fn(), - teacherFindManyMock: vi.fn(), - })); +const { + courseFindManyMock, + sectionFindManyMock, + teacherFindManyMock, + youngEventFindManyMock, +} = vi.hoisted(() => ({ + courseFindManyMock: vi.fn(), + sectionFindManyMock: vi.fn(), + teacherFindManyMock: vi.fn(), + youngEventFindManyMock: vi.fn(), +})); vi.mock("@/lib/db/prisma", () => ({ prisma: { course: { findMany: courseFindManyMock }, section: { findMany: sectionFindManyMock }, teacher: { findMany: teacherFindManyMock }, + youngEvent: { findMany: youngEventFindManyMock }, }, })); @@ -44,6 +50,7 @@ describe("sitemap route", () => { courseFindManyMock.mockReset().mockResolvedValue([{ jwId: "CS100" }]); sectionFindManyMock.mockReset().mockResolvedValue([{ jwId: "CS100-01" }]); teacherFindManyMock.mockReset().mockResolvedValue([{ id: 42 }]); + youngEventFindManyMock.mockReset().mockResolvedValue([{ youngId: "1001" }]); }); afterEach(() => { @@ -67,6 +74,7 @@ describe("sitemap route", () => { "https://life.example/catalog/links", "https://life.example/catalog/bus/map", "https://life.example/catalog/weather", + "https://life.example/catalog/young-events", "https://life.example/api/docs/tag/catalog-section", "https://life.example/usage/mobile", "https://life.example/usage/bot", @@ -77,11 +85,16 @@ describe("sitemap route", () => { "https://life.example/catalog/courses/CS100", "https://life.example/catalog/sections/CS100-01", "https://life.example/catalog/teachers/42", + "https://life.example/catalog/young-events/1001", ]); expect(sectionFindManyMock).toHaveBeenCalledWith({ where: { retiredAt: null }, select: { jwId: true }, }); + expect(youngEventFindManyMock).toHaveBeenCalledWith({ + where: { isActive: true }, + select: { youngId: true }, + }); }); it("reuses a successful generation within the runtime TTL", async () => { diff --git a/tests/unit/static-import-state.test.ts b/tests/unit/static-import-state.test.ts index 9b5677b3b..f69078872 100644 --- a/tests/unit/static-import-state.test.ts +++ b/tests/unit/static-import-state.test.ts @@ -79,7 +79,9 @@ describe("global static import state", () => { await expect( assertStaticImportStateAllowsSnapshot(tx, input()), - ).rejects.toThrow("revision 2 was already committed"); + ).rejects.toThrow( + `revision ${STATIC_IMPORT_TRANSFORM_REVISION + 1} was already committed`, + ); }); it("rejects older snapshots and changed content at the same time", async () => { diff --git a/tests/unit/static-loader-young-plan.test.ts b/tests/unit/static-loader-young-plan.test.ts new file mode 100644 index 000000000..690c31a36 --- /dev/null +++ b/tests/unit/static-loader-young-plan.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import type { Snapshot } from "@/static-loader/snapshot"; +import { loadYoungEvents } from "@/static-loader/young-plan"; + +const ACTIVE_TABLE = "young_mobile_item_enrolment_list_result_records"; +const ENDED_TABLE = "young_mobile_item_end_list_result_records"; + +function fakeSnapshot({ + metadata = { young_events_mode: "full" }, + tables = {}, +}: { + metadata?: Record; + tables?: Record[]>; +}): Snapshot { + return { + metadata: () => metadata, + hasTable: (table: string) => table in tables, + queryAll: (table: string) => tables[table] ?? [], + queryGrouped: () => new Map(), + } as unknown as Snapshot; +} + +describe("static young event plan", () => { + it("returns null when the snapshot predates the young builder", () => { + const snapshot = fakeSnapshot({ metadata: {} }); + expect(loadYoungEvents(snapshot)).toBeNull(); + }); + + it("maps upstream columns and strips internal store columns from rawJson", () => { + const snapshot = fakeSnapshot({ + tables: { + [ACTIVE_TABLE]: [ + { + id: 123, + itemName: "秋日读书会", + itemCategory_dictText: "单次项目", + businessDeptName: "校团委", + organizer_dictText: "学生会", + itemStatus_dictText: "进行中", + registrationStatus: "报名中", + placeInfo: "东区图书馆", + pic: "https://example.com/pic.jpg", + validHour: "2.5", + peopleNum: "30", + applyNum: 12, + st: "2026-09-10 14:00:00", + et: "2026-09-10 16:00:00", + applySt: "2026-09-01 00:00:00", + applyEt: "2026-09-09 23:59:59", + store_id: 5, + fetch_id: 7, + position: 0, + }, + ], + }, + }); + + const builds = loadYoungEvents(snapshot); + expect(builds).toHaveLength(1); + const build = builds?.[0]; + expect(build).toMatchObject({ + youngId: "123", + name: "秋日读书会", + category: "单次项目", + department: "校团委", + organizer: "学生会", + status: "进行中", + registrationStatus: "报名中", + location: "东区图书馆", + imageUrl: "https://example.com/pic.jpg", + hours: 2.5, + capacity: 30, + appliedCount: 12, + isActive: true, + }); + expect(build?.startAt?.toISOString()).toBe("2026-09-10T06:00:00.000Z"); + expect(build?.applyEndAt?.toISOString()).toBe("2026-09-09T15:59:59.000Z"); + + const raw = JSON.parse(build?.rawJson ?? "{}") as Record; + expect(raw.itemName).toBe("秋日读书会"); + expect(raw).not.toHaveProperty("store_id"); + expect(raw).not.toHaveProperty("fetch_id"); + expect(raw).not.toHaveProperty("position"); + }); + + it("merges ended and active rows with active winning on youngId conflict", () => { + const snapshot = fakeSnapshot({ + tables: { + [ENDED_TABLE]: [{ id: 1, itemName: "旧活动" }], + [ACTIVE_TABLE]: [ + { id: 1, itemName: "同名进行中活动" }, + { id: 2, itemName: "另一个活动" }, + ], + }, + }); + + const builds = loadYoungEvents(snapshot); + expect(builds).toHaveLength(2); + const conflicted = builds?.find((build) => build.youngId === "1"); + expect(conflicted).toMatchObject({ + name: "同名进行中活动", + isActive: true, + }); + expect(builds?.find((build) => build.youngId === "2")?.isActive).toBe(true); + }); + + it("skips rows without a usable id and tolerates missing columns", () => { + const snapshot = fakeSnapshot({ + tables: { + [ACTIVE_TABLE]: [{ itemName: "没有 id" }, { id: 9 }], + }, + }); + + const builds = loadYoungEvents(snapshot); + expect(builds).toHaveLength(1); + expect(builds?.[0]).toMatchObject({ + youngId: "9", + name: "9", + isActive: true, + }); + expect(builds?.[0]?.startAt).toBeUndefined(); + }); + + it("returns an empty list when the young tables exist but hold no rows", () => { + const snapshot = fakeSnapshot({ + tables: { [ACTIVE_TABLE]: [], [ENDED_TABLE]: [] }, + }); + expect(loadYoungEvents(snapshot)).toEqual([]); + }); +}); diff --git a/tests/unit/young-event-service.test.ts b/tests/unit/young-event-service.test.ts new file mode 100644 index 000000000..f29f50581 --- /dev/null +++ b/tests/unit/young-event-service.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { youngEventMock } = vi.hoisted(() => ({ + youngEventMock: { + count: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + }, +})); + +vi.mock("@/lib/db/prisma", () => ({ + prisma: { youngEvent: youngEventMock }, +})); + +import { + getYoungEvent, + listYoungEventCategories, + listYoungEvents, +} from "@/features/young/server/young-event-service"; + +const RECORD = { + youngId: "42", + name: "秋日读书会", + category: "单次项目", + department: "校团委", + organizer: "学生会", + status: "进行中", + registrationStatus: "报名中", + location: "东区图书馆", + imageUrl: null, + hours: 2.5, + capacity: 30, + appliedCount: 12, + startAt: new Date("2026-09-10T06:00:00.000Z"), + endAt: null, + applyStartAt: null, + applyEndAt: null, + isActive: true, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("young event service", () => { + it("lists events with filters, ordering, and pagination metadata", async () => { + youngEventMock.count.mockResolvedValue(21); + youngEventMock.findMany.mockResolvedValue([RECORD]); + + const result = await listYoungEvents({ + active: true, + category: "单次项目", + search: "读书", + page: 2, + pageSize: 20, + }); + + expect(youngEventMock.count).toHaveBeenCalledWith({ + where: { + isActive: true, + category: "单次项目", + name: { contains: "读书", mode: "insensitive" }, + }, + }); + expect(youngEventMock.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: [ + { isActive: "desc" }, + { startAt: { sort: "desc", nulls: "last" } }, + { youngId: "asc" }, + ], + skip: 20, + take: 20, + }), + ); + expect(result.pagination).toEqual({ + page: 2, + pageSize: 20, + total: 21, + totalPages: 2, + }); + expect(result.data[0]).toMatchObject({ + youngId: "42", + name: "秋日读书会", + isActive: true, + startAt: "2026-09-10T14:00:00+08:00", + endAt: null, + }); + }); + + it("returns null detail for unknown youngId", async () => { + youngEventMock.findUnique.mockResolvedValue(null); + await expect(getYoungEvent("missing")).resolves.toBeNull(); + }); + + it("includes rawJson in detail results", async () => { + youngEventMock.findUnique.mockResolvedValue({ + ...RECORD, + rawJson: { id: 42, itemName: "秋日读书会" }, + }); + + const event = await getYoungEvent("42"); + expect(event?.youngId).toBe("42"); + expect(event?.rawJson).toEqual({ id: 42, itemName: "秋日读书会" }); + }); + + it("lists distinct non-null categories in order", async () => { + youngEventMock.findMany.mockResolvedValue([ + { category: "单次项目" }, + { category: "系列项目" }, + ]); + + await expect(listYoungEventCategories()).resolves.toEqual([ + "单次项目", + "系列项目", + ]); + expect(youngEventMock.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { category: { not: null } }, + distinct: ["category"], + }), + ); + }); +}); From db35aa3ee3a7a2153a4229c1e10caca75121769f Mon Sep 17 00:00:00 2001 From: TianKai Ma Date: Wed, 2 Sep 2026 11:20:57 +0800 Subject: [PATCH 2/3] feat(young): cache event poster images in R2 behind a lazy proxy route imageUrl stored from the static snapshot is a raw young.ustc.edu.cn pic path, so rendering it directly hotlinks (and breaks, since the path is relative). Add GET /api/catalog/young-events/[youngId]/image: serve from R2_PUBLICATIONS under young-events/images/ with immutable cache headers; on miss fetch https://young.ustc.edu.cn/login/, persist via waitUntil, and stream to the client. The service now exposes the local proxy path as imageUrl across REST/GraphQL/MCP/web. --- docs/contracts/young-event.json | 14 +- prisma/seed.sql | 2 +- public/openapi.generated.json | 87 +++++++ .../young/server/young-event-image-service.ts | 162 +++++++++++++ .../young/server/young-event-service.ts | 11 +- src/lib/api/routes/young-event-routes.ts | 48 ++++ .../young-events/[youngId]/image/+server.ts | 23 ++ tests/e2e/fixtures/scenario.json | 3 +- tests/e2e/src/app/_shared/page-contract.ts | 21 +- tests/fixtures/dev-seed.ts | 1 + .../mcp/catalog/young-events.test.ts | 4 +- tests/integration/rest/young-events/test.ts | 38 ++- .../unit/openapi-rate-limit-contract.test.ts | 1 + tests/unit/young-event-image-route.test.ts | 220 ++++++++++++++++++ tests/unit/young-event-service.test.ts | 24 ++ 15 files changed, 649 insertions(+), 10 deletions(-) create mode 100644 src/features/young/server/young-event-image-service.ts create mode 100644 src/routes/api/catalog/young-events/[youngId]/image/+server.ts create mode 100644 tests/unit/young-event-image-route.test.ts diff --git a/docs/contracts/young-event.json b/docs/contracts/young-event.json index a1beac293..c8e3f0250 100644 --- a/docs/contracts/young-event.json +++ b/docs/contracts/young-event.json @@ -8,9 +8,10 @@ }, "rules": { "public-no-signin": "Young events are public campus activity information; sign-up itself still happens on young.ustc.edu.cn, so sign-in is not a prerequisite for browsing.", - "static-snapshot-sourced": "Events are scraped by the static repo (authenticated young.ustc.edu.cn enrolmentList/endList APIs) into the published SQLite snapshot and imported into Postgres by the static loader; the server never calls young.ustc.edu.cn at request time.", + "static-snapshot-sourced": "Events are scraped by the static repo (authenticated young.ustc.edu.cn enrolmentList/endList APIs) into the published SQLite snapshot and imported into Postgres by the static loader; the only request-time call to young.ustc.edu.cn is the poster-image proxy, which fetches the public /login/ URL once and caches it in R2.", "snapshot-authoritative": "The static snapshot is authoritative; the loader upserts by upstream youngId and deletes events absent from the snapshot. An empty snapshot never wipes existing rows (treated as upstream breakage).", "raw-payload-preserved": "Curated columns cover the common fields; the complete upstream record stays available under rawJson for REST and MCP consumers.", + "image-proxy-cached": "The database stores the raw upstream `pic` path; the public imageUrl field is a local proxy path (/api/catalog/young-events/[youngId]/image). The proxy route lazily fetches https://young.ustc.edu.cn/login/ on first miss, stores the bytes in the R2 publications bucket under young-events/images/, and serves them with immutable year-long cache headers. Origin failures return 502 and are never cached.", "shanghai-local-times": "Upstream datetimes are Asia/Shanghai local times; startAt/endAt/applyStartAt/applyEndAt are stored and returned as instants.", "read-only": "Events are imported facts; no transport exposes writes." }, @@ -28,13 +29,22 @@ "returns": "paginated { youngId, name, category, department, organizer, status, location, imageUrl, hours, capacity, appliedCount, startAt, endAt, applyStartAt, applyEndAt, isActive }", "notes": [ "Optional query parameters: active (true/false), category, search (name), page, pageSize.", - "Default order: signup-open (active) events first, then by startAt descending." + "Default order: signup-open (active) events first, then by startAt descending.", + "imageUrl is the local cached proxy path /api/catalog/young-events/[youngId]/image (null when the event has no poster)." ] }, { "path": "/api/catalog/young-events/[youngId]", "returns": "single event including rawJson", "notes": ["404 when the youngId is unknown."] + }, + { + "path": "/api/catalog/young-events/[youngId]/image", + "returns": "poster image bytes (Content-Type from the origin or the file extension)", + "notes": [ + "Cache-aside proxy: serves the R2-cached copy under young-events/images/ with immutable year-long cache headers and ETag/304 support; on a miss it fetches https://young.ustc.edu.cn/login/ and stores the result.", + "404 when the youngId is unknown or the event has no image; 502 when the origin fetch fails (failures are not cached)." + ] } ] }, diff --git a/prisma/seed.sql b/prisma/seed.sql index 4a0772153..ec87578a8 100644 --- a/prisma/seed.sql +++ b/prisma/seed.sql @@ -649,7 +649,7 @@ INSERT INTO public."UserSectionSubscription" ("userId", "sectionId") VALUES ('cm -- Data for Name: YoungEvent; Type: TABLE DATA; Schema: public; Owner: - -- -INSERT INTO public."YoungEvent" ("youngId", name, category, department, organizer, status, "registrationStatus", location, "imageUrl", hours, capacity, "appliedCount", "startAt", "endAt", "applyStartAt", "applyEndAt", "isActive", "rawJson") VALUES ('dev-scenario-young-event', '第二课堂示例活动', '单次项目', '校团委', '学生会', '进行中', '报名中', '东区图书馆', NULL, 2, 30, 5, '2026-05-10 06:00:00+00', '2026-05-10 08:00:00+00', '2026-04-28 16:00:00+00', '2026-05-09 15:59:59+00', true, '{"id": "dev-scenario-young-event", "itemName": "第二课堂示例活动"}'::jsonb) ON CONFLICT DO NOTHING; +INSERT INTO public."YoungEvent" ("youngId", name, category, department, organizer, status, "registrationStatus", location, "imageUrl", hours, capacity, "appliedCount", "startAt", "endAt", "applyStartAt", "applyEndAt", "isActive", "rawJson") VALUES ('dev-scenario-young-event', '第二课堂示例活动', '单次项目', '校团委', '学生会', '进行中', '报名中', '东区图书馆', 'group1/M00/31/B5/wKgUEWpR3ciAJX_MAABnEoFLBaI860.jpg', 2, 30, 5, '2026-05-10 06:00:00+00', '2026-05-10 08:00:00+00', '2026-04-28 16:00:00+00', '2026-05-09 15:59:59+00', true, '{"id": "dev-scenario-young-event", "itemName": "第二课堂示例活动", "pic": "group1/M00/31/B5/wKgUEWpR3ciAJX_MAABnEoFLBaI860.jpg"}'::jsonb) ON CONFLICT DO NOTHING; INSERT INTO public."YoungEvent" ("youngId", name, category, "isActive", "rawJson") VALUES ('dev-scenario-young-event-ended', '第二课堂已结束活动', '单次项目', false, '{"id": "dev-scenario-young-event-ended"}'::jsonb) ON CONFLICT DO NOTHING; -- Account-deletion tests intentionally exercise ON DELETE SET NULL. Restore diff --git a/public/openapi.generated.json b/public/openapi.generated.json index 474711ded..f4e133e5b 100644 --- a/public/openapi.generated.json +++ b/public/openapi.generated.json @@ -8485,6 +8485,93 @@ } } }, + "/api/catalog/young-events/{youngId}/image": { + "get": { + "operationId": "get-api-catalog-young-events-youngId-image", + "summary": "Serve the cached poster image for a second-classroom (Young) signup event", + "tags": [ + "catalog.young-events" + ], + "parameters": [ + { + "in": "path", + "name": "youngId", + "schema": { + "type": "string", + "minLength": 1, + "description": "Upstream young.ustc.edu.cn event identifier." + }, + "required": true, + "description": "Upstream young.ustc.edu.cn event identifier." + } + ], + "responses": { + "200": { + "description": "Binary response", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "304": { + "description": "Response 304" + }, + "400": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "404": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "502": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "503": { + "description": "Error response", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the mutation", + "schema": { + "type": "integer", + "minimum": 0 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + } + } + } + }, "/api/community/comments/{id}/reactions": { "post": { "operationId": "addCommentReaction", diff --git a/src/features/young/server/young-event-image-service.ts b/src/features/young/server/young-event-image-service.ts new file mode 100644 index 000000000..f766457e2 --- /dev/null +++ b/src/features/young/server/young-event-image-service.ts @@ -0,0 +1,162 @@ +import { getCloudflareR2PublicationsBucket } from "@/lib/adapters/cloudflare-runtime"; +import { prisma } from "@/lib/db/prisma"; + +const YOUNG_EVENT_IMAGE_ORIGIN = "https://young.ustc.edu.cn/login/"; +const YOUNG_EVENT_IMAGE_KEY_PREFIX = "young-events/images/"; + +export const YOUNG_EVENT_IMAGE_CACHE_HEADERS = { + "Cache-Control": "public, max-age=31536000, immutable, no-transform", + "Cloudflare-CDN-Cache-Control": "public, max-age=31536000, immutable", +} as const; + +export class YoungEventImageStorageUnavailableError extends Error { + readonly code = "young_event_image_storage_unavailable"; +} + +export class YoungEventImageOriginError extends Error { + readonly code = "young_event_image_origin_error"; +} + +const IMAGE_PATH_SEGMENT = /^[A-Za-z0-9._~-]+$/; + +/** + * The stored imageUrl is a raw young.ustc.edu.cn `pic` path (for example + * `group1/M00/31/B5/x.jpg`). Only accept strict relative segments so the value + * can never escape the origin prefix or the R2 key prefix. + */ +export function normalizeYoungEventImagePath(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + const segments = trimmed.split("/"); + for (const segment of segments) { + if (segment === "." || segment === "..") return null; + if (!IMAGE_PATH_SEGMENT.test(segment)) return null; + } + return trimmed; +} + +const EXTENSION_CONTENT_TYPES: Record = { + ".avif": "image/avif", + ".gif": "image/gif", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", +}; + +function guessImageContentType(imagePath: string) { + const lastSegment = imagePath.slice(imagePath.lastIndexOf("/") + 1); + const dot = lastSegment.lastIndexOf("."); + if (dot < 0) return undefined; + return EXTENSION_CONTENT_TYPES[lastSegment.slice(dot).toLowerCase()]; +} + +function normalizeEtag(etag: string) { + const trimmed = etag.trim(); + if (trimmed.startsWith('W/"') || trimmed.startsWith('"')) return trimmed; + return `"${trimmed.replaceAll('"', "")}"`; +} + +function requestMatchesEtag(request: Request, etag: string) { + const value = request.headers.get("If-None-Match"); + if (!value) return false; + return value.split(",").some((candidate) => { + const normalized = candidate.trim().replace(/^W\//, ""); + return normalized === "*" || normalized === etag; + }); +} + +function imageResponseHeaders(etag?: string) { + const headers = new Headers(YOUNG_EVENT_IMAGE_CACHE_HEADERS); + if (etag) headers.set("ETag", etag); + headers.set("X-Content-Type-Options", "nosniff"); + return headers; +} + +/** + * Lazily cache a poster image in R2 and serve it from our own origin. + * Returns null when the event is unknown, has no image, or stores a value + * that is not a safe relative pic path. + */ +export async function getYoungEventImageResponse(input: { + request: Request; + youngId: string; + defer?: (promise: Promise) => void; +}): Promise { + const record = await prisma.youngEvent.findUnique({ + where: { youngId: input.youngId }, + select: { imageUrl: true }, + }); + const imagePath = record?.imageUrl + ? normalizeYoungEventImagePath(record.imageUrl) + : null; + if (!imagePath) return null; + + const bucket = getCloudflareR2PublicationsBucket(); + if (!bucket) { + throw new YoungEventImageStorageUnavailableError( + "R2_PUBLICATIONS binding is required", + ); + } + + const key = `${YOUNG_EVENT_IMAGE_KEY_PREFIX}${imagePath}`; + const contentTypeFallback = + guessImageContentType(imagePath) ?? "application/octet-stream"; + + const head = await bucket.head(key); + if (head) { + const etag = head.etag ? normalizeEtag(head.etag) : undefined; + const headers = imageResponseHeaders(etag); + if (etag && requestMatchesEtag(input.request, etag)) { + return new Response(null, { status: 304, headers }); + } + const object = await bucket.get(key); + if (object?.body) { + headers.set( + "Content-Type", + object.httpMetadata?.contentType ?? + head.httpMetadata?.contentType ?? + contentTypeFallback, + ); + headers.set("Content-Length", String(object.size)); + return new Response(object.body, { headers }); + } + } + + // Cache miss (or a head/get race): fetch the origin once, stream the bytes + // to the client, and persist the copy in R2 for subsequent requests. + let origin: globalThis.Response; + try { + origin = await fetch(`${YOUNG_EVENT_IMAGE_ORIGIN}${imagePath}`); + } catch (error) { + throw new YoungEventImageOriginError( + "Failed to fetch young event image from origin", + { cause: error }, + ); + } + if (!origin.ok) { + throw new YoungEventImageOriginError( + `Young event image origin responded ${origin.status}`, + ); + } + + const contentType = origin.headers.get("Content-Type") ?? contentTypeFallback; + const body = await origin.arrayBuffer().catch((error: unknown) => { + throw new YoungEventImageOriginError( + "Failed to read young event image from origin", + { cause: error }, + ); + }); + + const store = bucket.put(key, body, { httpMetadata: { contentType } }); + if (input.defer) { + input.defer(store); + } else { + await store; + } + + const headers = imageResponseHeaders(); + headers.set("Content-Type", contentType); + headers.set("Content-Length", String(body.byteLength)); + return new Response(body, { headers }); +} diff --git a/src/features/young/server/young-event-service.ts b/src/features/young/server/young-event-service.ts index 18453a46d..efa02e5c5 100644 --- a/src/features/young/server/young-event-service.ts +++ b/src/features/young/server/young-event-service.ts @@ -66,6 +66,15 @@ function toShanghaiIso(date: Date | null): string | null { return date == null ? null : formatShanghaiTimestamp(date); } +/** + * Local cache-aside proxy for the upstream poster image. The database stores + * the raw young.ustc.edu.cn `pic` path; the public field points at our own + * origin so clients never resolve the relative path against us directly. + */ +export function youngEventImageUrl(youngId: string) { + return `/api/catalog/young-events/${youngId}/image`; +} + function toYoungEventSummary(record: YoungEventRecord): YoungEventSummary { return { youngId: record.youngId, @@ -76,7 +85,7 @@ function toYoungEventSummary(record: YoungEventRecord): YoungEventSummary { status: record.status, registrationStatus: record.registrationStatus, location: record.location, - imageUrl: record.imageUrl, + imageUrl: record.imageUrl ? youngEventImageUrl(record.youngId) : null, hours: record.hours, capacity: record.capacity, appliedCount: record.appliedCount, diff --git a/src/lib/api/routes/young-event-routes.ts b/src/lib/api/routes/young-event-routes.ts index 18a3c74e7..c7ef9abb6 100644 --- a/src/lib/api/routes/young-event-routes.ts +++ b/src/lib/api/routes/young-event-routes.ts @@ -1,3 +1,8 @@ +import { + getYoungEventImageResponse, + YoungEventImageOriginError, + YoungEventImageStorageUnavailableError, +} from "@/features/young/server/young-event-image-service"; import { getYoungEvent, listYoungEvents, @@ -5,9 +10,11 @@ import { import { handleRouteError, notFound, + parseRouteParams, parseRouteQuery, schemaJsonResponse, } from "@/lib/api/helpers"; +import { youngEventYoungIdPathParamsSchema } from "@/lib/api/schemas/request-path-schemas"; import { paginatedYoungEventResponseSchema, youngEventDetailSchema, @@ -57,3 +64,44 @@ export async function getYoungEventDetailRoute( return handleRouteError("Failed to fetch young event", error); } } + +export async function getYoungEventImageRoute( + request: Request, + params: { youngId: string }, + options: { defer?: (promise: Promise) => void } = {}, +) { + const parsed = await parseRouteParams( + Promise.resolve(params), + youngEventYoungIdPathParamsSchema, + "Invalid young event ID", + ); + if (parsed instanceof Response) return parsed; + + try { + const result = await getYoungEventImageResponse({ + request, + youngId: parsed.youngId, + defer: options.defer, + }); + if (!result) return notFound("Young event image not found"); + return result; + } catch (error) { + if (error instanceof YoungEventImageStorageUnavailableError) { + const response = handleRouteError( + "Young event image storage unavailable", + error, + 503, + ); + response.headers.set("Retry-After", "60"); + return response; + } + if (error instanceof YoungEventImageOriginError) { + return handleRouteError( + "Failed to fetch young event image from origin", + error, + 502, + ); + } + return handleRouteError("Failed to fetch young event image", error); + } +} diff --git a/src/routes/api/catalog/young-events/[youngId]/image/+server.ts b/src/routes/api/catalog/young-events/[youngId]/image/+server.ts new file mode 100644 index 000000000..a4da5c92e --- /dev/null +++ b/src/routes/api/catalog/young-events/[youngId]/image/+server.ts @@ -0,0 +1,23 @@ +import type { RequestHandler } from "@sveltejs/kit"; +import { getCloudflareTaskScheduler } from "@/lib/adapters/cloudflare-runtime"; +import { getYoungEventImageRoute } from "@/lib/api/routes/young-event-routes"; +import { observedApiRoute } from "@/lib/log/api-observability"; + +/** + * Serve the cached poster image for a second-classroom (Young) signup event. + * @pathParams youngEventYoungIdPathParamsSchema + * @response binary + * @response 304 + * @response 400:openApiErrorSchema + * @response 404:openApiErrorSchema + * @response 502:openApiErrorSchema + * @response 503:openApiErrorSchema + */ +export const GET: RequestHandler = ({ request, params, platform }) => + observedApiRoute(() => + getYoungEventImageRoute( + request, + { youngId: params.youngId }, + { defer: getCloudflareTaskScheduler(platform) }, + ), + )(request); diff --git a/tests/e2e/fixtures/scenario.json b/tests/e2e/fixtures/scenario.json index 476dd0b99..332388200 100644 --- a/tests/e2e/fixtures/scenario.json +++ b/tests/e2e/fixtures/scenario.json @@ -247,6 +247,7 @@ "category": "单次项目", "department": "校团委", "organizer": "学生会", - "location": "东区图书馆" + "location": "东区图书馆", + "imageUrl": "group1/M00/31/B5/wKgUEWpR3ciAJX_MAABnEoFLBaI860.jpg" } } diff --git a/tests/e2e/src/app/_shared/page-contract.ts b/tests/e2e/src/app/_shared/page-contract.ts index 1e1f4ad0f..bae8b32b7 100644 --- a/tests/e2e/src/app/_shared/page-contract.ts +++ b/tests/e2e/src/app/_shared/page-contract.ts @@ -50,10 +50,25 @@ const API_REFERENCE_UI_QUALITY_EXCEPTIONS = { ], } satisfies UiQualityAllowlist; +const YOUNG_EVENT_DETAIL_UI_QUALITY_EXCEPTIONS = { + "broken-image": [ + { + match: + /^img: visible image (?:did not finish loading|has no decoded pixels): .*\/api\/catalog\/young-events\/[^/]+\/image$/, + reason: + "Poster images are proxied lazily from young.ustc.edu.cn on an R2 cache miss; CI network to the origin is not guaranteed.", + }, + ], +} satisfies UiQualityAllowlist; + function getContractUiQuality(routePath: string): UiQualityAllowlist { - return routePath.startsWith("/api/docs") - ? API_REFERENCE_UI_QUALITY_EXCEPTIONS - : {}; + if (routePath.startsWith("/api/docs")) { + return API_REFERENCE_UI_QUALITY_EXCEPTIONS; + } + if (routePath === "/catalog/young-events/[youngId]") { + return YOUNG_EVENT_DETAIL_UI_QUALITY_EXCEPTIONS; + } + return {}; } function getContractWaitUntil(routePath: string) { diff --git a/tests/fixtures/dev-seed.ts b/tests/fixtures/dev-seed.ts index 098e945f6..44ae45e2e 100644 --- a/tests/fixtures/dev-seed.ts +++ b/tests/fixtures/dev-seed.ts @@ -128,6 +128,7 @@ export const DEV_SEED = { department: s.youngEvent.department, organizer: s.youngEvent.organizer, location: s.youngEvent.location, + imageUrl: s.youngEvent.imageUrl, }, } as const; diff --git a/tests/integration/mcp/catalog/young-events.test.ts b/tests/integration/mcp/catalog/young-events.test.ts index 63967f875..5c5d84409 100644 --- a/tests/integration/mcp/catalog/young-events.test.ts +++ b/tests/integration/mcp/catalog/young-events.test.ts @@ -97,7 +97,9 @@ describe("第二课堂活动", () => { page: 1, }); const fullEvent = full.data?.find((item) => item.youngId === ACTIVE_ID); - expect(fullEvent?.imageUrl).toBe("https://example.com/pic.jpg"); + expect(fullEvent?.imageUrl).toBe( + `/api/catalog/young-events/${ACTIVE_ID}/image`, + ); expect(fullEvent?.department).toBe("校团委"); }); diff --git a/tests/integration/rest/young-events/test.ts b/tests/integration/rest/young-events/test.ts index 9687f0eab..405c488b6 100644 --- a/tests/integration/rest/young-events/test.ts +++ b/tests/integration/rest/young-events/test.ts @@ -7,10 +7,14 @@ * `limit` alias) filters. * - `GET /api/catalog/young-events/[youngId]` — Fetch one event by its * young.ustc.edu.cn identifier. + * - `GET /api/catalog/young-events/[youngId]/image` — Poster image proxy backed + * by an R2 cache; only deterministic error cases are covered here because a + * cache miss fetches the live young.ustc.edu.cn origin. * * ## Response * - 200 list: `{ data: YoungEventSummary[], pagination: { page, pageSize, total, totalPages } }` - * - 200 detail: `YoungEventDetail` (summary fields + `rawJson`) + * - 200 detail: `YoungEventDetail` (summary fields + `rawJson`); `imageUrl` is + * the local proxy path `/api/catalog/young-events/[youngId]/image` or null * - 400: `{ error: string }` on invalid query * - 404: `{ error: string }` on unknown youngId * @@ -20,9 +24,12 @@ * ## Edge Cases * - The dev seed includes one active and one ended event * (`DEV_SEED.youngEvent`), so search and filter assertions are deterministic. + * - The active seed event stores a poster pic path; the ended seed event has + * none, so image 404s are deterministic. * - `totalPages` is always >= 1, even when total is 0 */ import { expect, test } from "@playwright/test"; +import { DEV_SEED } from "../../../e2e/utils/dev-seed"; import { assertApiContract } from "../_shared/api-contract"; test.describe("GET /api/catalog/young-events 接口", () => { @@ -98,4 +105,33 @@ test.describe("GET /api/catalog/young-events 接口", () => { const body = (await response.json()) as { error?: string }; expect(typeof body.error).toBe("string"); }); + + test("详情 imageUrl 指向本地缓存代理路径", async ({ request }) => { + const response = await request.get( + `/api/catalog/young-events/${DEV_SEED.youngEvent.youngId}`, + ); + expect(response.status()).toBe(200); + const body = (await response.json()) as { imageUrl?: string | null }; + expect(body.imageUrl).toBe( + `/api/catalog/young-events/${DEV_SEED.youngEvent.youngId}/image`, + ); + }); + + test("未知 youngId 的海报返回 404", async ({ request }) => { + const response = await request.get( + "/api/catalog/young-events/e2e-unknown-young-id/image", + ); + expect(response.status()).toBe(404); + const body = (await response.json()) as { error?: string }; + expect(typeof body.error).toBe("string"); + }); + + test("无海报的已结束活动返回 404", async ({ request }) => { + const response = await request.get( + "/api/catalog/young-events/dev-scenario-young-event-ended/image", + ); + expect(response.status()).toBe(404); + const body = (await response.json()) as { error?: string }; + expect(typeof body.error).toBe("string"); + }); }); diff --git a/tests/unit/openapi-rate-limit-contract.test.ts b/tests/unit/openapi-rate-limit-contract.test.ts index 8eecd817f..4e3f9131e 100644 --- a/tests/unit/openapi-rate-limit-contract.test.ts +++ b/tests/unit/openapi-rate-limit-contract.test.ts @@ -46,6 +46,7 @@ const PROTECTED_MUTATIONS = [ const EXPECTED_STORAGE_FAILURES = [ ["get", "/api/publications/objects/{kind}/{sha256}"], + ["get", "/api/catalog/young-events/{youngId}/image"], ] as const; type Operation = { diff --git a/tests/unit/young-event-image-route.test.ts b/tests/unit/young-event-image-route.test.ts new file mode 100644 index 000000000..d1e2fffd3 --- /dev/null +++ b/tests/unit/young-event-image-route.test.ts @@ -0,0 +1,220 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + youngEventFindUnique: vi.fn(), + bucket: { + head: vi.fn(), + get: vi.fn(), + put: vi.fn(), + }, + fetchMock: vi.fn(), +})); + +vi.mock("@/lib/db/prisma", () => ({ + prisma: { youngEvent: { findUnique: mocks.youngEventFindUnique } }, +})); + +vi.mock("@/lib/adapters/cloudflare-runtime", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/lib/adapters/cloudflare-runtime") + >()), + getCloudflareR2PublicationsBucket: () => mocks.bucket, +})); + +import { getYoungEventImageRoute } from "@/lib/api/routes/young-event-routes"; + +const PIC_PATH = "group1/M00/31/B5/wKgUEWpR3ciAJX_MAABnEoFLBaI860.jpg"; +const R2_KEY = `young-events/images/${PIC_PATH}`; +const ORIGIN_URL = `https://young.ustc.edu.cn/login/${PIC_PATH}`; +const ROUTE_URL = "https://life.test/api/catalog/young-events/42/image"; + +function stream(value: string) { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(value)); + controller.close(); + }, + }); +} + +function eventWithImage(imageUrl: string | null = PIC_PATH) { + return { imageUrl }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", mocks.fetchMock); + mocks.youngEventFindUnique.mockResolvedValue(eventWithImage()); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("young event image route", () => { + it("streams the cached object from R2 with immutable cache headers", async () => { + mocks.bucket.head.mockResolvedValue({ + size: 5, + etag: "r2-etag", + httpMetadata: { contentType: "image/jpeg" }, + }); + mocks.bucket.get.mockResolvedValue({ + size: 5, + body: stream("bytes"), + httpMetadata: { contentType: "image/jpeg" }, + }); + + const response = await getYoungEventImageRoute(new Request(ROUTE_URL), { + youngId: "42", + }); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe( + "public, max-age=31536000, immutable, no-transform", + ); + expect(response.headers.get("Cloudflare-CDN-Cache-Control")).toBe( + "public, max-age=31536000, immutable", + ); + expect(response.headers.get("ETag")).toBe('"r2-etag"'); + expect(response.headers.get("Content-Type")).toBe("image/jpeg"); + expect(response.headers.get("Content-Length")).toBe("5"); + expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff"); + await expect(response.text()).resolves.toBe("bytes"); + expect(mocks.bucket.head).toHaveBeenCalledWith(R2_KEY); + expect(mocks.fetchMock).not.toHaveBeenCalled(); + expect(mocks.bucket.put).not.toHaveBeenCalled(); + }); + + it("answers 304 when If-None-Match matches the cached object", async () => { + mocks.bucket.head.mockResolvedValue({ size: 5, etag: '"r2-etag"' }); + + const response = await getYoungEventImageRoute( + new Request(ROUTE_URL, { headers: { "If-None-Match": 'W/"r2-etag"' } }), + { youngId: "42" }, + ); + + expect(response.status).toBe(304); + expect(mocks.bucket.get).not.toHaveBeenCalled(); + expect(mocks.fetchMock).not.toHaveBeenCalled(); + }); + + it("fetches the origin on a cache miss, stores via defer, and serves the bytes", async () => { + mocks.bucket.head.mockResolvedValue(null); + const deferred: Promise[] = []; + mocks.fetchMock.mockResolvedValue( + new Response(stream("origin-bytes"), { + status: 200, + headers: { "Content-Type": "image/jpeg" }, + }), + ); + mocks.bucket.put.mockResolvedValue(undefined); + + const response = await getYoungEventImageRoute( + new Request(ROUTE_URL), + { youngId: "42" }, + { defer: (promise) => deferred.push(promise) }, + ); + + expect(response.status).toBe(200); + expect(mocks.fetchMock).toHaveBeenCalledWith(ORIGIN_URL); + expect(response.headers.get("Content-Type")).toBe("image/jpeg"); + expect(response.headers.get("Cache-Control")).toContain("immutable"); + await expect(response.text()).resolves.toBe("origin-bytes"); + + expect(mocks.bucket.put).toHaveBeenCalledWith( + R2_KEY, + expect.any(ArrayBuffer), + { httpMetadata: { contentType: "image/jpeg" } }, + ); + expect(deferred).toHaveLength(1); + await expect(deferred[0]).resolves.toBeUndefined(); + }); + + it("falls back to an extension-based content type when the origin omits it", async () => { + mocks.bucket.head.mockResolvedValue(null); + mocks.fetchMock.mockResolvedValue( + new Response(stream("png-bytes"), { status: 200 }), + ); + mocks.youngEventFindUnique.mockResolvedValue( + eventWithImage("group1/M00/31/B5/poster.PNG"), + ); + + const response = await getYoungEventImageRoute(new Request(ROUTE_URL), { + youngId: "42", + }); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("image/png"); + expect(mocks.bucket.put).toHaveBeenCalledWith( + "young-events/images/group1/M00/31/B5/poster.PNG", + expect.any(ArrayBuffer), + { httpMetadata: { contentType: "image/png" } }, + ); + }); + + it("responds 502 and caches nothing when the origin fails", async () => { + mocks.bucket.head.mockResolvedValue(null); + mocks.fetchMock.mockResolvedValue(new Response("nope", { status: 404 })); + + const response = await getYoungEventImageRoute(new Request(ROUTE_URL), { + youngId: "42", + }); + + expect(response.status).toBe(502); + const body = (await response.json()) as { error?: string }; + expect(typeof body.error).toBe("string"); + expect(mocks.bucket.put).not.toHaveBeenCalled(); + + mocks.fetchMock.mockRejectedValue(new Error("network down")); + const networkFailure = await getYoungEventImageRoute( + new Request(ROUTE_URL), + { youngId: "42" }, + ); + expect(networkFailure.status).toBe(502); + expect(mocks.bucket.put).not.toHaveBeenCalled(); + }); + + it("responds 404 for an unknown youngId", async () => { + mocks.youngEventFindUnique.mockResolvedValue(null); + + const response = await getYoungEventImageRoute(new Request(ROUTE_URL), { + youngId: "missing", + }); + + expect(response.status).toBe(404); + const body = (await response.json()) as { error?: string }; + expect(typeof body.error).toBe("string"); + expect(mocks.bucket.head).not.toHaveBeenCalled(); + }); + + it("responds 404 when the event has no image", async () => { + mocks.youngEventFindUnique.mockResolvedValue(eventWithImage(null)); + + const response = await getYoungEventImageRoute(new Request(ROUTE_URL), { + youngId: "42", + }); + + expect(response.status).toBe(404); + expect(mocks.bucket.head).not.toHaveBeenCalled(); + }); + + it.each([ + "https://evil.example/x.jpg", + "//evil.example/x.jpg", + "/etc/passwd", + "group1/../../secret", + "..", + "group1\\M00\\x.jpg", + "group1/M00/../x.jpg", + ])("rejects the unsafe stored path %j without touching R2", async (raw) => { + mocks.youngEventFindUnique.mockResolvedValue(eventWithImage(raw)); + + const response = await getYoungEventImageRoute(new Request(ROUTE_URL), { + youngId: "42", + }); + + expect(response.status).toBe(404); + expect(mocks.bucket.head).not.toHaveBeenCalled(); + expect(mocks.fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/young-event-service.test.ts b/tests/unit/young-event-service.test.ts index f29f50581..10334bbe5 100644 --- a/tests/unit/young-event-service.test.ts +++ b/tests/unit/young-event-service.test.ts @@ -104,6 +104,30 @@ describe("young event service", () => { expect(event?.rawJson).toEqual({ id: 42, itemName: "秋日读书会" }); }); + it("maps a stored pic path to the local image proxy URL", async () => { + const withImage = { + ...RECORD, + imageUrl: "group1/M00/31/B5/wKgUEWpR3ciAJX_MAABnEoFLBaI860.jpg", + }; + youngEventMock.findMany.mockResolvedValue([withImage]); + youngEventMock.count.mockResolvedValue(1); + youngEventMock.findUnique.mockResolvedValue({ ...withImage, rawJson: {} }); + + const listed = await listYoungEvents(); + expect(listed.data[0]?.imageUrl).toBe("/api/catalog/young-events/42/image"); + + const detail = await getYoungEvent("42"); + expect(detail?.imageUrl).toBe("/api/catalog/young-events/42/image"); + }); + + it("keeps imageUrl null when the event has no poster", async () => { + youngEventMock.findMany.mockResolvedValue([RECORD]); + youngEventMock.count.mockResolvedValue(1); + + const listed = await listYoungEvents(); + expect(listed.data[0]?.imageUrl).toBeNull(); + }); + it("lists distinct non-null categories in order", async () => { youngEventMock.findMany.mockResolvedValue([ { category: "单次项目" }, From 7bf9eb14facc5419842c4111727c6c5f6253be02 Mon Sep 17 00:00:00 2001 From: TianKai Ma Date: Wed, 2 Sep 2026 11:41:37 +0800 Subject: [PATCH 3/3] fix(young): harden image proxy against origin content-type and size abuse Only persist image/* payloads (an upstream error page would otherwise become immutable same-origin content), cap origin reads at 10 MB, and add short CDN caching to 404/502 image responses to absorb repeat misses. Also return youngId in the MCP found payload for consistency with the not-found shape. --- .../young/server/young-event-image-service.ts | 38 ++++++++++++- src/lib/api/routes/young-event-routes.ts | 12 +++- .../mcp/tools/catalog/young-event-tools.ts | 7 ++- .../mcp/catalog/young-events.test.ts | 2 +- tests/unit/young-event-image-route.test.ts | 57 +++++++++++++++++++ 5 files changed, 110 insertions(+), 6 deletions(-) diff --git a/src/features/young/server/young-event-image-service.ts b/src/features/young/server/young-event-image-service.ts index f766457e2..c82681f0e 100644 --- a/src/features/young/server/young-event-image-service.ts +++ b/src/features/young/server/young-event-image-service.ts @@ -17,6 +17,10 @@ export class YoungEventImageOriginError extends Error { readonly code = "young_event_image_origin_error"; } +/** Posters are small; cap origin reads so a bad response cannot exhaust the + * 128 MB Worker memory limit or flood the bucket. */ +export const YOUNG_EVENT_IMAGE_MAX_BYTES = 10 * 1024 * 1024; + const IMAGE_PATH_SEGMENT = /^[A-Za-z0-9._~-]+$/; /** @@ -140,13 +144,45 @@ export async function getYoungEventImageResponse(input: { ); } - const contentType = origin.headers.get("Content-Type") ?? contentTypeFallback; + // Never persist non-image bytes under an immutable year-long cache: an + // upstream error page would become same-origin content on our domain. + const declaredType = origin.headers + .get("Content-Type") + ?.split(";")[0] + ?.trim() + .toLowerCase(); + if (declaredType && !declaredType.startsWith("image/")) { + throw new YoungEventImageOriginError( + `Young event image origin returned non-image content type ${declaredType}`, + ); + } + const contentType = declaredType ?? contentTypeFallback; + if (!contentType.startsWith("image/")) { + throw new YoungEventImageOriginError( + "Young event image origin content type is not an image", + ); + } + + const declaredLength = Number(origin.headers.get("Content-Length")); + if ( + Number.isFinite(declaredLength) && + declaredLength > YOUNG_EVENT_IMAGE_MAX_BYTES + ) { + throw new YoungEventImageOriginError( + `Young event image origin response exceeds ${YOUNG_EVENT_IMAGE_MAX_BYTES} bytes`, + ); + } const body = await origin.arrayBuffer().catch((error: unknown) => { throw new YoungEventImageOriginError( "Failed to read young event image from origin", { cause: error }, ); }); + if (body.byteLength > YOUNG_EVENT_IMAGE_MAX_BYTES) { + throw new YoungEventImageOriginError( + `Young event image origin response exceeds ${YOUNG_EVENT_IMAGE_MAX_BYTES} bytes`, + ); + } const store = bucket.put(key, body, { httpMetadata: { contentType } }); if (input.defer) { diff --git a/src/lib/api/routes/young-event-routes.ts b/src/lib/api/routes/young-event-routes.ts index c7ef9abb6..ba7fff750 100644 --- a/src/lib/api/routes/young-event-routes.ts +++ b/src/lib/api/routes/young-event-routes.ts @@ -83,7 +83,13 @@ export async function getYoungEventImageRoute( youngId: parsed.youngId, defer: options.defer, }); - if (!result) return notFound("Young event image not found"); + if (!result) { + // Short CDN caching on errors absorbs repeat misses without pinning a + // stale 404 if the event gains a poster later. + const response = notFound("Young event image not found"); + response.headers.set("Cache-Control", "public, max-age=300"); + return response; + } return result; } catch (error) { if (error instanceof YoungEventImageStorageUnavailableError) { @@ -96,11 +102,13 @@ export async function getYoungEventImageRoute( return response; } if (error instanceof YoungEventImageOriginError) { - return handleRouteError( + const response = handleRouteError( "Failed to fetch young event image from origin", error, 502, ); + response.headers.set("Cache-Control", "public, max-age=60"); + return response; } return handleRouteError("Failed to fetch young event image", error); } diff --git a/src/lib/mcp/tools/catalog/young-event-tools.ts b/src/lib/mcp/tools/catalog/young-event-tools.ts index 507342362..89f9b4392 100644 --- a/src/lib/mcp/tools/catalog/young-event-tools.ts +++ b/src/lib/mcp/tools/catalog/young-event-tools.ts @@ -54,11 +54,14 @@ async function getYoungEventTool({ } if (resolvedMode === "full") { - return jsonToolResult({ found: true, event }, { mode: "full" }); + return jsonToolResult({ found: true, youngId, event }, { mode: "full" }); } const { rawJson: _rawJson, ...summary } = event; - return jsonToolResult({ found: true, event: summary }, { mode: "default" }); + return jsonToolResult( + { found: true, youngId, event: summary }, + { mode: "default" }, + ); } export function registerYoungEventTools(server: McpServer) { diff --git a/tests/integration/mcp/catalog/young-events.test.ts b/tests/integration/mcp/catalog/young-events.test.ts index 5c5d84409..8c99a9ec1 100644 --- a/tests/integration/mcp/catalog/young-events.test.ts +++ b/tests/integration/mcp/catalog/young-events.test.ts @@ -18,7 +18,7 @@ beforeAll(async () => { status: "进行中", registrationStatus: "报名中", location: "东区图书馆", - imageUrl: "https://example.com/pic.jpg", + imageUrl: "group1/M00/31/B5/wKgUEWpR3ciAJX_MAABnEoFLBaI860.jpg", hours: 2, capacity: 30, appliedCount: 5, diff --git a/tests/unit/young-event-image-route.test.ts b/tests/unit/young-event-image-route.test.ts index d1e2fffd3..77a6439ab 100644 --- a/tests/unit/young-event-image-route.test.ts +++ b/tests/unit/young-event-image-route.test.ts @@ -21,6 +21,7 @@ vi.mock("@/lib/adapters/cloudflare-runtime", async (importOriginal) => ({ getCloudflareR2PublicationsBucket: () => mocks.bucket, })); +import { YOUNG_EVENT_IMAGE_MAX_BYTES } from "@/features/young/server/young-event-image-service"; import { getYoungEventImageRoute } from "@/lib/api/routes/young-event-routes"; const PIC_PATH = "group1/M00/31/B5/wKgUEWpR3ciAJX_MAABnEoFLBaI860.jpg"; @@ -161,6 +162,7 @@ describe("young event image route", () => { }); expect(response.status).toBe(502); + expect(response.headers.get("Cache-Control")).toBe("public, max-age=60"); const body = (await response.json()) as { error?: string }; expect(typeof body.error).toBe("string"); expect(mocks.bucket.put).not.toHaveBeenCalled(); @@ -174,6 +176,60 @@ describe("young event image route", () => { expect(mocks.bucket.put).not.toHaveBeenCalled(); }); + it("rejects a non-image origin content type without caching it", async () => { + mocks.bucket.head.mockResolvedValue(null); + mocks.fetchMock.mockResolvedValue( + new Response(stream("error page"), { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }), + ); + + const response = await getYoungEventImageRoute(new Request(ROUTE_URL), { + youngId: "42", + }); + + expect(response.status).toBe(502); + expect(mocks.bucket.put).not.toHaveBeenCalled(); + }); + + it("rejects an origin response whose Content-Length exceeds the cap", async () => { + mocks.bucket.head.mockResolvedValue(null); + mocks.fetchMock.mockResolvedValue( + new Response(stream("tiny"), { + status: 200, + headers: { + "Content-Type": "image/jpeg", + "Content-Length": String(YOUNG_EVENT_IMAGE_MAX_BYTES + 1), + }, + }), + ); + + const response = await getYoungEventImageRoute(new Request(ROUTE_URL), { + youngId: "42", + }); + + expect(response.status).toBe(502); + expect(mocks.bucket.put).not.toHaveBeenCalled(); + }); + + it("rejects an origin body that exceeds the cap after reading", async () => { + mocks.bucket.head.mockResolvedValue(null); + mocks.fetchMock.mockResolvedValue( + new Response(stream("x".repeat(YOUNG_EVENT_IMAGE_MAX_BYTES + 1)), { + status: 200, + headers: { "Content-Type": "image/jpeg" }, + }), + ); + + const response = await getYoungEventImageRoute(new Request(ROUTE_URL), { + youngId: "42", + }); + + expect(response.status).toBe(502); + expect(mocks.bucket.put).not.toHaveBeenCalled(); + }); + it("responds 404 for an unknown youngId", async () => { mocks.youngEventFindUnique.mockResolvedValue(null); @@ -182,6 +238,7 @@ describe("young event image route", () => { }); expect(response.status).toBe(404); + expect(response.headers.get("Cache-Control")).toBe("public, max-age=300"); const body = (await response.json()) as { error?: string }; expect(typeof body.error).toBe("string"); expect(mocks.bucket.head).not.toHaveBeenCalled();