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
@@ -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