feat(排行榜): 重写全服 Top100,补上「我的排名」,后台那条挪进 admin
起因是 Top100 的「已解决」「提交数」两列一直空白:列的 key 还是 snake_case(accepted_number / submission_number),而数据早在c2a8120拆掉 转换层后就是 camelCase 了。naive-ui 按 row[key] 取值,取到 undefined 就渲染 空白、不报错 —— 和d3348f9是同一个病根。 顺着这条线把整个端点重写了: **上限不再由调用方传。** `top` 参数原来有三个调用方各传各的(100 / 10 / 0), 而它会覆盖 limit 与 offset、total 却按全量算,正是36e4ac2那个「每页都是同样 100 条」的成因。现在 100 写死在服务端,参数只剩 limit / offset。 「全服 Top10」不需要另一个上限,它就是这个榜的第一页。 **排序补了第三档 asc(user.id)。** 前两个键完全相同的学生在真实数据里成片存在 (都是 0/0),没有稳定兜底键时 postgres 每次返回的顺序可以不同,翻页会看到重复 或漏掉的人。老代码缺这一档。 **新增 me(我的全服名次)。** 名次 = 排在我前面的人数 + 1,三个排序键逐级比较, 与列表的 orderBy 逐字对应 —— 少比一级就会出现「显示第 7 名、实际排在表格第 9 行」。 榜上高亮我那一行,名次超出 100 时在 footer 单独给一行。未登录、教师/超管返回 null。 **后台那条搬去 /api/admin/rankings/users**(requireSuperAdmin,无上限)。 原来它走的是公开端点的 top=0 分支,也就是任何匿名请求都能 ?top=0&limit=250 翻走全校学生名单和个性签名 —— 而 /profiles/:username 恰恰为了收紧枚举面才做了 「匿名一律返回空」,注释里还专门点了 /rankings/users 的名。这条页面本来就是 requiresSuperAdmin,它调的另外两个接口也都是 requireSuperAdmin,守卫对得上。 顺手去掉恒真条件 gte(acceptedNumber, 0):该列是 notNull default 0。 同一次扫了全仓 218 个表格列定义,筛出 70 个没有 render 的(只有这些才靠 key 直接取值),比对全部类型定义里的字段名 —— 除这两处外没有漏网的。`_id` 和 `test_case` 是真字段名,不能改。另外收掉两处同类的雷: admin/setting/config.vue 手写的 `interface Testcase` 字段名和类型都是错的 (真实数据是 createTime: number,不是 create_time: string),改用契约的 OrphanTestCase;serverColumns 里 last_heartbeat / create_time 两个残留 key 有 render 兜着没出事,一并改正。 实测(造 120 个探针用户,含 3 个 AC 与提交数完全相同的并列,验完已清库): 122 人时 total=100;offset=95 末页 5 条;offset=100 越界返回空且不发 SQL; 并列三人稳定占据前三;student(ac=2) 拿到 rank=121 走「不在榜上」分支; 把 ac 调到 450 时 rank=4 且表格第 4 行正是 student(名次与行号对得上); 升成超管后 me 变 null;后台端点 total=121 无上限、keyword=probe_01 命中 10 条、 未登录 401;传 top=1 已被忽略。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
activityRankItemSchema,
|
||||
metricsSchema,
|
||||
problemRankSchema,
|
||||
myRankSchema,
|
||||
rankProfileSchema,
|
||||
registerRequestSchema,
|
||||
updateProfileRequestSchema,
|
||||
@@ -17,10 +18,11 @@ import {
|
||||
countDistinct,
|
||||
desc,
|
||||
eq,
|
||||
gt,
|
||||
gte,
|
||||
ilike,
|
||||
inArray,
|
||||
isNull,
|
||||
lt,
|
||||
lte,
|
||||
min,
|
||||
or,
|
||||
@@ -145,36 +147,108 @@ accountRoutes.get("/users/:id/metrics", async (c) => {
|
||||
return success(c, metricsSchema.parse({ now: new Date().toISOString(), first: row.first, latest: row.latest }))
|
||||
})
|
||||
|
||||
accountRoutes.get("/rankings/users", async (c) => {
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
/**
|
||||
* 全服榜单的大小。**写死在服务端,不接受调用方传** —— 上限是这个端点的属性,
|
||||
* 不是调用方的选择。
|
||||
*
|
||||
* 之前它是个 `top` 查询参数,三个调用方各传各的(100 / 10 / 0),
|
||||
* 而 top 又会覆盖 limit 与 offset,total 却按全量人数算 —— 于是分页器算出几十页、
|
||||
* 页页内容相同(36e4ac2)。「全服 Top10」不需要另一个上限,取 limit=10&offset=0 即可;
|
||||
* 后台那个「不限量」的用法搬去了 /admin/rankings/users。
|
||||
*/
|
||||
const LEADERBOARD_SIZE = 100
|
||||
|
||||
/** 入榜人群:正常状态的学生与学生管理员。教师和超管不参与排名。 */
|
||||
const leaderboardWhere = and(
|
||||
inArray(schema.user.adminType, ["Regular User", "Student Admin"]),
|
||||
eq(schema.user.isDisabled, false),
|
||||
)
|
||||
|
||||
/**
|
||||
* 榜单排序:AC 多的在前 → 同 AC 时提交少的在前 → 再同就按 id。
|
||||
*
|
||||
* 第三档不是凑数:前两个键完全相同的学生在真实数据里成片存在(都是 0/0),
|
||||
* 没有稳定的兜底键时 postgres 每次返回的顺序可以不同,翻页会看到重复或漏掉的人。
|
||||
*/
|
||||
const leaderboardOrder = [
|
||||
desc(schema.userProfile.acceptedNumber),
|
||||
asc(schema.userProfile.submissionNumber),
|
||||
asc(schema.user.id),
|
||||
]
|
||||
|
||||
accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: LEADERBOARD_SIZE })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const top = queryInteger(c.req.query("top"), 0, { min: 0, max: 10_000 })
|
||||
const username = c.req.query("username")?.trim() ?? ""
|
||||
const where = and(
|
||||
inArray(schema.user.adminType, ["Regular User", "Student Admin"]),
|
||||
eq(schema.user.isDisabled, false),
|
||||
gte(schema.userProfile.acceptedNumber, 0),
|
||||
username ? ilike(schema.user.username, `%${username}%`) : undefined,
|
||||
)
|
||||
|
||||
const [totalRow] = await db.select({ value: count() }).from(schema.userProfile)
|
||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)).where(where)
|
||||
// top 只是「榜单取前 N 名」的上限,分页仍要在这 N 条之内生效:
|
||||
// 否则 top=100 时每页都返回同样的 100 条,而 total 又是全量人数,翻页翻不动
|
||||
const total = top > 0 ? Math.min(totalRow?.value ?? 0, top) : (totalRow?.value ?? 0)
|
||||
const pageLimit = top > 0 ? Math.max(0, Math.min(limit, top - offset)) : limit
|
||||
const rows = pageLimit === 0 ? [] : await db.select({ profile: schema.userProfile, user: schema.user }).from(schema.userProfile)
|
||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)).where(where)
|
||||
.orderBy(desc(schema.userProfile.acceptedNumber), asc(schema.userProfile.submissionNumber))
|
||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(leaderboardWhere)
|
||||
const total = Math.min(totalRow?.value ?? 0, LEADERBOARD_SIZE)
|
||||
|
||||
// 末页可能只剩不足 limit 条,越界页一条不剩 —— 后者直接不发 SQL
|
||||
const pageLimit = Math.max(0, Math.min(limit, total - offset))
|
||||
const rows = pageLimit === 0 ? [] : await db
|
||||
.select({ profile: schema.userProfile, user: schema.user }).from(schema.userProfile)
|
||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(leaderboardWhere).orderBy(...leaderboardOrder)
|
||||
.limit(pageLimit).offset(offset)
|
||||
const results = rows.map(({ profile, user }) => rankProfileSchema.parse({
|
||||
|
||||
return success(c, userRankSchema.parse({
|
||||
results: rows.map(serializeRankRow),
|
||||
total,
|
||||
me: await myLeaderboardRank(c.get("user")?.id),
|
||||
}))
|
||||
})
|
||||
|
||||
function serializeRankRow({ profile, user }: {
|
||||
profile: typeof schema.userProfile.$inferSelect
|
||||
user: typeof schema.user.$inferSelect
|
||||
}) {
|
||||
return rankProfileSchema.parse({
|
||||
id: profile.id,
|
||||
user: sampleUser(user, profile.realName),
|
||||
acceptedNumber: profile.acceptedNumber,
|
||||
submissionNumber: profile.submissionNumber,
|
||||
mood: profile.mood,
|
||||
}))
|
||||
return success(c, userRankSchema.parse({ results, total }))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 「我」的全服名次,登录且身份入榜时才有。
|
||||
*
|
||||
* 名次 = 排在我前面的人数 + 1,三个排序键**逐级**比较,与列表的 orderBy 逐字对应 ——
|
||||
* 少比一级就会出现「显示第 7 名、实际排在表格第 9 行」这种对不上的情况。
|
||||
* 三个键全等才算并列,此时名次相同。
|
||||
*/
|
||||
async function myLeaderboardRank(userId: number | undefined) {
|
||||
if (!userId) return null
|
||||
const [mine] = await db
|
||||
.select({ profile: schema.userProfile, user: schema.user }).from(schema.userProfile)
|
||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(and(leaderboardWhere, eq(schema.user.id, userId))).limit(1)
|
||||
if (!mine) return null
|
||||
|
||||
const { acceptedNumber, submissionNumber } = mine.profile
|
||||
const [ahead] = await db.select({ value: count() }).from(schema.userProfile)
|
||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(and(leaderboardWhere, or(
|
||||
gt(schema.userProfile.acceptedNumber, acceptedNumber),
|
||||
and(
|
||||
eq(schema.userProfile.acceptedNumber, acceptedNumber),
|
||||
lt(schema.userProfile.submissionNumber, submissionNumber),
|
||||
),
|
||||
and(
|
||||
eq(schema.userProfile.acceptedNumber, acceptedNumber),
|
||||
eq(schema.userProfile.submissionNumber, submissionNumber),
|
||||
lt(schema.user.id, userId),
|
||||
),
|
||||
)))
|
||||
|
||||
return myRankSchema.parse({
|
||||
...serializeRankRow(mine),
|
||||
rank: (ahead?.value ?? 0) + 1,
|
||||
})
|
||||
}
|
||||
|
||||
accountRoutes.get("/rankings/activity", async (c) => {
|
||||
const start = c.req.query("start")
|
||||
|
||||
Reference in New Issue
Block a user