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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/contracts/graphql.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions docs/contracts/mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@
{
"name": "Weather",
"tools": ["catalog_weather_get"]
},
{
"name": "Young Events",
"tools": ["catalog_young_event_list", "catalog_young_event_get"]
}
]
},
Expand Down
106 changes: 106 additions & 0 deletions docs/contracts/young-event.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
{
"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 only request-time call to young.ustc.edu.cn is the poster-image proxy, which fetches the public /login/<pic> 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/<pic> on first miss, stores the bytes in the R2 publications bucket under young-events/images/<pic>, 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."
},
"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.",
"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/<pic> with immutable year-long cache headers and ETag/304 support; on a miss it fetches https://young.ustc.edu.cn/login/<pic> 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)."
]
}
]
},
"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"
]
}
}
}
}
33 changes: 33 additions & 0 deletions docs/graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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!
}
3 changes: 3 additions & 0 deletions docs/interface-hierarchy.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ Life@USTC
│ │ ├── timetable
│ │ ├── next-departures
│ │ └── map
│ ├── young-events
│ │ ├── list
│ │ └── get
│ └── links
├── workspace current user's campus work
│ ├── overview
Expand Down
30 changes: 30 additions & 0 deletions messages/en-us.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 30 additions & 0 deletions messages/zh-cn.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "校园新闻与通知",
Expand Down
33 changes: 33 additions & 0 deletions prisma/migrations/20260901210000_add_young_events/migration.sql
Original file line number Diff line number Diff line change
@@ -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");
27 changes: 27 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
8 changes: 8 additions & 0 deletions prisma/seed.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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', '第二课堂示例活动', '单次项目', '校团委', '学生会', '进行中', '报名中', '东区图书馆', '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
-- ownership on the named scenario rows so reseeding is a complete reset.
UPDATE public."Homework"
Expand Down
Loading
Loading