refactor(契约): 契约闸门铺到其余学生端接口,并清掉与契约等价的重复类型
Some checks failed
Deploy / deploy (push) Has been cancelled

## 闸门覆盖

从 7 条扩到 20 条:竞赛列表/详情/口令/榜单、题单列表/详情/题目/徽章/进度、
全服榜与活跃榜、公告列表/详情、教程、用户度量、流程图历史/当前/统计、
提交统计与统计明细、相似题目、题目 AC 榜。

## 顺手把两处形状收进契约

- `contestRankItemSchema.submissionInfo` 原来只是 `Record<string, unknown>`,
  前端被迫再声明一份 `SubmissionInfo` 去覆盖它。现在 JSONB 的 snake_case 形状
  (is_ac / ac_time / is_first_ac / error_number / checked)进了契约,那层覆盖随之消失。
- 竞赛题目列表用 `problemListItemSchema.array()` 而不是契约里的
  `contestProblemsSchema` —— 后者是 `array(union([列表项, 详情]))`,联合类型会让
  `filterResult` 的类型收窄落到详情分支,而且学生侧这条接口只下发列表项。

## 清掉两处确证重复的派生

用类型探针(双向可赋值)逐对验证,`Problem` 与 `Message` 的本地派生与契约**完全等价**,
是没有内容的重复:`Problem` 的 languages/template 收窄、`Message` 的 submission 收窄
都已经在前两轮搬进契约。其余七处派生(Submission 的 result|9、Profile、AdminProblem、
Exercise、ContestRank、BlankProblem、TutorialEdit 等)探针确认**不等价**,都有真实理由,
一律保留。

`Problem` 原来还挂着 hasAstRules / visible / answers 三个可选字段,探针证明它们对
赋值没有影响(契约里本来就有 hasAstRules),去掉后没有任何调用点报错。

## 验证

- vue-tsc 与 tsc -p apps/api 均 exit 0;vite build 通过;check:routes 无遮蔽;
- 契约 schema 直跑真实接口:学生会话 13 通过 / 0 失败,管理员会话 15 通过 / 0 失败;
  跳过的三条都是预期的权限或空数据(/submissions/statistics 与 /flowcharts/statistics
  要教师权限、/users/:id/metrics 只统计公开提交)。
This commit is contained in:
2026-09-10 04:23:41 -06:00
parent aab0404ed7
commit de34a47996
3 changed files with 249 additions and 128 deletions

View File

@@ -1,54 +1,55 @@
import {
type AiAnalysisRecord,
type Contest as OjContest,
type ContestAccess,
type ContestList,
type ActivityRankItem,
type FormatCodeResponse,
type Metrics,
type TutorialSummary,
type ClassComparisonResponse,
type ClassRankItem,
type ClassUserRank,
type UserRank,
type ProblemRank,
type CreateSubmissionResponse,
type ProblemAuthor,
type ProblemListItem,
type YearlyAc,
type CreateFlowchartResponse,
type FlowchartCurrent,
type FlowchartDetail,
type FlowchartList,
type FlowchartSubmission,
type AiDetail,
type DurationData,
type HeatmapItem,
type LoginSummary,
type SolvedList,
type ProblemSet,
type ProblemSetBadge,
type ProblemSetList,
type ProblemSetProblem,
type ProblemSetProgressList,
type UserBadge,
problemDetailSchema,
problemListSchema,
problemListItemSchema,
submissionDetailSchema,
submissionListSchema,
submissionStatisticsSchema,
submissionStatisticsItemsSchema,
onlineCountSchema,
websiteConfigSchema,
type FlowchartStatistics,
type SubmissionStatistics,
type SubmissionStatisticsItems,
contestListSchema,
contestSchema,
contestAccessSchema,
contestRankSchema,
announcementListSchema,
announcementSchema,
problemSetListSchema,
problemSetSchema,
problemSetBadgeSchema,
userBadgeSchema,
tutorialSchema,
metricsSchema,
activityRankItemSchema,
problemRankSchema,
userRankSchema,
flowchartListSchema,
flowchartDetailSchema,
flowchartCurrentSchema,
flowchartStatisticsSchema,
problemSetProgressListSchema,
problemSetProblemSchema,
} from "@oj2/contract"
import api from "utils/api"
import { contract } from "utils/contract"
import { filterResult } from "oj/transforms"
import type {
Announcement,
AnnouncementListItem,
ContestRank,
Profile,
Message,
Exercise,
@@ -57,7 +58,6 @@ import type {
ReactionState,
SubmissionListPayload,
SubmitCodePayload,
Tutorial,
TutorialProgress,
} from "utils/types"
@@ -183,8 +183,13 @@ async function getSubmissionPage(
)
}
export function getRankOfProblem(problemId: string) {
return api.get<ProblemRank>(`problems/${encodeURIComponent(problemId)}/rank`)
export async function getRankOfProblem(problemId: string) {
const endpoint = `problems/${encodeURIComponent(problemId)}/rank`
return contract(
"GET /problems/:id/rank",
problemRankSchema,
await api.get<unknown>(endpoint),
)
}
export function getTodaySubmissionCount(language?: string) {
@@ -201,38 +206,56 @@ export function adminRejudge(id: string) {
* 统计面板展开一行时拉这个人的明细。username 这里要**精确**到人,
* 和上面那个按班级模糊匹配的不是一回事。
*/
export function getSubmissionStatisticsItems(
export async function getSubmissionStatisticsItems(
duration: { start?: string; end: string },
username: string,
problemID?: string,
) {
return api.get<SubmissionStatisticsItems>("submissions/statistics/items", {
params: { ...duration, problemId: problemID, username },
})
const endpoint = "submissions/statistics/items"
return contract(
"GET /submissions/statistics/items",
submissionStatisticsItemsSchema,
await api.get<unknown>(endpoint, {
params: { ...duration, problemId: problemID, username },
}),
)
}
export function getSubmissionStatistics(
export async function getSubmissionStatistics(
duration: { start?: string; end: string },
problemID?: string,
username?: string,
) {
return api.get<SubmissionStatistics>("submissions/statistics", {
params: { ...duration, problemId: problemID, username },
})
const endpoint = "submissions/statistics"
return contract(
"GET /submissions/statistics",
submissionStatisticsSchema,
await api.get<unknown>(endpoint, {
params: { ...duration, problemId: problemID, username },
}),
)
}
/**
* 全服榜单。上限100 名)由服务端定,调用方只管翻页 ——
* 「全服 Top10」就是这个榜的第一页取 limit=10 即可,不需要另一个上限参数。
*/
export function getRank(offset: number, limit: number) {
return api.get<UserRank>("rankings/users", { params: { offset, limit } })
export async function getRank(offset: number, limit: number) {
const endpoint = "rankings/users"
return contract(
"GET /rankings/users",
userRankSchema,
await api.get<unknown>(endpoint, { params: { offset, limit } }),
)
}
export function getActivityRank(start: string) {
return api.get<ActivityRankItem[]>("rankings/activity", {
params: { start },
})
export async function getActivityRank(start: string) {
const endpoint = "rankings/activity"
return contract(
"GET /rankings/activity",
activityRankItemSchema.array(),
await api.get<unknown>(endpoint, { params: { start } }),
)
}
export function getClassRank(grade?: number | null) {
@@ -261,22 +284,37 @@ export function getClassPK(
})
}
export function getContestList(query: {
export async function getContestList(query: {
offset: number
limit: number
keyword: string
status: string
tag: string
}) {
return api.get<ContestList>("contests", { params: query })
const endpoint = "contests"
return contract(
"GET /contests",
contestListSchema,
await api.get<unknown>(endpoint, { params: query }),
)
}
export function getContest(id: string) {
return api.get<OjContest>(`contests/${encodeURIComponent(id)}`)
export async function getContest(id: string) {
const endpoint = `contests/${encodeURIComponent(id)}`
return contract(
"GET /contests/:id",
contestSchema,
await api.get<unknown>(endpoint),
)
}
export function getContestAccess(id: string) {
return api.get<ContestAccess>(`contests/${encodeURIComponent(id)}/access`)
export async function getContestAccess(id: string) {
const endpoint = `contests/${encodeURIComponent(id)}/access`
return contract(
"GET /contests/:id/access",
contestAccessSchema,
await api.get<unknown>(endpoint),
)
}
// 注意和 GET /access 不一样:这个返回裸 true密码错是 403 走 catch
@@ -290,21 +328,29 @@ export function checkContestPassword(contestID: string, password: string) {
}
export async function getContestProblems(contestID: string) {
const res = await api.get<ProblemListItem[]>(
`contests/${encodeURIComponent(contestID)}/problems`,
const endpoint = `contests/${encodeURIComponent(contestID)}/problems`
// 用 problemListItemSchema.array(),不是契约的 contestProblemsSchema ——
// 后者是 `array(union([列表项, 详情]))`,联合类型会让 filterResult 的类型收窄
// 落到详情分支上,而且学生侧这条接口只下发列表项。
const res = contract(
"GET /contests/:id/problems",
problemListItemSchema.array(),
await api.get<unknown>(endpoint),
)
return res.map(filterResult)
}
export function getContestRank(
export async function getContestRank(
contestID: string,
query: { limit: number; offset: number },
) {
// submissionInfo 在契约里是 Record<string, unknown>JSONB 原文),
// 前端在这里收窄成 SubmissionInfo见 utils/types 的 ContestRank
return api.get<{ results: ContestRank[]; total: number }>(
`contests/${encodeURIComponent(contestID)}/rank`,
{ params: query },
const endpoint = `contests/${encodeURIComponent(contestID)}/rank`
return contract(
"GET /contests/:id/rank",
contestRankSchema,
await api.get<unknown>(endpoint, { params: query }),
)
}
@@ -320,14 +366,22 @@ export function updateProfile(data: { realName: string; mood: string }) {
return api.put<Profile>("me/profile", data)
}
export function getAnnouncementList(offset = 0, limit = 10) {
return api.get<{ results: AnnouncementListItem[]; total: number }>("announcements", {
params: { limit, offset },
})
export async function getAnnouncementList(offset = 0, limit = 10) {
const endpoint = "announcements"
return contract(
"GET /announcements",
announcementListSchema,
await api.get<unknown>(endpoint, { params: { limit, offset } }),
)
}
export function getAnnouncement(id: number) {
return api.get<Announcement>(`announcements/${id}`)
export async function getAnnouncement(id: number) {
const endpoint = `announcements/${id}`
return contract(
"GET /announcements/:id",
announcementSchema,
await api.get<unknown>(endpoint),
)
}
export function getMessageList(offset = 0, limit = 10) {
@@ -345,12 +399,22 @@ export function setReaction(problemID: number, type: ReactionKey) {
return api.post<ReactionState>(`problems/${problemID}/reaction`, { type })
}
export function getMetrics(userid: number) {
return api.get<Metrics>(`users/${userid}/metrics`)
export async function getMetrics(userid: number) {
const endpoint = `users/${userid}/metrics`
return contract(
"GET /users/:id/metrics",
metricsSchema,
await api.get<unknown>(endpoint),
)
}
export function getTutorial(id: number) {
return api.get<Tutorial>(`tutorials/${id}`)
export async function getTutorial(id: number) {
const endpoint = `tutorials/${id}`
return contract(
"GET /tutorials/:id",
tutorialSchema,
await api.get<unknown>(endpoint),
)
}
export function getTutorials(type: "python" | "c") {
@@ -399,10 +463,14 @@ export function getAIPinnedReport() {
// ==================== 相似题目推荐 ====================
export function getSimilarProblems(problemId: string) {
return api
.get<ProblemListItem[]>(`problems/${encodeURIComponent(problemId)}/similar`)
.then((response) => response.map(filterResult))
export async function getSimilarProblems(problemId: string) {
const endpoint = `problems/${encodeURIComponent(problemId)}/similar`
const res = contract(
"GET /problems/:id/similar",
problemListItemSchema.array(),
await api.get<unknown>(endpoint),
)
return res.map(filterResult)
}
export type { YearlyAc as YearlyACData } from "@oj2/contract"
@@ -427,7 +495,7 @@ export function getFlowchartSubmission(id: string) {
return api.get<FlowchartSubmission>(`flowcharts/${encodeURIComponent(id)}`)
}
export function getFlowchartSubmissions(params: {
export async function getFlowchartSubmissions(params: {
username?: string
problemId?: string
myself?: string
@@ -436,17 +504,27 @@ export function getFlowchartSubmissions(params: {
today?: string
grade?: string
}) {
return api.get<FlowchartList>("flowcharts", { params })
const endpoint = "flowcharts"
return contract(
"GET /flowcharts",
flowchartListSchema,
await api.get<unknown>(endpoint, { params }),
)
}
export function getFlowchartStatistics(
export async function getFlowchartStatistics(
duration: { start?: string; end: string },
problemID?: string,
username?: string,
) {
return api.get<FlowchartStatistics>("flowcharts/statistics", {
params: { ...duration, problemId: problemID, username },
})
const endpoint = "flowcharts/statistics"
return contract(
"GET /flowcharts/statistics",
flowchartStatisticsSchema,
await api.get<unknown>(endpoint, {
params: { ...duration, problemId: problemID, username },
}),
)
}
export function retryFlowchartSubmission(submissionId: string) {
@@ -455,36 +533,59 @@ export function retryFlowchartSubmission(submissionId: string) {
)
}
export function getCurrentProblemFlowchartSubmission(problemId: number) {
return api.get<FlowchartCurrent>(`problems/${problemId}/flowchart/current`)
export async function getCurrentProblemFlowchartSubmission(problemId: number) {
const endpoint = `problems/${problemId}/flowchart/current`
return contract(
"GET /problems/:id/flowchart/current",
flowchartCurrentSchema,
await api.get<unknown>(endpoint),
)
}
export function getFlowchartSubmissionDetail(problemId: number, page = 0) {
return api.get<FlowchartDetail>(`problems/${problemId}/flowchart/history`, {
params: { page },
})
export async function getFlowchartSubmissionDetail(problemId: number, page = 0) {
const endpoint = `problems/${problemId}/flowchart/history`
return contract(
"GET /problems/:id/flowchart/history",
flowchartDetailSchema,
await api.get<unknown>(endpoint, { params: { page } }),
)
}
// ==================== 题单相关API ====================
export function getProblemSetList(
export async function getProblemSetList(
offset = 0,
limit = 10,
keyword = "",
difficulty = "",
status = "",
) {
return api.get<ProblemSetList>("problem-sets", {
params: { offset, limit, keyword, difficulty, status },
})
const endpoint = "problem-sets"
return contract(
"GET /problem-sets",
problemSetListSchema,
await api.get<unknown>(endpoint, {
params: { offset, limit, keyword, difficulty, status },
}),
)
}
export function getProblemSetDetail(id: number) {
return api.get<ProblemSet>(`problem-sets/${id}`)
export async function getProblemSetDetail(id: number) {
const endpoint = `problem-sets/${id}`
return contract(
"GET /problem-sets/:id",
problemSetSchema,
await api.get<unknown>(endpoint),
)
}
export function getProblemSetProblems(problemSetId: number) {
return api.get<ProblemSetProblem[]>(`problem-sets/${problemSetId}/problems`)
export async function getProblemSetProblems(problemSetId: number) {
const endpoint = `problem-sets/${problemSetId}/problems`
return contract(
"GET /problem-sets/:id/problems",
problemSetProblemSchema.array(),
await api.get<unknown>(endpoint),
)
}
export function joinProblemSet(problemSetId: number) {
@@ -503,17 +604,25 @@ export function updateProblemSetProgress(
})
}
export function getUserBadges(username?: string) {
return api.get<UserBadge[]>(
`users/${encodeURIComponent(username ?? "me")}/badges`,
export async function getUserBadges(username?: string) {
const endpoint = `users/${encodeURIComponent(username ?? "me")}/badges`
return contract(
"GET /users/:username/badges",
userBadgeSchema.array(),
await api.get<unknown>(endpoint),
)
}
export function getProblemSetBadges(problemSetId: number) {
return api.get<ProblemSetBadge[]>(`problem-sets/${problemSetId}/badges`)
export async function getProblemSetBadges(problemSetId: number) {
const endpoint = `problem-sets/${problemSetId}/badges`
return contract(
"GET /problem-sets/:id/badges",
problemSetBadgeSchema.array(),
await api.get<unknown>(endpoint),
)
}
export function getProblemSetUserProgress(
export async function getProblemSetUserProgress(
problemSetId: number,
params?: {
limit?: number
@@ -522,9 +631,11 @@ export function getProblemSetUserProgress(
completionStatus?: "" | "completed" | "in_progress" | "not_started"
},
) {
return api.get<ProblemSetProgressList>(
`problem-sets/${problemSetId}/user-progress`,
{ params },
const endpoint = `problem-sets/${problemSetId}/user-progress`
return contract(
"GET /problem-sets/:id/user-progress",
problemSetProgressListSchema,
await api.get<unknown>(endpoint, { params }),
)
}