From 71670e609b364040def6809103d881184cd74678 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 28 Sep 2026 08:27:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=88=90=E5=B0=B1):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E3=80=8C=E6=9C=80=E9=95=BF=E8=BF=9E=E7=BB=AD=E6=96=B0=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E5=91=A8=E6=95=B0=E3=80=8D=E6=8C=87=E6=A0=87=EF=BC=8C?= =?UTF-8?q?=E6=8C=89=E8=AF=BE=E8=A1=A8=E7=9A=84=E8=8A=82=E5=A5=8F=E7=AE=97?= =?UTF-8?q?=E8=BF=9E=E7=BB=AD=EF=BC=9B=E4=BF=AE=E6=8E=89=E6=AF=94=E8=B5=9B?= =?UTF-8?q?=E6=88=90=E5=B0=B1=E9=87=8D=E6=89=AB=E5=9B=A0=E5=AD=A4=E5=84=BF?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按天的连续成就对这里的学生基本拿不到:2025 秋最长连续交题天数的中位数是 1 天, 连续 7 天的只有 3 人。按周算,连续 2/4/8 周分别有约 73%/49%/8% 的学生做到过。 - 新指标 max_ac_week_streak:连续几个自然周(周一起、东八区)每周都至少新通过一道题 (不含比赛)。判题结算时增量累计(比按天那段多一道判断:重判更早的提交不会把连续清零); 后台建成就触发重扫时,先从提交记录全量补算(refreshAcWeekStreakForAll),同 contest_joined - 老的「坚持三天」「坚持一周」不动:下架的成就连已获得的人也看不到 - 修:库里 28 个已不存在的用户留下了 935 条提交(其中 11 人有比赛提交),全量补算往 user_stat 写时撞外键,整次失败——比赛类成就的重扫因此一直是失败的;两处都改为只算 还在的用户。本地修好后重扫「初入赛场」补发了 3 人 - 本地验证:连续 2/4/8 周补发 1086/618/123 人,与独立 SQL 一致 线上要在后台手动建成就(指标选「最长连续新通过周数」),建的时候自动补发。 Co-Authored-By: Claude Opus 5.5 --- apps/api/src/services/achievement-metrics.ts | 7 ++ apps/api/src/services/achievements.ts | 86 +++++++++++++++++++- 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/apps/api/src/services/achievement-metrics.ts b/apps/api/src/services/achievement-metrics.ts index 7c84ad6..c445b20 100644 --- a/apps/api/src/services/achievement-metrics.ts +++ b/apps/api/src/services/achievement-metrics.ts @@ -40,6 +40,13 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [ name: "最长连续 AC 天数", helpText: "连续每天至少 AC 一题的最长天数", }, + // 按周不按天:学生跟着课表来,2025 秋最长连续交题天数的中位数是 1 天、连续 7 天的 + // 只有 3 人,按天的「坚持一周」基本拿不到;按周的连续 2/4/8 周分别约 73%/49%/8% + { + key: "max_ac_week_streak", + name: "最长连续新通过周数", + helpText: "连续几个自然周(周一起)每周都至少新通过一道题(不含比赛)", + }, { key: "languages_used", name: "使用语言数", helpText: "用过多少种编程语言" }, { key: "contest_joined", diff --git a/apps/api/src/services/achievements.ts b/apps/api/src/services/achievements.ts index 3d7aaaf..6ee8a03 100644 --- a/apps/api/src/services/achievements.ts +++ b/apps/api/src/services/achievements.ts @@ -13,7 +13,7 @@ import { import { db, schema } from "../db" import { publishAchievementNotification } from "../events" -import { calendarDay, dayNumber, localHour } from "../time" +import { calendarDay, dayNumber, localHour, localTime, weekStart } from "../time" import { findMetric } from "./achievement-metrics" import { isAccepted, JudgeStatus } from "../judge/status" import { asRecord } from "../routes/helpers" @@ -23,6 +23,27 @@ function numberMetric(metrics: Record, key: string) { return typeof value === "number" ? value : 0 } +/** + * 「连续几周都有新通过」:`week` 是这次新通过所在那周的周一(东八区日历日)。 + * 和上一次新通过同一周不变;正好下一周就接上;隔了一周以上从 1 重来。 + * + * 比「坚持三天」那段多一道判断:这次的周比记下的还早(重判了一条很早以前的提交) + * 就什么都不动 —— 否则会把「当前连续」错误地重置成 1、还把 `_last_ac_week` 拨回过去。 + */ +function nextWeekStreak(value: Record, week: string) { + const last = typeof value._last_ac_week === "string" ? value._last_ac_week : null + if (last && week <= last) return {} + const current = + last && dayNumber(week) - dayNumber(last) === 7 + ? numberMetric(value, "_current_ac_week_streak") + 1 + : 1 + return { + _last_ac_week: week, + _current_ac_week_streak: current, + max_ac_week_streak: Math.max(numberMetric(value, "max_ac_week_streak"), current), + } +} + async function unlockAchievements( userId: number, metrics: Record, @@ -138,6 +159,7 @@ export async function updateAchievementsForSubmission(submissionId: string) { value.max_ac_in_one_day = Math.max( ...Object.values(perDay).filter((item): item is number => typeof item === "number"), ) + Object.assign(value, nextWeekStreak(value, calendarDay(weekStart(row.submission.createTime)))) } const activeDates = Array.isArray(value._active_dates) ? value._active_dates.filter((item): item is string => typeof item === "string") @@ -281,6 +303,8 @@ export async function rescanAchievement(achievementId: number) { // contest_joined 不由判题结算维护,扫之前先把它刷新一遍,否则永远读到旧值(或没有值) if (achievement.metric === "contest_joined") await refreshContestJoinedForAll() + // max_ac_week_streak 是后加的指标,判题结算只从上线那天起累计,存量用户要从提交记录补算 + if (achievement.metric === "max_ac_week_streak") await refreshAcWeekStreakForAll() const already = new Set( ( @@ -436,6 +460,9 @@ async function refreshContestJoinedForAll() { value: countDistinct(schema.submission.contestId), }) .from(schema.submission) + // 只算还在的用户,理由同 refreshAcWeekStreakForAll:库里有 11 个已不存在的用户 + // 留下了比赛提交,不关联的话这里 upsert 撞外键,比赛类成就的重扫一直是失败的 + .innerJoin(schema.user, eq(schema.user.id, schema.submission.userId)) .where(isNotNull(schema.submission.contestId)) .groupBy(schema.submission.userId) const now = new Date().toISOString() @@ -459,3 +486,60 @@ async function refreshContestJoinedForAll() { }) } } + +/** + * 从提交记录重算所有人的「最长连续新通过周数」,连同接着累计要用的 + * `_last_ac_week` / `_current_ac_week_streak` 一起写回 user_stat(jsonb 浅合并, + * 其余指标不动,写法同 refreshContestJoinedForAll)。 + * + * 「新通过」= 这道题(不含比赛)第一次通过的那次,和判题结算里 firstAc 同一个口径; + * 周按东八区自然周(周一起),SQL 里用 localTime 取墙上时间再 date_trunc。 + */ +async function refreshAcWeekStreakForAll() { + const firstAccepted = db + .select({ + userId: schema.submission.userId, + firstAt: sql`min(${schema.submission.createTime})`.as("first_at"), + }) + .from(schema.submission) + // 只算还在的用户:库里有提交的 user_id 已经对不上任何用户(submission.user_id 上没有 + // 外键,是历史遗留),直接写 user_stat 会撞 user_stat 的外键,整次重算失败 + .innerJoin(schema.user, eq(schema.user.id, schema.submission.userId)) + .where( + and( + isNull(schema.submission.contestId), + inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]), + ), + ) + .groupBy(schema.submission.userId, schema.submission.problemId) + .as("first_accepted") + const week = sql`to_char(date_trunc('week', ${localTime(firstAccepted.firstAt)}), 'YYYY-MM-DD')` + const rows = await db + .select({ userId: firstAccepted.userId, week }) + .from(firstAccepted) + .groupBy(firstAccepted.userId, week) + .orderBy(firstAccepted.userId, week) + + const byUser = new Map>() + for (const row of rows) { + const value = byUser.get(row.userId) ?? {} + Object.assign(value, nextWeekStreak(value, row.week)) + byUser.set(row.userId, value) + } + + const entries = [...byUser] + const now = new Date().toISOString() + for (let start = 0; start < entries.length; start += STAT_UPSERT_CHUNK) { + const chunk = entries.slice(start, start + STAT_UPSERT_CHUNK) + await db + .insert(schema.userStat) + .values(chunk.map(([userId, metrics]) => ({ userId, metrics, updateTime: now }))) + .onConflictDoUpdate({ + target: schema.userStat.userId, + set: { + metrics: sql`${schema.userStat.metrics} || excluded.metrics`, + updateTime: sql`excluded.update_time`, + }, + }) + } +}