试用前排查:WebSocket 心跳与断线重连、登录失败按账号限制、旧浏览器提示
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s

- 服务器 30 秒发一次 ping:Nginx Proxy Manager 默认 60 秒没数据就断开,窗体开着不操作程序会被结束
- 连接意外断开时提示、清掉失效的窗体、自动重连,连上后提示重新运行
- 登录失败:同一账号 10 次只锁这个账号,同一 IP 300 次,登录成功不再清零 IP 计数
- index.html:浏览器太旧时说明原因和解决办法,加载慢时提示稍等

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 09:05:05 +08:00
1 parent 982b1297aa
commit 27fdc3615d
8 files changed
+137 -23

No files matched your search

+4
View File
@@ -322,6 +322,10 @@ flowchart TB
- **检查的默认等待 2 秒 → 5 秒**:机器忙时,点"数据分析"后查库、画图表会超过 2 秒,正确的程序被判不通过。条件满足就立即往下走,放宽只让不通过的检查多等一会儿;规则里原来写的 3 秒一并去掉。
- **删除学生时删除他的数据库**:用户编号在删掉最大的编号后会重新用给下一个新学生,原来新学生会看到前一个人的库。
- **手动备份与恢复**:`deploy/backup.sh` 把平台数据库和每个学生的库(SQLite 一致读取)导出到宿主机的 `backups/<日期时间>/`;`deploy/restore.sh` 先备份现在的数据,再停平台、替换(删掉旧库的 -wal 文件)、启动。本机 Docker 实测备份、改数据、恢复后数据回到备份时的样子。
- **WebSocket 心跳与断线重连**:线上前面的 Nginx Proxy Manager 默认 60 秒没数据就断开 WebSocket(本机用默认配置的 nginx 实测),学生的窗体开着一分钟不操作,程序就被结束、点了没反应。服务器改为 30 秒没收到数据就发 ping(Bun 的 idleTimeout),经 nginx 空闲 150 秒仍连着。连接意外断开时(部署重启、网络抖动),运行区显示"正在重新连接…",清掉已经失效的窗体,按 1、2、4…最长 10 秒的间隔自动重连;连上后提示"刚才的程序已经结束,请重新运行",实训台恢复。
- **登录失败的限制**:原来按 IP 计数、任何人登录成功就清零,学生用自己的账号登录一次就能接着猜同学的 6 位数字密码。改为同一个账号 10 分钟输错 10 次只锁这个账号(同班其他人不受影响),同一 IP 10 分钟 300 次(登录成功不清零)。
- **旧浏览器提示**:网页按 Chrome / Edge 107 以上打包,机房的旧浏览器或 360 浏览器的兼容模式会一片空白。index.html 里用老写法的一段脚本:网页程序有语法错误时说明浏览器太旧、怎么切换极速模式,并显示浏览器版本字符串方便老师排查;15 秒还没显示出来时提示正在加载。
- **首次打开的下载量**:实验页第一次打开约 1.5 MB(压缩后,代码编辑器 Monaco 占 1 MB),之后浏览器长期缓存。全班 45 人同时第一次打开约 70 MB,服务器公网带宽 5 Mbps 时要近 2 分钟。
## 技术风险与待确认事项
+2 -1
View File
@@ -50,8 +50,9 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
app.post('/api/login', async (c) => {
const ip = clientIp(c)
if (tooManyFailures(ip)) return c.json({ message: '输错次数太多,请 10 分钟后再试' }, 429)
const { username, password } = await body<{ username: string; password: string }>(c)
const blocked = tooManyFailures(ip, String(username ?? ''))
if (blocked) return c.json({ message: blocked }, 429)
const tokens = await login(db, String(username ?? ''), String(password ?? ''), ip)
if (!tokens) return c.json({ message: '账号或密码不对' }, 401)
return c.json(await withUser(tokens))
+27 -15
View File
@@ -12,8 +12,10 @@ import { config } from './config'
const ACCESS_SECONDS = 15 * 60
const REFRESH_DAYS = 30
// 全班通常共用学校的一个出口 IP,上限不能太小,否则几个同学输错就把全班挡在外面
const MAX_FAILURES = 30
// 防暴力猜密码:同一个账号输错太多次只锁这个账号(学生密码是 6 位数字,不能让人一直猜);
// 全班通常共用学校的一个出口 IP,按 IP 的上限要大,否则几个同学输错就把全班挡在外面
const MAX_FAILURES_PER_ACCOUNT = 10
const MAX_FAILURES_PER_IP = 300
const FAILURE_WINDOW_MS = 10 * 60_000
export interface User {
@@ -55,17 +57,27 @@ function secret(db: Database): string {
const randomString = () => Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url')
const hash = (token: string) => new Bun.CryptoHasher('sha256').update(token).digest('hex')
/** 每个 IP 一段时间内的失败次数,防止暴力猜密码 */
/** 一段时间内的失败次数,键是 "ip:地址" 或 "user:账号" */
const failures = new Map<string, { count: number; since: number }>()
export function tooManyFailures(ip: string) {
const entry = failures.get(ip)
if (!entry) return false
if (Date.now() - entry.since > FAILURE_WINDOW_MS) {
failures.delete(ip)
return false
}
return entry.count >= MAX_FAILURES
function failureCount(key: string) {
const entry = failures.get(key)
if (entry && Date.now() - entry.since > FAILURE_WINDOW_MS) failures.delete(key)
return failures.get(key)?.count ?? 0
}
function addFailure(key: string) {
failureCount(key) // 先清掉过期的
const entry = failures.get(key) ?? { count: 0, since: Date.now() }
entry.count++
failures.set(key, entry)
}
/** 输错太多次时返回提示,否则返回 null */
export function tooManyFailures(ip: string, username: string): string | null {
if (failureCount(`user:${username.trim()}`) >= MAX_FAILURES_PER_ACCOUNT) return '这个账号的密码输错太多次,请 10 分钟后再试,或请老师查看密码'
if (failureCount(`ip:${ip}`) >= MAX_FAILURES_PER_IP) return '输错次数太多,请 10 分钟后再试'
return null
}
async function issueTokens(db: Database, userId: number, tokenVersion: number): Promise<Tokens> {
@@ -86,12 +98,12 @@ export async function login(db: Database, username: string, password: string, ip
.query<{ id: number; password: string; token_version: number }, [string]>('SELECT id, password, token_version FROM users WHERE username = ?')
.get(username.trim())
if (!row || row.password !== password) {
const entry = failures.get(ip) ?? { count: 0, since: Date.now() }
entry.count++
failures.set(ip, entry)
addFailure(`ip:${ip}`)
addFailure(`user:${username.trim()}`)
return null
}
failures.delete(ip)
// 只清这个账号的;按 IP 的不清,否则用自己的账号登录一次就能接着猜别人的密码
failures.delete(`user:${username.trim()}`)
return issueTokens(db, row.id, row.token_version)
}
+4
View File
@@ -52,6 +52,10 @@ const server = Bun.serve({
},
websocket: {
data: {} as SocketData,
// 30 秒没收到数据就发一次 ping(浏览器自动回 pong)。线上前面有 Nginx Proxy Manager,
// nginx 默认 60 秒没数据就断开 WebSocket,学生的窗体开着不操作一分钟程序就会被结束
idleTimeout: 30,
sendPings: true,
open(ws) {
const userId = ws.data.userId!
ws.data.session = new RunSession((message) => ws.send(JSON.stringify(message)), () => userDatabaseDir(userId))
+27 -5
View File
@@ -90,11 +90,33 @@ test('账号或密码不对时登录失败,老师登录后能看到自己的
expect(me.body.user).toMatchObject({ username: config.teacherUsername, role: 'teacher' })
})
test('同一个 IP 输错 30 次后暂停登录,别的 IP 不受影响', async () => {
const attacker = new Browser('10.0.0.66')
for (let i = 0; i < 30; i++) await attacker.login('admin', `guess${i}`)
expect((await attacker.login('admin', 'whatever')).status).toBe(429)
expect((await new Browser('10.0.0.67').login('admin', 'whatever')).status).toBe(401)
test('同一个账号输错 10 次后暂停这个账号的登录,同一 IP 的其他同学不受影响', async () => {
const t = await teacher()
await classWithStudents(t, [
{ username: 'guessed', name: '甲', password: '123456' },
{ username: 'other', name: '乙', password: '654321' },
])
const classmate = new Browser('10.0.0.66')
for (let i = 0; i < 9; i++) await classmate.login('guessed', `00000${i}`)
// 用自己的账号登录成功,不会清掉别人账号的失败次数
expect((await classmate.login('other', '654321')).status).toBe(200)
await classmate.login('guessed', '000009')
const blocked = await new Browser('10.0.0.67').login('guessed', '123456')
expect(blocked.status).toBe(429)
expect(blocked.body.message).toContain('这个账号')
expect((await new Browser('10.0.0.66').login('other', '654321')).status).toBe(200)
})
test('同一个 IP 输错 300 次后暂停登录,登录成功也不清零,别的 IP 不受影响', async () => {
const t = await teacher()
await classWithStudents(t, [{ username: 'mine', name: '甲', password: '111111' }])
const attacker = new Browser('10.0.0.68')
for (let i = 0; i < 300; i++) {
await attacker.login(`nobody${i % 50}`, 'guess')
if (i === 150) expect((await attacker.login('mine', '111111')).status).toBe(200)
}
expect((await attacker.login('mine', '111111')).status).toBe(429)
expect((await new Browser('10.0.0.69').login('mine', '111111')).status).toBe(200)
})
describe('班级和名单', () => {
+30
View File
@@ -8,5 +8,35 @@
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<!--
浏览器太旧时(机房的旧版 Chrome、360 浏览器的兼容模式),网页程序运行不起来,页面一片空白。
这段用最老的写法,旧浏览器也能执行:网页程序出了语法错误时说明原因;15 秒后还没显示出来(网络慢),
先告诉学生在加载。网页程序启动后会替换掉这些文字。
-->
<script>
;(function () {
var tooOld = false
function show(title, text) {
var app = document.getElementById('app')
// Vue 启动后会在容器上加 data-v-app,那时不再显示
if (!app || app.hasAttribute('data-v-app')) return
app.innerHTML =
'<div style="max-width:560px;margin:80px auto;padding:0 16px;font:16px/1.8 sans-serif;color:#333">' +
'<h2 style="margin:0 0 12px">' + title + '</h2><p>' + text + '</p>' +
'<p>用 360 浏览器、QQ 浏览器时,请点地址栏右边的闪电图标,切换到<b>极速模式</b>;或者换用新版的 <b>Chrome</b>、<b>Edge</b>。' +
'还是打不开的话,请把下面这行文字告诉老师:</p>' +
'<p style="font-size:13px;color:#888;word-break:break-all">' + navigator.userAgent.replace(/</g, '&lt;') + '</p></div>'
}
window.addEventListener('error', function (event) {
if (event && event.error instanceof SyntaxError) {
tooOld = true
show('网页没能打开', '浏览器版本太旧,运行不了本网站。')
}
})
setTimeout(function () {
if (!tooOld) show('网页正在加载…', '第一次打开要下载约 1.5 MB,全班同时打开时会慢一些,请稍等。如果一直停在这里,可能是浏览器版本太旧。')
}, 15000)
})()
</script>
</body>
</html>
+9 -1
View File
@@ -79,7 +79,11 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
:box="box"
@reply="session.replyMessageBox(box.requestId, $event)"
/>
<div v-if="state.forms.length === 0" class="desktop-hint">点击"运行"后,学生程序的窗体会显示在这里</div>
<div v-if="state.connection === 'lost'" class="desktop-hint lost">与服务器的连接断开了,正在重新连接…</div>
<div v-else-if="state.connection === 'restored' && state.forms.length === 0" class="desktop-hint lost">
连接已恢复。刚才的程序随连接断开已经结束,请重新运行
</div>
<div v-else-if="state.forms.length === 0" class="desktop-hint">点击"运行"后,学生程序的窗体会显示在这里</div>
<div class="desktop-tools">
<span v-if="scale < 1" class="desktop-scale" title="窗体比运行区大,按比例缩小显示;点&quot;放大&quot;可以看得更清楚">
缩放 {{ Math.round(scale * 100) }}%
@@ -219,6 +223,10 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
justify-content: center;
color: rgb(255 255 255 / 0.7);
}
.desktop-hint.lost {
color: #ffd591;
font-weight: 600;
}
.photos {
font-size: 12px;
+34 -1
View File
@@ -45,6 +45,11 @@ export function createRunSession() {
/** 正在播放的声音(媒体播放器控件,实训台上的广播喇叭随之亮起) */
playing: null as { name: string } | null,
metrics: { compileMs: 0, startMs: 0, lastRoundTripMs: 0 },
/**
* 和服务器的连接:lost 表示意外断开(网络不稳、服务器在更新),正在自动重连;
* restored 表示断开后又连上了(提示学生刚才的程序已经结束),下次运行时变回 open
*/
connection: 'connecting' as 'connecting' | 'open' | 'lost' | 'restored',
})
/** id → 控件节点,patch 按 id 修改节点属性 */
const nodes = shallowReactive(new Map<string, ControlNode>())
@@ -54,6 +59,12 @@ export function createRunSession() {
/** 正在建立或已建立的连接;同时多处调用 connect() 时共用同一个 */
let connecting: Promise<void> | undefined
let actionSentAt = 0
/** 离开页面时主动断开,不再重连 */
let closed = false
let reconnectTimer: ReturnType<typeof setTimeout> | undefined
/** 连续重连失败的次数,等待时间随之加长(最长 RECONNECT_MAX_MS) */
let reconnectFailures = 0
const RECONNECT_MAX_MS = 10_000
/** 每个媒体播放器控件一个 <audio> */
const players = new Map<string, HTMLAudioElement>()
@@ -92,12 +103,27 @@ export function createRunSession() {
const ws = new WebSocket(`${protocol}://${location.host}/ws?token=${encodeURIComponent(token ?? '')}`)
socket = ws
ws.onmessage = (event) => handle(JSON.parse(event.data) as ServerMessage)
ws.onopen = () => resolve()
ws.onopen = () => {
state.connection = state.connection === 'lost' ? 'restored' : 'open'
reconnectFailures = 0
resolve()
}
ws.onclose = () => {
// 服务器那边的程序随连接一起结束了;窗体留在页面上只会"点了没反应"
if (!closed && (state.forms.length > 0 || state.status === 'compiling' || state.status === 'running')) {
state.errors.push('与服务器的连接断开了(网络不稳或服务器正在更新),程序已经结束。连上之后请重新运行。')
}
stopAudio()
state.forms = []
state.messageBoxes = []
state.status = 'stopped'
state.board = null
socket = undefined
connecting = undefined
if (!closed) {
state.connection = 'lost'
reconnectTimer = setTimeout(() => void connect(), Math.min(1000 * 2 ** reconnectFailures++, RECONNECT_MAX_MS))
}
resolve()
}
}),
@@ -112,6 +138,11 @@ export function createRunSession() {
/** sampleId:当前实验(服务器据此在第一次运行时建好实验预置的数据库) */
async function run(files: SourceFile[], sampleId?: string) {
await connect()
if (socket?.readyState !== WebSocket.OPEN) {
state.errors = ['连不上服务器,请检查网络,稍后再点"运行"。']
return
}
state.connection = 'open'
state.diagnostics = []
state.console = ''
state.errors = []
@@ -251,6 +282,8 @@ export function createRunSession() {
/** 离开页面时断开连接:服务器随即结束正在运行的程序 */
function close() {
closed = true
clearTimeout(reconnectTimer)
socket?.close()
}