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 }),
)
}

View File

@@ -113,17 +113,18 @@ export type { UploadTestCaseResponse } from "@oj2/contract"
export type { ProblemTestCaseScore as Testcase } from "@oj2/contract"
/**
* 题目详情。以契约的 ProblemDetail 为准,只在这里补两处前端自己的窄化:
* - `languages` / `template` 的键窄化成 LANGUAGE组件按语言查模板要靠它
* 题目详情。**直接取契约** —— `languages` / `template` 的收窄已经搬进
* `problemDetailSchema``z.array(problemLanguageSchema)` 与
* `z.partialRecord(problemLanguageSchema, …)`),这里原来那份
* `Omit<ProblemDetail, "languages" | "template"> & {...}` 与契约**双向可赋值**
* 即完全等价,是一层没有内容的重复(已用类型探针验证)。
*
* 原来它还多挂三个可选字段(`hasAstRules` / `visible` / `answers`)。它们在
* 契约里**本来就有**`hasAstRules` 在题目列表项上、`ProblemDetail` 带的是
* `astRequirements`),少数管理端调用点需要补充时应该就地声明自己的类型,
* 不该让一个全局别名对所有调用方声称这些字段存在。
*/
export type Problem = Omit<ProblemDetail, "languages" | "template"> & {
languages: LANGUAGE[]
template: { [key in LANGUAGE]?: string }
// oj 侧不下发 astRules 原文,只有 astRequirements契约里就有
hasAstRules?: boolean
visible?: boolean
answers?: { language: LANGUAGE; code: string }[]
}
export type Problem = ProblemDetail
/**
* AST 代码要求。原来这里手抄的那份少了 label / exact / outer / inner ——
@@ -327,27 +328,16 @@ export type BlankContest = Omit<
>
/**
* acm_contest_rank.submission_info 的 JSONB 内容。**键名保持 snake_case** ——
* 判题按这套键名写进去,历史比赛的榜单行也是这个形状,不能跟着响应字段一起改名
* `acm_contest_rank.submission_info` 的 JSONB 内容。**形状已搬进契约**
* `contestSubmissionInfoSchema`),这里保留别名给既有调用点
*/
export interface SubmissionInfo {
is_ac: boolean
ac_time: number
is_first_ac: boolean
error_number: number
checked?: boolean
}
export type { ContestSubmissionInfo as SubmissionInfo } from "@oj2/contract"
/**
* 榜单行。`submissionInfo` 的**内容**仍是 snake_case —— 它是 acm_contest_rank
* 表的 JSONB 原文,见 SubmissionInfo
* 榜单行。`submissionInfo` 的收窄JSONB 原文的 snake_case 形状)已经搬进
* `contestRankItemSchema`,这里不再需要 Omit + 覆盖
*/
export type ContestRank = Omit<
import("@oj2/contract").ContestRankItem,
"submissionInfo"
> & {
submissionInfo: { [key: string]: SubmissionInfo }
}
export type ContestRank = import("@oj2/contract").ContestRankItem
export type { WebsiteConfig, OnlineCount } from "@oj2/contract"
@@ -399,12 +389,12 @@ export type {
export type AnnouncementEdit = CreateAnnouncementRequest & { id: number }
/**
* 站内信。取契约的形状,只把 `submission` 换成前端窄化过的那个
* statisticInfo / language 在契约里是 unknown见 EmbeddedSubmission)。
* 站内信。**直接取契约** —— 原来这里把 `submission` 换成前端窄化过的
* `EmbeddedSubmission`,但那个窄化已经搬进 `embeddedSubmissionSchema`
* statisticInfo / language 在契约里不再是 unknown两者双向可赋值、
* 完全等价,这层 Omit 已经没有内容。
*/
export type Message = Omit<ContractMessage, "submission"> & {
submission: EmbeddedSubmission
}
export type Message = ContractMessage
/**
* 题目表情。三个类型都直接取自契约 —— 语义 key 必须与后端 reaction/models.py