列表接口按行发查询是从阶段 3 一路带过来的写法:`Promise.all(rows.map(...))` 看着是并发的,但每行都往库里打一次,行数一多就是几百上千次往返。同样的模式 也散在几个后台批处理和写入路径里。全部改成「先收集 id,一条 inArray/group by 查回来建 Map」——`routes/problem.ts` 的 getProblemTags 早就是这么写的,这次 只是把剩下的地方对齐。 用户可见的列表: - `GET /problem-sets` 每行 5 条(题目数/我的进度/奖章/已获奖章/创建者), limit 上限 250 就是 1250 次往返。改成固定 5 条,与行数无关。 - `GET /contests` 每场比赛一条 creator 查询。后台的比赛列表本来就是 join 出来的,只有这条公开列表漏了。 - `GET /admin/problems`、`GET /admin/contests/:id/problems` 每题一条标签查询。 - `GET /admin/problem-sets` 每行 3 条;`.../badges` 每个奖章一条 count。 后台批处理: - `refreshContestJoinedForAll` 原来是每个用户 1 条 count 加一个独立事务里的 insert/select for update/update。改成一条 group by 出全部用户的场次,再分批 upsert,`metrics || excluded.metrics` 是 jsonb 浅合并,只覆盖 contest_joined 一个键,其余指标原样保留 —— 合并在一条语句里完成,for update 那把锁不再需要。 - `rescanAchievement` 补发循环、`unlockAchievements`:命中的一次插完, onConflictDoNothing 的 returning 就是真新解锁的那批,unlockCount 改成一次 +N。 - `resyncProgress` 逐行 UPDATE 改成一条,completed 用 least() 夹住。 写入路径: - 标签解析抽出 normalizeTagNames + findTagsByName(一条 lower(name) IN), 新建题、改题、批量打标签三条路共用。 - 克隆比赛:题面一条 INSERT、标签一条 SELECT 加一条 INSERT。新旧题的对应 关系靠 _id 认,不依赖 returning 的行序。 - `POST /admin/website` 8 个键一条多行 upsert。 - 题单奖章判定一次插完。 `/ai/duration`:原来每个时间桶两条查询、桶之间还串行,一年 12 个桶 24 次往返。 改成先算桶、再一条查询把整段区间拉回来在内存里分桶。时间戳用 `extract(epoch) * 1000` 取毫秒回来比,别指望 Date.parse 认 pg 那个 `2026-08-12 00:00:00+00` 格式。**相邻桶首尾相接、两端闭区间**(落在边界上的 提交两个桶都算)这条旧语义是照搬的,不要顺手改成半开区间。 验证:本机起 dev 栈,造了覆盖各分支的种子数据(创建者重复的题单、零题目/零 奖章的题单、completed > total 的脏进度、除不尽的百分比、大小写混写的已有标签、 带/不带标签的比赛题、正好落在分桶边界上的提交),旧代码跑一遍、新代码跑一遍: - 51 个接口响应逐字节一致 - 12 张表的快照逐行一致(唯一差别是 progress_detail 里的 submit_time 墙钟值) - 打开 log_statement=all 数过条数,例如 `GET /admin/problems` 20 道题 24 → 5,`GET /problem-sets` 28 → 8,`/ai/duration` years:1 28 → 5, 202 个用户的成就补发 1828 → 614 一处可观察的行为变化:补发现在整批共用一个 unlockTime,原来是每人一个 new Date()。rescanAchievement 上方的注释本来就写着补发会给几百人盖同一个 时间戳、前端据此只显示「已获得」不显示日期,所以这个方向是对的。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -158,18 +158,43 @@ aiRoutes.get("/ai/duration", requireAuth, async (c) => {
|
||||
: duration === "months:6" ? { count: 6, unit: "months", rewind: (date: Date) => shiftMonths(date, -7), advance: (date: Date) => shiftMonths(date, 1) }
|
||||
: duration === "years:1" ? { count: 12, unit: "months", rewind: (date: Date) => shiftMonths(date, -13), advance: (date: Date) => shiftMonths(date, 1) }
|
||||
: { count: 4, unit: "weeks", rewind: (date: Date) => new Date(date.getTime() - 5 * 7 * 864e5), advance: (date: Date) => new Date(date.getTime() + 7 * 864e5) }
|
||||
// 先把 count 个时间桶算出来,再一条查询把整段区间的提交拉回来在内存里分桶。
|
||||
// 以前是每个桶两条查询、桶之间还是串行的,一年 12 个桶就是 24 次往返。
|
||||
// 相邻桶首尾相接、两端都是闭区间(end_i == start_{i+1}),落在边界上的提交
|
||||
// 两个桶都算 —— 这是旧行为,照搬,不要「顺手」改成半开区间。
|
||||
let cursor = config.rewind(new Date(endText))
|
||||
const data = []
|
||||
const buckets: { start: Date; end: Date }[] = []
|
||||
for (let index = 0; index < config.count; index++) {
|
||||
const start = config.advance(cursor)
|
||||
const end = config.advance(start)
|
||||
buckets.push({ start, end: config.advance(start) })
|
||||
cursor = start
|
||||
const [submissions, solved] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.submission).where(and(eq(schema.submission.userId, user.id), gte(schema.submission.createTime, start.toISOString()), lte(schema.submission.createTime, end.toISOString()))),
|
||||
db.select({ value: countDistinct(schema.submission.problemId) }).from(schema.submission).where(and(eq(schema.submission.userId, user.id), inArray(schema.submission.result, accepted), gte(schema.submission.createTime, start.toISOString()), lte(schema.submission.createTime, end.toISOString()))),
|
||||
])
|
||||
data.push(durationDataSchema.parse({ unit: config.unit, index: config.count - 1 - index, start: start.toISOString(), end: end.toISOString(), grade: solved[0]?.value ? "B" : "", problemCount: solved[0]?.value ?? 0, submissionCount: submissions[0]?.value ?? 0 }))
|
||||
}
|
||||
// 时间戳取 epoch 毫秒回来,比较在 JS 里做,和原来在 SQL 里比 timestamptz 等价,
|
||||
// 不受 pg 那个「空格分隔 + +00 偏移」字符串格式能否被 Date.parse 认的影响
|
||||
const rows = await db.select({
|
||||
time: sql<number>`extract(epoch from ${schema.submission.createTime}) * 1000`.mapWith(Number),
|
||||
problemId: schema.submission.problemId,
|
||||
result: schema.submission.result,
|
||||
}).from(schema.submission).where(and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
gte(schema.submission.createTime, buckets[0]!.start.toISOString()),
|
||||
lte(schema.submission.createTime, buckets.at(-1)!.end.toISOString()),
|
||||
))
|
||||
const data = buckets.map((bucket, index) => {
|
||||
const from = bucket.start.getTime()
|
||||
const to = bucket.end.getTime()
|
||||
const inRange = rows.filter((row) => row.time >= from && row.time <= to)
|
||||
const solved = new Set(inRange.filter((row) => accepted.includes(row.result)).map((row) => row.problemId)).size
|
||||
return durationDataSchema.parse({
|
||||
unit: config.unit,
|
||||
index: config.count - 1 - index,
|
||||
start: bucket.start.toISOString(),
|
||||
end: bucket.end.toISOString(),
|
||||
grade: solved ? "B" : "",
|
||||
problemCount: solved,
|
||||
submissionCount: inRange.length,
|
||||
})
|
||||
})
|
||||
return success(c, data)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user