perf: 首屏 JS 减少 2/3、随机评分查询提速,WebSocket 防刷和登录限流
Deploy / deploy (build, debian, 22) (push) Canceled after 0s

- prettier 点“整理”时才加载,编辑器页面按路由懒加载:首屏 JS 2345 KB → 766 KB
- 随机评分先随机抽 64 个 rowid 挑选,抽不中再退回全表随机:6 万条提交时 50ms → 1ms
- WebSocket 消息最大 64 KB;AI 回复中再发来的消息直接拒绝,不再排队
- 同一用户名 10 分钟失败 10 次、同一 IP 失败 100 次后暂停登录(返回 429,登录框显示提示)
- 作品展的 iframe 加 loading="lazy"
- 新增 ws.test.ts(正常生成、回复中再发消息、中途断开、握手失败)和随机评分、登录限流的测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 22:19:50 +08:00
1 parent bd84e3f8d1
commit 325474bdb7
10 files changed
+269 -37

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1
- `src/auth.ts` — Django-compatible password hashing; JWT login, no cookies: `POST /account/login` returns a 15-min HS256 `access_token` (sent as `Authorization: Bearer`, signed with `JWT_SECRET`, random per process if unset) and a 14-day opaque `refresh_token` (only its sha256 is stored, in `auth_refresh_token`; `POST /account/refresh` trades it for a new access token, logout / password change / deactivation delete it). An invalid or expired Bearer token is a 401 even on public endpoints so the client knows to refresh. `requireUser/requireAdmin/requireSuper` (401 / 403) - `src/auth.ts` — Django-compatible password hashing; JWT login, no cookies: `POST /account/login` returns a 15-min HS256 `access_token` (sent as `Authorization: Bearer`, signed with `JWT_SECRET`, random per process if unset) and a 14-day opaque `refresh_token` (only its sha256 is stored, in `auth_refresh_token`; `POST /account/refresh` trades it for a new access token, logout / password change / deactivation delete it). An invalid or expired Bearer token is a 401 even on public endpoints so the client knows to refresh. `requireUser/requireAdmin/requireSuper` (401 / 403)
- `src/routes/` — `account`, `tasks` (tutorial / challenge / creation), `files` (task assets + image upload), `submission`, `showcase` (mounted under `/submission/showcase`), `prompt` - `src/routes/` — `account`, `tasks` (tutorial / challenge / creation), `files` (task assets + image upload), `submission`, `showcase` (mounted under `/submission/showcase`), `prompt`
- `src/services/` — `score.ts` (rating weights super×0.5, admin×0.3, normal×0.2, then Bayesian smoothing with C=3 and zones), `gradebook.ts`, `llm.ts` (OpenAI-compatible streaming via fetch; DeepSeek default, ARK for doubao models), `classifier.ts` (Bloom-level L1–L6 prompt tagging, runs in background), `cascade.ts` (Django's CASCADE deletes, done by hand), `pyround.ts` (`pyRound`/`pySum` reproduce Python's `round()` and `sum()` so scores match the old system bit-for-bit) - `src/services/` — `score.ts` (rating weights super×0.5, admin×0.3, normal×0.2, then Bayesian smoothing with C=3 and zones), `gradebook.ts`, `llm.ts` (OpenAI-compatible streaming via fetch; DeepSeek default, ARK for doubao models), `classifier.ts` (Bloom-level L1–L6 prompt tagging, runs in background), `cascade.ts` (Django's CASCADE deletes, done by hand), `pyround.ts` (`pyRound`/`pySum` reproduce Python's `round()` and `sum()` so scores match the old system bit-for-bit)
- `src/ws.ts` — `/ws/prompt/<task_id>/?token=<access_token>` (code generation chat) and `/ws/guidance/<task_id>/?token=...` (prompt coaching); messages per connection are processed serially; on disconnect the pending user message is deleted and the LLM request aborted - `src/ws.ts` — `/ws/prompt/<task_id>/?token=<access_token>` (code generation chat) and `/ws/guidance/<task_id>/?token=...` (prompt coaching); one message per connection at a time (a message sent while the AI is still replying gets an error, payloads capped at 64 KB); on disconnect the pending user message is deleted and the LLM request aborted
- `scripts/` — one-off CLIs: `create-superuser.ts`, `classify-prompts.ts`, `recalculate-scores.ts` - `scripts/` — one-off CLIs: `create-superuser.ts`, `classify-prompts.ts`, `recalculate-scores.ts`
- Roles: `super`, `admin`, `normal`. Task model is multi-table: `task_task` + one of `task_tutorial` / `task_challenge` / `task_creation` (`task_ptr_id`) - Roles: `super`, `admin`, `normal`. Task model is multi-table: `task_task` + one of `task_tutorial` / `task_challenge` / `task_creation` (`task_ptr_id`)
+19 -14
View File
@@ -48,9 +48,9 @@
/> />
<n-alert <n-alert
type="error" type="error"
v-if="showStudentError" v-if="studentError"
class="message" class="message"
title="登录失败,请检查密码" :title="studentError"
/> />
<n-button <n-button
block block
@@ -79,9 +79,9 @@
</n-form-item> </n-form-item>
<n-alert <n-alert
type="error" type="error"
v-if="showAdminError" v-if="adminError"
class="message" class="message"
title="登录失败,请检查用户名和密码" :title="adminError"
/> />
<n-button <n-button
block block
@@ -113,7 +113,7 @@ const selectedClass = ref<string | null>(null)
const selectedUsername = ref<string | null>(null) const selectedUsername = ref<string | null>(null)
const studentPassword = ref("") const studentPassword = ref("")
const studentLoading = ref(false) const studentLoading = ref(false)
const showStudentError = ref(false) const studentError = ref("")
// Classes data // Classes data
const classes = ref<string[]>([]) const classes = ref<string[]>([])
@@ -135,7 +135,7 @@ const nameOptions = computed(() =>
const adminName = ref("") const adminName = ref("")
const adminPassword = ref("") const adminPassword = ref("")
const adminLoading = ref(false) const adminLoading = ref(false)
const showAdminError = ref(false) const adminError = ref("")
// Load classes on mount (cached — not reloaded on tab switch) // Load classes on mount (cached — not reloaded on tab switch)
onMounted(async () => { onMounted(async () => {
@@ -155,13 +155,13 @@ function onTabChange() {
selectedClass.value = null selectedClass.value = null
selectedUsername.value = null selectedUsername.value = null
studentPassword.value = "" studentPassword.value = ""
showStudentError.value = false studentError.value = ""
namesError.value = false namesError.value = false
names.value = [] names.value = []
// Reset admin tab state // Reset admin tab state
adminName.value = "" adminName.value = ""
adminPassword.value = "" adminPassword.value = ""
showAdminError.value = false adminError.value = ""
} }
async function onClassChange(classname: string) { async function onClassChange(classname: string) {
@@ -178,10 +178,15 @@ async function onClassChange(classname: string) {
} }
} }
// 失败次数太多被暂时禁止登录时,显示服务端给的提示
function loginError(err: any, fallback: string) {
return err.response?.status === 429 ? err.response.data.detail : fallback
}
async function submitStudent() { async function submitStudent() {
if (!selectedUsername.value || !studentPassword.value) return if (!selectedUsername.value || !studentPassword.value) return
studentLoading.value = true studentLoading.value = true
showStudentError.value = false studentError.value = ""
try { try {
const data = await Account.login( const data = await Account.login(
selectedUsername.value, selectedUsername.value,
@@ -194,8 +199,8 @@ async function submitStudent() {
router.replace( router.replace(
window.location.pathname + window.location.search + window.location.hash, window.location.pathname + window.location.search + window.location.hash,
) )
} catch { } catch (err: any) {
showStudentError.value = true studentError.value = loginError(err, "登录失败,请检查密码")
} finally { } finally {
studentLoading.value = false studentLoading.value = false
} }
@@ -204,7 +209,7 @@ async function submitStudent() {
async function submitAdmin() { async function submitAdmin() {
if (!adminName.value || !adminPassword.value) return if (!adminName.value || !adminPassword.value) return
adminLoading.value = true adminLoading.value = true
showAdminError.value = false adminError.value = ""
try { try {
const data = await Account.login(adminName.value, adminPassword.value) const data = await Account.login(adminName.value, adminPassword.value)
user.username = data.username user.username = data.username
@@ -214,8 +219,8 @@ async function submitAdmin() {
router.replace( router.replace(
window.location.pathname + window.location.search + window.location.hash, window.location.pathname + window.location.search + window.location.hash,
) )
} catch { } catch (err: any) {
showAdminError.value = true adminError.value = loginError(err, "登录失败,请检查用户名和密码")
} finally { } finally {
adminLoading.value = false adminLoading.value = false
} }
+9 -5
View File
@@ -79,11 +79,6 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import prettier from "prettier/standalone"
import * as htmlParser from "prettier/parser-html"
import * as cssParser from "prettier/parser-postcss"
import * as babelParser from "prettier/parser-babel"
import * as estreeParser from "prettier/plugins/estree"
import Editor from "./Editor.vue" import Editor from "./Editor.vue"
import Toolbar from "./Toolbar.vue" import Toolbar from "./Toolbar.vue"
import { html, css, js, tab, size, reset } from "../../store/editors" import { html, css, js, tab, size, reset } from "../../store/editors"
@@ -105,6 +100,15 @@ function changeSize(num: number) {
} }
async function formatCode() { async function formatCode() {
// prettier 加上各语言插件近 1MB,只在点“格式化”时才加载
const [prettier, htmlParser, cssParser, babelParser, estreeParser] =
await Promise.all([
import("prettier/standalone"),
import("prettier/parser-html"),
import("prettier/parser-postcss"),
import("prettier/parser-babel"),
import("prettier/plugins/estree"),
])
const [htmlFormatted, cssFormatted, jsFormatted] = await Promise.all([ const [htmlFormatted, cssFormatted, jsFormatted] = await Promise.all([
prettier.format(html.value, { prettier.format(html.value, {
parser: "html", parser: "html",
+1
View File
@@ -39,6 +39,7 @@
<iframe <iframe
:srcdoc="buildSrcdoc(item)" :srcdoc="buildSrcdoc(item)"
sandbox="allow-scripts" sandbox="allow-scripts"
loading="lazy"
scrolling="no" scrolling="no"
class="preview-iframe" class="preview-iframe"
/> />
+2 -1
View File
@@ -1,7 +1,8 @@
import { createWebHistory, createRouter } from "vue-router" import { createWebHistory, createRouter } from "vue-router"
import { loginModal } from "./store/modal" import { loginModal } from "./store/modal"
import Workspace from "./pages/Workspace.vue" // 编辑器(CodeMirror 等)比较大,用到时才加载;几个路由共用同一个加载函数,切换时不会重新挂载
const Workspace = () => import("./pages/Workspace.vue")
import { hasLogin } from "./token" import { hasLogin } from "./token"
import { authed } from "./store/user" import { authed } from "./store/user"
+37 -2
View File
@@ -13,7 +13,7 @@ import {
requireUser, requireUser,
setPassword, setPassword,
} from "../auth" } from "../auth"
import { HttpError, body, bool, intParam, orNotFound, pageQuery, pageWindow, query, type Env } from "../http" import { HttpError, body, bool, intParam, orNotFound, pageQuery, pageWindow, query, type Ctx, type Env } from "../http"
const router = new Hono<Env>() const router = new Hono<Env>()
@@ -54,10 +54,45 @@ async function insertUser(u: {
const userExists = (username: string) => const userExists = (username: string) =>
!!get(`SELECT 1 FROM account_user WHERE username = $username`, { username }) !!get(`SELECT 1 FROM account_user WHERE username = $username`, { username })
// 登录失败次数限制(进程内存里计数):同一用户名 10 分钟内失败 10 次、同一 IP 失败 100 次后暂停登录。
// 机房的电脑共用一个出口 IP,IP 的阈值要放宽,主要防的是对单个账号猜密码
const LOGIN_WINDOW_MS = 10 * 60_000
const MAX_FAILS_PER_USER = 10
const MAX_FAILS_PER_IP = 100
const loginFailures = new Map<string, { n: number; reset: number }>()
const failCount = (key: string) => {
const f = loginFailures.get(key)
return f && f.reset > Date.now() ? f.n : 0
}
function recordFailure(key: string) {
const now = Date.now()
const f = loginFailures.get(key)
if (f && f.reset > now) f.n++
else loginFailures.set(key, { n: 1, reset: now + LOGIN_WINDOW_MS })
}
setInterval(() => {
const now = Date.now()
for (const [k, f] of loginFailures) if (f.reset <= now) loginFailures.delete(k)
}, LOGIN_WINDOW_MS).unref()
// 生产环境经过 Caddy,它会把 X-Forwarded-For 设成真实的客户端地址
const clientIp = (c: Ctx) => c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || "local"
router.post("/login", async (c) => { router.post("/login", async (c) => {
const payload = await body(c, z.object({ username: z.string(), password: z.string() })) const payload = await body(c, z.object({ username: z.string(), password: z.string() }))
const userKey = `user:${payload.username}`
const ipKey = `ip:${clientIp(c)}`
if (failCount(userKey) >= MAX_FAILS_PER_USER || failCount(ipKey) >= MAX_FAILS_PER_IP) {
throw new HttpError(429, "登录失败次数太多,请 10 分钟后再试")
}
const user = await authenticate(payload.username, payload.password) const user = await authenticate(payload.username, payload.password)
if (!user) throw new HttpError(401, "账号密码错误") if (!user) {
recordFailure(userKey)
recordFailure(ipKey)
throw new HttpError(401, "账号密码错误")
}
loginFailures.delete(userKey)
return c.json({ username: user.username, role: user.role, ...(await issueTokens(user)) }) return c.json({ username: user.username, role: user.role, ...(await issueTokens(user)) })
}) })
+20 -8
View File
@@ -314,18 +314,30 @@ router.get("/random-for-rating", (c) => {
} }
if (user.role === Role.NORMAL && todayRatingCount(user.id) >= 30) return c.json(null) if (user.role === Role.NORMAL && todayRatingCount(user.id) >= 30) return c.json(null)
// 只在索引上随机挑出 rowid,再读这一行的代码;否则每个候选都要读一遍 html/css/js const cond = `s.user_id != $me
const base = `SELECT s.rowid AS rid FROM submission_submission s
WHERE s.user_id != $me
AND NOT EXISTS (SELECT 1 FROM submission_rating r WHERE r.submission_id = s.id AND r.user_id = $me) AND NOT EXISTS (SELECT 1 FROM submission_rating r WHERE r.submission_id = s.id AND r.user_id = $me)
${excludeId ? "AND s.id != $exclude" : ""}` ${excludeId ? "AND s.id != $exclude" : ""}`
const params = { me: user.id, ...(excludeId ? { exclude: excludeId } : {}) } const params = { me: user.id, ...(excludeId ? { exclude: excludeId } : {}) }
const picked = const fewRatings = `(SELECT COUNT(*) FROM submission_rating r WHERE r.submission_id = s.id) < 5`
get<{ rid: number }>( // 每个学生生成代码时都会调这个接口,ORDER BY RANDOM() 要把所有提交过一遍(6 万条约 50ms,期间整个服务都卡住)。
`${base} AND (SELECT COUNT(*) FROM submission_rating r WHERE r.submission_id = s.id) < 5 // 先随机抽几十个 rowid 试试,抽到评分少于 5 个的就用;抽不中再退回全表随机
const maxRowid = get<{ m: number | null }>(`SELECT MAX(rowid) AS m FROM submission_submission`)!.m ?? 0
let picked: { rid: number } | null = null
for (let round = 0; round < 3 && maxRowid && !picked; round++) {
const sample = Array.from({ length: 64 }, () => 1 + Math.floor(Math.random() * maxRowid))
// 抽样作为参数传入(SQL 文本固定,db.query 按文本缓存语句,拼进 SQL 会让缓存越来越大)
picked = get<{ rid: number }>(
`SELECT s.rowid AS rid FROM submission_submission s
WHERE s.rowid IN (SELECT value FROM json_each($sample)) AND ${cond} AND ${fewRatings}
ORDER BY RANDOM() LIMIT 1`, ORDER BY RANDOM() LIMIT 1`,
params, { ...params, sample: JSON.stringify(sample) },
) ?? get<{ rid: number }>(`${base} ORDER BY RANDOM() LIMIT 1`, params) )
}
// 只在索引上随机挑出 rowid,再读这一行的代码;否则每个候选都要读一遍 html/css/js
const base = `SELECT s.rowid AS rid FROM submission_submission s WHERE ${cond}`
picked ??=
get<{ rid: number }>(`${base} AND ${fewRatings} ORDER BY RANDOM() LIMIT 1`, params) ??
get<{ rid: number }>(`${base} ORDER BY RANDOM() LIMIT 1`, params)
if (!picked) return c.json(null) if (!picked) return c.json(null)
const pick = get( const pick = get(
`SELECT s.id, u.username, t.title AS task_title, t.display AS task_display, `SELECT s.id, u.username, t.title AS task_title, t.display AS task_display,
+14 -5
View File
@@ -21,8 +21,8 @@ export interface WsData {
pendingUserMessageId: number | null pendingUserMessageId: number | null
abort: AbortController abort: AbortController
closed: boolean closed: boolean
// 串行处理消息(与 Django Channels 的行为一致) // 正在处理一条消息(等 AI 回复);期间再发来的直接拒绝,不排队,免得一次塞几百条刷模型费用
queue: Promise<void> busy: boolean
// 引导对话只在本次连接内保留上下文 // 引导对话只在本次连接内保留上下文
guidanceHistory: ChatMessage[] guidanceHistory: ChatMessage[]
} }
@@ -57,7 +57,7 @@ export async function upgrade(
pendingUserMessageId: null, pendingUserMessageId: null,
abort: new AbortController(), abort: new AbortController(),
closed: false, closed: false,
queue: Promise.resolve(), busy: false,
guidanceHistory: [], guidanceHistory: [],
}, },
}) })
@@ -185,6 +185,8 @@ export const websocket: WebSocketHandler<WsData> = {
// AI 生成可能要一两分钟,期间没有客户端消息 // AI 生成可能要一两分钟,期间没有客户端消息
idleTimeout: 960, idleTimeout: 960,
sendPings: true, sendPings: true,
// 提示词不会这么长;Bun 默认 16MB
maxPayloadLength: 64 * 1024,
open(ws) { open(ws) {
const d = ws.data const d = ws.data
@@ -222,13 +224,20 @@ export const websocket: WebSocketHandler<WsData> = {
} catch { } catch {
return return
} }
if (ws.data.busy) {
send(ws, { type: "error", content: "上一条消息还在处理,请等 AI 回复完再发" })
return
}
const handler = ws.data.kind === "prompt" ? handlePrompt : handleGuidance const handler = ws.data.kind === "prompt" ? handlePrompt : handleGuidance
ws.data.queue = ws.data.queue ws.data.busy = true
.then(() => (ws.data.closed ? undefined : handler(ws, data))) handler(ws, data)
.catch((e) => { .catch((e) => {
console.error("websocket handler failed:", e) console.error("websocket handler failed:", e)
send(ws, { type: "error", content: "服务器出错,请刷新页面重试" }) send(ws, { type: "error", content: "服务器出错,请刷新页面重试" })
}) })
.finally(() => {
ws.data.busy = false
})
}, },
close(ws) { close(ws) {
+32 -1
View File
@@ -5,7 +5,7 @@ import { config } from "../src/config"
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises" import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"
import { tmpdir } from "node:os" import { tmpdir } from "node:os"
import { join } from "node:path" import { join } from "node:path"
import { client, freshDb, makeSubmission, makeTask, makeUser, uuid } from "./helpers" import { PASSWORD, client, freshDb, makeSubmission, makeTask, makeUser, uuid } from "./helpers"
beforeEach(freshDb) beforeEach(freshDb)
@@ -50,6 +50,17 @@ describe("权限", () => {
expect(res.status).toBe(404) expect(res.status).toBe(404)
}) })
test("同一用户名连续登录失败 10 次后暂停登录", async () => {
makeUser("brute")
const anon = await client()
for (let i = 0; i < 10; i++) {
expect((await anon("POST", "/api/account/login", { username: "brute", password: "wrong" })).status).toBe(401)
}
// 密码对了也要等窗口过去
const res = await anon("POST", "/api/account/login", { username: "brute", password: PASSWORD })
expect(res.status).toBe(429)
})
test("封号后 access token 和 refresh token 都失效", async () => { test("封号后 access token 和 refresh token 都失效", async () => {
const id = makeUser("stu") const id = makeUser("stu")
makeUser("boss", "super") makeUser("boss", "super")
@@ -176,6 +187,26 @@ describe("评分", () => {
}) })
}) })
describe("随机评分", () => {
test("不给自己的、已评过的;没有可评的返回 null", async () => {
const me = makeUser("me")
const other = makeUser("other")
const task = makeTask("T")
makeSubmission(me, task)
const subs = Array.from({ length: 5 }, () => makeSubmission(other, task))
const c = await client("me")
const seen = new Set<string>()
for (let i = 0; i < 5; i++) {
const res = await c("GET", "/api/submission/random-for-rating")
expect(subs.map(uuid)).toContain(res.json.submission_id)
expect(seen.has(res.json.submission_id)).toBe(false)
seen.add(res.json.submission_id)
await c("PUT", `/api/submission/${res.json.submission_id}/score`, { score: 4 })
}
expect((await c("GET", "/api/submission/random-for-rating")).json).toBeNull()
})
})
describe("提交与删除", () => { describe("提交与删除", () => {
test("手写提示词提交会生成对话,删除提交连带删除提示词", async () => { test("手写提示词提交会生成对话,删除提交连带删除提示词", async () => {
makeUser("stu") makeUser("stu")
+134
View File
@@ -0,0 +1,134 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
import type { Server } from "bun"
import { app } from "../src/app"
import { config } from "../src/config"
import { all } from "../src/db"
import { upgrade, websocket, type WsData } from "../src/ws"
import { client, freshDb, makeTask, makeUser } from "./helpers"
// 假的模型接口:每段之间停一下,方便测试中途断开
const PIECES = ["好的", "```html\n<p>hi</p>\n```"]
const llm = Bun.serve({
port: 0,
fetch() {
const enc = new TextEncoder()
const stream = new ReadableStream({
async start(ctrl) {
for (const piece of PIECES) {
ctrl.enqueue(enc.encode(`data: ${JSON.stringify({ choices: [{ delta: { content: piece } }] })}\n\n`))
await Bun.sleep(150)
}
ctrl.enqueue(enc.encode("data: [DONE]\n\n"))
ctrl.close()
},
})
return new Response(stream, { headers: { "content-type": "text/event-stream" } })
},
})
let server: Server<WsData>
const saved = { ...config }
beforeAll(() => {
Object.assign(config, { llmApiKey: "k", llmBaseUrl: `http://localhost:${llm.port}` })
server = Bun.serve<WsData>({
port: 0,
async fetch(req, srv) {
const res = await upgrade(req, srv)
return res !== null ? res : app.fetch(req)
},
websocket,
})
})
afterAll(() => {
Object.assign(config, saved)
server.stop(true)
llm.stop(true)
})
beforeEach(freshDb)
interface Conn {
ws: WebSocket
msgs: any[]
next: (type: string) => Promise<any>
}
// 握手失败时返回 null
function connect(path: string): Promise<Conn | null> {
return new Promise((resolve) => {
const ws = new WebSocket(`ws://localhost:${server.port}${path}`)
const msgs: any[] = []
const waiters: { type: string; resolve: (m: any) => void }[] = []
ws.onmessage = (e) => {
const m = JSON.parse(String(e.data))
msgs.push(m)
const i = waiters.findIndex((w) => w.type === m.type)
if (i >= 0) waiters.splice(i, 1)[0]!.resolve(m)
}
const next = (type: string) =>
new Promise<any>((res) => {
const seen = msgs.find((m) => m.type === type && !m._taken)
if (seen) {
seen._taken = true
res(seen)
} else {
waiters.push({ type, resolve: (m) => ((m._taken = true), res(m)) })
}
})
ws.onopen = () => resolve({ ws, msgs, next })
ws.onerror = () => resolve(null)
})
}
async function setup() {
makeUser("stu")
const task = makeTask("挑战")
const stu = await client("stu")
return { task, token: stu.tokens.access_token }
}
describe("WebSocket 代码生成", () => {
test("正常生成:流式返回并保存一问一答", async () => {
const { task, token } = await setup()
const c = (await connect(`/ws/prompt/${task}/?token=${token}`))!
expect((await c.next("init")).messages).toEqual([])
c.ws.send(JSON.stringify({ type: "message", content: "做个页面" }))
const done = await c.next("complete")
expect(done.code.html).toBe("<p>hi</p>")
const streamed = c.msgs.filter((m) => m.type === "stream").map((m) => m.content).join("")
expect(streamed).toBe(PIECES.join(""))
expect(all(`SELECT role FROM prompt_message ORDER BY id`).map((m) => m.role)).toEqual(["user", "assistant"])
c.ws.close()
})
test("AI 回复中再发消息会被拒绝", async () => {
const { task, token } = await setup()
const c = (await connect(`/ws/prompt/${task}/?token=${token}`))!
await c.next("init")
c.ws.send(JSON.stringify({ type: "message", content: "第一条" }))
c.ws.send(JSON.stringify({ type: "message", content: "第二条" }))
expect((await c.next("error")).content).toContain("上一条消息还在处理")
await c.next("complete")
expect(all(`SELECT content FROM prompt_message WHERE role = 'user'`)).toEqual([{ content: "第一条" }])
c.ws.close()
})
test("中途断开:删掉没有回复的提问", async () => {
const { task, token } = await setup()
const c = (await connect(`/ws/prompt/${task}/?token=${token}`))!
await c.next("init")
c.ws.send(JSON.stringify({ type: "message", content: "做个页面" }))
await c.next("stream")
c.ws.close()
await Bun.sleep(400)
expect(all(`SELECT * FROM prompt_message`)).toEqual([])
})
test("token 无效或任务不存在时握手失败", async () => {
const { task, token } = await setup()
expect(await connect(`/ws/prompt/${task}/?token=bad`)).toBeNull()
expect(await connect(`/ws/prompt/9999/?token=${token}`)).toBeNull()
})
})