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:
1 parent
73788c4595
commit
cdeb7c18ef
22 files changed
+489
-210
No files matched your search
@@ -161,3 +161,7 @@ export const requireSuper = (c: Ctx) => requireRoles(c, [Role.SUPER])
|
||||
|
||||
export const isStaff = (user: User) =>
|
||||
user.role === Role.ADMIN || user.role === Role.SUPER
|
||||
|
||||
// 未公开的教程只有超管能看,未公开的挑战管理员也能看(和能编辑它们的人一致)
|
||||
export const canSeeUnpublished = (user: User | null | undefined, kind: "tutorial" | "challenge") =>
|
||||
!!user && (kind === "tutorial" ? user.role === Role.SUPER : isStaff(user))
|
||||
@@ -54,19 +54,6 @@ async function insertUser(u: {
|
||||
const userExists = (username: string) =>
|
||||
!!get(`SELECT 1 FROM account_user WHERE username = $username`, { username })
|
||||
|
||||
router.post("/register", async (c) => {
|
||||
const payload = await body(
|
||||
c,
|
||||
z.object({
|
||||
username: z.string(),
|
||||
email: z.email(),
|
||||
password: z.string().min(6).max(20),
|
||||
}),
|
||||
)
|
||||
await insertUser(payload)
|
||||
return c.json({ message: "创建成功" })
|
||||
})
|
||||
|
||||
router.post("/login", async (c) => {
|
||||
const payload = await body(c, z.object({ username: z.string(), password: z.string() }))
|
||||
const user = await authenticate(payload.username, payload.password)
|
||||
|
||||
@@ -4,8 +4,8 @@ import { existsSync } from "node:fs"
|
||||
import { dirname, extname, join, resolve, sep } from "node:path"
|
||||
import { config } from "../config"
|
||||
import { all, get, run } from "../db"
|
||||
import { requireAdmin, requireSuper, type User } from "../auth"
|
||||
import { HttpError, ValidationError, intParam, orNotFound, type Ctx, type Env } from "../http"
|
||||
import { canSeeUnpublished, requireAdmin, requireSuper, type User } from "../auth"
|
||||
import { HttpError, ValidationError, intParam, notFound, orNotFound, type Ctx, type Env } from "../http"
|
||||
|
||||
// 与 Django 的 get_valid_filename 一致:空格换成下划线,去掉除字母数字、-、_、. 以外的字符
|
||||
export function validFilename(name: string) {
|
||||
@@ -53,8 +53,8 @@ type Kind = "challenge" | "tutorial"
|
||||
|
||||
function findTask(kind: Kind, display: number) {
|
||||
return orNotFound(
|
||||
get<{ id: number; task_type: string; display: number }>(
|
||||
`SELECT t.id, t.task_type, t.display FROM task_task t
|
||||
get<{ id: number; task_type: string; display: number; is_public: number }>(
|
||||
`SELECT t.id, t.task_type, t.display, t.is_public FROM task_task t
|
||||
JOIN task_${kind} x ON x.task_ptr_id = t.id WHERE t.display = $display`,
|
||||
{ display },
|
||||
),
|
||||
@@ -72,6 +72,7 @@ for (const kind of ["challenge", "tutorial"] as const) {
|
||||
|
||||
assets.get(`/${kind}/:display`, (c) => {
|
||||
const task = findTask(kind, intParam(c, "display"))
|
||||
if (!task.is_public && !canSeeUnpublished(c.get("user"), kind)) notFound()
|
||||
const rows = all<{ name: string; file: string }>(
|
||||
`SELECT name, file FROM task_taskasset WHERE task_id = $id ORDER BY id`,
|
||||
{ id: task.id },
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Hono } from "hono"
|
||||
import { z } from "zod"
|
||||
import { all, get, run, tx, now, iso } from "../db"
|
||||
import { Role, requireAdmin, requireSuper, requireUser, type User } from "../auth"
|
||||
import { HttpError, body, bool, intParam, orNotFound, type Env } from "../http"
|
||||
import { Role, canSeeUnpublished, requireAdmin, requireSuper, requireUser, type User } from "../auth"
|
||||
import { HttpError, body, bool, intParam, notFound, orNotFound, type Env } from "../http"
|
||||
import { deleteTask } from "../services/cascade"
|
||||
|
||||
const TASK_COLUMNS = `t.id, t.created, t.modified, t.display, t.title, t.content, t.task_type,
|
||||
@@ -134,6 +134,7 @@ tutorial.get("/:display", (c) => {
|
||||
{ display: intParam(c, "display") },
|
||||
),
|
||||
)
|
||||
if (!row.is_public && !canSeeUnpublished(c.get("user"), "tutorial")) notFound()
|
||||
return c.json(taskOut(row))
|
||||
})
|
||||
|
||||
@@ -227,6 +228,7 @@ challenge.get("/:display", (c) => {
|
||||
display: intParam(c, "display"),
|
||||
}),
|
||||
)
|
||||
if (!r.is_public && !canSeeUnpublished(c.get("user"), "challenge")) notFound()
|
||||
return c.json({
|
||||
...taskOut(r),
|
||||
score: r.score,
|
||||
|
||||
+59
-21
@@ -46,10 +46,14 @@ export const GUIDANCE_SYSTEM_PROMPT = `你是一个提示词写作教练,帮
|
||||
5. 如果回复以 [READY] 开头,[READY] 不要加粗,必须保持原始文本
|
||||
6. 不要生成任何代码`
|
||||
|
||||
const DEFAULT_MODEL = "deepseek-flash"
|
||||
// 走火山方舟(ARK)接口的模型
|
||||
const ARK_MODELS = new Set(["doubao-seed-2-1-lite-260915"])
|
||||
|
||||
// 客户端可选的模型;不认识的(比如浏览器里存着改版前的模型名)一律换成默认模型
|
||||
export function chatModel(model?: string) {
|
||||
return model && (model === config.llmModel || ARK_MODELS.has(model)) ? model : config.llmModel
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: "system" | "user" | "assistant"
|
||||
content: string
|
||||
@@ -63,10 +67,14 @@ interface CompletionOptions {
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
// 流式生成:超过这么久没收到新数据才算超时,另有一个总时长上限
|
||||
const STREAM_IDLE_MS = 120_000
|
||||
const STREAM_MAX_MS = 15 * 60_000
|
||||
|
||||
export const llmConfigured = () => !!config.llmApiKey
|
||||
|
||||
function buildRequest(messages: ChatMessage[], opts: CompletionOptions) {
|
||||
const model = opts.model || DEFAULT_MODEL
|
||||
const model = opts.model || config.llmModel
|
||||
const ark = ARK_MODELS.has(model)
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
@@ -102,30 +110,60 @@ async function* streamCompletion(
|
||||
model?: string,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<string> {
|
||||
const res = await buildRequest(messages, { model, stream: true, signal })
|
||||
await ensureOk(res)
|
||||
if (!res.body) return
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
for await (const chunk of res.body) {
|
||||
buffer += decoder.decode(chunk, { stream: true })
|
||||
let idx: number
|
||||
while ((idx = buffer.indexOf("\n")) >= 0) {
|
||||
const line = buffer.slice(0, idx).trim()
|
||||
buffer = buffer.slice(idx + 1)
|
||||
if (!line.startsWith("data:")) continue
|
||||
const data = line.slice(5).trim()
|
||||
if (data === "[DONE]") return
|
||||
const json = JSON.parse(data)
|
||||
if (json.error) throw new Error(json.error.message ?? JSON.stringify(json.error))
|
||||
const content = json.choices?.[0]?.delta?.content
|
||||
if (content) yield content
|
||||
// 带思考的模型生成整页代码可能超过两分钟,只要一直有数据就不该中断
|
||||
const idle = new AbortController()
|
||||
let timer = setTimeout(() => idle.abort(new Error("模型响应超时")), STREAM_IDLE_MS)
|
||||
const resetIdle = () => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => idle.abort(new Error("模型响应超时")), STREAM_IDLE_MS)
|
||||
}
|
||||
const signals = [idle.signal]
|
||||
if (signal) signals.push(signal)
|
||||
try {
|
||||
const res = await buildRequest(messages, {
|
||||
model,
|
||||
stream: true,
|
||||
signal: AbortSignal.any(signals),
|
||||
timeoutMs: STREAM_MAX_MS,
|
||||
})
|
||||
await ensureOk(res)
|
||||
if (!res.body) return
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
// 返回 true 表示收到了 [DONE]
|
||||
function* parse(lines: string[]): Generator<string, boolean> {
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim()
|
||||
if (!line.startsWith("data:")) continue
|
||||
const data = line.slice(5).trim()
|
||||
if (data === "[DONE]") return true
|
||||
const json = JSON.parse(data)
|
||||
if (json.error) throw new Error(json.error.message ?? JSON.stringify(json.error))
|
||||
const content = json.choices?.[0]?.delta?.content
|
||||
if (content) yield content
|
||||
}
|
||||
return false
|
||||
}
|
||||
for await (const chunk of res.body) {
|
||||
resetIdle()
|
||||
buffer += decoder.decode(chunk, { stream: true })
|
||||
const lines = buffer.split("\n")
|
||||
buffer = lines.pop()!
|
||||
if (yield* parse(lines)) return
|
||||
}
|
||||
// 最后一行可能没有换行
|
||||
yield* parse([buffer + decoder.decode()])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
export function streamChat(history: ChatMessage[], model?: string, signal?: AbortSignal) {
|
||||
return streamCompletion([{ role: "system", content: SYSTEM_PROMPT }, ...history], model, signal)
|
||||
return streamCompletion(
|
||||
[{ role: "system", content: SYSTEM_PROMPT }, ...history],
|
||||
chatModel(model),
|
||||
signal,
|
||||
)
|
||||
}
|
||||
|
||||
export function streamGuidance(history: ChatMessage[], signal?: AbortSignal) {
|
||||
|
||||
+20
-4
@@ -1,5 +1,5 @@
|
||||
import type { Server, ServerWebSocket, WebSocketHandler } from "bun"
|
||||
import { all, run, iso, uuidOut } from "./db"
|
||||
import { all, get, run, iso, uuidOut } from "./db"
|
||||
import { userFromAccessToken, type User } from "./auth"
|
||||
import { canAccessTask, getOrCreateActiveConversation, insertMessage } from "./services/prompt"
|
||||
import {
|
||||
@@ -41,8 +41,12 @@ export async function upgrade(
|
||||
const match = PATH_RE.exec(url.pathname)
|
||||
if (!match) return null
|
||||
const user = await userFromAccessToken(url.searchParams.get("token"))
|
||||
if (!user) return new Response("Unauthorized", { status: 401 })
|
||||
const taskId = Number(match[2])
|
||||
if (!user || !canAccessTask(user, taskId)) return new Response("Forbidden", { status: 403 })
|
||||
if (!get(`SELECT 1 FROM task_task WHERE id = $id`, { id: taskId })) {
|
||||
return new Response("Not Found", { status: 404 })
|
||||
}
|
||||
if (!canAccessTask(user, taskId)) return new Response("Forbidden", { status: 403 })
|
||||
|
||||
const ok = server.upgrade(req, {
|
||||
data: {
|
||||
@@ -155,6 +159,8 @@ async function handleGuidance(ws: WS, data: any) {
|
||||
try {
|
||||
full = await streamToClient(ws, streamGuidance(ws.data.guidanceHistory, ws.data.abort.signal))
|
||||
} catch (e) {
|
||||
// 这条提问没有回复,留在上下文里会出现连续两条用户消息
|
||||
ws.data.guidanceHistory.pop()
|
||||
send(ws, { type: "error", content: `AI 服务出错:${e instanceof Error ? e.message : e}` })
|
||||
return
|
||||
}
|
||||
@@ -182,7 +188,14 @@ export const websocket: WebSocketHandler<WsData> = {
|
||||
|
||||
open(ws) {
|
||||
const d = ws.data
|
||||
d.conversationId = getOrCreateActiveConversation(d.user.id, d.taskId)
|
||||
try {
|
||||
d.conversationId = getOrCreateActiveConversation(d.user.id, d.taskId)
|
||||
} catch (e) {
|
||||
// 比如任务刚被删掉;不关掉的话客户端会一直等 init
|
||||
console.error("websocket open failed:", e)
|
||||
ws.close(1011, "conversation unavailable")
|
||||
return
|
||||
}
|
||||
if (d.kind === "guidance") {
|
||||
send(ws, { type: "init", messages: [] })
|
||||
return
|
||||
@@ -212,7 +225,10 @@ export const websocket: WebSocketHandler<WsData> = {
|
||||
const handler = ws.data.kind === "prompt" ? handlePrompt : handleGuidance
|
||||
ws.data.queue = ws.data.queue
|
||||
.then(() => (ws.data.closed ? undefined : handler(ws, data)))
|
||||
.catch((e) => console.error("websocket handler failed:", e))
|
||||
.catch((e) => {
|
||||
console.error("websocket handler failed:", e)
|
||||
send(ws, { type: "error", content: "服务器出错,请刷新页面重试" })
|
||||
})
|
||||
},
|
||||
|
||||
close(ws) {
|
||||
|
||||
@@ -17,6 +17,34 @@ describe("权限", () => {
|
||||
expect((await adm("GET", "/api/submission/gradebook/?classname=a")).status).toBe(403)
|
||||
})
|
||||
|
||||
test("未公开的教程只有超管能看,未公开的挑战管理员也能看", async () => {
|
||||
makeUser("stu")
|
||||
makeUser("adm", "admin")
|
||||
makeUser("boss", "super")
|
||||
makeTask("草稿教程", "tutorial", 1, false)
|
||||
makeTask("草稿挑战", "challenge", 2, false)
|
||||
makeTask("公开挑战", "challenge", 3, true)
|
||||
const [anon, stu, adm, boss] = await Promise.all([client(), client("stu"), client("adm"), client("boss")])
|
||||
for (const path of ["/api/tutorial/1", "/api/challenge/2", "/api/assets/challenge/2"]) {
|
||||
expect((await anon("GET", path)).status).toBe(404)
|
||||
expect((await stu("GET", path)).status).toBe(404)
|
||||
}
|
||||
expect((await stu("GET", "/api/challenge/3")).status).toBe(200)
|
||||
expect((await adm("GET", "/api/tutorial/1")).status).toBe(404)
|
||||
expect((await adm("GET", "/api/challenge/2")).status).toBe(200)
|
||||
expect((await adm("GET", "/api/assets/challenge/2")).status).toBe(200)
|
||||
expect((await boss("GET", "/api/tutorial/1")).status).toBe(200)
|
||||
})
|
||||
|
||||
test("没有公开注册接口", async () => {
|
||||
const res = await (await client())("POST", "/api/account/register", {
|
||||
username: "x",
|
||||
email: "x@x.com",
|
||||
password: "123456",
|
||||
})
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
test("封号后 access token 和 refresh token 都失效", async () => {
|
||||
const id = makeUser("stu")
|
||||
makeUser("boss", "super")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { pyRound, pySum } from "../src/services/pyround"
|
||||
import { extractCode, parseGuidanceResponse } from "../src/services/llm"
|
||||
import { chatModel, extractCode, parseGuidanceResponse, streamChat } from "../src/services/llm"
|
||||
import { config } from "../src/config"
|
||||
import { checkPassword, makePassword } from "../src/auth"
|
||||
import { formatMicros, iso, uuidHex, uuidOut } from "../src/db"
|
||||
import { toCsv } from "../src/services/gradebook"
|
||||
@@ -67,3 +68,49 @@ test("时间与 UUID 格式", () => {
|
||||
test("CSV 转义", () => {
|
||||
expect(toCsv([["a", 'b"c', "d,e"], [1, 2.5, ""]])).toBe('a,"b""c","d,e"\r\n1,2.5,\r\n')
|
||||
})
|
||||
|
||||
describe("streamChat", () => {
|
||||
test("不认识的模型换成默认模型,最后一行没有换行也能读到", async () => {
|
||||
let model = ""
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
model = ((await req.json()) as any).model
|
||||
const line = (s: string) => `data: ${JSON.stringify({ choices: [{ delta: { content: s } }] })}`
|
||||
// 一行拆在两个分块之间,最后一行不带换行
|
||||
const body = `${line("你好")}\n\n${line("世")}\n${line("界")}`
|
||||
const enc = new TextEncoder()
|
||||
const cut = body.indexOf("世") + 2
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
async start(ctrl) {
|
||||
ctrl.enqueue(enc.encode(body.slice(0, cut)))
|
||||
await Bun.sleep(5)
|
||||
ctrl.enqueue(enc.encode(body.slice(cut)))
|
||||
ctrl.close()
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
},
|
||||
})
|
||||
const saved = { ...config }
|
||||
Object.assign(config, { llmApiKey: "k", llmBaseUrl: `http://localhost:${server.port}` })
|
||||
try {
|
||||
let out = ""
|
||||
for await (const s of streamChat([{ role: "user", content: "hi" }], "deepseek-v4-flash-thinking")) out += s
|
||||
expect(out).toBe("你好世界")
|
||||
expect(model).toBe(config.llmModel)
|
||||
} finally {
|
||||
Object.assign(config, saved)
|
||||
server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("chatModel 只放行配置里的模型", () => {
|
||||
expect(chatModel("doubao-seed-2-1-lite-260915")).toBe("doubao-seed-2-1-lite-260915")
|
||||
expect(chatModel(config.llmModel)).toBe(config.llmModel)
|
||||
expect(chatModel("gpt-5")).toBe(config.llmModel)
|
||||
expect(chatModel("")).toBe(config.llmModel)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user