原来只有 `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:
1 parent
e600fd24cf
commit
ed56a209ea
122 files changed
+10557
-4843
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]]),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
"",
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
// 读不到就退回默认值,但**不写缓存** —— 数据库抖一下不该让接下来一整分钟
|
||||
// 全站都按默认参数限流
|
||||
|
||||
@@ -22,14 +22,44 @@ const STOPWORDS = new Set(
|
||||
)
|
||||
|
||||
const CUSTOM_WORDS = [
|
||||
"循环结构", "条件判断", "判断条件", "结束条件", "循环条件",
|
||||
"异常处理", "边界条件", "输入输出", "输入验证", "开始结束",
|
||||
"结束节点", "开始节点", "判断节点", "流程走向", "逻辑错误",
|
||||
"逻辑缺陷", "逻辑不清", "缺少分支", "缺少步骤", "缺少判断",
|
||||
"缺少循环", "死循环", "无限循环", "循环出口", "循环体",
|
||||
"条件分支", "分支结构", "分支不全", "分支缺失", "符号使用",
|
||||
"符号不规范", "连线混乱", "变量初始化", "赋值操作", "累加操作",
|
||||
"终止条件", "退出条件", "返回值",
|
||||
"循环结构",
|
||||
"条件判断",
|
||||
"判断条件",
|
||||
"结束条件",
|
||||
"循环条件",
|
||||
"异常处理",
|
||||
"边界条件",
|
||||
"输入输出",
|
||||
"输入验证",
|
||||
"开始结束",
|
||||
"结束节点",
|
||||
"开始节点",
|
||||
"判断节点",
|
||||
"流程走向",
|
||||
"逻辑错误",
|
||||
"逻辑缺陷",
|
||||
"逻辑不清",
|
||||
"缺少分支",
|
||||
"缺少步骤",
|
||||
"缺少判断",
|
||||
"缺少循环",
|
||||
"死循环",
|
||||
"无限循环",
|
||||
"循环出口",
|
||||
"循环体",
|
||||
"条件分支",
|
||||
"分支结构",
|
||||
"分支不全",
|
||||
"分支缺失",
|
||||
"符号使用",
|
||||
"符号不规范",
|
||||
"连线混乱",
|
||||
"变量初始化",
|
||||
"赋值操作",
|
||||
"累加操作",
|
||||
"终止条件",
|
||||
"退出条件",
|
||||
"返回值",
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user