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