perf: 首屏 JS 减少 2/3、随机评分查询提速,WebSocket 防刷和登录限流
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
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:
1 parent
bd84e3f8d1
commit
325474bdb7
10 files changed
+269
-37
No files matched your search
@@ -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/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/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`
|
||||
- Roles: `super`, `admin`, `normal`. Task model is multi-table: `task_task` + one of `task_tutorial` / `task_challenge` / `task_creation` (`task_ptr_id`)
|
||||
|
||||
|
||||
@@ -48,9 +48,9 @@
|
||||
/>
|
||||
<n-alert
|
||||
type="error"
|
||||
v-if="showStudentError"
|
||||
v-if="studentError"
|
||||
class="message"
|
||||
title="登录失败,请检查密码"
|
||||
:title="studentError"
|
||||
/>
|
||||
<n-button
|
||||
block
|
||||
@@ -79,9 +79,9 @@
|
||||
</n-form-item>
|
||||
<n-alert
|
||||
type="error"
|
||||
v-if="showAdminError"
|
||||
v-if="adminError"
|
||||
class="message"
|
||||
title="登录失败,请检查用户名和密码"
|
||||
:title="adminError"
|
||||
/>
|
||||
<n-button
|
||||
block
|
||||
@@ -113,7 +113,7 @@ const selectedClass = ref<string | null>(null)
|
||||
const selectedUsername = ref<string | null>(null)
|
||||
const studentPassword = ref("")
|
||||
const studentLoading = ref(false)
|
||||
const showStudentError = ref(false)
|
||||
const studentError = ref("")
|
||||
|
||||
// Classes data
|
||||
const classes = ref<string[]>([])
|
||||
@@ -135,7 +135,7 @@ const nameOptions = computed(() =>
|
||||
const adminName = ref("")
|
||||
const adminPassword = ref("")
|
||||
const adminLoading = ref(false)
|
||||
const showAdminError = ref(false)
|
||||
const adminError = ref("")
|
||||
|
||||
// Load classes on mount (cached — not reloaded on tab switch)
|
||||
onMounted(async () => {
|
||||
@@ -155,13 +155,13 @@ function onTabChange() {
|
||||
selectedClass.value = null
|
||||
selectedUsername.value = null
|
||||
studentPassword.value = ""
|
||||
showStudentError.value = false
|
||||
studentError.value = ""
|
||||
namesError.value = false
|
||||
names.value = []
|
||||
// Reset admin tab state
|
||||
adminName.value = ""
|
||||
adminPassword.value = ""
|
||||
showAdminError.value = false
|
||||
adminError.value = ""
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!selectedUsername.value || !studentPassword.value) return
|
||||
studentLoading.value = true
|
||||
showStudentError.value = false
|
||||
studentError.value = ""
|
||||
try {
|
||||
const data = await Account.login(
|
||||
selectedUsername.value,
|
||||
@@ -194,8 +199,8 @@ async function submitStudent() {
|
||||
router.replace(
|
||||
window.location.pathname + window.location.search + window.location.hash,
|
||||
)
|
||||
} catch {
|
||||
showStudentError.value = true
|
||||
} catch (err: any) {
|
||||
studentError.value = loginError(err, "登录失败,请检查密码")
|
||||
} finally {
|
||||
studentLoading.value = false
|
||||
}
|
||||
@@ -204,7 +209,7 @@ async function submitStudent() {
|
||||
async function submitAdmin() {
|
||||
if (!adminName.value || !adminPassword.value) return
|
||||
adminLoading.value = true
|
||||
showAdminError.value = false
|
||||
adminError.value = ""
|
||||
try {
|
||||
const data = await Account.login(adminName.value, adminPassword.value)
|
||||
user.username = data.username
|
||||
@@ -214,8 +219,8 @@ async function submitAdmin() {
|
||||
router.replace(
|
||||
window.location.pathname + window.location.search + window.location.hash,
|
||||
)
|
||||
} catch {
|
||||
showAdminError.value = true
|
||||
} catch (err: any) {
|
||||
adminError.value = loginError(err, "登录失败,请检查用户名和密码")
|
||||
} finally {
|
||||
adminLoading.value = false
|
||||
}
|
||||
|
||||
@@ -79,11 +79,6 @@
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
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 Toolbar from "./Toolbar.vue"
|
||||
import { html, css, js, tab, size, reset } from "../../store/editors"
|
||||
@@ -105,6 +100,15 @@ function changeSize(num: number) {
|
||||
}
|
||||
|
||||
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([
|
||||
prettier.format(html.value, {
|
||||
parser: "html",
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
<iframe
|
||||
:srcdoc="buildSrcdoc(item)"
|
||||
sandbox="allow-scripts"
|
||||
loading="lazy"
|
||||
scrolling="no"
|
||||
class="preview-iframe"
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createWebHistory, createRouter } from "vue-router"
|
||||
import { loginModal } from "./store/modal"
|
||||
|
||||
import Workspace from "./pages/Workspace.vue"
|
||||
// 编辑器(CodeMirror 等)比较大,用到时才加载;几个路由共用同一个加载函数,切换时不会重新挂载
|
||||
const Workspace = () => import("./pages/Workspace.vue")
|
||||
import { hasLogin } from "./token"
|
||||
import { authed } from "./store/user"
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
requireUser,
|
||||
setPassword,
|
||||
} 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>()
|
||||
|
||||
@@ -54,10 +54,45 @@ async function insertUser(u: {
|
||||
const userExists = (username: string) =>
|
||||
!!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) => {
|
||||
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)
|
||||
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)) })
|
||||
})
|
||||
|
||||
|
||||
@@ -314,18 +314,30 @@ router.get("/random-for-rating", (c) => {
|
||||
}
|
||||
if (user.role === Role.NORMAL && todayRatingCount(user.id) >= 30) return c.json(null)
|
||||
|
||||
// 只在索引上随机挑出 rowid,再读这一行的代码;否则每个候选都要读一遍 html/css/js
|
||||
const base = `SELECT s.rowid AS rid FROM submission_submission s
|
||||
WHERE s.user_id != $me
|
||||
const cond = `s.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" : ""}`
|
||||
const params = { me: user.id, ...(excludeId ? { exclude: excludeId } : {}) }
|
||||
const picked =
|
||||
get<{ rid: number }>(
|
||||
`${base} AND (SELECT COUNT(*) FROM submission_rating r WHERE r.submission_id = s.id) < 5
|
||||
const fewRatings = `(SELECT COUNT(*) FROM submission_rating r WHERE r.submission_id = s.id) < 5`
|
||||
// 每个学生生成代码时都会调这个接口,ORDER BY RANDOM() 要把所有提交过一遍(6 万条约 50ms,期间整个服务都卡住)。
|
||||
// 先随机抽几十个 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`,
|
||||
params,
|
||||
) ?? get<{ rid: number }>(`${base} ORDER BY RANDOM() LIMIT 1`, params)
|
||||
{ ...params, sample: JSON.stringify(sample) },
|
||||
)
|
||||
}
|
||||
// 只在索引上随机挑出 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)
|
||||
const pick = get(
|
||||
`SELECT s.id, u.username, t.title AS task_title, t.display AS task_display,
|
||||
|
||||
+14
-5
@@ -21,8 +21,8 @@ export interface WsData {
|
||||
pendingUserMessageId: number | null
|
||||
abort: AbortController
|
||||
closed: boolean
|
||||
// 串行处理消息(与 Django Channels 的行为一致)
|
||||
queue: Promise<void>
|
||||
// 正在处理一条消息(等 AI 回复);期间再发来的直接拒绝,不排队,免得一次塞几百条刷模型费用
|
||||
busy: boolean
|
||||
// 引导对话只在本次连接内保留上下文
|
||||
guidanceHistory: ChatMessage[]
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export async function upgrade(
|
||||
pendingUserMessageId: null,
|
||||
abort: new AbortController(),
|
||||
closed: false,
|
||||
queue: Promise.resolve(),
|
||||
busy: false,
|
||||
guidanceHistory: [],
|
||||
},
|
||||
})
|
||||
@@ -185,6 +185,8 @@ export const websocket: WebSocketHandler<WsData> = {
|
||||
// AI 生成可能要一两分钟,期间没有客户端消息
|
||||
idleTimeout: 960,
|
||||
sendPings: true,
|
||||
// 提示词不会这么长;Bun 默认 16MB
|
||||
maxPayloadLength: 64 * 1024,
|
||||
|
||||
open(ws) {
|
||||
const d = ws.data
|
||||
@@ -222,13 +224,20 @@ export const websocket: WebSocketHandler<WsData> = {
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (ws.data.busy) {
|
||||
send(ws, { type: "error", content: "上一条消息还在处理,请等 AI 回复完再发" })
|
||||
return
|
||||
}
|
||||
const handler = ws.data.kind === "prompt" ? handlePrompt : handleGuidance
|
||||
ws.data.queue = ws.data.queue
|
||||
.then(() => (ws.data.closed ? undefined : handler(ws, data)))
|
||||
ws.data.busy = true
|
||||
handler(ws, data)
|
||||
.catch((e) => {
|
||||
console.error("websocket handler failed:", e)
|
||||
send(ws, { type: "error", content: "服务器出错,请刷新页面重试" })
|
||||
})
|
||||
.finally(() => {
|
||||
ws.data.busy = false
|
||||
})
|
||||
},
|
||||
|
||||
close(ws) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { config } from "../src/config"
|
||||
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
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)
|
||||
|
||||
@@ -50,6 +50,17 @@ describe("权限", () => {
|
||||
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 () => {
|
||||
const id = makeUser("stu")
|
||||
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("提交与删除", () => {
|
||||
test("手写提示词提交会生成对话,删除提交连带删除提示词", async () => {
|
||||
makeUser("stu")
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user