原来只有 `apps/web` 在 Prettier 下(配置在 `apps/web/.prettierrc.toml`、脚本在 web 的 package.json),后端和契约从来没格式化过 —— 手写在 100 列上下,`db/schema.ts` 还是 drizzle-kit pull 留下的 tab 缩进。两套口径分叉久了,跨端改一处就得记着「这边 什么风格」。 - 配置搬到根目录 `.prettierrc.toml`,内容不变(`semi=false`,其余全默认, printWidth 80 —— 和前端已有的格式一致,不另立一套宽度); - 脚本统一成根目录 `bun run fmt`,覆盖 `apps/*/src`、`apps/web/tests` 和两个构建 配置;web 自己那份 `fmt` 和重复的 prettier 依赖删掉; - `.prettierignore` 挡掉两类不该碰的:drizzle-kit 生成的 `src/db/meta/` 结构快照 (它是 db:generate 的比对输入,只该由 drizzle-kit 写)、unplugin 每次 dev 都会 重写的 `auto-imports.d.ts` / `components.d.ts`; - 全量跑了一遍。纯格式,无行为改动:api typecheck / check:routes / check:ast、 前端 type-check 全过,起 api 打了接口确认正常。前端这 39 个文件的小改动是 prettier 版本漂移(类型断言的换行口径变了),不是新配置带来的。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e600fd24cf
commit
ed56a209ea
122 files changed
+10557
-4843
No files matched your search
+387
-168
@@ -33,34 +33,75 @@ contentRoutes.get("/announcements", async (c) => {
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const [totalRows, rows] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.announcement).where(eq(schema.announcement.visible, true)),
|
||||
db.select({ announcement: schema.announcement, user: schema.user, realName: schema.userProfile.realName })
|
||||
.from(schema.announcement).innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(schema.announcement)
|
||||
.where(eq(schema.announcement.visible, true)),
|
||||
db
|
||||
.select({
|
||||
announcement: schema.announcement,
|
||||
user: schema.user,
|
||||
realName: schema.userProfile.realName,
|
||||
})
|
||||
.from(schema.announcement)
|
||||
.innerJoin(
|
||||
schema.user,
|
||||
eq(schema.announcement.createdById, schema.user.id),
|
||||
)
|
||||
.leftJoin(
|
||||
schema.userProfile,
|
||||
eq(schema.userProfile.userId, schema.user.id),
|
||||
)
|
||||
.where(eq(schema.announcement.visible, true))
|
||||
.orderBy(desc(schema.announcement.top), desc(schema.announcement.createTime)).limit(limit).offset(offset),
|
||||
.orderBy(
|
||||
desc(schema.announcement.top),
|
||||
desc(schema.announcement.createTime),
|
||||
)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
return success(c, {
|
||||
results: rows.map(({ announcement, user, realName }) => ({
|
||||
id: announcement.id,
|
||||
title: announcement.title,
|
||||
tag: announcement.tag,
|
||||
top: announcement.top,
|
||||
createdBy: sampleUser(user, realName),
|
||||
createTime: announcement.createTime,
|
||||
lastUpdateTime: announcement.lastUpdateTime,
|
||||
} satisfies AnnouncementListItem)),
|
||||
results: rows.map(
|
||||
({ announcement, user, realName }) =>
|
||||
({
|
||||
id: announcement.id,
|
||||
title: announcement.title,
|
||||
tag: announcement.tag,
|
||||
top: announcement.top,
|
||||
createdBy: sampleUser(user, realName),
|
||||
createTime: announcement.createTime,
|
||||
lastUpdateTime: announcement.lastUpdateTime,
|
||||
}) satisfies AnnouncementListItem,
|
||||
),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
} satisfies AnnouncementList)
|
||||
})
|
||||
|
||||
contentRoutes.get("/announcements/:id", async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [row] = await db.select({ announcement: schema.announcement, user: schema.user, realName: schema.userProfile.realName })
|
||||
.from(schema.announcement).innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
|
||||
const [row] = await db
|
||||
.select({
|
||||
announcement: schema.announcement,
|
||||
user: schema.user,
|
||||
realName: schema.userProfile.realName,
|
||||
})
|
||||
.from(schema.announcement)
|
||||
.innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(and(eq(schema.announcement.id, id), eq(schema.announcement.visible, true))).limit(1)
|
||||
if (!row) return failure(c, 404, "announcement-not-found", "Announcement does not exist")
|
||||
.where(
|
||||
and(
|
||||
eq(schema.announcement.id, id),
|
||||
eq(schema.announcement.visible, true),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!row)
|
||||
return failure(
|
||||
c,
|
||||
404,
|
||||
"announcement-not-found",
|
||||
"Announcement does not exist",
|
||||
)
|
||||
return success(c, {
|
||||
id: row.announcement.id,
|
||||
title: row.announcement.title,
|
||||
@@ -78,36 +119,62 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const [totalRows, rows] = await Promise.all([
|
||||
db.select({ value: count() }).from(schema.message).where(eq(schema.message.recipientId, user.id)),
|
||||
db.select({ message: schema.message, sender: schema.user, realName: schema.userProfile.realName, submission: schema.submission, displayId: schema.problem.displayId })
|
||||
.from(schema.message).innerJoin(schema.user, eq(schema.message.senderId, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.innerJoin(schema.submission, eq(schema.message.submissionId, schema.submission.id))
|
||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||
.where(eq(schema.message.recipientId, user.id)).orderBy(desc(schema.message.createTime)).limit(limit).offset(offset),
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(schema.message)
|
||||
.where(eq(schema.message.recipientId, user.id)),
|
||||
db
|
||||
.select({
|
||||
message: schema.message,
|
||||
sender: schema.user,
|
||||
realName: schema.userProfile.realName,
|
||||
submission: schema.submission,
|
||||
displayId: schema.problem.displayId,
|
||||
})
|
||||
.from(schema.message)
|
||||
.innerJoin(schema.user, eq(schema.message.senderId, schema.user.id))
|
||||
.leftJoin(
|
||||
schema.userProfile,
|
||||
eq(schema.userProfile.userId, schema.user.id),
|
||||
)
|
||||
.innerJoin(
|
||||
schema.submission,
|
||||
eq(schema.message.submissionId, schema.submission.id),
|
||||
)
|
||||
.innerJoin(
|
||||
schema.problem,
|
||||
eq(schema.submission.problemId, schema.problem.id),
|
||||
)
|
||||
.where(eq(schema.message.recipientId, user.id))
|
||||
.orderBy(desc(schema.message.createTime))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
return success(c, {
|
||||
results: rows.map(({ message, sender, realName, submission, displayId }) => ({
|
||||
id: message.id,
|
||||
sender: sampleUser(sender, realName),
|
||||
createTime: message.createTime,
|
||||
message: message.message,
|
||||
submission: {
|
||||
id: submission.id,
|
||||
createTime: submission.createTime,
|
||||
userId: submission.userId,
|
||||
username: submission.username,
|
||||
code: submission.code,
|
||||
result: submission.result,
|
||||
// info / ip / contestId 三个字段不在 embeddedSubmissionSchema 里,故不传 ——
|
||||
// 对齐旧后端 SubmissionSafeModelSerializer 的 exclude,这三个键不出现在响应中
|
||||
language: submission.language,
|
||||
statisticInfo: objectValue(submission.statisticInfo),
|
||||
// 展示用题号而非数字主键,站内信页面拿它拼 /problem/<题号>
|
||||
problem: displayId,
|
||||
showLink: true,
|
||||
} satisfies EmbeddedSubmission,
|
||||
} satisfies Message)),
|
||||
results: rows.map(
|
||||
({ message, sender, realName, submission, displayId }) =>
|
||||
({
|
||||
id: message.id,
|
||||
sender: sampleUser(sender, realName),
|
||||
createTime: message.createTime,
|
||||
message: message.message,
|
||||
submission: {
|
||||
id: submission.id,
|
||||
createTime: submission.createTime,
|
||||
userId: submission.userId,
|
||||
username: submission.username,
|
||||
code: submission.code,
|
||||
result: submission.result,
|
||||
// info / ip / contestId 三个字段不在 embeddedSubmissionSchema 里,故不传 ——
|
||||
// 对齐旧后端 SubmissionSafeModelSerializer 的 exclude,这三个键不出现在响应中
|
||||
language: submission.language,
|
||||
statisticInfo: objectValue(submission.statisticInfo),
|
||||
// 展示用题号而非数字主键,站内信页面拿它拼 /problem/<题号>
|
||||
problem: displayId,
|
||||
showLink: true,
|
||||
} satisfies EmbeddedSubmission,
|
||||
}) satisfies Message,
|
||||
),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
} satisfies MessageList)
|
||||
})
|
||||
@@ -120,15 +187,39 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
|
||||
*/
|
||||
contentRoutes.post("/messages", requireSuperAdmin, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const parsed = createMessageRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid message payload")
|
||||
if (parsed.data.recipientId === user.id) return failure(c, 400, "invalid-recipient", "Can not send a message to yourself")
|
||||
const parsed = createMessageRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid message payload")
|
||||
if (parsed.data.recipientId === user.id)
|
||||
return failure(
|
||||
c,
|
||||
400,
|
||||
"invalid-recipient",
|
||||
"Can not send a message to yourself",
|
||||
)
|
||||
const [[recipient], [submission]] = await Promise.all([
|
||||
db.select({ id: schema.user.id }).from(schema.user).where(and(eq(schema.user.id, parsed.data.recipientId), eq(schema.user.isDisabled, false))).limit(1),
|
||||
db.select({ id: schema.submission.id }).from(schema.submission).where(eq(schema.submission.id, parsed.data.submissionId)).limit(1),
|
||||
db
|
||||
.select({ id: schema.user.id })
|
||||
.from(schema.user)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.user.id, parsed.data.recipientId),
|
||||
eq(schema.user.isDisabled, false),
|
||||
),
|
||||
)
|
||||
.limit(1),
|
||||
db
|
||||
.select({ id: schema.submission.id })
|
||||
.from(schema.submission)
|
||||
.where(eq(schema.submission.id, parsed.data.submissionId))
|
||||
.limit(1),
|
||||
])
|
||||
if (!recipient) return failure(c, 404, "user-not-found", "User does not exist")
|
||||
if (!submission) return failure(c, 404, "submission-not-found", "Submission does not exist")
|
||||
if (!recipient)
|
||||
return failure(c, 404, "user-not-found", "User does not exist")
|
||||
if (!submission)
|
||||
return failure(c, 404, "submission-not-found", "Submission does not exist")
|
||||
await db.insert(schema.message).values({
|
||||
message: parsed.data.message,
|
||||
createTime: new Date().toISOString(),
|
||||
@@ -140,11 +231,22 @@ contentRoutes.post("/messages", requireSuperAdmin, async (c) => {
|
||||
})
|
||||
|
||||
async function reactionState(problemId: number, userId: number) {
|
||||
const [mine] = await db.select({ type: schema.reaction.type }).from(schema.reaction)
|
||||
.where(and(eq(schema.reaction.problemId, problemId), eq(schema.reaction.userId, userId))).limit(1)
|
||||
const [mine] = await db
|
||||
.select({ type: schema.reaction.type })
|
||||
.from(schema.reaction)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.reaction.problemId, problemId),
|
||||
eq(schema.reaction.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!mine) return { mine: null, counts: null } satisfies ReactionState
|
||||
const rows = await db.select({ type: schema.reaction.type, value: count() }).from(schema.reaction)
|
||||
.where(eq(schema.reaction.problemId, problemId)).groupBy(schema.reaction.type)
|
||||
const rows = await db
|
||||
.select({ type: schema.reaction.type, value: count() })
|
||||
.from(schema.reaction)
|
||||
.where(eq(schema.reaction.problemId, problemId))
|
||||
.groupBy(schema.reaction.type)
|
||||
// fromEntries 推不出这个键集,但 options 就是 ReactionKey 的全集,断言是成立的。
|
||||
// row.type 不必再 safeParse:reaction.type 列上挂着 $type<ReactionKey>()
|
||||
const counts = Object.fromEntries(
|
||||
@@ -161,41 +263,85 @@ contentRoutes.get("/problems/:id/reaction", requireAuth, async (c) => {
|
||||
|
||||
contentRoutes.post("/problems/:id/reaction", requireAuth, async (c) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = setReactionRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid reaction")
|
||||
const parsed = setReactionRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid reaction")
|
||||
const user = c.get("user")!
|
||||
const [[problem], [solved]] = await Promise.all([
|
||||
db.select({ id: schema.problem.id }).from(schema.problem).where(and(eq(schema.problem.id, problemId), eq(schema.problem.visible, true))).limit(1),
|
||||
db.select({ id: schema.submission.id }).from(schema.submission).where(and(
|
||||
eq(schema.submission.userId, user.id), eq(schema.submission.problemId, problemId),
|
||||
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
|
||||
)).limit(1),
|
||||
db
|
||||
.select({ id: schema.problem.id })
|
||||
.from(schema.problem)
|
||||
.where(
|
||||
and(eq(schema.problem.id, problemId), eq(schema.problem.visible, true)),
|
||||
)
|
||||
.limit(1),
|
||||
db
|
||||
.select({ id: schema.submission.id })
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.submission.userId, user.id),
|
||||
eq(schema.submission.problemId, problemId),
|
||||
inArray(schema.submission.result, [
|
||||
JudgeStatus.ACCEPTED,
|
||||
JudgeStatus.AST_CHECK_FAILED,
|
||||
]),
|
||||
),
|
||||
)
|
||||
.limit(1),
|
||||
])
|
||||
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
if (!solved) return failure(c, 403, "accepted-submission-required", "An accepted submission is required")
|
||||
await db.insert(schema.reaction).values({
|
||||
problemId,
|
||||
userId: user.id,
|
||||
type: parsed.data.type,
|
||||
createTime: new Date().toISOString(),
|
||||
}).onConflictDoNothing({ target: [schema.reaction.problemId, schema.reaction.userId] })
|
||||
if (!problem)
|
||||
return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
if (!solved)
|
||||
return failure(
|
||||
c,
|
||||
403,
|
||||
"accepted-submission-required",
|
||||
"An accepted submission is required",
|
||||
)
|
||||
await db
|
||||
.insert(schema.reaction)
|
||||
.values({
|
||||
problemId,
|
||||
userId: user.id,
|
||||
type: parsed.data.type,
|
||||
createTime: new Date().toISOString(),
|
||||
})
|
||||
.onConflictDoNothing({
|
||||
target: [schema.reaction.problemId, schema.reaction.userId],
|
||||
})
|
||||
return success(c, await reactionState(problemId, user.id))
|
||||
})
|
||||
|
||||
contentRoutes.get("/tutorials", async (c) => {
|
||||
const type = c.req.query("type") === "c" ? "c" : "python"
|
||||
const rows = await db.select({ id: schema.tutorial.id, title: schema.tutorial.title }).from(schema.tutorial)
|
||||
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type))).orderBy(asc(schema.tutorial.order))
|
||||
const rows = await db
|
||||
.select({ id: schema.tutorial.id, title: schema.tutorial.title })
|
||||
.from(schema.tutorial)
|
||||
.where(
|
||||
and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)),
|
||||
)
|
||||
.orderBy(asc(schema.tutorial.order))
|
||||
return success(c, rows satisfies TutorialSummary[])
|
||||
})
|
||||
|
||||
contentRoutes.get("/tutorials/:id", async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [row] = await db.select({ tutorial: schema.tutorial, user: schema.user, realName: schema.userProfile.realName })
|
||||
.from(schema.tutorial).innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
|
||||
const [row] = await db
|
||||
.select({
|
||||
tutorial: schema.tutorial,
|
||||
user: schema.user,
|
||||
realName: schema.userProfile.realName,
|
||||
})
|
||||
.from(schema.tutorial)
|
||||
.innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
|
||||
if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true)))
|
||||
.limit(1)
|
||||
if (!row)
|
||||
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
return success(c, {
|
||||
id: row.tutorial.id,
|
||||
title: row.tutorial.title,
|
||||
@@ -222,48 +368,73 @@ contentRoutes.get("/tutorials/:id", async (c) => {
|
||||
contentRoutes.get("/learn/progress", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const type = c.req.query("type") === "c" ? "c" : "python"
|
||||
const visible = and(eq(schema.tutorial.type, type), eq(schema.tutorial.isPublic, true))
|
||||
const visible = and(
|
||||
eq(schema.tutorial.type, type),
|
||||
eq(schema.tutorial.isPublic, true),
|
||||
)
|
||||
|
||||
// 从 tutorial 打底 left join 进度,而不是反过来:没读过的课也要有一行零,
|
||||
// 否则目录里「练习 0/5」和「这课没有练习」在前端分不出来
|
||||
const [rows, exerciseRows] = await Promise.all([
|
||||
db.select({
|
||||
tutorialId: schema.tutorial.id,
|
||||
viewCount: schema.tutorialProgress.viewCount,
|
||||
totalSeconds: schema.tutorialProgress.totalSeconds,
|
||||
firstViewedAt: schema.tutorialProgress.firstViewedAt,
|
||||
lastViewedAt: schema.tutorialProgress.lastViewedAt,
|
||||
}).from(schema.tutorial)
|
||||
.leftJoin(schema.tutorialProgress, and(
|
||||
eq(schema.tutorialProgress.tutorialId, schema.tutorial.id),
|
||||
eq(schema.tutorialProgress.userId, user.id),
|
||||
))
|
||||
db
|
||||
.select({
|
||||
tutorialId: schema.tutorial.id,
|
||||
viewCount: schema.tutorialProgress.viewCount,
|
||||
totalSeconds: schema.tutorialProgress.totalSeconds,
|
||||
firstViewedAt: schema.tutorialProgress.firstViewedAt,
|
||||
lastViewedAt: schema.tutorialProgress.lastViewedAt,
|
||||
})
|
||||
.from(schema.tutorial)
|
||||
.leftJoin(
|
||||
schema.tutorialProgress,
|
||||
and(
|
||||
eq(schema.tutorialProgress.tutorialId, schema.tutorial.id),
|
||||
eq(schema.tutorialProgress.userId, user.id),
|
||||
),
|
||||
)
|
||||
.where(visible)
|
||||
.orderBy(asc(schema.tutorial.order)),
|
||||
db.select({
|
||||
tutorialId: schema.exercise.tutorialId,
|
||||
total: count(),
|
||||
solved: sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(Number),
|
||||
}).from(schema.exercise)
|
||||
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId))
|
||||
.leftJoin(schema.exerciseAttempt, and(
|
||||
eq(schema.exerciseAttempt.exerciseId, schema.exercise.id),
|
||||
eq(schema.exerciseAttempt.userId, user.id),
|
||||
))
|
||||
db
|
||||
.select({
|
||||
tutorialId: schema.exercise.tutorialId,
|
||||
total: count(),
|
||||
solved:
|
||||
sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(
|
||||
Number,
|
||||
),
|
||||
})
|
||||
.from(schema.exercise)
|
||||
.innerJoin(
|
||||
schema.tutorial,
|
||||
eq(schema.tutorial.id, schema.exercise.tutorialId),
|
||||
)
|
||||
.leftJoin(
|
||||
schema.exerciseAttempt,
|
||||
and(
|
||||
eq(schema.exerciseAttempt.exerciseId, schema.exercise.id),
|
||||
eq(schema.exerciseAttempt.userId, user.id),
|
||||
),
|
||||
)
|
||||
.where(visible)
|
||||
.groupBy(schema.exercise.tutorialId),
|
||||
])
|
||||
const exercises = new Map(exerciseRows.map((row) => [row.tutorialId, row]))
|
||||
|
||||
return success(c, rows.map((row) => ({
|
||||
tutorialId: row.tutorialId,
|
||||
viewCount: row.viewCount ?? 0,
|
||||
totalSeconds: row.totalSeconds ?? 0,
|
||||
firstViewedAt: row.firstViewedAt,
|
||||
lastViewedAt: row.lastViewedAt,
|
||||
exerciseTotal: exercises.get(row.tutorialId)?.total ?? 0,
|
||||
exerciseSolved: exercises.get(row.tutorialId)?.solved ?? 0,
|
||||
} satisfies TutorialProgress)))
|
||||
return success(
|
||||
c,
|
||||
rows.map(
|
||||
(row) =>
|
||||
({
|
||||
tutorialId: row.tutorialId,
|
||||
viewCount: row.viewCount ?? 0,
|
||||
totalSeconds: row.totalSeconds ?? 0,
|
||||
firstViewedAt: row.firstViewedAt,
|
||||
lastViewedAt: row.lastViewedAt,
|
||||
exerciseTotal: exercises.get(row.tutorialId)?.total ?? 0,
|
||||
exerciseSolved: exercises.get(row.tutorialId)?.solved ?? 0,
|
||||
}) satisfies TutorialProgress,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -276,31 +447,44 @@ contentRoutes.get("/learn/progress", requireAuth, async (c) => {
|
||||
contentRoutes.post("/tutorials/:id/progress", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = tutorialProgressPingSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid progress payload")
|
||||
const [tutorial] = await db.select({ id: schema.tutorial.id }).from(schema.tutorial)
|
||||
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
|
||||
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
const parsed = tutorialProgressPingSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid progress payload")
|
||||
const [tutorial] = await db
|
||||
.select({ id: schema.tutorial.id })
|
||||
.from(schema.tutorial)
|
||||
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true)))
|
||||
.limit(1)
|
||||
if (!tutorial)
|
||||
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const { seconds, opened } = parsed.data
|
||||
await db.insert(schema.tutorialProgress).values({
|
||||
userId: user.id,
|
||||
tutorialId: id,
|
||||
viewCount: opened ? 1 : 0,
|
||||
totalSeconds: seconds,
|
||||
firstViewedAt: now,
|
||||
lastViewedAt: now,
|
||||
}).onConflictDoUpdate({
|
||||
target: [schema.tutorialProgress.userId, schema.tutorialProgress.tutorialId],
|
||||
set: {
|
||||
// 累加在库里做,不是「读出来加一下再写回去」:同一个学生开两个标签页
|
||||
// 同时上报时,读改写会互相覆盖,时长凭空少掉一半
|
||||
viewCount: sql`${schema.tutorialProgress.viewCount} + ${opened ? 1 : 0}`,
|
||||
totalSeconds: sql`${schema.tutorialProgress.totalSeconds} + ${seconds}`,
|
||||
await db
|
||||
.insert(schema.tutorialProgress)
|
||||
.values({
|
||||
userId: user.id,
|
||||
tutorialId: id,
|
||||
viewCount: opened ? 1 : 0,
|
||||
totalSeconds: seconds,
|
||||
firstViewedAt: now,
|
||||
lastViewedAt: now,
|
||||
},
|
||||
})
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
schema.tutorialProgress.userId,
|
||||
schema.tutorialProgress.tutorialId,
|
||||
],
|
||||
set: {
|
||||
// 累加在库里做,不是「读出来加一下再写回去」:同一个学生开两个标签页
|
||||
// 同时上报时,读改写会互相覆盖,时长凭空少掉一半
|
||||
viewCount: sql`${schema.tutorialProgress.viewCount} + ${opened ? 1 : 0}`,
|
||||
totalSeconds: sql`${schema.tutorialProgress.totalSeconds} + ${seconds}`,
|
||||
lastViewedAt: now,
|
||||
},
|
||||
})
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
@@ -317,60 +501,95 @@ contentRoutes.post("/tutorials/:id/progress", requireAuth, async (c) => {
|
||||
contentRoutes.post("/exercises/:id/attempts", requireAuth, async (c) => {
|
||||
const user = c.get("user")!
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const parsed = exerciseAttemptRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid attempt payload")
|
||||
const parsed = exerciseAttemptRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
)
|
||||
if (!parsed.success)
|
||||
return failure(c, 400, "invalid-request", "Invalid attempt payload")
|
||||
// 练习跟着教程走:教程没公开,它底下的练习也不该能上报
|
||||
const [exercise] = await db.select({ id: schema.exercise.id }).from(schema.exercise)
|
||||
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId))
|
||||
.where(and(eq(schema.exercise.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
|
||||
if (!exercise) return failure(c, 404, "exercise-not-found", "Exercise does not exist")
|
||||
const [exercise] = await db
|
||||
.select({ id: schema.exercise.id })
|
||||
.from(schema.exercise)
|
||||
.innerJoin(
|
||||
schema.tutorial,
|
||||
eq(schema.tutorial.id, schema.exercise.tutorialId),
|
||||
)
|
||||
.where(and(eq(schema.exercise.id, id), eq(schema.tutorial.isPublic, true)))
|
||||
.limit(1)
|
||||
if (!exercise)
|
||||
return failure(c, 404, "exercise-not-found", "Exercise does not exist")
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const { correct } = parsed.data
|
||||
const answer = correct ? null : (parsed.data.answer ?? null)
|
||||
await db.insert(schema.exerciseAttempt).values({
|
||||
userId: user.id,
|
||||
exerciseId: id,
|
||||
attempts: 1,
|
||||
wrongAttempts: correct ? 0 : 1,
|
||||
solved: correct,
|
||||
attemptsToSolve: correct ? 1 : null,
|
||||
lastWrongAnswer: answer,
|
||||
firstAttemptAt: now,
|
||||
lastAttemptAt: now,
|
||||
solvedAt: correct ? now : null,
|
||||
}).onConflictDoUpdate({
|
||||
target: [schema.exerciseAttempt.userId, schema.exerciseAttempt.exerciseId],
|
||||
set: {
|
||||
// 一律在库里算,不读出来改了再写回去:两个标签页同时提交会互相覆盖。
|
||||
//
|
||||
// 每一列都先看 `solved`:做对之后这一行就冻住了,只有 lastAttemptAt 还动。
|
||||
// 不冻的话,学生做对后随手再点几下提交,「他试了几次才做对」就被改花了。
|
||||
attempts: sql`${schema.exerciseAttempt.attempts} + case when ${schema.exerciseAttempt.solved} then 0 else 1 end`,
|
||||
wrongAttempts: sql`${schema.exerciseAttempt.wrongAttempts} + case when ${schema.exerciseAttempt.solved} or ${correct} then 0 else 1 end`,
|
||||
solved: sql`${schema.exerciseAttempt.solved} or ${correct}`,
|
||||
attemptsToSolve: sql`case
|
||||
await db
|
||||
.insert(schema.exerciseAttempt)
|
||||
.values({
|
||||
userId: user.id,
|
||||
exerciseId: id,
|
||||
attempts: 1,
|
||||
wrongAttempts: correct ? 0 : 1,
|
||||
solved: correct,
|
||||
attemptsToSolve: correct ? 1 : null,
|
||||
lastWrongAnswer: answer,
|
||||
firstAttemptAt: now,
|
||||
lastAttemptAt: now,
|
||||
solvedAt: correct ? now : null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
schema.exerciseAttempt.userId,
|
||||
schema.exerciseAttempt.exerciseId,
|
||||
],
|
||||
set: {
|
||||
// 一律在库里算,不读出来改了再写回去:两个标签页同时提交会互相覆盖。
|
||||
//
|
||||
// 每一列都先看 `solved`:做对之后这一行就冻住了,只有 lastAttemptAt 还动。
|
||||
// 不冻的话,学生做对后随手再点几下提交,「他试了几次才做对」就被改花了。
|
||||
attempts: sql`${schema.exerciseAttempt.attempts} + case when ${schema.exerciseAttempt.solved} then 0 else 1 end`,
|
||||
wrongAttempts: sql`${schema.exerciseAttempt.wrongAttempts} + case when ${schema.exerciseAttempt.solved} or ${correct} then 0 else 1 end`,
|
||||
solved: sql`${schema.exerciseAttempt.solved} or ${correct}`,
|
||||
attemptsToSolve: sql`case
|
||||
when ${schema.exerciseAttempt.solved} then ${schema.exerciseAttempt.attemptsToSolve}
|
||||
when ${correct} then ${schema.exerciseAttempt.attempts} + 1
|
||||
else null end`,
|
||||
solvedAt: sql`case
|
||||
solvedAt: sql`case
|
||||
when ${schema.exerciseAttempt.solved} then ${schema.exerciseAttempt.solvedAt}
|
||||
when ${correct} then ${now}::timestamptz
|
||||
else null end`,
|
||||
lastWrongAnswer: sql`case
|
||||
lastWrongAnswer: sql`case
|
||||
when ${schema.exerciseAttempt.solved} or ${correct} then ${schema.exerciseAttempt.lastWrongAnswer}
|
||||
else ${answer} end`,
|
||||
lastAttemptAt: now,
|
||||
},
|
||||
})
|
||||
lastAttemptAt: now,
|
||||
},
|
||||
})
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
contentRoutes.get("/tutorials/:id/exercises", async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [tutorial] = await db.select({ id: schema.tutorial.id }).from(schema.tutorial)
|
||||
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
|
||||
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
const rows = await db.select().from(schema.exercise).where(eq(schema.exercise.tutorialId, id)).orderBy(asc(schema.exercise.order))
|
||||
return success(c, rows.map((row) => ({ id: row.id, type: row.type, data: objectValue(row.data), order: row.order } satisfies Exercise)))
|
||||
const [tutorial] = await db
|
||||
.select({ id: schema.tutorial.id })
|
||||
.from(schema.tutorial)
|
||||
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true)))
|
||||
.limit(1)
|
||||
if (!tutorial)
|
||||
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.exercise)
|
||||
.where(eq(schema.exercise.tutorialId, id))
|
||||
.orderBy(asc(schema.exercise.order))
|
||||
return success(
|
||||
c,
|
||||
rows.map(
|
||||
(row) =>
|
||||
({
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
data: objectValue(row.data),
|
||||
order: row.order,
|
||||
}) satisfies Exercise,
|
||||
),
|
||||
)
|
||||
})
|
||||
Reference in new issue
Block a user