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

+37 -2
View File
@@ -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)) })
})
+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)
// 只在索引上随机挑出 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
View File
@@ -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) {