feat(成就): 新增「最长连续新通过周数」指标,按课表的节奏算连续;修掉比赛成就重扫因孤儿提交失败
Deploy / deploy (push) Canceled after 0s

按天的连续成就对这里的学生基本拿不到: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 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-28 08:27:25 +08:00
1 parent 8ca427b5cd
commit 71670e609b
2 files changed
+92 -1

No files matched your search

@@ -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",
+85 -1
View File
@@ -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<string, unknown>, key: string) {
return typeof value === "number" ? value : 0
}
/**
* 「连续几周都有新通过」:`week` 是这次新通过所在那周的周一(东八区日历日)。
* 和上一次新通过同一周不变;正好下一周就接上;隔了一周以上从 1 重来。
*
* 比「坚持三天」那段多一道判断:这次的周比记下的还早(重判了一条很早以前的提交)
* 就什么都不动 —— 否则会把「当前连续」错误地重置成 1、还把 `_last_ac_week` 拨回过去。
*/
function nextWeekStreak(value: Record<string, unknown>, 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<string, unknown>,
@@ -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<string>`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<string>`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<number, Record<string, unknown>>()
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`,
},
})
}
}