fix: 修复中危问题:注册接口、模型名、LLM 超时、WebSocket 断线、未公开任务泄露等

服务端
- 删掉没人用的 /account/register,外网不能再批量注册小号
- 模型名只放行豆包和配置的 LLM_MODEL,浏览器里存的旧模型名换成默认模型;
  聊天默认模型改读 LLM_MODEL
- 流式生成改成 120 秒无数据才超时(另有 15 分钟总上限),思考模型不会被中途掐断;
  SSE 最后一行没有换行时不再丢内容
- WebSocket:token 无效 401、任务不存在 404,建对话失败时关闭连接;
  引导出错时把没有回复的提问移出上下文
- 未公开的教程只有超管能看,未公开的挑战和素材管理员也能看,其他人 404
- Caddy:/media 加 CSP sandbox 和 nosniff,上传的 html/svg 直接打开不能跑脚本;
  加 CORS,预览 iframe 里的代码可以读取字体和 fetch 素材

前端
- 新增 ReconnectingSocket:意外断开后退避重连,握手失败先刷新 token;
  断线时清掉“生成中”状态,旧连接的事件不再改写新连接
- marked 扩展抽到 utils/markdown.ts:教程用独立实例,不再改全局 marked;
  学生内容的渲染保留高亮,不带会渲染原始 HTML 的 preview 扩展
- 挑战页、创作页加载完成前离开时不再建立连接,离开时关闭引导弹窗
- 教程、提交列表、用户管理跟随浏览器前进后退;快速翻页只用最后一次结果,
  改筛选条件不再请求两次
- 随机评分弹窗在 AI 回复完之后不再弹出

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 21:33:44 +08:00
1 parent 73788c4595
commit cdeb7c18ef
22 files changed
+489 -210

No files matched your search

+41 -41
View File
@@ -1,5 +1,5 @@
import { ref } from "vue"
import { wsUrl } from "../token"
import { ReconnectingSocket } from "../utils/socket"
export interface GuidanceMessage {
role: "user" | "assistant"
@@ -15,31 +15,19 @@ export const isReady = ref(false)
export const isOpen = ref(false)
export const initialPrompt = ref("")
let ws: WebSocket | null = null
let _taskId = 0
// 取 token 要等一下,期间又重连或关闭时作废这次连接
let connectSeq = 0
// 打开弹窗时带过来的提示词,连上后发一次;重连时不再重发
let unsentPrompt = ""
async function connect(taskId: number) {
if (ws) ws.close()
ws = null
const seq = ++connectSeq
const url = await wsUrl(`/ws/guidance/${taskId}/`)
if (seq !== connectSeq) return
ws = new WebSocket(url)
setupHandlers(ws)
}
function setupHandlers(socket: WebSocket) {
socket.onopen = () => {
const socket = new ReconnectingSocket({
onOpen() {
connected.value = true
if (initialPrompt.value) {
sendToSocket(initialPrompt.value)
if (unsentPrompt) {
sendToSocket(unsentPrompt)
unsentPrompt = ""
}
}
socket.onmessage = (event) => {
const data = JSON.parse(event.data)
},
onMessage(data) {
if (data.type === "init") {
streaming.value = false
streamingContent.value = ""
@@ -57,18 +45,38 @@ function setupHandlers(socket: WebSocket) {
streamingContent.value = ""
messages.value.push({ role: "assistant", content: data.content })
}
}
socket.onclose = () => {
},
onDrop() {
connected.value = false
}
if (streaming.value) dropPendingMessage()
},
onGiveUp() {
messages.value.push({
role: "assistant",
content: "与服务器的连接已断开,请刷新页面重试",
})
},
})
function connect(taskId: number) {
socket.connect(`/ws/guidance/${taskId}/`)
}
function sendToSocket(content: string) {
if (!ws || ws.readyState !== WebSocket.OPEN) return
if (!socket.send({ type: "message", content })) return
streaming.value = true
messages.value.push({ role: "user", content })
ws.send(JSON.stringify({ type: "message", content }))
}
function dropPendingMessage() {
if (
messages.value.length > 0 &&
messages.value[messages.value.length - 1].role === "user"
) {
messages.value.pop()
}
streaming.value = false
streamingContent.value = ""
}
export function openGuidance(taskId: number, prompt: string) {
@@ -76,6 +84,7 @@ export function openGuidance(taskId: number, prompt: string) {
_taskId = taskId
initialPrompt.value = prompt
unsentPrompt = prompt
messages.value = []
isReady.value = false
streamingContent.value = ""
@@ -89,26 +98,17 @@ export function sendGuidance(content: string) {
}
export function stopGuidance() {
if (
messages.value.length > 0 &&
messages.value[messages.value.length - 1].role === "user"
) {
messages.value.pop()
}
streaming.value = false
streamingContent.value = ""
dropPendingMessage()
if (_taskId) {
initialPrompt.value = ""
unsentPrompt = ""
connect(_taskId)
}
}
export function closeGuidance() {
connectSeq++
if (ws) {
ws.close()
ws = null
}
socket.close()
unsentPrompt = ""
isOpen.value = false
messages.value = []
isReady.value = false
+70 -73
View File
@@ -1,5 +1,5 @@
import { ref } from "vue"
import { wsUrl } from "../token"
import { ReconnectingSocket } from "../utils/socket"
import { html, css, js } from "./editors"
export interface PromptMessage {
@@ -28,81 +28,75 @@ export function setOnCodeComplete(fn: typeof _onCodeComplete) {
_onCodeComplete = fn
}
let ws: WebSocket | null = null
// 取 token 要等一下,期间又连了别的题或断开时作废这次连接
let connectSeq = 0
export async function connectPrompt(taskId: number) {
currentTaskId.value = taskId
if (ws) ws.close()
ws = null
const seq = ++connectSeq
const url = await wsUrl(`/ws/prompt/${taskId}/`)
if (seq !== connectSeq) return
ws = new WebSocket(url)
ws.onopen = () => {
connected.value = true
}
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.type === "init") {
streaming.value = false
streamingContent.value = ""
const alreadyLoaded = conversationId.value === data.conversation_id
conversationId.value = data.conversation_id
if (!alreadyLoaded) {
messages.value = data.messages || []
const lastAssistant = [...messages.value]
.reverse()
.find((m) => m.role === "assistant" && m.code)
if (lastAssistant?.code) {
applyCode(lastAssistant.code)
}
function onMessage(data: any) {
if (data.type === "init") {
streaming.value = false
streamingContent.value = ""
const alreadyLoaded = conversationId.value === data.conversation_id
conversationId.value = data.conversation_id
if (!alreadyLoaded) {
messages.value = data.messages || []
const lastAssistant = [...messages.value]
.reverse()
.find((m) => m.role === "assistant" && m.code)
if (lastAssistant?.code) {
applyCode(lastAssistant.code)
}
} else if (data.type === "stream") {
streaming.value = true
streamingContent.value += data.content
} else if (data.type === "complete") {
streaming.value = false
messages.value.push({
role: "assistant",
content: streamingContent.value,
id: data.message_id,
code: data.code,
submitted: false,
})
streamingContent.value = ""
if (data.code) {
applyCode(data.code)
if (_onCodeComplete) {
_onCodeComplete(data.code, data.message_id)
}
}
} else if (data.type === "error") {
streaming.value = false
streamingContent.value = ""
messages.value.push({
role: "assistant",
content: data.content,
})
}
}
ws.onclose = () => {
connected.value = false
} else if (data.type === "stream") {
streaming.value = true
streamingContent.value += data.content
} else if (data.type === "complete") {
streaming.value = false
messages.value.push({
role: "assistant",
content: streamingContent.value,
id: data.message_id,
code: data.code,
submitted: false,
})
streamingContent.value = ""
if (data.code) {
applyCode(data.code)
if (_onCodeComplete) {
_onCodeComplete(data.code, data.message_id)
}
}
} else if (data.type === "error") {
streaming.value = false
streamingContent.value = ""
messages.value.push({
role: "assistant",
content: data.content,
})
}
}
const socket = new ReconnectingSocket({
onOpen() {
connected.value = true
},
onMessage,
onDrop() {
connected.value = false
// 断开时服务端会删掉还没有回复的提问,这边也去掉
if (streaming.value) dropPendingPrompt()
},
onGiveUp() {
messages.value.push({
role: "assistant",
content: "与服务器的连接已断开,请刷新页面重试",
})
},
})
export function connectPrompt(taskId: number) {
currentTaskId.value = taskId
socket.connect(`/ws/prompt/${taskId}/`)
}
export function disconnectPrompt() {
connectSeq++
if (ws) {
ws.close()
ws = null
}
socket.close()
messages.value = []
conversationId.value = ""
connected.value = false
@@ -113,13 +107,12 @@ export function disconnectPrompt() {
}
export function sendPrompt(content: string, model: string = "") {
if (!ws || ws.readyState !== WebSocket.OPEN) return
if (!socket.send({ type: "message", content, model })) return
streaming.value = true
messages.value.push({ role: "user", content })
ws.send(JSON.stringify({ type: "message", content, model }))
}
export function stopPrompt() {
function dropPendingPrompt() {
if (
messages.value.length > 0 &&
messages.value[messages.value.length - 1].role === "user"
@@ -128,6 +121,10 @@ export function stopPrompt() {
}
streaming.value = false
streamingContent.value = ""
}
export function stopPrompt() {
dropPendingPrompt()
if (currentTaskId.value) {
connectPrompt(currentTaskId.value)
}