- 新接口 /me/last-visit:上次登录到这次登录之间的题库提交,不调 AI(原来要同步等模型最长 60 秒弹框才出来) - 班里今天在做时让位给班里卡,只在卡底留一句;班里那天就是上次来那天时两张卡合成一张 - 第一次登录说「欢迎来到判题狗」,隔了 30 天以上说「好久不见」 - 删掉弹框、store 和 /ai/login-summary Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2b4676ae34
commit
91d9c9c6d7
15 files changed
+389
-289
No files matched your search
@@ -300,7 +300,9 @@ export async function getContestPassword(c: Context, contestId: number) {
|
||||
return session?.value.contestPasswords[String(contestId)] ?? null
|
||||
}
|
||||
|
||||
export async function getPreviousLogin(c: Context) {
|
||||
/** 上次登录、这次登录的时刻 —— 首页「上次来」卡的窗口就是这两个之间 */
|
||||
export async function getLoginWindow(c: Context) {
|
||||
const session = await getStoredSession(c)
|
||||
return session?.value.previousLogin ?? null
|
||||
if (!session) return null
|
||||
return { previousLogin: session.value.previousLogin, loginAt: session.value.createdAt }
|
||||
}
|
||||
+3
-101
@@ -9,20 +9,18 @@ import {
|
||||
type AiHintDone,
|
||||
type HintDiagnosis,
|
||||
type HeatmapItem,
|
||||
type LoginSummary,
|
||||
} from "@oj2/contract"
|
||||
import { and, count, countDistinct, eq, gte, inArray, isNull, lte, sql } from "drizzle-orm"
|
||||
import { and, count, eq, gte, inArray, lte, sql } from "drizzle-orm"
|
||||
import { Hono, type Context } from "hono"
|
||||
|
||||
import { requireAuth, type AppEnv } from "../auth/middleware"
|
||||
import { getPreviousLogin } from "../auth/session"
|
||||
import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { failure, parseBody, readJson, success } from "../http"
|
||||
import { completeChat, streamChat, streamWhole } from "../services/ai"
|
||||
import { streamChat, streamWhole } from "../services/ai"
|
||||
import { generateFilteredHint } from "../services/hint-filter"
|
||||
import { accepted, buildDetail, buildDuration, listSolved } from "../services/learning-stats"
|
||||
import { buildDetail, buildDuration, listSolved } from "../services/learning-stats"
|
||||
import { decideHintLevel } from "../services/hint-level"
|
||||
import { hintDiagnosis, hintPrompt, referenceAnswer } from "../services/hint-diagnosis"
|
||||
import { consumeToken } from "../services/throttling"
|
||||
@@ -135,102 +133,6 @@ aiRoutes.get("/ai/heatmap", requireAuth, async (c) => {
|
||||
)
|
||||
})
|
||||
|
||||
aiRoutes.get("/ai/login-summary", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const end = new Date()
|
||||
const [userRow] = await db
|
||||
.select({
|
||||
createTime: schema.user.createTime,
|
||||
lastLogin: schema.user.lastLogin,
|
||||
})
|
||||
.from(schema.user)
|
||||
.where(eq(schema.user.id, user.id))
|
||||
.limit(1)
|
||||
const previous = await getPreviousLogin(c)
|
||||
let start = new Date(
|
||||
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()),
|
||||
),
|
||||
),
|
||||
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: 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(),
|
||||
newProblemCount: newProblems[0]?.value ?? 0,
|
||||
submissionCount: submissions[0]?.value ?? 0,
|
||||
acceptedCount: acceptedRows[0]?.value ?? 0,
|
||||
solvedCount: solvedRows[0]?.value ?? 0,
|
||||
flowchartSubmissionCount: flowRows[0]?.value ?? 0,
|
||||
}
|
||||
let analysis = ""
|
||||
let analysisError: string | undefined
|
||||
// 这支是登录后自动触发的,没有用户点击 —— 更要过限流,否则反复刷新就是反复调模型。
|
||||
// 被限住时安静跳过:analysis 本来就是可选的,弹窗里的统计数字照常显示。
|
||||
if (
|
||||
summary.submissionCount >= 3 &&
|
||||
(await consumeToken("user", aiThrottleKey(user.id))).allowed
|
||||
) {
|
||||
try {
|
||||
analysis = await completeChat(
|
||||
"你是 OnlineJudge 的学习助教。请根据统计数据给出简短分析(1-2句),再给出一行以“结论:”开头的结论。",
|
||||
JSON.stringify(summary),
|
||||
)
|
||||
} catch (error) {
|
||||
analysisError = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
return success(c, { summary, analysis, analysisError } satisfies LoginSummary)
|
||||
})
|
||||
|
||||
aiRoutes.get("/ai/pinned", requireAuth, async (c) => {
|
||||
const [row] = await db
|
||||
.select({ analysis: schema.aiAnalysis, username: schema.user.username })
|
||||
|
||||
@@ -11,15 +11,31 @@ import {
|
||||
type ClassComparisonResponse,
|
||||
type ClassRankItem,
|
||||
type ClassUserRank,
|
||||
type LastVisit,
|
||||
FLOWCHART_PASS_GRADES,
|
||||
} from "@oj2/contract"
|
||||
import { and, desc, eq, gte, inArray, isNotNull, isNull, like, lte, sql } from "drizzle-orm"
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
inArray,
|
||||
isNotNull,
|
||||
isNull,
|
||||
like,
|
||||
lt,
|
||||
lte,
|
||||
sql,
|
||||
} from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
||||
import { getLoginWindow } from "../auth/session"
|
||||
import { db, schema } from "../db"
|
||||
import { failure, parseBody, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { accepted } from "../services/learning-stats"
|
||||
import { calendarDay, dayStart, localTime } from "../time"
|
||||
import { queryInteger, rounded } from "./helpers"
|
||||
|
||||
@@ -362,6 +378,119 @@ async function classLessonProblems(className: string, lookbackDays: number) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 「上次来」超过这么多天就不提那次做了什么,只在问候里说一句隔了多久 */
|
||||
const LAST_VISIT_MAX_DAYS = 30
|
||||
|
||||
/**
|
||||
* 首页的「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对。
|
||||
* 原来是登录后弹的「登录速报」,数字外加 AI 两句话 —— AI 只拿到那五个数,说的就是复述,
|
||||
* 还要同步等模型(最长 60 秒)弹框才出来;现在拆进首页,没做对的题给「接着做」
|
||||
*/
|
||||
classroomRoutes.get("/me/last-visit", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const window = await getLoginWindow(c)
|
||||
const previousLogin = window?.previousLogin ?? null
|
||||
if (
|
||||
!window ||
|
||||
!previousLogin ||
|
||||
Date.now() - new Date(previousLogin).getTime() > LAST_VISIT_MAX_DAYS * 864e5
|
||||
) {
|
||||
return success(c, { previousLogin, summary: null } satisfies LastVisit)
|
||||
}
|
||||
const rows = await db
|
||||
.select({
|
||||
problemId: schema.submission.problemId,
|
||||
problemDisplayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
result: schema.submission.result,
|
||||
createTime: schema.submission.createTime,
|
||||
})
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
isNull(schema.submission.contestId),
|
||||
gte(schema.submission.createTime, previousLogin),
|
||||
lt(schema.submission.createTime, window.loginAt),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(schema.submission.createTime))
|
||||
if (!rows.length) return success(c, { previousLogin, summary: null } satisfies LastVisit)
|
||||
|
||||
// 按题归拢:rows 按时间正序,所以后来的覆盖 lastResult / lastTime,第一次 AC 的先进 solved
|
||||
const byProblem = new Map<
|
||||
number,
|
||||
{
|
||||
problemDisplayId: string
|
||||
title: string
|
||||
attempts: number
|
||||
lastResult: (typeof rows)[number]["result"]
|
||||
lastTime: string
|
||||
solved: boolean
|
||||
}
|
||||
>()
|
||||
const solved: { problemDisplayId: string; title: string }[] = []
|
||||
for (const row of rows) {
|
||||
const item = byProblem.get(row.problemId) ?? {
|
||||
problemDisplayId: row.problemDisplayId,
|
||||
title: row.title,
|
||||
attempts: 0,
|
||||
lastResult: row.result,
|
||||
lastTime: row.createTime,
|
||||
solved: false,
|
||||
}
|
||||
item.attempts++
|
||||
item.lastResult = row.result
|
||||
item.lastTime = row.createTime
|
||||
if (!item.solved && accepted.includes(row.result)) {
|
||||
item.solved = true
|
||||
solved.push({ problemDisplayId: row.problemDisplayId, title: row.title })
|
||||
}
|
||||
byProblem.set(row.problemId, item)
|
||||
}
|
||||
// 那次没做对的,再看以前、以及这次登录之后有没有做对过 —— 做对过的就不再催「接着做」
|
||||
const tried = [...byProblem].filter(([, item]) => !item.solved).map(([id]) => id)
|
||||
const everSolved = tried.length
|
||||
? new Set(
|
||||
(
|
||||
await db
|
||||
.selectDistinct({ problemId: schema.submission.problemId })
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
isNull(schema.submission.contestId),
|
||||
inArray(schema.submission.problemId, tried),
|
||||
inArray(schema.submission.result, accepted),
|
||||
),
|
||||
)
|
||||
).map((row) => row.problemId),
|
||||
)
|
||||
: new Set<number>()
|
||||
const unsolved = tried
|
||||
.filter((id) => !everSolved.has(id))
|
||||
.map((id) => byProblem.get(id)!)
|
||||
.sort((a, b) => b.lastTime.localeCompare(a.lastTime))
|
||||
.slice(0, 3)
|
||||
.map(({ problemDisplayId, title, attempts, lastResult }) => ({
|
||||
problemDisplayId,
|
||||
title,
|
||||
attempts,
|
||||
lastResult,
|
||||
}))
|
||||
return success(c, {
|
||||
previousLogin,
|
||||
summary: {
|
||||
submissionCount: rows.length,
|
||||
solvedCount: solved.length,
|
||||
lastSubmitTime: rows.at(-1)!.createTime,
|
||||
unsolved,
|
||||
solved: solved.slice(0, 12),
|
||||
},
|
||||
} satisfies LastVisit)
|
||||
})
|
||||
|
||||
/**
|
||||
* 学生首页的「班里在做」。课上老师报题号、全班去找 —— 这是 2025 秋七成非比赛提交的
|
||||
* 来路,但界面上原本没有它的入口,没听清题号的只能问同桌。
|
||||
|
||||
Reference in new issue
Block a user