看板「改 / 新的一节 / 清掉」和后台课堂作业的增改删,前后比较学生看到的那一节, 真变了才按班推 lesson_update(加了、去掉了、语言、顺序),走学生的 /ws/config,老师不收。 没点刷新又改会合并成一条。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0fd94ee8bf
commit
df8af40d61
12 files changed
+344
-14
No files matched your search
+25
-1
@@ -1,4 +1,4 @@
|
||||
import type { FlowchartUpdate } from '@oj2/contract'
|
||||
import type { FlowchartUpdate, LessonUpdate } from '@oj2/contract'
|
||||
|
||||
import { redis } from './redis'
|
||||
|
||||
@@ -50,6 +50,30 @@ export function parseSessionRevoked(raw: string): SessionRevoked | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按班广播:课上老师改了今天这节布置的题(见契约 lessonUpdateSchema)。学生的 /ws/config 连接
|
||||
* 握手时按自己的班订阅 classEventTopic;换了班要等重连才跟上,课上不会发生。
|
||||
*/
|
||||
export const classEventChannel = 'class:events'
|
||||
|
||||
export function classEventTopic(className: string) {
|
||||
return `events:class:${className}`
|
||||
}
|
||||
|
||||
export async function publishLessonUpdate(className: string, data: LessonUpdate) {
|
||||
await redis.publish(classEventChannel, JSON.stringify({ className, data }))
|
||||
}
|
||||
|
||||
export function parseClassEvent(raw: string): { className: string; data: LessonUpdate } | null {
|
||||
try {
|
||||
const value = JSON.parse(raw) as { className: string; data: LessonUpdate }
|
||||
if (typeof value.className !== 'string' || !value.className || !value.data || typeof value.data !== 'object') return null
|
||||
return value
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface UserEvent {
|
||||
userId: number
|
||||
data: FlowchartUpdate | Record<string, unknown>
|
||||
|
||||
@@ -141,6 +141,7 @@ const server = Bun.serve<SubmissionSocketData>({
|
||||
tokenVersion: session.tokenVersion,
|
||||
username: user.username,
|
||||
adminType: user.adminType,
|
||||
className: user.className,
|
||||
},
|
||||
})
|
||||
) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Hono } from 'hono'
|
||||
import { requireTeacher, type AppEnv } from '../../auth/middleware'
|
||||
import { db, schema } from '../../db'
|
||||
import { failure, parseBody, success } from '../../http'
|
||||
import { lessonDoneCounts, resolveLessonProblems } from '../../services/lesson'
|
||||
import { announceLessonChange, lessonDoneCounts, resolveLessonProblems, todayLessonSnapshot } from '../../services/lesson'
|
||||
import { localDateTime } from '../../time'
|
||||
import { queryInteger } from '../helpers'
|
||||
|
||||
@@ -125,10 +125,13 @@ adminLessonRoutes.post('/lessons', requireTeacher, async (c) => {
|
||||
if (!parsed.success) return parsed.response
|
||||
const result = await lessonValues(parsed.data)
|
||||
if ('code' in result) return failure(c, 400, result.code, result.message)
|
||||
const before = await todayLessonSnapshot(result.values.className)
|
||||
const [row] = await db
|
||||
.insert(schema.classLesson)
|
||||
.values({ ...result.values, createdBy: c.get('user')!.id, updatedAt: new Date().toISOString() })
|
||||
.returning({ id: schema.classLesson.id })
|
||||
// 补登的是今天、而且是最后一节,学生那边看到的就变了
|
||||
await announceLessonChange(result.values.className, before)
|
||||
return success(c, { id: row!.id })
|
||||
})
|
||||
|
||||
@@ -138,6 +141,9 @@ adminLessonRoutes.put('/lessons/:id', requireTeacher, async (c) => {
|
||||
if (!parsed.success) return parsed.response
|
||||
const result = await lessonValues(parsed.data)
|
||||
if ('code' in result) return failure(c, 400, result.code, result.message)
|
||||
// 班级能改:原来那个班和新的班学生看到的都可能变,两边各比一次
|
||||
const classes = await affectedClasses(id, result.values.className)
|
||||
const before = await Promise.all(classes.map((className) => todayLessonSnapshot(className)))
|
||||
// 布置的老师不换,和看板上「改」一样认第一次
|
||||
const [row] = await db
|
||||
.update(schema.classLesson)
|
||||
@@ -145,11 +151,21 @@ adminLessonRoutes.put('/lessons/:id', requireTeacher, async (c) => {
|
||||
.where(eq(schema.classLesson.id, id))
|
||||
.returning({ id: schema.classLesson.id })
|
||||
if (!row) return failure(c, 404, 'lesson-not-found', '这一节已经被删掉了')
|
||||
await Promise.all(classes.map((className, index) => announceLessonChange(className, before[index]!)))
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
adminLessonRoutes.delete('/lessons/:id', requireTeacher, async (c) => {
|
||||
const id = queryInteger(c.req.param('id'), 0, { min: 1 })
|
||||
const [className] = await affectedClasses(id)
|
||||
const before = className ? await todayLessonSnapshot(className) : null
|
||||
await db.delete(schema.classLesson).where(eq(schema.classLesson.id, id))
|
||||
if (className) await announceLessonChange(className, before)
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
/** 这一节原来属于哪个班(已经没了就没有),再加上要改成的班,去重 */
|
||||
async function affectedClasses(id: number, nextClass?: string) {
|
||||
const [row] = await db.select({ className: schema.classLesson.className }).from(schema.classLesson).where(eq(schema.classLesson.id, id))
|
||||
return [...new Set([row?.className, nextClass].filter((name): name is string => !!name))]
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import { failure, parseBody, success } from '../http'
|
||||
import { isAccepted } from '../judge/status'
|
||||
import { activityRows, classMemberIds, FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS, suggestActiveClass } from '../services/class-activity'
|
||||
import { accepted } from '../services/learning-stats'
|
||||
import { resolveLessonProblems } from '../services/lesson'
|
||||
import { announceLessonChange, resolveLessonProblems, todayLessonSnapshot } from '../services/lesson'
|
||||
import { calendarDay, dayStart, localTime } from '../time'
|
||||
|
||||
export const classroomRoutes = new Hono<AppEnv>()
|
||||
@@ -744,6 +744,8 @@ classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => {
|
||||
|
||||
const [exists] = await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.className, className)).limit(1)
|
||||
if (!exists) return failure(c, 400, 'class-not-found', `没有 ${className} 这个班`)
|
||||
// 改之前学生看到的那一节,改完比一比、告诉全班改了哪些
|
||||
const before = await todayLessonSnapshot(className)
|
||||
|
||||
// 要改的是哪一行:点名的那一节(只认这个班今天的),新开一节就没有,老写法是今天最后布置的那一节
|
||||
const [target] = newLesson
|
||||
@@ -762,6 +764,7 @@ classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => {
|
||||
.limit(1)
|
||||
if (!problemDisplayIds.length) {
|
||||
if (target) await db.delete(schema.classLesson).where(eq(schema.classLesson.id, target.id))
|
||||
await announceLessonChange(className, before)
|
||||
return success(c, null)
|
||||
}
|
||||
|
||||
@@ -785,5 +788,6 @@ classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => {
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
await announceLessonChange(className, before)
|
||||
return success(c, null)
|
||||
})
|
||||
@@ -1,8 +1,9 @@
|
||||
import { isSqlProblem, STUDENT_ROLES, type LessonLanguage } from '@oj2/contract'
|
||||
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
|
||||
import { isSqlProblem, STUDENT_ROLES, type LessonLanguage, type LessonUpdate } from '@oj2/contract'
|
||||
import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm'
|
||||
|
||||
import { db, schema } from '../db'
|
||||
import { dayNumber, dayStart } from '../time'
|
||||
import { publishLessonUpdate } from '../events'
|
||||
import { calendarDay, dayNumber, dayStart } from '../time'
|
||||
import { FLOWCHART_PASSED, SOLVED_RESULTS } from './class-activity'
|
||||
|
||||
/**
|
||||
@@ -97,3 +98,65 @@ export async function lessonDoneCounts(lessons: { id: number; className: string;
|
||||
`)
|
||||
return new Map([...result].map((row) => [row.k, row.done]))
|
||||
}
|
||||
|
||||
interface LessonSnapshot {
|
||||
id: number
|
||||
problemIds: number[]
|
||||
language: LessonLanguage | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生此刻看到的那一节:这个班今天最后布置的(和 routes/classroom.ts 的 lessonPlan 同一个取法),
|
||||
* 没布置为 null。改之前、改之后各拍一张,交给 announceLessonChange 比
|
||||
*/
|
||||
export async function todayLessonSnapshot(className: string): Promise<LessonSnapshot | null> {
|
||||
const [row] = await db
|
||||
.select({ id: schema.classLesson.id, problemIds: schema.classLesson.problemIds, language: schema.classLesson.language })
|
||||
.from(schema.classLesson)
|
||||
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, calendarDay())))
|
||||
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
|
||||
.limit(1)
|
||||
return row?.problemIds.length ? { ...row, language: row.language ?? null } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 改完之后再拍一张,和改之前那张比;学生看到的那一节变了,就告诉这个班改了哪些(见契约 lessonUpdateSchema)。
|
||||
* 推送失败只记日志:布置已经存下了,不能因为 Redis 抖一下让老师看到报错
|
||||
*/
|
||||
export async function announceLessonChange(className: string, before: LessonSnapshot | null) {
|
||||
try {
|
||||
const after = await todayLessonSnapshot(className)
|
||||
if (!before && !after) return
|
||||
const sameLesson = !!before && !!after && before.id === after.id
|
||||
if (sameLesson && before.language === after.language && before.problemIds.join() === after.problemIds.join()) return
|
||||
|
||||
const ids = [...new Set([...(before?.problemIds ?? []), ...(after?.problemIds ?? [])])]
|
||||
const rows = ids.length
|
||||
? await db
|
||||
.select({ id: schema.problem.id, displayId: schema.problem.displayId, title: schema.problem.title })
|
||||
.from(schema.problem)
|
||||
.where(and(inArray(schema.problem.id, ids), eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
|
||||
: []
|
||||
const byId = new Map(rows.map((row) => [row.id, { problemDisplayId: row.displayId, title: row.title }]))
|
||||
const pick = (list: number[]) => list.flatMap((id) => byId.get(id) ?? [])
|
||||
|
||||
const afterIds = after?.problemIds ?? []
|
||||
const beforeIds = sameLesson ? before.problemIds : []
|
||||
const added = afterIds.filter((id) => !beforeIds.includes(id))
|
||||
const removed = beforeIds.filter((id) => !afterIds.includes(id))
|
||||
const kept = afterIds.filter((id) => beforeIds.includes(id))
|
||||
const keptBefore = beforeIds.filter((id) => afterIds.includes(id))
|
||||
await publishLessonUpdate(className, {
|
||||
type: 'lesson_update',
|
||||
kind: !after ? 'cleared' : sameLesson ? 'changed' : 'assigned',
|
||||
language: after?.language ?? null,
|
||||
problems: pick(afterIds),
|
||||
added: sameLesson ? pick(added) : [],
|
||||
removed: pick(removed),
|
||||
languageChanged: sameLesson && before.language !== after.language,
|
||||
reordered: sameLesson && kept.join() !== keptBefore.join(),
|
||||
} satisfies LessonUpdate)
|
||||
} catch (error) {
|
||||
console.error('Failed to announce lesson change', error)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { submissionUpdateSchema, type FlowchartUpdate } from '@oj2/contract'
|
||||
import { STUDENT_ROLES, submissionUpdateSchema, type FlowchartUpdate } from '@oj2/contract'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
|
||||
import { touchSession } from './auth/session'
|
||||
@@ -9,7 +9,18 @@ import { parseSubmissionEvent, submissionUpdateChannel, userSubmissionTopic } fr
|
||||
import { JudgeStatus } from './judge/status'
|
||||
import { createSubscriberRedis } from './redis'
|
||||
import { asRecord } from './routes/helpers'
|
||||
import { configTopic, configUpdateChannel, parseSessionRevoked, parseUserEvent, sessionRevokedChannel, userEventChannel, userEventTopic } from './events'
|
||||
import {
|
||||
classEventChannel,
|
||||
classEventTopic,
|
||||
configTopic,
|
||||
configUpdateChannel,
|
||||
parseClassEvent,
|
||||
parseSessionRevoked,
|
||||
parseUserEvent,
|
||||
sessionRevokedChannel,
|
||||
userEventChannel,
|
||||
userEventTopic,
|
||||
} from './events'
|
||||
|
||||
/** 本机的几种写法。开发时 Vite 代理会让 Origin(5173)和 Host(3000)对不上 */
|
||||
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]'])
|
||||
@@ -63,6 +74,8 @@ export interface SubmissionSocketData {
|
||||
/** 握手时从会话里读,三种 kind 都会填;collab 通道用它判断老师身份、拼 room_open 里的姓名 */
|
||||
username?: string
|
||||
adminType?: string
|
||||
/** 握手时的班级。学生的 config 通道按它订阅班级广播(老师改了这节课的题),见 classTopicOf */
|
||||
className?: string | null
|
||||
/** 当前所在协作房间的房主(学生)id,见 collab/handler.ts */
|
||||
roomOwnerId?: number
|
||||
}
|
||||
@@ -204,6 +217,12 @@ export function startSessionSweep() {
|
||||
return timer
|
||||
}
|
||||
|
||||
/** 学生的 config 通道额外挂的班级 topic;老师、没分班的没有 */
|
||||
function classTopicOf(ws: Bun.ServerWebSocket<SubmissionSocketData>) {
|
||||
const { className, adminType } = ws.data
|
||||
return className && adminType && STUDENT_ROLES.includes(adminType as (typeof STUDENT_ROLES)[number]) ? classEventTopic(className) : null
|
||||
}
|
||||
|
||||
export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSocketData> {
|
||||
return {
|
||||
open(ws) {
|
||||
@@ -219,6 +238,8 @@ export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSoc
|
||||
}
|
||||
if (ws.data.kind === 'config') {
|
||||
ws.subscribe(configTopic)
|
||||
const classTopic = classTopicOf(ws)
|
||||
if (classTopic) ws.subscribe(classTopic)
|
||||
return
|
||||
}
|
||||
ws.subscribe(userSubmissionTopic(ws.data.userId))
|
||||
@@ -264,6 +285,8 @@ export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSoc
|
||||
}
|
||||
if (ws.data.kind === 'config') {
|
||||
ws.unsubscribe(configTopic)
|
||||
const classTopic = classTopicOf(ws)
|
||||
if (classTopic) ws.unsubscribe(classTopic)
|
||||
return
|
||||
}
|
||||
ws.unsubscribe(userSubmissionTopic(ws.data.userId))
|
||||
@@ -384,6 +407,12 @@ export async function bridgeSubmissionEvents(server: Bun.Server<SubmissionSocket
|
||||
server.publish(configTopic, raw)
|
||||
return
|
||||
}
|
||||
if (channel === classEventChannel) {
|
||||
// 禁用的学生不用查:sweepSessions / publishSessionRevoked 会把那条连接断掉
|
||||
const event = parseClassEvent(raw)
|
||||
if (event) server.publish(classEventTopic(event.className), JSON.stringify(event.data))
|
||||
return
|
||||
}
|
||||
if (channel === sessionRevokedChannel) {
|
||||
const revoked = parseSessionRevoked(raw)
|
||||
if (!revoked) return
|
||||
@@ -439,6 +468,6 @@ export async function bridgeSubmissionEvents(server: Bun.Server<SubmissionSocket
|
||||
})
|
||||
// 连接层的 error 已经由 createSubscriberRedis 里的 withErrorLogging 打了
|
||||
// (带连接名),这里再挂一个只会把同一条错误打两遍
|
||||
await subscriber.subscribe(submissionUpdateChannel, userEventChannel, configUpdateChannel, sessionRevokedChannel)
|
||||
await subscriber.subscribe(submissionUpdateChannel, userEventChannel, configUpdateChannel, sessionRevokedChannel, classEventChannel)
|
||||
return subscriber
|
||||
}
|
||||
Reference in new issue
Block a user