Files
OJ2/apps/api/src/routes/mascot.ts
T
xuyueandClaude Opus 5.5 aa7e0a6eb8
Deploy / deploy (push) Successful in 50s
feat(看板娘): 接上全班进度、难度、提示、知识点、排名等数据,说的话更贴每个人
- 题目页:这节课同班做对的人多就说「全班已经 N 个人做对了」、都卡着就安慰、第一个做对的夸一句;
  通过率不到三成的题错了说「本来就难」、高于七成的说「再看看题」;AI 提示开了没用就提一句;
  做对了还有挑战就问敢不敢;流程图分低、拿 S 各有话
- 常点「太难了」的学生错了换软话;这周交得多、例子跑得少的念一句;卡住却从不举手的劝举手;
  这周要提示多的劝先自己想
- 首页(这节课没要做的时)每次戳随机说一件:知识点差几道升级、周榜差几道超过前一名、
  今天明天的比赛、三天内的新公告;两周以上没来说「好久不见」,只说一回
- 排名页第一名、差前一名几道;成就页差一点的;自学没全对的练一练;专题差最后一道拿奖章
- 运行例子、自己输入、举手也记进 mascot_event(trial、help),GET /mascot/profile
  多给「太难了」次数和这一周交、跑例子、举手、要提示的次数

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:33:35 +08:00

115 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mascotEventSchema, type MascotProfile } from '@oj2/contract'
import { and, count, countDistinct, desc, eq, gte, inArray, isNull, sql } from 'drizzle-orm'
import { Hono } from 'hono'
import { requireAuth, type AppEnv } from '../auth/middleware'
import { db, schema } from '../db'
import { parseBody, success } from '../http'
import { dayStart, weekStart } from '../time'
export const mascotRoutes = new Hono<AppEnv>()
/**
* 每人每分钟最多记这么多条。正常点她一分钟到不了十条(连点只记开头、彩蛋和嫌烦那几下),
* 拿脚本刷的才会撞上;撞上了就静默丢掉,不报错 —— 前台本来也不等这个结果
*/
const EVENTS_PER_MINUTE = 30
const windows = new Map<number, { start: number; count: number }>()
function allow(userId: number) {
const now = Date.now()
const entry = windows.get(userId)
if (!entry || now - entry.start >= 60_000) {
windows.set(userId, { start: now, count: 1 })
// 顺手清掉过期的,免得 Map 跟着在线人数一直涨
if (windows.size > 2000) for (const [id, it] of windows) if (now - it.start >= 60_000) windows.delete(id)
return true
}
entry.count++
return entry.count <= EVENTS_PER_MINUTE
}
/** 记一条看板娘互动(种类见契约 mascotEventSchema) */
mascotRoutes.post('/mascot/events', requireAuth, async (c) => {
const parsed = await parseBody(c, mascotEventSchema, '看板娘记录的格式不对')
if (!parsed.success) return parsed.response
const user = c.get('user')!
if (allow(user.id)) {
const { kind, situation, line, action, path } = parsed.data
await db.insert(schema.mascotEvent).values({ userId: user.id, kind, situation, line, action, path })
}
return success(c, null)
})
/** 最近多少次题库提交拿来看学生常犯哪类错 */
const RECENT_SUBMISSIONS = 20
/** 看板娘按学生的行为挑话用的那几个数(契约 mascotProfileSchema) */
mascotRoutes.get('/mascot/profile', requireAuth, async (c) => {
const userId = c.get('user')!.id
const s = schema.submission
const week = weekStart()
const e = schema.mascotEvent
const [pokes, today, recent, tooHard, weekSubmits, weekEvents, weekHints] = await Promise.all([
db
.select({ value: count() })
.from(schema.mascotEvent)
.where(and(eq(schema.mascotEvent.userId, userId), eq(schema.mascotEvent.kind, 'open'))),
db
.select({ value: countDistinct(s.problemId) })
.from(s)
.where(and(eq(s.userId, userId), eq(s.result, 0), gte(s.createTime, dayStart()))),
db
.select({ result: s.result, errorType: sql<string | null>`${s.statisticInfo}->'runtime_error'->>'type'` })
.from(s)
.where(and(eq(s.userId, userId), isNull(s.contestId)))
.orderBy(desc(s.createTime))
.limit(RECENT_SUBMISSIONS),
db
.select({ value: count() })
.from(schema.reaction)
.where(and(eq(schema.reaction.userId, userId), eq(schema.reaction.type, 'too_hard'))),
db
.select({ value: count() })
.from(s)
.where(and(eq(s.userId, userId), isNull(s.contestId), gte(s.createTime, week))),
db
.select({ kind: e.kind, value: count() })
.from(e)
.where(and(eq(e.userId, userId), inArray(e.kind, ['trial', 'help']), gte(e.createdAt, week)))
.groupBy(e.kind),
db
.select({ value: count() })
.from(schema.aiHint)
.innerJoin(s, eq(s.id, schema.aiHint.submissionId))
.where(and(eq(s.userId, userId), gte(schema.aiHint.createTime, week))),
])
const eventCount = (kind: string) => weekEvents.find((row) => row.kind === kind)?.value ?? 0
const types = new Map<string, number>()
for (const row of recent) if (row.result === 4 && row.errorType) types.set(row.errorType, (types.get(row.errorType) ?? 0) + 1)
const topType = [...types.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null
const tally = (...results: number[]) => recent.filter((row) => results.includes(row.result)).length
return success(c, {
pokes: pokes[0]?.value ?? 0,
todaySolved: today[0]?.value ?? 0,
tooHard: tooHard[0]?.value ?? 0,
week: {
submits: weekSubmits[0]?.value ?? 0,
trials: eventCount('trial'),
helps: eventCount('help'),
aiHints: weekHints[0]?.value ?? 0,
},
recent: {
total: recent.length,
wrongAnswer: tally(-1),
compileError: tally(-2),
runtimeError: tally(4, 3),
timeout: tally(1, 2),
runtimeErrorType: topType,
},
} satisfies MascotProfile)
})