feat(个人主页): 后端一个接口给齐主页要的东西:做对的题带题名和日期、四个时间、本班第几、成就,老师和本人多拿知识点和没做对的题

- GET /profiles/:username/overview:同学看拿不到知识点和没做对的题,多一份「你和他」
- DELETE /me/avatar:不用图片,退回名字的字
- 知识点地图抽成 knowledgeMapOf,老师看学生也能用
- 删掉没人用了的 /users/:id/metrics

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-13 00:03:37 +08:00
1 parent afbc240a19
commit cbf5b9d922
3 files changed
+272 -127

No files matched your search

+202 -30
View File
@@ -7,7 +7,7 @@ import {
updateProfileRequestSchema,
type KnowledgeLevel,
type KnowledgeMap,
type Metrics,
type ProfileOverview,
type ProblemRank,
type WeeklyRank,
type WeeklyRankItem,
@@ -16,6 +16,7 @@ import {
and,
count,
countDistinct,
desc,
eq,
gte,
inArray,
@@ -41,8 +42,9 @@ import { getBooleanOption } from "../services/options"
import { sniffImageExtension } from "../services/image"
import { getUserProfileById } from "../services/profile"
import { clientIp, countAttempt, lockoutRemaining, type AttemptRule } from "../services/throttling"
import { localTime, weekStart } from "../time"
import { asRecord, queryInteger, sampleUser } from "./helpers"
import { audienceWhere, loadEntrants, SOLVED_RESULTS, standings } from "../services/ranking"
import { localTime, termStart, weekStart } from "../time"
import { asRecord, isTeacherOrAbove, sampleUser } from "./helpers"
export const accountRoutes = new Hono<AppEnv>()
@@ -143,6 +145,181 @@ accountRoutes.get("/profiles/:username", optionalAuth, async (c) => {
return success(c, profile)
})
/**
* 个人主页那一页的数据(设计稿「我的主页与设置」定稿)。看的人是本人、同学还是老师,
* 由这里裁:交过没做对的题和知识点只给本人和老师;不计入排名只告诉本人和老师。
*/
accountRoutes.get("/profiles/:username/overview", requireAuth, async (c) => {
const viewer = c.get("user")!
const [target] = await db
.select({
id: schema.user.id,
className: schema.user.className,
adminType: schema.user.adminType,
rankHiddenAt: schema.user.rankHiddenAt,
})
.from(schema.user)
.where(
and(
sql`lower(${schema.user.username}) = lower(${c.req.param("username")})`,
eq(schema.user.isDisabled, false),
),
)
.limit(1)
if (!target) return failure(c, 404, "user-not-found", "没有这个用户")
const privileged = target.id === viewer.id || isTeacherOrAbove(viewer)
const isStudent = (STUDENT_ROLES as readonly string[]).includes(target.adminType)
const firstSolved = min(schema.submission.createTime)
const lastTried = max(schema.submission.createTime)
const [times, solved, unsolved, knowledge, achievements, achievementTotal, classStandings] =
await Promise.all([
// 比赛提交也算:首末提交、学习天数一直是连比赛一起算的
db
.select({
first: min(schema.submission.createTime),
latest: max(schema.submission.createTime),
activeDays: countDistinct(sql`date(${localTime(schema.submission.createTime)})`),
})
.from(schema.submission)
.where(eq(schema.submission.userId, target.id)),
db
.select({
problemId: schema.submission.problemId,
displayId: schema.problem.displayId,
title: schema.problem.title,
solvedAt: firstSolved,
})
.from(schema.submission)
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId))
.where(
and(
eq(schema.submission.userId, target.id),
isNull(schema.submission.contestId),
inArray(schema.submission.result, SOLVED_RESULTS),
),
)
.groupBy(schema.submission.problemId, schema.problem.displayId, schema.problem.title)
.orderBy(desc(firstSolved), schema.problem.displayId),
privileged
? db
.select({
displayId: schema.problem.displayId,
title: schema.problem.title,
attempts: count(),
})
.from(schema.submission)
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId))
.where(
and(eq(schema.submission.userId, target.id), isNull(schema.submission.contestId)),
)
.groupBy(schema.submission.problemId, schema.problem.displayId, schema.problem.title)
.having(sql`not bool_or(${inArray(schema.submission.result, SOLVED_RESULTS)})`)
.orderBy(desc(lastTried))
: null,
privileged ? knowledgeMapOf(target.id, target.className) : null,
db
.select({
id: schema.achievement.id,
name: schema.achievement.name,
description: schema.achievement.description,
icon: schema.achievement.icon,
rarity: schema.achievement.rarity,
unlockTime: schema.userAchievement.unlockTime,
})
.from(schema.userAchievement)
.innerJoin(
schema.achievement,
eq(schema.achievement.id, schema.userAchievement.achievementId),
)
.where(
and(eq(schema.userAchievement.userId, target.id), eq(schema.achievement.visible, true)),
)
.orderBy(desc(schema.userAchievement.unlockTime), schema.achievement.order),
db
.select({ total: count() })
.from(schema.achievement)
.where(eq(schema.achievement.visible, true)),
// 这学期本班第几,和排名页同口径(不计入排名的人不在 rankedStudents 里,自然查不到)
isStudent && target.className && !target.rankHiddenAt
? loadEntrants(audienceWhere("class", target.className), termStart()).then((entrants) =>
standings(entrants, null, true),
)
: [],
])
const tagRows = solved.length
? await db
.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name })
.from(schema.problemTags)
.innerJoin(schema.problemTag, eq(schema.problemTag.id, schema.problemTags.problemtagId))
.where(
and(
eq(schema.problemTag.category, "knowledge"),
inArray(
schema.problemTags.problemId,
solved.map((row) => row.problemId),
),
),
)
.orderBy(schema.problemTag.name)
: []
const tagsOf = new Map<number, string[]>()
for (const row of tagRows) {
const list = tagsOf.get(row.problemId)
if (list) list.push(row.name)
else tagsOf.set(row.problemId, [row.name])
}
const mine = classStandings.find((row) => row.entrant.id === target.id && row.solved > 0)
// 「你和他」:只有同学看同学才给
let versus: ProfileOverview["versus"] = null
if (!privileged) {
const viewerSolved = await db
.selectDistinct({ problemId: schema.submission.problemId })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, viewer.id),
isNull(schema.submission.contestId),
inArray(schema.submission.result, SOLVED_RESULTS),
),
)
const theirs = new Set(solved.map((row) => row.problemId))
const me = classStandings.find((row) => row.entrant.id === viewer.id && row.solved > 0)
versus = {
solved: viewerSolved.length,
both: viewerSolved.filter((row) => theirs.has(row.problemId)).length,
classRank: me ? { rank: me.rank, total: classStandings.length } : null,
}
}
return success(c, {
firstSubmissionAt: times[0]?.first ?? null,
latestSubmissionAt: times[0]?.latest ?? null,
activeDays: times[0]?.activeDays ?? 0,
now: new Date().toISOString(),
classRank: mine ? { rank: mine.rank, total: classStandings.length } : null,
rankHidden: privileged && isStudent && !!target.rankHiddenAt,
solved: solved.flatMap((row) =>
row.solvedAt
? [
{
displayId: row.displayId,
title: row.title,
solvedAt: row.solvedAt,
tags: tagsOf.get(row.problemId) ?? [],
},
]
: [],
),
unsolved,
knowledge,
versus,
achievements: { total: achievementTotal[0]?.total ?? 0, unlocked: achievements },
} satisfies ProfileOverview)
})
accountRoutes.put("/me/profile", requireAuth, async (c) => {
const parsed = await parseBody(c, updateProfileRequestSchema, "Invalid profile payload")
if (!parsed.success) return parsed.response
@@ -179,24 +356,14 @@ accountRoutes.post("/me/avatar", requireAuth, async (c) => {
return success(c, { avatar })
})
accountRoutes.get("/users/:id/metrics", async (c) => {
const userId = queryInteger(c.req.param("id"), 0, { min: 1 })
// 比赛提交也算:首末提交时间、学习天数都连比赛一起统计
const [row] = await db
.select({
first: min(schema.submission.createTime),
latest: max(schema.submission.createTime),
activeDays: countDistinct(sql`date(${localTime(schema.submission.createTime)})`),
})
.from(schema.submission)
.where(eq(schema.submission.userId, userId))
if (!row?.first || !row.latest) return failure(c, 404, "no-submissions", "暂无提交")
return success(c, {
now: new Date().toISOString(),
first: row.first,
latest: row.latest,
activeDays: row.activeDays,
} satisfies Metrics)
/** 不用图片了,退回默认头像(前端画成名字最后一个字,见 RankAvatar.vue) */
accountRoutes.delete("/me/avatar", requireAuth, async (c) => {
const avatar = `${config.avatarUriPrefix}/default.png`
await db
.update(schema.userProfile)
.set({ avatar })
.where(eq(schema.userProfile.userId, c.get("user")!.id))
return success(c, { avatar })
})
/**
@@ -342,6 +509,11 @@ function levelOf(solved: number, thresholds: number[]) {
*/
accountRoutes.get("/me/knowledge", requireAuth, async (c) => {
const user = c.get("user")!
return success(c, await knowledgeMapOf(user.id, user.className))
})
/** 一个人的知识点地图:「我的知识点」和老师在个人主页上看学生共用 */
async function knowledgeMapOf(userId: number, className: string | null): Promise<KnowledgeMap> {
const tags = await db
.select({
id: schema.problemTag.id,
@@ -361,7 +533,7 @@ accountRoutes.get("/me/knowledge", requireAuth, async (c) => {
.groupBy(schema.problemTag.id, schema.problemTag.name)
.having(sql`count(*) >= ${KNOWLEDGE_MIN_PROBLEMS}`)
const tagIds = tags.map((tag) => tag.id)
if (!tagIds.length) return success(c, { tags: [], classTouched: [] } satisfies KnowledgeMap)
if (!tagIds.length) return { tags: [], classTouched: [] }
const firstAccepted = db
.select({
@@ -371,7 +543,7 @@ accountRoutes.get("/me/knowledge", requireAuth, async (c) => {
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
eq(schema.submission.userId, userId),
isNull(schema.submission.contestId),
inArray(schema.submission.result, ACCEPTED_RESULTS),
),
@@ -398,7 +570,7 @@ accountRoutes.get("/me/knowledge", requireAuth, async (c) => {
),
)
.groupBy(schema.problemTags.problemtagId),
user.className
className
? db
.select({
tagId: schema.problemTags.problemtagId,
@@ -412,7 +584,7 @@ accountRoutes.get("/me/knowledge", requireAuth, async (c) => {
)
.where(
and(
eq(schema.user.className, user.className),
eq(schema.user.className, className),
eq(schema.user.isDisabled, false),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
isNull(schema.submission.contestId),
@@ -422,13 +594,13 @@ accountRoutes.get("/me/knowledge", requireAuth, async (c) => {
)
.groupBy(schema.problemTags.problemtagId)
: [],
user.className
className
? db
.select({ total: count() })
.from(schema.user)
.where(
and(
eq(schema.user.className, user.className),
eq(schema.user.className, className),
eq(schema.user.isDisabled, false),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
),
@@ -444,7 +616,7 @@ accountRoutes.get("/me/knowledge", requireAuth, async (c) => {
.map((row) => row.tagId),
)
return success(c, {
return {
tags: tags
.sort((a, b) => b.problemCount - a.problemCount)
.map((tag) => {
@@ -461,8 +633,8 @@ accountRoutes.get("/me/knowledge", requireAuth, async (c) => {
} satisfies KnowledgeLevel
}),
classTouched: tags.filter((tag) => touchedIds.has(tag.id)).map((tag) => tag.name),
} satisfies KnowledgeMap)
})
}
}
accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
const user = c.get("user")!
-88
View File
@@ -1,88 +0,0 @@
<script setup lang="ts">
import { MOOD_MAX } from "@oj2/contract"
import { updateProfile, uploadAvatar } from "oj/api"
import { useUserStore } from "shared/store/user"
const userStore = useUserStore()
const message = useMessage()
async function beforeUpload(data: { file: UploadFileInfo; fileList: UploadFileInfo[] }) {
if (!data.file.file) return false
if (data.file.file.size > 2 * 1024 * 1024) {
message.warning("图片太大啦!不能超过 2 MB 啊")
return false
}
return true
}
async function upload({ file }: UploadCustomRequestOptions) {
try {
await uploadAvatar(file.file!)
message.success("上传成功")
userStore.getMyProfile()
} catch (err) {
message.error("上传失败")
}
}
async function saveProfile() {
// 以前限 256 字,老的长签名还在库里:输入框挡不住已有的字,存之前先说清楚
const length = userStore.profile?.mood?.trim().length ?? 0
if (length > MOOD_MAX) {
message.warning(`个性签名最多 ${MOOD_MAX} 个字,现在有 ${length} 个,删短一点再保存`)
return
}
try {
await updateProfile({
realName: userStore.profile?.realName ?? "",
mood: userStore.profile?.mood ?? "",
})
message.success("更改成功")
} catch (err) {
message.error("更改失败")
}
}
</script>
<template>
<n-flex class="container" vertical v-if="userStore.profile">
<h2>个人信息设置</h2>
<n-form>
<n-avatar round :size="120" :src="userStore.profile.avatar" alt="头像" />
<n-form-item label="">
<n-upload
:show-file-list="false"
accept="image/*"
@before-upload="beforeUpload"
:custom-request="upload"
>
<n-button>上传头像</n-button>
</n-upload>
</n-form-item>
<!-- <n-form-item label="真名">
<n-input v-model:value="userStore.profile.realName" />
</n-form-item> -->
<n-form-item label="个性签名">
<n-input
v-model:value="userStore.profile.mood"
:maxlength="MOOD_MAX"
show-count
placeholder="会显示在排名和个人主页上"
/>
</n-form-item>
<n-button @click="saveProfile">更改信息</n-button>
</n-form>
</n-flex>
</template>
<style scoped>
.container {
max-width: 640px;
margin: 0 auto;
padding-top: 8px;
gap: var(--oj-gap);
}
h2 {
margin: 0;
font-size: var(--oj-fs-title);
}
</style>
+70 -9
View File
@@ -1,6 +1,7 @@
import { z } from "zod"
import { sampleUserSchema } from "./common"
import { achievementRaritySchema } from "./achievement"
import { userProfileSchema } from "./auth"
/** 个性签名最多几个字。以前是 256,排名上要露出来后改成 30(九成签名本来就不超过 26 字) */
@@ -19,14 +20,6 @@ export const updateProfileRequestSchema = z.object({
mood: z.string().trim().max(MOOD_MAX, `个性签名最多 ${MOOD_MAX} 个字`).nullable().optional(),
})
export const metricsSchema = z.object({
now: z.string(),
latest: z.string(),
first: z.string(),
/** 有提交的日历天数(东八区),不是首末提交之间跨了多少天 */
activeDays: z.number().int(),
})
export const rankProfileSchema = z.object({
id: z.number().int(),
user: sampleUserSchema,
@@ -84,7 +77,6 @@ export type ProblemRank = z.infer<typeof problemRankSchema>
export type RankProfile = z.infer<typeof rankProfileSchema>
export type WeeklyRankItem = z.infer<typeof weeklyRankItemSchema>
export type WeeklyRank = z.infer<typeof weeklyRankSchema>
export type Metrics = z.infer<typeof metricsSchema>
export type PublicProfile = z.infer<typeof publicProfileSchema>
@@ -120,3 +112,72 @@ export const knowledgeMapSchema = z.object({
export type KnowledgeLevel = z.infer<typeof knowledgeLevelSchema>
export type KnowledgeMap = z.infer<typeof knowledgeMapSchema>
/**
* 个人主页(`/profiles/:username/overview`,设计稿「我的主页与设置」定稿)。
*
* 看自己、同学看、老师看是同一份接口,差别在后端按看的人裁:`unsolved` 和 `knowledge`
* 只给本人和老师(知识点不开放给同学 —— 开放了就成了另一张排行榜),同学拿到的是 null。
*/
export const profileSolvedSchema = z.object({
displayId: z.string(),
title: z.string(),
/** 第一次做对的时刻,按它分天 */
solvedAt: z.string(),
/** 知识点标签(category = knowledge),「按知识点」看时按它分组 */
tags: z.array(z.string()),
})
export const profileUnsolvedSchema = z.object({
displayId: z.string(),
title: z.string(),
attempts: z.number().int(),
})
export const profileAchievementSchema = z.object({
id: z.number().int(),
name: z.string(),
description: z.string(),
icon: z.string(),
rarity: achievementRaritySchema,
unlockTime: z.string(),
})
export const profileOverviewSchema = z.object({
/** 四个时间(用户 2026-10-12 要求保留)。一次没交过时前两个为 null、activeDays 为 0 */
firstSubmissionAt: z.string().nullable(),
latestSubmissionAt: z.string().nullable(),
/** 有提交的日历天数(东八区),比赛提交也算 */
activeDays: z.number().int(),
now: z.string(),
/** 这学期在本班第几(和排名页同口径)。没班级、一道没做对、不计入排名、不是学生时为 null */
classRank: z.object({ rank: z.number().int(), total: z.number().int() }).nullable(),
/** 被老师设成不计入排名。只告诉本人和老师,同学看到的恒为 false */
rankHidden: z.boolean(),
/** 做对的题,新的在前;比赛里的提交不算 */
solved: z.array(profileSolvedSchema),
/** 交过还没做对的题,最近交的在前;同学看时为 null */
unsolved: z.array(profileUnsolvedSchema).nullable(),
knowledge: knowledgeMapSchema.nullable(),
/**
* 同学看他时的「你和他」:你做对几道、其中几道他也做对了、你在本班第几(同班才有)。
* 本人、老师看时为 null
*/
versus: z
.object({
solved: z.number().int(),
both: z.number().int(),
classRank: z.object({ rank: z.number().int(), total: z.number().int() }).nullable(),
})
.nullable(),
achievements: z.object({
total: z.number().int(),
/** 解锁了的,新的在前 */
unlocked: z.array(profileAchievementSchema),
}),
})
export type ProfileSolved = z.infer<typeof profileSolvedSchema>
export type ProfileUnsolved = z.infer<typeof profileUnsolvedSchema>
export type ProfileAchievement = z.infer<typeof profileAchievementSchema>
export type ProfileOverview = z.infer<typeof profileOverviewSchema>