chore(格式): Prettier 统一到全仓,后端和契约一次性格式化
Deploy / deploy (push) Has been cancelled

原来只有 `apps/web` 在 Prettier 下(配置在 `apps/web/.prettierrc.toml`、脚本在
web 的 package.json),后端和契约从来没格式化过 —— 手写在 100 列上下,`db/schema.ts`
还是 drizzle-kit pull 留下的 tab 缩进。两套口径分叉久了,跨端改一处就得记着「这边
什么风格」。

- 配置搬到根目录 `.prettierrc.toml`,内容不变(`semi=false`,其余全默认,
  printWidth 80 —— 和前端已有的格式一致,不另立一套宽度);
- 脚本统一成根目录 `bun run fmt`,覆盖 `apps/*/src`、`apps/web/tests` 和两个构建
  配置;web 自己那份 `fmt` 和重复的 prettier 依赖删掉;
- `.prettierignore` 挡掉两类不该碰的:drizzle-kit 生成的 `src/db/meta/` 结构快照
  (它是 db:generate 的比对输入,只该由 drizzle-kit 写)、unplugin 每次 dev 都会
  重写的 `auto-imports.d.ts` / `components.d.ts`;
- 全量跑了一遍。纯格式,无行为改动:api typecheck / check:routes / check:ast、
  前端 type-check 全过,起 api 打了接口确认正常。前端这 39 个文件的小改动是
  prettier 版本漂移(类型断言的换行口径变了),不是新配置带来的。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5 committed 2026-09-16 08:27:34 -06:00
1 parent e600fd24cf
commit ed56a209ea
122 files changed
+10557 -4843

No files matched your search

+76 -16
View File
@@ -14,24 +14,85 @@ export interface AchievementMetric {
}
export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
{ key: "accepted_count", name: "AC 题目数", helpText: "去重后通过的题目数量(不含比赛)" },
{ key: "mid_ac_count", name: "中等题 AC 数", helpText: "去重后通过的中等难度题目数(不含比赛)" },
{ key: "hard_ac_count", name: "困难题 AC 数", helpText: "去重后通过的困难题目数(不含比赛)" },
{ key: "submission_count", name: "提交总数", helpText: "提交次数(不含比赛)" },
{
key: "accepted_count",
name: "AC 题目数",
helpText: "去重后通过的题目数量(不含比赛)",
},
{
key: "mid_ac_count",
name: "中等题 AC 数",
helpText: "去重后通过的中等难度题目数(不含比赛)",
},
{
key: "hard_ac_count",
name: "困难题 AC 数",
helpText: "去重后通过的困难题目数(不含比赛)",
},
{
key: "submission_count",
name: "提交总数",
helpText: "提交次数(不含比赛)",
},
{ key: "active_days", name: "活跃天数", helpText: "有过提交的累计天数" },
{ key: "max_ac_streak_days", name: "最长连续 AC 天数", helpText: "连续每天至少 AC 一题的最长天数" },
{
key: "max_ac_streak_days",
name: "最长连续 AC 天数",
helpText: "连续每天至少 AC 一题的最长天数",
},
{ key: "languages_used", name: "使用语言数", helpText: "用过多少种编程语言" },
{ key: "contest_joined", name: "参赛场次", helpText: "参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)" },
{
key: "contest_joined",
name: "参赛场次",
helpText: "参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)",
},
{ key: "badge_count", name: "题单奖章数", helpText: "获得的题单奖章数量" },
{ key: "problemset_completed", name: "完成题单数", helpText: "完成的题单数量" },
{ key: "first_try_ac_count", name: "一发入魂次数", helpText: "首次提交即通过的次数" },
{ key: "midnight_submissions", name: "凌晨提交次数", helpText: "0:00–5:00 之间的提交次数" },
{ key: "early_bird_submissions", name: "早起提交次数", helpText: "5:00–7:00 之间的提交次数" },
{ key: "compile_error_count", name: "编译错误次数", helpText: "累计编译错误的次数" },
{ key: "max_wa_before_ac", name: "屡败屡战", helpText: "单题失败最多多少次后终于通过" },
{ key: "max_ac_in_one_day", name: "单日最多 AC", helpText: "一天之内最多通过多少题" },
{ key: "max_code_lines", name: "最长代码行数", helpText: "提交过的最长代码有多少行" },
{ key: "achievement_unlocked_count", name: "已解锁成就数", helpText: "已解锁的成就数量(不含白金档)", meta: true },
{
key: "problemset_completed",
name: "完成题单数",
helpText: "完成的题单数量",
},
{
key: "first_try_ac_count",
name: "一发入魂次数",
helpText: "首次提交即通过的次数",
},
{
key: "midnight_submissions",
name: "凌晨提交次数",
helpText: "0:00–5:00 之间的提交次数",
},
{
key: "early_bird_submissions",
name: "早起提交次数",
helpText: "5:00–7:00 之间的提交次数",
},
{
key: "compile_error_count",
name: "编译错误次数",
helpText: "累计编译错误的次数",
},
{
key: "max_wa_before_ac",
name: "屡败屡战",
helpText: "单题失败最多多少次后终于通过",
},
{
key: "max_ac_in_one_day",
name: "单日最多 AC",
helpText: "一天之内最多通过多少题",
},
{
key: "max_code_lines",
name: "最长代码行数",
helpText: "提交过的最长代码有多少行",
},
{
key: "achievement_unlocked_count",
name: "已解锁成就数",
helpText: "已解锁的成就数量(不含白金档)",
meta: true,
},
]
const BY_KEY = new Map(ACHIEVEMENT_METRICS.map((item) => [item.key, item]))
@@ -43,4 +104,3 @@ export function findMetric(key: string) {
export function metricName(key: string) {
return BY_KEY.get(key)?.name ?? key
}
+301 -106
View File
@@ -1,4 +1,15 @@
import { and, count, countDistinct, eq, inArray, isNotNull, isNull, ne, notInArray, sql } from "drizzle-orm"
import {
and,
count,
countDistinct,
eq,
inArray,
isNotNull,
isNull,
ne,
notInArray,
sql,
} from "drizzle-orm"
import { db, schema } from "../db"
import { publishAchievementNotification } from "../events"
@@ -12,49 +23,90 @@ function numberMetric(metrics: Record<string, unknown>, key: string) {
return typeof value === "number" ? value : 0
}
async function unlockAchievements(userId: number, metrics: Record<string, unknown>, onlyMeta = false) {
const unlocked = await db.select({ id: schema.userAchievement.achievementId }).from(schema.userAchievement)
async function unlockAchievements(
userId: number,
metrics: Record<string, unknown>,
onlyMeta = false,
) {
const unlocked = await db
.select({ id: schema.userAchievement.achievementId })
.from(schema.userAchievement)
.where(eq(schema.userAchievement.userId, userId))
const filters = [eq(schema.achievement.visible, true)]
if (unlocked.length) filters.push(notInArray(schema.achievement.id, unlocked.map((row) => row.id)))
if (onlyMeta) filters.push(eq(schema.achievement.metric, "achievement_unlocked_count"))
if (unlocked.length)
filters.push(
notInArray(
schema.achievement.id,
unlocked.map((row) => row.id),
),
)
if (onlyMeta)
filters.push(eq(schema.achievement.metric, "achievement_unlocked_count"))
else filters.push(ne(schema.achievement.metric, "achievement_unlocked_count"))
const candidates = await db.select().from(schema.achievement).where(and(...filters))
const candidates = await db
.select()
.from(schema.achievement)
.where(and(...filters))
const hits = candidates.filter((achievement) => {
const value = metrics[achievement.metric]
if (typeof value !== "number") return false
return achievement.operator === "gte" ? value >= achievement.threshold : value <= achievement.threshold
return achievement.operator === "gte"
? value >= achievement.threshold
: value <= achievement.threshold
})
if (hits.length === 0) return []
// 命中的成就一次插完,冲突忽略后 returning 回来的就是「这次真新解锁的」。
// 一个用户对同一个成就只会解锁一次,所以每个成就都恰好 +1,一条 UPDATE 就够。
const inserted = await db.insert(schema.userAchievement).values(hits.map((achievement) => ({
userId,
achievementId: achievement.id,
unlockTime: new Date().toISOString(),
backfilled: false,
notified: false,
}))).onConflictDoNothing({ target: [schema.userAchievement.achievementId, schema.userAchievement.userId] })
const inserted = await db
.insert(schema.userAchievement)
.values(
hits.map((achievement) => ({
userId,
achievementId: achievement.id,
unlockTime: new Date().toISOString(),
backfilled: false,
notified: false,
})),
)
.onConflictDoNothing({
target: [
schema.userAchievement.achievementId,
schema.userAchievement.userId,
],
})
.returning({ achievementId: schema.userAchievement.achievementId })
if (inserted.length === 0) return []
const insertedIds = new Set(inserted.map((row) => row.achievementId))
await db.update(schema.achievement).set({ unlockCount: sql`${schema.achievement.unlockCount} + 1` })
await db
.update(schema.achievement)
.set({ unlockCount: sql`${schema.achievement.unlockCount} + 1` })
.where(inArray(schema.achievement.id, [...insertedIds]))
return hits.filter((achievement) => insertedIds.has(achievement.id))
}
export async function updateAchievementsForSubmission(submissionId: string) {
const [row] = await db.select({ submission: schema.submission, problem: schema.problem }).from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.where(eq(schema.submission.id, submissionId)).limit(1)
const [row] = await db
.select({ submission: schema.submission, problem: schema.problem })
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.submission.problemId, schema.problem.id),
)
.where(eq(schema.submission.id, submissionId))
.limit(1)
if (!row || row.submission.contestId !== null) return []
const priorRows = await db.select({ result: schema.submission.result }).from(schema.submission).where(and(
eq(schema.submission.userId, row.submission.userId),
eq(schema.submission.problemId, row.submission.problemId),
isNull(schema.submission.contestId),
ne(schema.submission.id, row.submission.id),
))
const priorRows = await db
.select({ result: schema.submission.result })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, row.submission.userId),
eq(schema.submission.problemId, row.submission.problemId),
isNull(schema.submission.contestId),
ne(schema.submission.id, row.submission.id),
),
)
const priorAccepted = priorRows.some((item) => isAccepted(item.result))
const accepted = isAccepted(row.submission.result)
const firstAc = accepted && !priorAccepted
@@ -63,97 +115,184 @@ export async function updateAchievementsForSubmission(submissionId: string) {
const hour = localHour(row.submission.createTime)
const metrics = await db.transaction(async (tx) => {
await tx.insert(schema.userStat).values({
userId: row.submission.userId,
metrics: {},
updateTime: new Date().toISOString(),
}).onConflictDoNothing({ target: schema.userStat.userId })
const [stat] = await tx.select().from(schema.userStat).where(eq(schema.userStat.userId, row.submission.userId)).for("update")
await tx
.insert(schema.userStat)
.values({
userId: row.submission.userId,
metrics: {},
updateTime: new Date().toISOString(),
})
.onConflictDoNothing({ target: schema.userStat.userId })
const [stat] = await tx
.select()
.from(schema.userStat)
.where(eq(schema.userStat.userId, row.submission.userId))
.for("update")
if (!stat) throw new Error("User achievement stat could not be created")
const value = objectValue(stat.metrics)
value.submission_count = numberMetric(value, "submission_count") + 1
if (firstAc) {
value.accepted_count = numberMetric(value, "accepted_count") + 1
if (row.problem.difficulty === "Mid") value.mid_ac_count = numberMetric(value, "mid_ac_count") + 1
if (row.problem.difficulty === "High") value.hard_ac_count = numberMetric(value, "hard_ac_count") + 1
if (firstTry) value.first_try_ac_count = numberMetric(value, "first_try_ac_count") + 1
value.max_wa_before_ac = Math.max(numberMetric(value, "max_wa_before_ac"), priorRows.length)
if (row.problem.difficulty === "Mid")
value.mid_ac_count = numberMetric(value, "mid_ac_count") + 1
if (row.problem.difficulty === "High")
value.hard_ac_count = numberMetric(value, "hard_ac_count") + 1
if (firstTry)
value.first_try_ac_count = numberMetric(value, "first_try_ac_count") + 1
value.max_wa_before_ac = Math.max(
numberMetric(value, "max_wa_before_ac"),
priorRows.length,
)
const perDay = objectValue(value._ac_per_day)
perDay[date] = (typeof perDay[date] === "number" ? perDay[date] : 0) + 1
value._ac_per_day = perDay
value.max_ac_in_one_day = Math.max(...Object.values(perDay).filter((item): item is number => typeof item === "number"))
value.max_ac_in_one_day = Math.max(
...Object.values(perDay).filter(
(item): item is number => typeof item === "number",
),
)
}
const activeDates = Array.isArray(value._active_dates) ? value._active_dates.filter((item): item is string => typeof item === "string") : []
const activeDates = Array.isArray(value._active_dates)
? value._active_dates.filter(
(item): item is string => typeof item === "string",
)
: []
if (!activeDates.includes(date)) activeDates.push(date)
value._active_dates = activeDates
value.active_days = activeDates.length
if (accepted) {
const last = typeof value._last_ac_date === "string" ? value._last_ac_date : null
const last =
typeof value._last_ac_date === "string" ? value._last_ac_date : null
if (last !== date) {
// 差一天要按日历日算,不能用 Date 相减:夏令时地区相邻两天差 23/25 小时,
// 除 86400000 得到的不是 1,`=== 1` 会静默把连续打卡判成断掉。
const current = last && dayNumber(date) - dayNumber(last) === 1
? numberMetric(value, "_current_ac_streak") + 1
: 1
const current =
last && dayNumber(date) - dayNumber(last) === 1
? numberMetric(value, "_current_ac_streak") + 1
: 1
value._last_ac_date = date
value._current_ac_streak = current
value.max_ac_streak_days = Math.max(numberMetric(value, "max_ac_streak_days"), current)
value.max_ac_streak_days = Math.max(
numberMetric(value, "max_ac_streak_days"),
current,
)
}
}
const languages = Array.isArray(value._languages) ? value._languages.filter((item): item is string => typeof item === "string") : []
if (!languages.includes(row.submission.language)) languages.push(row.submission.language)
const languages = Array.isArray(value._languages)
? value._languages.filter(
(item): item is string => typeof item === "string",
)
: []
if (!languages.includes(row.submission.language))
languages.push(row.submission.language)
value._languages = languages
value.languages_used = languages.length
if (hour < 5) value.midnight_submissions = numberMetric(value, "midnight_submissions") + 1
else if (hour < 7) value.early_bird_submissions = numberMetric(value, "early_bird_submissions") + 1
if (row.submission.result === JudgeStatus.COMPILE_ERROR) value.compile_error_count = numberMetric(value, "compile_error_count") + 1
value.max_code_lines = Math.max(numberMetric(value, "max_code_lines"), row.submission.code.split(/\r?\n/).length)
await tx.update(schema.userStat).set({ metrics: value, updateTime: new Date().toISOString() }).where(eq(schema.userStat.id, stat.id))
if (hour < 5)
value.midnight_submissions =
numberMetric(value, "midnight_submissions") + 1
else if (hour < 7)
value.early_bird_submissions =
numberMetric(value, "early_bird_submissions") + 1
if (row.submission.result === JudgeStatus.COMPILE_ERROR)
value.compile_error_count = numberMetric(value, "compile_error_count") + 1
value.max_code_lines = Math.max(
numberMetric(value, "max_code_lines"),
row.submission.code.split(/\r?\n/).length,
)
await tx
.update(schema.userStat)
.set({ metrics: value, updateTime: new Date().toISOString() })
.where(eq(schema.userStat.id, stat.id))
return value
})
const first = await unlockAchievements(row.submission.userId, metrics)
if (!first.length) return []
const [meta] = await db.select({ value: count() }).from(schema.userAchievement)
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
.where(and(eq(schema.userAchievement.userId, row.submission.userId), ne(schema.achievement.rarity, "platinum")))
const [meta] = await db
.select({ value: count() })
.from(schema.userAchievement)
.innerJoin(
schema.achievement,
eq(schema.userAchievement.achievementId, schema.achievement.id),
)
.where(
and(
eq(schema.userAchievement.userId, row.submission.userId),
ne(schema.achievement.rarity, "platinum"),
),
)
metrics.achievement_unlocked_count = meta?.value ?? 0
await db.update(schema.userStat).set({ metrics, updateTime: new Date().toISOString() }).where(eq(schema.userStat.userId, row.submission.userId))
return [...first, ...(await unlockAchievements(row.submission.userId, metrics, true))]
await db
.update(schema.userStat)
.set({ metrics, updateTime: new Date().toISOString() })
.where(eq(schema.userStat.userId, row.submission.userId))
return [
...first,
...(await unlockAchievements(row.submission.userId, metrics, true)),
]
}
export async function updateAchievementsForProblemSet(userId: number) {
const [[badgeRow], [completedRow]] = await Promise.all([
db.select({ value: count() }).from(schema.userBadge).where(eq(schema.userBadge.userId, userId)),
db.select({ value: count() }).from(schema.problemsetProgress).where(and(
eq(schema.problemsetProgress.userId, userId),
eq(schema.problemsetProgress.isCompleted, true),
)),
db
.select({ value: count() })
.from(schema.userBadge)
.where(eq(schema.userBadge.userId, userId)),
db
.select({ value: count() })
.from(schema.problemsetProgress)
.where(
and(
eq(schema.problemsetProgress.userId, userId),
eq(schema.problemsetProgress.isCompleted, true),
),
),
])
const metrics = await db.transaction(async (tx) => {
await tx.insert(schema.userStat).values({
userId,
metrics: {},
updateTime: new Date().toISOString(),
}).onConflictDoNothing({ target: schema.userStat.userId })
const [stat] = await tx.select().from(schema.userStat)
.where(eq(schema.userStat.userId, userId)).for("update").limit(1)
await tx
.insert(schema.userStat)
.values({
userId,
metrics: {},
updateTime: new Date().toISOString(),
})
.onConflictDoNothing({ target: schema.userStat.userId })
const [stat] = await tx
.select()
.from(schema.userStat)
.where(eq(schema.userStat.userId, userId))
.for("update")
.limit(1)
if (!stat) throw new Error("User achievement stat could not be created")
const value = objectValue(stat.metrics)
value.badge_count = badgeRow?.value ?? 0
value.problemset_completed = completedRow?.value ?? 0
await tx.update(schema.userStat).set({ metrics: value, updateTime: new Date().toISOString() })
await tx
.update(schema.userStat)
.set({ metrics: value, updateTime: new Date().toISOString() })
.where(eq(schema.userStat.id, stat.id))
return value
})
const first = await unlockAchievements(userId, metrics)
if (!first.length) return []
const [meta] = await db.select({ value: count() }).from(schema.userAchievement)
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
.where(and(eq(schema.userAchievement.userId, userId), ne(schema.achievement.rarity, "platinum")))
const [meta] = await db
.select({ value: count() })
.from(schema.userAchievement)
.innerJoin(
schema.achievement,
eq(schema.userAchievement.achievementId, schema.achievement.id),
)
.where(
and(
eq(schema.userAchievement.userId, userId),
ne(schema.achievement.rarity, "platinum"),
),
)
metrics.achievement_unlocked_count = meta?.value ?? 0
await db.update(schema.userStat).set({ metrics, updateTime: new Date().toISOString() })
await db
.update(schema.userStat)
.set({ metrics, updateTime: new Date().toISOString() })
.where(eq(schema.userStat.userId, userId))
return [...first, ...(await unlockAchievements(userId, metrics, true))]
}
@@ -170,22 +309,39 @@ const USER_ACHIEVEMENT_INSERT_CHUNK = 1000
* 而不显示日期,否则一次补发会给几百人盖同一个时间戳,把「最近获得」板块冲垮。
*/
export async function rescanAchievement(achievementId: number) {
const [achievement] = await db.select().from(schema.achievement)
.where(and(eq(schema.achievement.id, achievementId), eq(schema.achievement.visible, true))).limit(1)
const [achievement] = await db
.select()
.from(schema.achievement)
.where(
and(
eq(schema.achievement.id, achievementId),
eq(schema.achievement.visible, true),
),
)
.limit(1)
if (!achievement) return { scanned: 0, unlocked: 0 }
const metric = findMetric(achievement.metric)
if (!metric) return { scanned: 0, unlocked: 0 }
// contest_joined 不由判题结算维护,扫之前先把它刷新一遍,否则永远读到旧值(或没有值)
if (achievement.metric === "contest_joined") await refreshContestJoinedForAll()
if (achievement.metric === "contest_joined")
await refreshContestJoinedForAll()
const already = new Set(
(await db.select({ userId: schema.userAchievement.userId }).from(schema.userAchievement)
.where(eq(schema.userAchievement.achievementId, achievement.id))).map((row) => row.userId),
(
await db
.select({ userId: schema.userAchievement.userId })
.from(schema.userAchievement)
.where(eq(schema.userAchievement.achievementId, achievement.id))
).map((row) => row.userId),
)
const stats = await db.select({ userId: schema.userStat.userId, metrics: schema.userStat.metrics })
const stats = await db
.select({
userId: schema.userStat.userId,
metrics: schema.userStat.metrics,
})
.from(schema.userStat)
const eligible = stats.filter((stat) => {
if (already.has(stat.userId)) return false
@@ -201,39 +357,62 @@ export async function rescanAchievement(achievementId: number) {
// 计数改成一次 +N,通知照旧逐人推(那是 Redis,不是数据库)。
const unlockTime = new Date().toISOString()
const unlockedUserIds: number[] = []
for (let start = 0; start < eligible.length; start += USER_ACHIEVEMENT_INSERT_CHUNK) {
for (
let start = 0;
start < eligible.length;
start += USER_ACHIEVEMENT_INSERT_CHUNK
) {
const chunk = eligible.slice(start, start + USER_ACHIEVEMENT_INSERT_CHUNK)
const inserted = await db.insert(schema.userAchievement).values(chunk.map((stat) => ({
userId: stat.userId,
achievementId: achievement.id,
unlockTime,
backfilled: true,
notified: false,
}))).onConflictDoNothing({ target: [schema.userAchievement.achievementId, schema.userAchievement.userId] })
const inserted = await db
.insert(schema.userAchievement)
.values(
chunk.map((stat) => ({
userId: stat.userId,
achievementId: achievement.id,
unlockTime,
backfilled: true,
notified: false,
})),
)
.onConflictDoNothing({
target: [
schema.userAchievement.achievementId,
schema.userAchievement.userId,
],
})
.returning({ userId: schema.userAchievement.userId })
unlockedUserIds.push(...inserted.map((row) => row.userId))
}
if (unlockedUserIds.length) {
await db.update(schema.achievement)
.set({ unlockCount: sql`${schema.achievement.unlockCount} + ${unlockedUserIds.length}` })
await db
.update(schema.achievement)
.set({
unlockCount: sql`${schema.achievement.unlockCount} + ${unlockedUserIds.length}`,
})
.where(eq(schema.achievement.id, achievement.id))
for (const userId of unlockedUserIds) {
await publishAchievementNotification(userId, [{
id: achievement.id,
name: achievement.name,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
kind: "achievement",
}])
await publishAchievementNotification(userId, [
{
id: achievement.id,
name: achievement.name,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
kind: "achievement",
},
])
}
// 补发的非白金成就同样计入「已解锁数」,要和判题结算一样接着做第二轮(元成就)判定。
// 旧 `rescan_achievement` 就漏了这步,OJ2 原样搬过来:2026-09-07 一次补发之后
// 269 人的计数停在旧值,其中 10 人实际够了「奖杯收藏家」却一直没发 ——
// 判题结算只在「这次有新解锁」时才重算,被补发的人不再解锁新成就就永远不会自愈。
if (achievement.rarity !== "platinum" && achievement.metric !== "achievement_unlocked_count") {
if (
achievement.rarity !== "platinum" &&
achievement.metric !== "achievement_unlocked_count"
) {
await refreshUnlockedCount(unlockedUserIds)
for (const meta of await metaAchievements()) await rescanAchievement(meta.id)
for (const meta of await metaAchievements())
await rescanAchievement(meta.id)
}
}
return { scanned: stats.length, unlocked: unlockedUserIds.length }
@@ -241,9 +420,20 @@ export async function rescanAchievement(achievementId: number) {
/** 以「已解锁数」为指标的元成就(奖杯收藏家)。只取上架的,和 rescan 的口径一致 */
export function metaAchievements() {
return db.select({ id: schema.achievement.id, name: schema.achievement.name, threshold: schema.achievement.threshold, operator: schema.achievement.operator })
return db
.select({
id: schema.achievement.id,
name: schema.achievement.name,
threshold: schema.achievement.threshold,
operator: schema.achievement.operator,
})
.from(schema.achievement)
.where(and(eq(schema.achievement.visible, true), eq(schema.achievement.metric, "achievement_unlocked_count")))
.where(
and(
eq(schema.achievement.visible, true),
eq(schema.achievement.metric, "achievement_unlocked_count"),
),
)
}
/**
@@ -278,7 +468,6 @@ export async function refreshUnlockedCount(userIds?: number[]) {
return rows.map((row) => row.user_id)
}
/** 同上,3 个参数一行 */
const STAT_UPSERT_CHUNK = 1000
@@ -299,19 +488,25 @@ const STAT_UPSERT_CHUNK = 1000
*/
async function refreshContestJoinedForAll() {
const rows = await db
.select({ userId: schema.submission.userId, value: countDistinct(schema.submission.contestId) })
.select({
userId: schema.submission.userId,
value: countDistinct(schema.submission.contestId),
})
.from(schema.submission)
.where(isNotNull(schema.submission.contestId))
.groupBy(schema.submission.userId)
const now = new Date().toISOString()
for (let start = 0; start < rows.length; start += STAT_UPSERT_CHUNK) {
const chunk = rows.slice(start, start + STAT_UPSERT_CHUNK)
await db.insert(schema.userStat)
.values(chunk.map((row) => ({
userId: row.userId,
metrics: { contest_joined: row.value },
updateTime: now,
})))
await db
.insert(schema.userStat)
.values(
chunk.map((row) => ({
userId: row.userId,
metrics: { contest_joined: row.value },
updateTime: now,
})),
)
.onConflictDoUpdate({
target: schema.userStat.userId,
set: {
+55 -19
View File
@@ -27,14 +27,27 @@ export async function completeChat(system: string, user: string) {
const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), {
method: "POST",
signal: AbortSignal.timeout(COMPLETE_TIMEOUT_MS),
headers: { "content-type": "application/json", authorization: `Bearer ${config.aiKey}` },
body: JSON.stringify(requestBody([
{ role: "system", content: system },
{ role: "user", content: user },
], false)),
headers: {
"content-type": "application/json",
authorization: `Bearer ${config.aiKey}`,
},
body: JSON.stringify(
requestBody(
[
{ role: "system", content: system },
{ role: "user", content: user },
],
false,
),
),
})
if (!response.ok) throw new Error(`AI provider returned HTTP ${response.status}: ${await response.text()}`)
const payload = await response.json() as { choices?: Array<{ message?: { content?: string } }> }
if (!response.ok)
throw new Error(
`AI provider returned HTTP ${response.status}: ${await response.text()}`,
)
const payload = (await response.json()) as {
choices?: Array<{ message?: { content?: string } }>
}
return payload.choices?.[0]?.message?.content?.trim() ?? ""
}
@@ -48,21 +61,37 @@ export function streamChat(
async start(controller) {
const send = (value: string) => controller.enqueue(encoder.encode(value))
if (!config.aiKey) {
send(`data: ${JSON.stringify({ type: "error", message: "缺少 AI_KEY" })}\n\n`)
send(
`data: ${JSON.stringify({ type: "error", message: "缺少 AI_KEY" })}\n\n`,
)
send("event: end\n\n")
controller.close()
return
}
try {
const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${config.aiKey}` },
body: JSON.stringify(requestBody([
{ role: "system", content: system },
{ role: "user", content: user },
], true)),
})
if (!response.ok || !response.body) throw new Error(`AI provider returned HTTP ${response.status}: ${await response.text()}`)
const response = await fetch(
new URL("/chat/completions", config.aiBaseUrl),
{
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${config.aiKey}`,
},
body: JSON.stringify(
requestBody(
[
{ role: "system", content: system },
{ role: "user", content: user },
],
true,
),
),
},
)
if (!response.ok || !response.body)
throw new Error(
`AI provider returned HTTP ${response.status}: ${await response.text()}`,
)
send("event: start\n\n")
const reader = response.body.getReader()
const decoder = new TextDecoder()
@@ -79,7 +108,12 @@ export function streamChat(
const data = line.slice(5).trim()
if (data === "[DONE]") continue
try {
const item = JSON.parse(data) as { choices?: Array<{ delta?: { content?: string }; finish_reason?: string | null }> }
const item = JSON.parse(data) as {
choices?: Array<{
delta?: { content?: string }
finish_reason?: string | null
}>
}
const choice = item.choices?.[0]
const content = choice?.delta?.content
if (content) {
@@ -96,7 +130,9 @@ export function streamChat(
if (onComplete) await onComplete(full)
send(`data: ${JSON.stringify({ type: "done" })}\n\n`)
} catch (error) {
send(`data: ${JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) })}\n\n`)
send(
`data: ${JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) })}\n\n`,
)
} finally {
send("event: end\n\n")
controller.close()
+56 -15
View File
@@ -29,23 +29,40 @@ export function contestStatus(contest: ContestRow) {
return "0" as const
}
export function isContestAdmin(user: AuthUser | null | undefined, contest: ContestRow) {
return Boolean(user && (user.id === contest.createdById || user.adminType === "Super Admin"))
export function isContestAdmin(
user: AuthUser | null | undefined,
contest: ContestRow,
) {
return Boolean(
user &&
(user.id === contest.createdById || user.adminType === "Super Admin"),
)
}
export function contestDetailsAllowed(user: AuthUser | null | undefined, contest: ContestRow) {
export function contestDetailsAllowed(
user: AuthUser | null | undefined,
contest: ContestRow,
) {
return contestStatus(contest) === "-1" || isContestAdmin(user, contest)
}
export function checkContestPassword(candidate: string | null | undefined, expected: string | null) {
export function checkContestPassword(
candidate: string | null | undefined,
expected: string | null,
) {
if (!candidate || !expected) return false
if (candidate === expected) return true
const parts = candidate.split("#")
if (parts.length !== 2) return false
const [signature, expiresAt] = parts
if (!signature || !expiresAt || !/^\d+$/.test(expiresAt)) return false
const expectedSignature = createHash("sha256").update(`${expected}${expiresAt}`).digest("hex").slice(0, 8)
return signature === expectedSignature && Date.now() < Number(expiresAt) * 1000
const expectedSignature = createHash("sha256")
.update(`${expected}${expiresAt}`)
.digest("hex")
.slice(0, 8)
return (
signature === expectedSignature && Date.now() < Number(expiresAt) * 1000
)
}
/**
@@ -58,9 +75,15 @@ export function checkContestPassword(candidate: string | null | undefined, expec
*
* 放宽的只有出题人自己的视角,学生看隐藏比赛照旧是 404。
*/
export async function findAccessibleContest(user: AuthUser | null | undefined, id: number) {
const [contest] = await db.select().from(schema.contest)
.where(eq(schema.contest.id, id)).limit(1)
export async function findAccessibleContest(
user: AuthUser | null | undefined,
id: number,
) {
const [contest] = await db
.select()
.from(schema.contest)
.where(eq(schema.contest.id, id))
.limit(1)
if (!contest) return null
return contest.visible || isContestAdmin(user, contest) ? contest : null
}
@@ -73,16 +96,25 @@ export async function canAccessContest<E extends AppEnv>(
checkType: "details" | "problems" | "ranks" | "submissions",
) {
const user = c.get("user")
if (!user) return { ok: false as const, code: "login-required", message: "请先登录" }
if (!user)
return { ok: false as const, code: "login-required", message: "请先登录" }
if (isContestAdmin(user, contest)) return { ok: true as const }
if (contest.password) {
const stored = await getContestPassword(c, contest.id)
if (!checkContestPassword(stored, contest.password)) {
return { ok: false as const, code: "wrong-password", message: "Wrong password or password expired" }
return {
ok: false as const,
code: "wrong-password",
message: "Wrong password or password expired",
}
}
}
if (contestStatus(contest) === "1" && checkType !== "details") {
return { ok: false as const, code: "contest-not-started", message: "Contest has not started yet." }
return {
ok: false as const,
code: "contest-not-started",
message: "Contest has not started yet.",
}
}
return { ok: true as const }
}
@@ -104,11 +136,20 @@ export function requireContestAccess(
): MiddlewareHandler<ContestEnv> {
return async (c, next) => {
const id = Number(c.req.param(paramName))
const contest = Number.isInteger(id) && id > 0 ? await findAccessibleContest(c.get("user"), id) : null
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
const contest =
Number.isInteger(id) && id > 0
? await findAccessibleContest(c.get("user"), id)
: null
if (!contest)
return failure(c, 404, "contest-not-found", "Contest does not exist")
const access = await canAccessContest(c, contest, checkType)
if (!access.ok) {
return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message)
return failure(
c,
access.code === "login-required" ? 401 : 403,
access.code,
access.message,
)
}
c.set("contest", contest)
await next()
+23 -7
View File
@@ -34,7 +34,8 @@ export function exerciseDataError(
case "mcq": {
const options = strings(data.options)
if (options.length < 2) return "选择题至少要有 2 个选项"
if (options.some((option) => !option.trim())) return "选择题的选项不能为空"
if (options.some((option) => !option.trim()))
return "选择题的选项不能为空"
return indexAnswerError(data.answer, options.length, "正确答案")
}
case "sort": {
@@ -53,12 +54,19 @@ export function exerciseDataError(
case "match": {
const left = strings(data.left)
const right = strings(data.right)
if (left.length < 2 || right.length < 2) return "连线题左右两列各至少 2 项"
if (left.length < 2 || right.length < 2)
return "连线题左右两列各至少 2 项"
if (left.length !== right.length) return "连线题左右两列的行数必须相等"
return indexAnswerError(data.answer, right.length, "连线答案", left.length)
return indexAnswerError(
data.answer,
right.length,
"连线答案",
left.length,
)
}
case "predict": {
if (!(typeof data.code === "string" && data.code.trim())) return "输出预测题的代码不能为空"
if (!(typeof data.code === "string" && data.code.trim()))
return "输出预测题的代码不能为空"
if (strings(data.answer).filter((item) => item.trim()).length === 0) {
return "输出预测题至少要有一个正确输出"
}
@@ -75,7 +83,13 @@ export function exerciseDataError(
if (buckets.length < 2) return "归类题至少要有 2 个分组"
if (items.length === 0) return "归类题至少要有一个项目"
// 归类题的下标**允许重复**:好几个项目落在同一个分组是常态,别顺手加去重
return indexAnswerError(data.answer, buckets.length, "归类答案", items.length, false)
return indexAnswerError(
data.answer,
buckets.length,
"归类答案",
items.length,
false,
)
}
}
}
@@ -122,7 +136,9 @@ function indexAnswerError(
? `请至少勾选一个${label}`
: `${label}的条数(${answer.length})和项目数(${length})对不上`
}
if (answer.some((item) => item < 0 || item >= bound)) return `${label}的下标越界`
if (unique && new Set(answer).size !== answer.length) return `${label}里有重复的下标`
if (answer.some((item) => item < 0 || item >= bound))
return `${label}的下标越界`
if (unique && new Set(answer).size !== answer.length)
return `${label}里有重复的下标`
return null
}
+21 -13
View File
@@ -32,20 +32,25 @@ async function runFormatter(command: string[], code: string) {
}
function formatSql(code: string) {
return code
.split(";")
.map((statement) => statement.trim())
.filter(Boolean)
.map((statement) =>
statement.replace(
/\b(select|from|where|join|left|right|inner|outer|on|group by|order by|having|limit|insert into|values|update|set|delete from|create table|drop table|alter table|and|or|as)\b/gi,
(keyword) => keyword.toUpperCase(),
),
)
.join(";\n\n") + (code.trim().endsWith(";") ? ";" : "")
return (
code
.split(";")
.map((statement) => statement.trim())
.filter(Boolean)
.map((statement) =>
statement.replace(
/\b(select|from|where|join|left|right|inner|outer|on|group by|order by|having|limit|insert into|values|update|set|delete from|create table|drop table|alter table|and|or|as)\b/gi,
(keyword) => keyword.toUpperCase(),
),
)
.join(";\n\n") + (code.trim().endsWith(";") ? ";" : "")
)
}
export async function formatCode(code: string, language: "python" | "c" | "cpp" | "sql") {
export async function formatCode(
code: string,
language: "python" | "c" | "cpp" | "sql",
) {
if (language === "sql") return formatSql(code)
if (language === "python") {
@@ -54,7 +59,10 @@ export async function formatCode(code: string, language: "python" | "c" | "cpp"
code,
)
if (result.exitCode !== 0) {
throw new CodeFormatError(result.stderr || "Invalid Python syntax", "syntax")
throw new CodeFormatError(
result.stderr || "Invalid Python syntax",
"syntax",
)
}
return result.stdout
}
+11 -3
View File
@@ -15,14 +15,22 @@ export const websiteOptionDefaults = {
export async function getOptions<const T extends readonly string[]>(keys: T) {
const rows = await db
.select({ key: schema.optionsSysoptions.key, value: schema.optionsSysoptions.value })
.select({
key: schema.optionsSysoptions.key,
value: schema.optionsSysoptions.value,
})
.from(schema.optionsSysoptions)
.where(inArray(schema.optionsSysoptions.key, [...keys]))
return Object.fromEntries(rows.map((row) => [row.key, row.value])) as Record<T[number], unknown>
return Object.fromEntries(rows.map((row) => [row.key, row.value])) as Record<
T[number],
unknown
>
}
export async function getWebsiteOptions() {
const keys = Object.keys(websiteOptionDefaults) as Array<keyof typeof websiteOptionDefaults>
const keys = Object.keys(websiteOptionDefaults) as Array<
keyof typeof websiteOptionDefaults
>
const values = await getOptions(keys)
return Object.fromEntries(
keys.map((key) => [key, values[key] ?? websiteOptionDefaults[key]]),
+143 -60
View File
@@ -6,8 +6,13 @@ import { objectValue } from "../routes/helpers"
type BadgeRow = typeof schema.problemsetBadge.$inferSelect
type ProgressRow = typeof schema.problemsetProgress.$inferSelect
type ProblemLink = { problemId: number; score: number; isRequired: boolean }
type BadgeCheck = Pick<ProgressRow,
"completedProblemsCount" | "totalProblemsCount" | "totalScore" | "progressDetail">
type BadgeCheck = Pick<
ProgressRow,
| "completedProblemsCount"
| "totalProblemsCount"
| "totalScore"
| "progressDetail"
>
/**
* 题单进度的唯一算法:学生做出一道题后的增量更新、后台改动题目后的批量重算,都走这一份。
@@ -24,7 +29,9 @@ export function computeProgress(
previousCompleteTime: string | null,
now = new Date().toISOString(),
) {
const scoreByProblem = new Map(links.map((link) => [String(link.problemId), link.score]))
const scoreByProblem = new Map(
links.map((link) => [String(link.problemId), link.score]),
)
// 已经移出题单的题目要从 detail 里剔掉,留着它 completed 就会比实际做出的题还多
const kept: Record<string, unknown> = {}
let totalScore = 0
@@ -44,7 +51,9 @@ export function computeProgress(
const required = links.filter((link) => link.isRequired)
const graded = required.length ? required : links
const gradedKeys = new Set(graded.map((link) => String(link.problemId)))
const completed = Object.keys(kept).filter((key) => gradedKeys.has(key)).length
const completed = Object.keys(kept).filter((key) =>
gradedKeys.has(key),
).length
const total = graded.length
// total > 0 这个前提不能省:0 === 0 同样成立,没有题目的题单会让人一加入就算「完成」,
// 还会写下 complete_time、计进「完成题单数」成就,而且后面补上题目也不会自愈。
@@ -55,7 +64,8 @@ export function computeProgress(
completedProblemsCount: completed,
totalScore,
// 乘 10000 四舍五入再除 100,保留两位小数
progressPercentage: total > 0 ? Math.round((completed / total) * 10000) / 100 : 0,
progressPercentage:
total > 0 ? Math.round((completed / total) * 10000) / 100 : 0,
isCompleted,
// 只设不清,语义是「曾经完成于」,对齐旧栈 problemset/models.py:218。
//
@@ -78,7 +88,8 @@ async function writeProgress(rows: ProgressWrite[]) {
for (let start = 0; start < rows.length; start += 1000) {
const chunk = rows.slice(start, start + 1000)
const values = sql.join(
chunk.map((row) => sql`(
chunk.map(
(row) => sql`(
${row.id}::bigint,
${JSON.stringify(row.progressDetail)}::jsonb,
${row.totalProblemsCount}::int,
@@ -87,7 +98,8 @@ async function writeProgress(rows: ProgressWrite[]) {
${row.progressPercentage}::double precision,
${row.isCompleted}::boolean,
${row.completeTime}::timestamptz
)`),
)`,
),
sql`, `,
)
await db.execute(sql`
@@ -118,13 +130,19 @@ async function writeProgress(rows: ProgressWrite[]) {
*/
export function eligibleForBadge(badge: BadgeRow, progress: BadgeCheck) {
if (badge.conditionType === "all_problems") {
return progress.totalProblemsCount > 0 &&
return (
progress.totalProblemsCount > 0 &&
progress.completedProblemsCount === progress.totalProblemsCount
)
}
if (badge.conditionType === "problem_count") {
return Object.keys(objectValue(progress.progressDetail)).length >= badge.conditionValue
return (
Object.keys(objectValue(progress.progressDetail)).length >=
badge.conditionValue
)
}
if (badge.conditionType === "score") return progress.totalScore >= badge.conditionValue
if (badge.conditionType === "score")
return progress.totalScore >= badge.conditionValue
return false
}
@@ -136,26 +154,45 @@ export function eligibleForBadge(badge: BadgeRow, progress: BadgeCheck) {
* 调用方手里已经有最新的进度时把它传进来(`known`),省掉一次回表;
* 更要紧的是别用刚写完库之前的旧值去判定。
*/
export async function recalculateBadge(badge: BadgeRow, known?: (BadgeCheck & { userId: number })[]) {
const progresses = known ?? await db.select().from(schema.problemsetProgress)
.where(eq(schema.problemsetProgress.problemsetId, badge.problemsetId))
const eligibleIds = progresses.filter((item) => eligibleForBadge(badge, item)).map((item) => item.userId)
export async function recalculateBadge(
badge: BadgeRow,
known?: (BadgeCheck & { userId: number })[],
) {
const progresses =
known ??
(await db
.select()
.from(schema.problemsetProgress)
.where(eq(schema.problemsetProgress.problemsetId, badge.problemsetId)))
const eligibleIds = progresses
.filter((item) => eligibleForBadge(badge, item))
.map((item) => item.userId)
await db.transaction(async (tx) => {
await tx.delete(schema.userBadge).where(and(
eq(schema.userBadge.badgeId, badge.id),
eligibleIds.length ? notInArray(schema.userBadge.userId, eligibleIds) : undefined,
))
await tx
.delete(schema.userBadge)
.where(
and(
eq(schema.userBadge.badgeId, badge.id),
eligibleIds.length
? notInArray(schema.userBadge.userId, eligibleIds)
: undefined,
),
)
if (!eligibleIds.length) return
const existing = await tx.select({ userId: schema.userBadge.userId }).from(schema.userBadge)
const existing = await tx
.select({ userId: schema.userBadge.userId })
.from(schema.userBadge)
.where(eq(schema.userBadge.badgeId, badge.id))
const have = new Set(existing.map((item) => item.userId))
const missing = eligibleIds.filter((id) => !have.has(id))
if (missing.length) {
await tx.insert(schema.userBadge).values(missing.map((userId) => ({
userId,
badgeId: badge.id,
earnedTime: new Date().toISOString(),
})))
await tx.insert(schema.userBadge).values(
missing.map((userId) => ({
userId,
badgeId: badge.id,
earnedTime: new Date().toISOString(),
})),
)
}
})
}
@@ -174,20 +211,32 @@ export async function recalculateBadge(badge: BadgeRow, known?: (BadgeCheck & {
*/
export async function resyncProgress(problemsetId: number) {
const [links, progresses, badges] = await Promise.all([
db.select({
problemId: schema.problemsetProblem.problemId,
score: schema.problemsetProblem.score,
isRequired: schema.problemsetProblem.isRequired,
}).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, problemsetId)),
db.select().from(schema.problemsetProgress)
db
.select({
problemId: schema.problemsetProblem.problemId,
score: schema.problemsetProblem.score,
isRequired: schema.problemsetProblem.isRequired,
})
.from(schema.problemsetProblem)
.where(eq(schema.problemsetProblem.problemsetId, problemsetId)),
db
.select()
.from(schema.problemsetProgress)
.where(eq(schema.problemsetProgress.problemsetId, problemsetId)),
db.select().from(schema.problemsetBadge)
db
.select()
.from(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.problemsetId, problemsetId)),
])
const now = new Date().toISOString()
const updated = progresses.map((progress) => ({
...progress,
...computeProgress(objectValue(progress.progressDetail), links, progress.completeTime, now),
...computeProgress(
objectValue(progress.progressDetail),
links,
progress.completeTime,
now,
),
}))
if (updated.length) await writeProgress(updated)
for (const badge of badges) await recalculateBadge(badge, updated)
@@ -214,59 +263,93 @@ export async function recordSolvedProblem(
const joined = await db
.select({ problemsetId: schema.problemsetProgress.problemsetId })
.from(schema.problemsetProgress)
.innerJoin(schema.problemsetProblem, and(
eq(schema.problemsetProblem.problemsetId, schema.problemsetProgress.problemsetId),
eq(schema.problemsetProblem.problemId, problemId),
))
.innerJoin(
schema.problemsetProblem,
and(
eq(
schema.problemsetProblem.problemsetId,
schema.problemsetProgress.problemsetId,
),
eq(schema.problemsetProblem.problemId, problemId),
),
)
.where(eq(schema.problemsetProgress.userId, userId))
const earned: BadgeRow[] = []
let updated = 0
for (const { problemsetId } of joined) {
const hits = await db.transaction(async (tx) => {
const [progress] = await tx.select().from(schema.problemsetProgress).where(and(
eq(schema.problemsetProgress.problemsetId, problemsetId),
eq(schema.problemsetProgress.userId, userId),
)).for("update").limit(1)
const [progress] = await tx
.select()
.from(schema.problemsetProgress)
.where(
and(
eq(schema.problemsetProgress.problemsetId, problemsetId),
eq(schema.problemsetProgress.userId, userId),
),
)
.for("update")
.limit(1)
if (!progress) return []
// 提交记录先补上,即使这道题早就记过 —— 老数据里有记了进度没记提交的行
const [existing] = await tx.select({ id: schema.problemsetSubmission.id })
.from(schema.problemsetSubmission).where(and(
eq(schema.problemsetSubmission.problemsetId, problemsetId),
eq(schema.problemsetSubmission.userId, userId),
eq(schema.problemsetSubmission.problemId, problemId),
)).limit(1)
const [existing] = await tx
.select({ id: schema.problemsetSubmission.id })
.from(schema.problemsetSubmission)
.where(
and(
eq(schema.problemsetSubmission.problemsetId, problemsetId),
eq(schema.problemsetSubmission.userId, userId),
eq(schema.problemsetSubmission.problemId, problemId),
),
)
.limit(1)
if (!existing) {
await tx.insert(schema.problemsetSubmission)
await tx
.insert(schema.problemsetSubmission)
.values({ problemsetId, userId, submissionId, problemId })
}
const detail = objectValue(progress.progressDetail)
if (String(problemId) in detail) return []
const links = await tx.select({
problemId: schema.problemsetProblem.problemId,
score: schema.problemsetProblem.score,
isRequired: schema.problemsetProblem.isRequired,
}).from(schema.problemsetProblem)
const links = await tx
.select({
problemId: schema.problemsetProblem.problemId,
score: schema.problemsetProblem.score,
isRequired: schema.problemsetProblem.isRequired,
})
.from(schema.problemsetProblem)
.where(eq(schema.problemsetProblem.problemsetId, problemsetId))
const link = links.find((item) => item.problemId === problemId)
if (!link) return []
detail[String(problemId)] = { score: link.score, submit_time: solvedAt }
const update = computeProgress(detail, links, progress.completeTime)
await tx.update(schema.problemsetProgress).set(update)
await tx
.update(schema.problemsetProgress)
.set(update)
.where(eq(schema.problemsetProgress.id, progress.id))
updated += 1
const badges = await tx.select().from(schema.problemsetBadge)
const badges = await tx
.select()
.from(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.problemsetId, problemsetId))
const eligible = badges.filter((badge) => eligibleForBadge(badge, { ...progress, ...update }))
const eligible = badges.filter((badge) =>
eligibleForBadge(badge, { ...progress, ...update }),
)
if (eligible.length === 0) return []
// 达标的奖章一次插完,冲突忽略后 returning 回来的就是这次真拿到的
const inserted = await tx.insert(schema.userBadge).values(eligible.map((badge) => ({
userId,
badgeId: badge.id,
earnedTime: new Date().toISOString(),
}))).onConflictDoNothing({ target: [schema.userBadge.badgeId, schema.userBadge.userId] })
const inserted = await tx
.insert(schema.userBadge)
.values(
eligible.map((badge) => ({
userId,
badgeId: badge.id,
earnedTime: new Date().toISOString(),
})),
)
.onConflictDoNothing({
target: [schema.userBadge.badgeId, schema.userBadge.userId],
})
.returning({ badgeId: schema.userBadge.badgeId })
const ids = new Set(inserted.map((row) => row.badgeId))
return eligible.filter((badge) => ids.has(badge.id))
+4 -1
View File
@@ -3,7 +3,10 @@ import { and, eq } from "drizzle-orm"
import { db, schema } from "../db"
export async function getUserProfileById(userId: number, showRealName: boolean) {
export async function getUserProfileById(
userId: number,
showRealName: boolean,
) {
const [row] = await db
.select({ profile: schema.userProfile, user: schema.user })
.from(schema.userProfile)
+30 -10
View File
@@ -95,13 +95,20 @@ export async function processTestCaseZip(
// 只按「精确文件名」取内容,不遍历压缩包里的条目 ——
// 条目名一律不参与路径拼接,zip slip(`../../etc/passwd` 这类条目名)从设计上就进不来。
const names = new Set(Object.keys(files).filter((name) => /^\d+\.(in|out|sql)$/.test(name)))
const names = new Set(
Object.keys(files).filter((name) => /^\d+\.(in|out|sql)$/.test(name)),
)
const selected = options.sql ? collectSqlScripts(names) : collectPairs(names).flat()
if (selected.length === 0) throw new TestCaseError("压缩包里没有找到从 1 开始连续编号的测试点")
const selected = options.sql
? collectSqlScripts(names)
: collectPairs(names).flat()
if (selected.length === 0)
throw new TestCaseError("压缩包里没有找到从 1 开始连续编号的测试点")
if (options.sql && selected.length < 2) {
// 题目页会展示测试点 1 的期望结果,只有一个测试点时学生可以对照着硬编码 AC
throw new TestCaseError("SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果")
throw new TestCaseError(
"SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果",
)
}
let total = 0
@@ -109,12 +116,16 @@ export async function processTestCaseZip(
for (const name of selected) {
const raw = files[name]!
if (raw.length > MAX_ENTRY_BYTES) {
throw new TestCaseError(`测试点 ${name} 超过 ${MAX_ENTRY_BYTES / 1024 / 1024}MB`)
throw new TestCaseError(
`测试点 ${name} 超过 ${MAX_ENTRY_BYTES / 1024 / 1024}MB`,
)
}
const content = normalizeNewlines(raw)
total += content.length
if (total > MAX_TOTAL_BYTES) {
throw new TestCaseError(`测试点总大小超过 ${MAX_TOTAL_BYTES / 1024 / 1024}MB`)
throw new TestCaseError(
`测试点总大小超过 ${MAX_TOTAL_BYTES / 1024 / 1024}MB`,
)
}
contents.set(name, content)
}
@@ -149,7 +160,9 @@ export async function processTestCaseZip(
collectPairs(names).forEach(([input, output], index) => {
const outputContent = contents.get(output)!
const entry: TestCaseEntry = {
stripped_output_md5: createHash("md5").update(rstrip(outputContent)).digest("hex"),
stripped_output_md5: createHash("md5")
.update(rstrip(outputContent))
.digest("hex"),
input_size: contents.get(input)!.length,
output_size: outputContent.length,
input_name: input,
@@ -179,7 +192,9 @@ export async function packTestCaseZip(testCaseId: string) {
throw new TestCaseError("Test case does not exists")
}
const names = new Set(entries)
const isSql = await readInfo(testCaseId).then((info) => Boolean(info?.sql)).catch(() => false)
const isSql = await readInfo(testCaseId)
.then((info) => Boolean(info?.sql))
.catch(() => false)
const selected = isSql ? collectSqlScripts(names) : collectPairs(names).flat()
const bundle: Record<string, Uint8Array> = {}
for (const name of [...selected, "info"]) {
@@ -207,7 +222,10 @@ export async function readSqlScripts(testCaseId: string) {
const names = collectSqlScripts(new Set(await readdir(directory)))
const scripts: { name: string; content: string }[] = []
for (const name of names) {
scripts.push({ name, content: await readFile(resolve(directory, name), "utf8") })
scripts.push({
name,
content: await readFile(resolve(directory, name), "utf8"),
})
}
return scripts
}
@@ -216,5 +234,7 @@ function randomId() {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join("")
return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join(
"",
)
}
+20 -7
View File
@@ -81,11 +81,17 @@ type ThrottleRedis = typeof redis & {
): Promise<[number, string]>
}
function parseBucketConfig(value: unknown, fallback: BucketConfig): BucketConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) return fallback
function parseBucketConfig(
value: unknown,
fallback: BucketConfig,
): BucketConfig {
if (!value || typeof value !== "object" || Array.isArray(value))
return fallback
const raw = value as Record<string, unknown>
const pick = (key: keyof BucketConfig) =>
typeof raw[key] === "number" && Number.isFinite(raw[key]) && (raw[key] as number) > 0
typeof raw[key] === "number" &&
Number.isFinite(raw[key]) &&
(raw[key] as number) > 0
? (raw[key] as number)
: fallback[key]
return {
@@ -106,7 +112,10 @@ function parseBucketConfig(value: unknown, fallback: BucketConfig): BucketConfig
* `throttling` 没有后台界面,只能直接改库,改完最多一分钟后生效。
*/
const BUCKET_CACHE_TTL = 60_000
const bucketCache = new Map<"user", { value: BucketConfig; expiresAt: number }>()
const bucketCache = new Map<
"user",
{ value: BucketConfig; expiresAt: number }
>()
export async function getBucketConfig(scope: "user"): Promise<BucketConfig> {
const cached = bucketCache.get(scope)
@@ -117,9 +126,13 @@ export async function getBucketConfig(scope: "user"): Promise<BucketConfig> {
try {
const values = await getOptions(["throttling"])
const throttling = values.throttling
value = !throttling || typeof throttling !== "object" || Array.isArray(throttling)
? fallback
: parseBucketConfig((throttling as Record<string, unknown>)[scope], fallback)
value =
!throttling || typeof throttling !== "object" || Array.isArray(throttling)
? fallback
: parseBucketConfig(
(throttling as Record<string, unknown>)[scope],
fallback,
)
} catch {
// 读不到就退回默认值,但**不写缓存** —— 数据库抖一下不该让接下来一整分钟
// 全站都按默认参数限流
+38 -8
View File
@@ -22,14 +22,44 @@ const STOPWORDS = new Set(
)
const CUSTOM_WORDS = [
"循环结构", "条件判断", "判断条件", "结束条件", "循环条件",
"异常处理", "边界条件", "输入输出", "输入验证", "开始结束",
"结束节点", "开始节点", "判断节点", "流程走向", "逻辑错误",
"逻辑缺陷", "逻辑不清", "缺少分支", "缺少步骤", "缺少判断",
"缺少循环", "死循环", "无限循环", "循环出口", "循环体",
"条件分支", "分支结构", "分支不全", "分支缺失", "符号使用",
"符号不规范", "连线混乱", "变量初始化", "赋值操作", "累加操作",
"终止条件", "退出条件", "返回值",
"循环结构",
"条件判断",
"判断条件",
"结束条件",
"循环条件",
"异常处理",
"边界条件",
"输入输出",
"输入验证",
"开始结束",
"结束节点",
"开始节点",
"判断节点",
"流程走向",
"逻辑错误",
"逻辑缺陷",
"逻辑不清",
"缺少分支",
"缺少步骤",
"缺少判断",
"缺少循环",
"死循环",
"无限循环",
"循环出口",
"循环体",
"条件分支",
"分支结构",
"分支不全",
"分支缺失",
"符号使用",
"符号不规范",
"连线混乱",
"变量初始化",
"赋值操作",
"累加操作",
"终止条件",
"退出条件",
"返回值",
]
/**