update
Deploy / deploy (push) Canceled after 0s

This commit is contained in:
xuyue committed 2026-09-27 19:36:28 +08:00
1 parent 23844ae301
commit d25b5dbe6d
268 files changed
+3684 -8861

No files matched your search

+69 -174
View File
@@ -11,17 +11,7 @@ import {
type HeatmapItem,
type LoginSummary,
} from "@oj2/contract"
import {
and,
count,
countDistinct,
eq,
gte,
inArray,
isNull,
lte,
sql,
} from "drizzle-orm"
import { and, count, countDistinct, eq, gte, inArray, isNull, lte, sql } from "drizzle-orm"
import { Hono, type Context } from "hono"
import { requireAuth, type AppEnv } from "../auth/middleware"
@@ -32,32 +22,12 @@ import { JudgeStatus } from "../judge/status"
import { failure, parseBody, readJson, success } from "../http"
import { completeChat, streamChat, streamWhole } from "../services/ai"
import { generateFilteredHint } from "../services/hint-filter"
import {
accepted,
buildDetail,
buildDuration,
listSolved,
} from "../services/learning-stats"
import { accepted, buildDetail, buildDuration, listSolved } from "../services/learning-stats"
import { decideHintLevel } from "../services/hint-level"
import {
hintDiagnosis,
hintPrompt,
referenceAnswer,
} from "../services/hint-diagnosis"
import { hintDiagnosis, hintPrompt, referenceAnswer } from "../services/hint-diagnosis"
import { consumeToken } from "../services/throttling"
import {
calendarDay,
dayNumber,
dayText,
localTime,
localWeekday,
} from "../time"
import {
countFailedSubmissions,
isTeacherOrAbove,
asRecord,
queryInteger,
} from "./helpers"
import { calendarDay, dayNumber, dayText, localTime, localWeekday } from "../time"
import { countFailedSubmissions, isTeacherOrAbove, asRecord, queryInteger } from "./helpers"
export const aiRoutes = new Hono<AppEnv>()
@@ -73,12 +43,7 @@ function aiThrottleKey(userId: number) {
async function throttleAi(c: Context<AppEnv>) {
const throttle = await consumeToken("user", aiThrottleKey(c.get("user")!.id))
if (throttle.allowed) return null
return failure(
c,
429,
"too-many-requests",
`Please wait ${Math.floor(throttle.wait)} seconds`,
)
return failure(c, 429, "too-many-requests", `Please wait ${Math.floor(throttle.wait)} seconds`)
}
async function targetUser(c: Context<AppEnv>, override?: string) {
@@ -104,18 +69,8 @@ async function targetUser(c: Context<AppEnv>, override?: string) {
aiRoutes.get("/ai/detail", requireAuth, async (c) => {
const start = c.req.query("start")
const end = c.req.query("end")
if (
!start ||
!end ||
Number.isNaN(Date.parse(start)) ||
Number.isNaN(Date.parse(end))
) {
return failure(
c,
400,
"invalid-range",
"start and end must be ISO 8601 timestamps",
)
if (!start || !end || Number.isNaN(Date.parse(start)) || Number.isNaN(Date.parse(end))) {
return failure(c, 400, "invalid-range", "start and end must be ISO 8601 timestamps")
}
const user = await targetUser(c)
if (!user) return failure(c, 404, "user-not-found", "User not found")
@@ -125,18 +80,8 @@ aiRoutes.get("/ai/detail", requireAuth, async (c) => {
aiRoutes.get("/ai/solved", requireAuth, async (c) => {
const start = c.req.query("start")
const end = c.req.query("end")
if (
!start ||
!end ||
Number.isNaN(Date.parse(start)) ||
Number.isNaN(Date.parse(end))
) {
return failure(
c,
400,
"invalid-range",
"start and end must be ISO 8601 timestamps",
)
if (!start || !end || Number.isNaN(Date.parse(start)) || Number.isNaN(Date.parse(end))) {
return failure(c, 400, "invalid-range", "start and end must be ISO 8601 timestamps")
}
const user = await targetUser(c)
if (!user) return failure(c, 404, "user-not-found", "User not found")
@@ -151,10 +96,7 @@ aiRoutes.get("/ai/duration", requireAuth, async (c) => {
return failure(c, 400, "invalid-end", "end must be an ISO timestamp")
const user = await targetUser(c)
if (!user) return failure(c, 404, "user-not-found", "User not found")
return success(
c,
await buildDuration(user, endText, c.req.query("duration") ?? "months:1"),
)
return success(c, await buildDuration(user, endText, c.req.query("duration") ?? "months:1"))
})
aiRoutes.get("/ai/heatmap", requireAuth, async (c) => {
@@ -174,14 +116,8 @@ aiRoutes.get("/ai/heatmap", requireAuth, async (c) => {
.where(
and(
eq(schema.submission.userId, user.id),
gte(
schema.submission.createTime,
new Date((firstMonday - 1) * 864e5).toISOString(),
),
lte(
schema.submission.createTime,
new Date(end.getTime() + 864e5).toISOString(),
),
gte(schema.submission.createTime, new Date((firstMonday - 1) * 864e5).toISOString()),
lte(schema.submission.createTime, new Date(end.getTime() + 864e5).toISOString()),
),
)
.groupBy(date)
@@ -192,8 +128,7 @@ aiRoutes.get("/ai/heatmap", requireAuth, async (c) => {
Array.from({ length: 53 }, (_, week) => {
const monday = firstMonday + week * 7
let value = 0
for (let offset = 0; offset < 7; offset++)
value += counts.get(dayText(monday + offset)) ?? 0
for (let offset = 0; offset < 7; offset++) value += counts.get(dayText(monday + offset)) ?? 0
// timestamp 是该周周一的 UTC 零点,前端按东八区只取年月日部件
return { timestamp: monday * 864e5, value } satisfies HeatmapItem
}),
@@ -213,64 +148,60 @@ aiRoutes.get("/ai/login-summary", requireAuth, async (c) => {
.limit(1)
const previous = await getPreviousLogin(c)
let start = new Date(
previous ??
userRow?.lastLogin ??
userRow?.createTime ??
end.getTime() - 7 * 864e5,
previous ?? userRow?.lastLogin ?? userRow?.createTime ?? end.getTime() - 7 * 864e5,
)
if (start >= end) start = new Date(end.getTime() - 864e5)
const range = and(
gte(schema.submission.createTime, start.toISOString()),
lte(schema.submission.createTime, end.toISOString()),
)
const [newProblems, submissions, acceptedRows, solvedRows, flowRows] =
await Promise.all([
db
.select({ value: count() })
.from(schema.problem)
.where(
and(
isNull(schema.problem.contestId),
eq(schema.problem.visible, true),
gte(schema.problem.createTime, start.toISOString()),
lte(schema.problem.createTime, end.toISOString()),
),
const [newProblems, submissions, acceptedRows, solvedRows, flowRows] = await Promise.all([
db
.select({ value: count() })
.from(schema.problem)
.where(
and(
isNull(schema.problem.contestId),
eq(schema.problem.visible, true),
gte(schema.problem.createTime, start.toISOString()),
lte(schema.problem.createTime, end.toISOString()),
),
db
.select({ value: count() })
.from(schema.submission)
.where(and(eq(schema.submission.userId, user.id), range)),
db
.select({ value: count() })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.result, accepted),
range,
),
),
db
.select({ value: count() })
.from(schema.submission)
.where(and(eq(schema.submission.userId, user.id), range)),
db
.select({ value: count() })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.result, accepted),
range,
),
db
.select({ value: countDistinct(schema.submission.problemId) })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.result, accepted),
range,
),
),
db
.select({ value: countDistinct(schema.submission.problemId) })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
inArray(schema.submission.result, accepted),
range,
),
db
.select({ value: count() })
.from(schema.flowchartSubmission)
.where(
and(
eq(schema.flowchartSubmission.userId, user.id),
gte(schema.flowchartSubmission.createTime, start.toISOString()),
lte(schema.flowchartSubmission.createTime, end.toISOString()),
),
),
db
.select({ value: count() })
.from(schema.flowchartSubmission)
.where(
and(
eq(schema.flowchartSubmission.userId, user.id),
gte(schema.flowchartSubmission.createTime, start.toISOString()),
lte(schema.flowchartSubmission.createTime, end.toISOString()),
),
])
),
])
const summary = {
start: start.toISOString(),
end: end.toISOString(),
@@ -306,10 +237,7 @@ aiRoutes.get("/ai/pinned", requireAuth, async (c) => {
.from(schema.aiAnalysis)
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
.where(
and(
eq(schema.aiAnalysis.userId, c.get("user")!.id),
eq(schema.aiAnalysis.isPinned, true),
),
and(eq(schema.aiAnalysis.userId, c.get("user")!.id), eq(schema.aiAnalysis.isPinned, true)),
)
.limit(1)
if (!row) return success(c, null)
@@ -326,22 +254,10 @@ aiRoutes.get("/ai/pinned", requireAuth, async (c) => {
})
aiRoutes.post("/ai/analysis", requireAuth, async (c) => {
const parsed = await parseBody(
c,
aiAnalysisRequestSchema,
"start, end and duration are required",
)
const parsed = await parseBody(c, aiAnalysisRequestSchema, "start, end and duration are required")
if (!parsed.success) return parsed.response
if (
Number.isNaN(Date.parse(parsed.data.start)) ||
Number.isNaN(Date.parse(parsed.data.end))
) {
return failure(
c,
400,
"invalid-range",
"start and end must be ISO 8601 timestamps",
)
if (Number.isNaN(Date.parse(parsed.data.start)) || Number.isNaN(Date.parse(parsed.data.end))) {
return failure(c, 400, "invalid-range", "start and end must be ISO 8601 timestamps")
}
// 传 username 的鉴权走 targetUser:非教师传了也只会拿到自己
const user = await targetUser(c, parsed.data.username)
@@ -423,19 +339,12 @@ async function recordHint(
}
aiRoutes.post("/ai/hint", requireAuth, async (c) => {
const parsed = await parseBody(
c,
aiHintRequestSchema,
"submissionId is required",
)
const parsed = await parseBody(c, aiHintRequestSchema, "submissionId is required")
if (!parsed.success) return parsed.response
const [row] = await db
.select({ submission: schema.submission, problem: schema.problem })
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.submission.problemId, schema.problem.id),
)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.where(
and(
eq(schema.submission.id, parsed.data.submissionId),
@@ -443,17 +352,11 @@ aiRoutes.post("/ai/hint", requireAuth, async (c) => {
),
)
.limit(1)
if (!row)
return failure(c, 404, "submission-not-found", "Submission not found")
if (!row) return failure(c, 404, "submission-not-found", "Submission not found")
// 比赛里不给 AI 提示,和「求助」按钮同一个口径。前端在比赛路由下压根不显示按钮,
// 这里是防直接 POST 的那一道 —— 比赛只有 ACM 模式,提示等于变相放水。
if (row.submission.contestId !== null)
return failure(
c,
403,
"contest-hint-disabled",
"Hint is disabled in contests",
)
return failure(c, 403, "contest-hint-disabled", "Hint is disabled in contests")
// 失败次数在端点这边也要卡一道:直接 POST 完全绕开前端的显示条件 ——
// 不然这就是个不限次数的免费 LLM 接口。数法(判题中的不算、判题机自己崩的不算)
// 由 countFailedSubmissions 统一,题目详情的 myFailedCount 走的是同一个函数,
@@ -461,10 +364,7 @@ aiRoutes.post("/ai/hint", requireAuth, async (c) => {
// 编译失败不数次数(理由见 HINT_MIN_FAILURES 的注释)。放开的只是这一次提交本身,
// 下面的 throttleAi 照样卡着,不会因此变成不限次数的接口。
if (row.submission.result !== JudgeStatus.COMPILE_ERROR) {
const failed = await countFailedSubmissions(
c.get("user")!.id,
row.submission.problemId,
)
const failed = await countFailedSubmissions(c.get("user")!.id, row.submission.problemId)
if (failed < HINT_MIN_FAILURES)
return failure(
c,
@@ -523,8 +423,7 @@ aiRoutes.post("/ai/hint", requireAuth, async (c) => {
aiRoutes.post("/ai/hint/:id/feedback", requireAuth, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = await readJson(c, aiHintFeedbackRequestSchema)
if (!id || !parsed.success)
return failure(c, 400, "invalid-request", "helpful is required")
if (!id || !parsed.success) return failure(c, 400, "invalid-request", "helpful is required")
// 只能评自己的提示:顺着 submission 核对是不是本人。别人的和不存在的一样回 404,
// 不透露那个 id 上有没有东西
const [updated] = await db
@@ -553,11 +452,7 @@ aiRoutes.post("/ai/hint/:id/feedback", requireAuth, async (c) => {
aiRoutes.post("/ai/class-analysis", requireAuth, async (c) => {
if (!isTeacherOrAbove(c.get("user")))
return failure(c, 403, "permission-denied", "Permission denied")
const parsed = await parseBody(
c,
classAnalysisRequestSchema,
"Class data is required",
)
const parsed = await parseBody(c, classAnalysisRequestSchema, "Class data is required")
if (!parsed.success) return parsed.response
const limited = await throttleAi(c)
if (limited) return limited