refactor: 后端从 Django 换成 Bun + SQLite
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
Deploy / deploy (build:staging, school, 8822) (push) Canceled after 0s

Django 多个 worker 内存占用 1-2 GB,换成 Bun 单进程后约 25 MB,
同时去掉 Postgres 和 Redis,只剩 Caddy + Bun 两个容器。

- server/:Hono + bun:sqlite + Bun 原生 WebSocket,接口与原 django-ninja
  保持兼容;表结构、时间/UUID 存储格式、密码哈希与 Django 一致,旧数据可直接使用
- 评分加权、贝叶斯平滑、成绩册的计算结果与原实现逐位一致(复刻 Python round/sum)
- scripts/:Postgres → SQLite 迁移、新建超管、提示词层级标注、重算分数
- 前端:Django Admin 去掉后,用户新建/修改改为页面内弹窗;
  非超管的挑战公开按钮置灰
- 权限不足返回 JSON 403(原来是重定向到不存在的登录页)
- docker-compose / Caddyfile / dev.sh / CLAUDE.md 更新,删除 AGENTS.md
- 服务器切换步骤见 docs/migrate-to-bun.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 17:45:09 +08:00
1 parent 47d4679984
commit 26f05d1d76
297 files changed
+4659 -42956

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
import { Hono } from "hono"
import { cors } from "hono/cors"
import { config } from "./config"
import { sessionMiddleware } from "./auth"
import { HttpError, ValidationError, type Env } from "./http"
import { isAllowedOrigin } from "./origin"
import account from "./routes/account"
import { tutorial, challenge, creation } from "./routes/tasks"
import { assets, upload } from "./routes/files"
import submission from "./routes/submission"
import prompt from "./routes/prompt"
import { mediaPath } from "./routes/files"
// strict: false —— 带不带结尾的斜杠都能匹配(前端两种写法都有)
export const app = new Hono<Env>({ strict: false })
app.use(
"/api/*",
cors({ origin: config.allowedOrigins, credentials: true }),
)
// 会话 cookie 是 SameSite=Lax,再加一道 Origin 校验防止跨站写请求
app.use("/api/*", async (c, next) => {
if (!["GET", "HEAD", "OPTIONS"].includes(c.req.method)) {
if (!isAllowedOrigin(c.req.header("origin") ?? null, c.req.header("host") ?? null)) {
return c.json({ detail: "跨站请求被拒绝" }, 403)
}
}
await next()
})
app.use("/api/*", sessionMiddleware)
const api = new Hono<Env>({ strict: false })
api.route("/account", account)
api.route("/tutorial", tutorial)
api.route("/challenge", challenge)
api.route("/creation", creation)
api.route("/submission", submission)
api.route("/upload", upload)
api.route("/assets", assets)
api.route("/prompt", prompt)
app.route("/api", api)
// 生产环境由 Caddy 直接提供 /media,这里主要给本地开发用
app.get("/media/*", async (c) => {
const relative = decodeURIComponent(c.req.path.slice("/media/".length))
let file
try {
file = Bun.file(mediaPath(relative))
} catch {
return c.notFound()
}
if (!(await file.exists())) return c.notFound()
return new Response(file)
})
app.notFound((c) => c.json({ detail: "Not Found" }, 404))
app.onError((err, c) => {
if (err instanceof HttpError) {
return c.json({ detail: err.detail }, err.status as any)
}
if (err instanceof ValidationError) {
return c.json({ detail: err.issues }, 422)
}
console.error(err)
return c.json({ detail: "服务器内部错误" }, 500)
})
+148
View File
@@ -0,0 +1,148 @@
import { pbkdf2 as pbkdf2Cb, randomBytes, timingSafeEqual } from "node:crypto"
import { promisify } from "node:util"
import { getCookie, setCookie, deleteCookie } from "hono/cookie"
import type { MiddlewareHandler } from "hono"
import { config } from "./config"
import { get, run, now, toDbTime } from "./db"
import { HttpError, type Ctx, type Env } from "./http"
const pbkdf2 = promisify(pbkdf2Cb)
export const Role = {
SUPER: "super",
ADMIN: "admin",
NORMAL: "normal",
} as const
export type Role = (typeof Role)[keyof typeof Role]
export interface User {
id: number
username: string
role: Role
classname: string
is_active: number
}
// ── 密码:兼容 Django 的 pbkdf2_sha256 格式,老用户无需重置密码 ──
const ITERATIONS = 600_000
export async function makePassword(raw: string): Promise<string> {
const salt = randomBytes(16).toString("base64url").slice(0, 22)
const hash = await pbkdf2(raw, salt, ITERATIONS, 32, "sha256")
return `pbkdf2_sha256$${ITERATIONS}$${salt}$${hash.toString("base64")}`
}
export async function checkPassword(raw: string, encoded: string) {
const [algorithm, iterations, salt, hash] = encoded.split("$")
if (algorithm !== "pbkdf2_sha256" || !iterations || !salt || !hash) {
return false
}
const expected = Buffer.from(hash, "base64")
const actual = await pbkdf2(
raw,
salt,
Number(iterations),
expected.length,
"sha256",
)
return actual.length === expected.length && timingSafeEqual(actual, expected)
}
export async function setPassword(userId: number, raw: string) {
run(
`UPDATE account_user SET password = $password, raw_password = $raw WHERE id = $id`,
{ password: await makePassword(raw), raw: raw.slice(0, 20), id: userId },
)
// 改密码后让该用户的其它会话失效(与 Django 行为一致)
run(`DELETE FROM auth_session WHERE user_id = $id`, { id: userId })
}
// ── 会话 ──
export async function authenticate(username: string, password: string) {
const row = get<User & { password: string }>(
`SELECT id, username, role, classname, is_active, password
FROM account_user WHERE username = $username`,
{ username },
)
if (!row || !row.is_active) {
// 防止通过响应时间判断用户是否存在
await makePassword(password)
return null
}
if (!(await checkPassword(password, row.password))) return null
const { password: _, ...user } = row
return user as User
}
export function login(c: Ctx, user: User) {
const key = randomBytes(24).toString("base64url")
const expire = new Date(Date.now() + config.sessionAgeSeconds * 1000)
run(`DELETE FROM auth_session WHERE expire_at < $now`, { now: now() })
run(
`INSERT INTO auth_session (key, user_id, expire_at) VALUES ($key, $user_id, $expire_at)`,
{ key, user_id: user.id, expire_at: toDbTime(expire) },
)
run(`UPDATE account_user SET last_login = $now WHERE id = $id`, {
now: now(),
id: user.id,
})
setCookie(c, config.sessionCookie, key, {
path: "/",
httpOnly: true,
sameSite: "Lax",
maxAge: config.sessionAgeSeconds,
})
}
export function logout(c: Ctx) {
const key = getCookie(c, config.sessionCookie)
if (key) run(`DELETE FROM auth_session WHERE key = $key`, { key })
deleteCookie(c, config.sessionCookie, { path: "/" })
}
export function userFromSessionKey(key: string | undefined): User | null {
if (!key) return null
return get<User>(
`SELECT u.id, u.username, u.role, u.classname, u.is_active
FROM auth_session s JOIN account_user u ON u.id = s.user_id
WHERE s.key = $key AND s.expire_at > $now AND u.is_active = 1`,
{ key, now: now() },
)
}
export function parseCookie(header: string | null, name: string) {
if (!header) return undefined
for (const part of header.split(";")) {
const [k, ...v] = part.trim().split("=")
if (k === name) return decodeURIComponent(v.join("="))
}
return undefined
}
export const sessionMiddleware: MiddlewareHandler<Env> = async (c, next) => {
c.set("user", userFromSessionKey(getCookie(c, config.sessionCookie)))
await next()
}
// ── 权限 ──
export function requireUser(c: Ctx): User {
const user = c.get("user")
if (!user) throw new HttpError(401, "请先登录")
return user
}
function requireRoles(c: Ctx, roles: Role[]): User {
const user = requireUser(c)
if (!roles.includes(user.role)) throw new HttpError(403, "没有权限")
return user
}
export const requireAdmin = (c: Ctx) =>
requireRoles(c, [Role.ADMIN, Role.SUPER])
export const requireSuper = (c: Ctx) => requireRoles(c, [Role.SUPER])
export const isStaff = (user: User) =>
user.role === Role.ADMIN || user.role === Role.SUPER
+31
View File
@@ -0,0 +1,31 @@
import { resolve } from "node:path"
const env = process.env
export const DEV = env.ENV !== "production"
export const config = {
port: Number(env.PORT ?? 8000),
dbPath: resolve(env.DB_PATH ?? "data/db.sqlite3"),
mediaRoot: resolve(env.MEDIA_ROOT ?? "../media"),
mediaUrl: "/media/",
// 允许跨域访问 / 发起写请求 / 建立 WebSocket 的前端来源
allowedOrigins: (
env.ALLOWED_ORIGINS ??
(DEV
? "http://localhost:3000"
: "https://web.xuyue.cc,http://10.13.114.114:91")
)
.split(",")
.map((s) => s.trim())
.filter(Boolean),
sessionCookie: "sessionid",
sessionAgeSeconds: 60 * 60 * 24 * 14,
// 测试时不调用真实的模型接口
llmApiKey: env.NODE_ENV === "test" ? "" : (env.LLM_API_KEY ?? ""),
llmBaseUrl: env.LLM_BASE_URL ?? "https://api.deepseek.com",
llmModel: env.LLM_MODEL ?? "deepseek-v4-flash",
arkApiKey: env.NODE_ENV === "test" ? "" : (env.ARK_API_KEY ?? ""),
arkBaseUrl: env.ARK_BASE_URL ?? "https://ark.cn-beijing.volces.com/api/v3",
}
+142
View File
@@ -0,0 +1,142 @@
import { Database } from "bun:sqlite"
import { mkdirSync } from "node:fs"
import { dirname } from "node:path"
import { config } from "./config"
// 表结构与原 Django 项目保持一致(表名、列名、时间格式、UUID 存储方式),
// 这样旧的 db.sqlite3 可以直接使用,Postgres 的数据也能原样导入。
const SCHEMA = `
CREATE TABLE IF NOT EXISTS "account_user" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "password" varchar(128) NOT NULL, "last_login" datetime NULL, "is_superuser" bool NOT NULL, "username" varchar(150) NOT NULL UNIQUE, "email" varchar(254) NOT NULL, "is_staff" bool NOT NULL, "is_active" bool NOT NULL, "date_joined" datetime NOT NULL, "role" varchar(20) NOT NULL, "raw_password" varchar(20) NULL, "classname" varchar(50) NOT NULL);
CREATE TABLE IF NOT EXISTS "task_task" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "created" datetime NOT NULL, "modified" datetime NOT NULL, "title" varchar(100) NOT NULL, "content" text NOT NULL, "task_type" varchar(20) NOT NULL, "is_public" bool NOT NULL, "display" integer NOT NULL, "example_css" text NULL, "example_html" text NULL, "example_js" text NULL);
CREATE INDEX IF NOT EXISTS "task_task_display_97da6aab" ON "task_task" ("display");
CREATE UNIQUE INDEX IF NOT EXISTS "task_task_display_task_type_f648533c_uniq" ON "task_task" ("display", "task_type");
CREATE TABLE IF NOT EXISTS "task_tutorial" ("task_ptr_id" bigint NOT NULL PRIMARY KEY REFERENCES "task_task" ("id") DEFERRABLE INITIALLY DEFERRED);
CREATE TABLE IF NOT EXISTS "task_challenge" ("task_ptr_id" bigint NOT NULL PRIMARY KEY REFERENCES "task_task" ("id") DEFERRABLE INITIALLY DEFERRED, "score" integer NOT NULL, "pass_score" real NULL, "author_id" bigint NULL REFERENCES "account_user" ("id") DEFERRABLE INITIALLY DEFERRED);
CREATE INDEX IF NOT EXISTS "task_challenge_author_id_d4492927" ON "task_challenge" ("author_id");
CREATE TABLE IF NOT EXISTS "task_creation" ("task_ptr_id" bigint NOT NULL PRIMARY KEY REFERENCES "task_task" ("id") DEFERRABLE INITIALLY DEFERRED, "html" text NOT NULL, "css" text NOT NULL, "js" text NOT NULL, "owner_id" bigint NOT NULL REFERENCES "account_user" ("id") DEFERRABLE INITIALLY DEFERRED);
CREATE INDEX IF NOT EXISTS "task_creation_owner_id_6ae3c55d" ON "task_creation" ("owner_id");
CREATE TABLE IF NOT EXISTS "task_taskasset" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "name" varchar(100) NOT NULL, "file" varchar(100) NOT NULL, "task_id" bigint NOT NULL REFERENCES "task_task" ("id") DEFERRABLE INITIALLY DEFERRED);
CREATE UNIQUE INDEX IF NOT EXISTS "task_taskasset_task_id_name_dc8ea9a8_uniq" ON "task_taskasset" ("task_id", "name");
CREATE INDEX IF NOT EXISTS "task_taskasset_task_id_0921deb6" ON "task_taskasset" ("task_id");
CREATE TABLE IF NOT EXISTS "submission_submission" ("created" datetime NOT NULL, "modified" datetime NOT NULL, "id" char(32) NOT NULL PRIMARY KEY, "score" real NOT NULL, "html" text NULL, "css" text NULL, "js" text NULL, "task_id" bigint NOT NULL REFERENCES "task_task" ("id") DEFERRABLE INITIALLY DEFERRED, "user_id" bigint NOT NULL REFERENCES "account_user" ("id") DEFERRABLE INITIALLY DEFERRED, "flag" varchar(10) NULL, "raw_score" real NOT NULL, "zone" varchar(10) NULL, "view_count" integer unsigned NOT NULL CHECK ("view_count" >= 0));
CREATE INDEX IF NOT EXISTS "submission_submission_task_id_91e9556b" ON "submission_submission" ("task_id");
CREATE INDEX IF NOT EXISTS "submission_submission_user_id_71741691" ON "submission_submission" ("user_id");
CREATE INDEX IF NOT EXISTS "submission_submission_flag_d96bc4aa" ON "submission_submission" ("flag");
CREATE INDEX IF NOT EXISTS "submission_submission_zone_a1bbf0a2" ON "submission_submission" ("zone");
CREATE TABLE IF NOT EXISTS "submission_rating" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "score" integer NOT NULL, "created" datetime NOT NULL, "submission_id" char(32) NOT NULL REFERENCES "submission_submission" ("id") DEFERRABLE INITIALLY DEFERRED, "user_id" bigint NOT NULL REFERENCES "account_user" ("id") DEFERRABLE INITIALLY DEFERRED);
CREATE UNIQUE INDEX IF NOT EXISTS "submission_rating_user_id_submission_id_29be4c39_uniq" ON "submission_rating" ("user_id", "submission_id");
CREATE INDEX IF NOT EXISTS "submission_rating_submission_id_a20dcece" ON "submission_rating" ("submission_id");
CREATE INDEX IF NOT EXISTS "submission_rating_user_id_cfd08017" ON "submission_rating" ("user_id");
CREATE TABLE IF NOT EXISTS "submission_award" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "created" datetime NOT NULL, "modified" datetime NOT NULL, "name" varchar(100) NOT NULL UNIQUE, "description" text NOT NULL, "sort_order" integer NOT NULL, "is_active" bool NOT NULL, "item_ordering" varchar(20) NOT NULL);
CREATE INDEX IF NOT EXISTS "submission_award_sort_order_280640ce" ON "submission_award" ("sort_order");
CREATE TABLE IF NOT EXISTS "submission_submissionaward" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "created" datetime NOT NULL, "modified" datetime NOT NULL, "sort_order" integer NOT NULL, "awarded_at" datetime NOT NULL, "award_id" bigint NOT NULL REFERENCES "submission_award" ("id") DEFERRABLE INITIALLY DEFERRED, "submission_id" char(32) NOT NULL REFERENCES "submission_submission" ("id") DEFERRABLE INITIALLY DEFERRED, "is_stale" bool NOT NULL);
CREATE UNIQUE INDEX IF NOT EXISTS "submission_submissionaward_submission_id_award_id_6fab326b_uniq" ON "submission_submissionaward" ("submission_id", "award_id");
CREATE INDEX IF NOT EXISTS "submission_submissionaward_sort_order_7d90e1d6" ON "submission_submissionaward" ("sort_order");
CREATE INDEX IF NOT EXISTS "submission_submissionaward_awarded_at_8093b2c7" ON "submission_submissionaward" ("awarded_at");
CREATE INDEX IF NOT EXISTS "submission_submissionaward_award_id_b229157e" ON "submission_submissionaward" ("award_id");
CREATE INDEX IF NOT EXISTS "submission_submissionaward_submission_id_209aabba" ON "submission_submissionaward" ("submission_id");
CREATE TABLE IF NOT EXISTS "prompt_conversation" ("created" datetime NOT NULL, "modified" datetime NOT NULL, "id" char(32) NOT NULL PRIMARY KEY, "is_active" bool NOT NULL, "task_id" bigint NOT NULL REFERENCES "task_task" ("id") DEFERRABLE INITIALLY DEFERRED, "user_id" bigint NOT NULL REFERENCES "account_user" ("id") DEFERRABLE INITIALLY DEFERRED);
CREATE INDEX IF NOT EXISTS "prompt_conversation_task_id_90287800" ON "prompt_conversation" ("task_id");
CREATE INDEX IF NOT EXISTS "prompt_conversation_user_id_ecc418b3" ON "prompt_conversation" ("user_id");
CREATE TABLE IF NOT EXISTS "prompt_message" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "role" varchar(10) NOT NULL, "content" text NOT NULL, "code_html" text NULL, "code_css" text NULL, "code_js" text NULL, "created" datetime NOT NULL, "conversation_id" char(32) NOT NULL REFERENCES "prompt_conversation" ("id") DEFERRABLE INITIALLY DEFERRED, "prompt_level" integer NULL, "source" varchar(12) NOT NULL, "submission_id" char(32) NULL UNIQUE REFERENCES "submission_submission" ("id") DEFERRABLE INITIALLY DEFERRED);
CREATE INDEX IF NOT EXISTS "prompt_message_conversation_id_af18dd4b" ON "prompt_message" ("conversation_id");
CREATE INDEX IF NOT EXISTS "prompt_message_prompt_level_e461b596" ON "prompt_message" ("prompt_level");
CREATE TABLE IF NOT EXISTS "auth_session" ("key" text NOT NULL PRIMARY KEY, "user_id" bigint NOT NULL REFERENCES "account_user" ("id") ON DELETE CASCADE, "expire_at" datetime NOT NULL);
CREATE INDEX IF NOT EXISTS "auth_session_user_id" ON "auth_session" ("user_id");
`
export function openDb(path: string): Database {
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true })
const db = new Database(path, { strict: true, create: true })
db.run("PRAGMA journal_mode = WAL")
db.run("PRAGMA synchronous = NORMAL")
db.run("PRAGMA busy_timeout = 5000")
db.run("PRAGMA foreign_keys = ON")
db.run(SCHEMA)
return db
}
export let db = openDb(process.env.NODE_ENV === "test" ? ":memory:" : config.dbPath)
// 仅供测试使用:换一个全新的内存库
export function resetDb() {
db.close()
db = openDb(":memory:")
}
type Params = Record<string, unknown>
export function all<T = any>(sql: string, params: Params = {}): T[] {
return db.query(sql).all(params as any) as T[]
}
export function get<T = any>(sql: string, params: Params = {}): T | null {
return (db.query(sql).get(params as any) as T) ?? null
}
export function run(sql: string, params: Params = {}) {
return db.query(sql).run(params as any)
}
export function tx<T>(fn: () => T): T {
return db.transaction(fn)()
}
// ── 时间:与 Django 在 SQLite 中的存储格式一致(UTC, "YYYY-MM-DD HH:MM:SS.ffffff")──
let lastMicros = 0
// 严格递增的时间戳,避免同一毫秒内连续插入的消息排序不稳定
export function now(): string {
let micros = Date.now() * 1000
if (micros <= lastMicros) micros = lastMicros + 1
lastMicros = micros
return formatMicros(micros)
}
export function formatMicros(micros: number): string {
const d = new Date(Math.floor(micros / 1000))
const frac = String(micros % 1_000_000).padStart(6, "0")
return d.toISOString().slice(0, 19).replace("T", " ") + "." + frac
}
export function toDbTime(d: Date): string {
return formatMicros(d.getTime() * 1000)
}
// 数据库时间 → ISO 8601,格式与 Django 的 JSON 输出一致(毫秒精度,UTC 用 Z)
export function iso(value: string): string
export function iso(value: string | null): string | null
export function iso(value: string | null): string | null {
if (!value) return null
return value.replace(" ", "T").slice(0, 23) + "Z"
}
// ── UUID:SQLite 中按 Django 的方式存成 32 位无连字符的十六进制 ──
const UUID_RE =
/^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i
export function uuidHex(value: string): string | null {
if (!UUID_RE.test(value)) return null
return value.replaceAll("-", "").toLowerCase()
}
export function uuidOut(hex: string): string
export function uuidOut(hex: string | null): string | null
export function uuidOut(hex: string | null): string | null {
if (!hex) return null
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
export function newUuidHex(): string {
return crypto.randomUUID().replaceAll("-", "")
}
export function likeEscape(s: string): string {
return "%" + s.replace(/[\\%_]/g, (m) => "\\" + m) + "%"
}
+102
View File
@@ -0,0 +1,102 @@
import type { Context } from "hono"
import { z } from "zod"
import type { User } from "./auth"
import { uuidHex } from "./db"
export type Env = { Variables: { user: User | null } }
export type Ctx = Context<Env>
export class HttpError extends Error {
constructor(
public status: number,
public detail: string,
) {
super(detail)
}
}
export function notFound(detail = "Not Found"): never {
throw new HttpError(404, detail)
}
export function orNotFound<T>(value: T, detail?: string): NonNullable<T> {
if (value === null || value === undefined) notFound(detail)
return value as NonNullable<T>
}
function formatIssues(error: z.ZodError) {
return error.issues.map((i) => ({
loc: i.path,
msg: i.message,
type: i.code,
}))
}
export class ValidationError extends Error {
constructor(public issues: unknown) {
super("validation error")
}
}
export async function body<S extends z.ZodType>(
c: Ctx,
schema: S,
): Promise<z.output<S>> {
let raw: unknown
try {
raw = await c.req.json()
} catch {
throw new ValidationError([{ loc: ["body"], msg: "无效的 JSON" }])
}
const result = schema.safeParse(raw)
if (!result.success) throw new ValidationError(formatIssues(result.error))
return result.data
}
export function query<S extends z.ZodType>(c: Ctx, schema: S): z.output<S> {
const result = schema.safeParse(c.req.query())
if (!result.success) throw new ValidationError(formatIssues(result.error))
return result.data
}
// 路径参数里的整数
export function intParam(c: Ctx, name: string): number {
const v = c.req.param(name) ?? ""
if (!/^-?\d+$/.test(v)) {
throw new ValidationError([{ loc: ["path", name], msg: "需要整数" }])
}
return Number(v)
}
// 路径参数里的 UUID,返回数据库中的 32 位十六进制形式
export function uuidParam(c: Ctx, name: string): string {
const hex = uuidHex(c.req.param(name) ?? "")
if (!hex) throw new ValidationError([{ loc: ["path", name], msg: "需要 UUID" }])
return hex
}
// 查询参数的通用类型(与 django-ninja / pydantic 的解析方式一致)
export const qs = {
int: z.coerce.number().int(),
num: z.coerce.number(),
bool: z
.enum(["true", "false", "1", "0", "on", "off", "yes", "no"])
.transform((v) => ["true", "1", "on", "yes"].includes(v)),
// 空字符串视为未传
opt<T extends z.ZodType>(schema: T) {
return z.preprocess((v) => (v === "" ? undefined : v), schema.optional())
},
}
export const pageQuery = z.object({
page: qs.opt(qs.int.min(1)).default(1),
page_size: qs.opt(qs.int.min(1)),
})
// 与 ninja PageNumberPagination 相同:默认 10 条,最多 100 条
export function pageWindow(p: { page: number; page_size?: number }) {
const size = Math.min(p.page_size ?? 10, 100)
return { limit: size, offset: (p.page - 1) * size }
}
export const bool = (v: unknown) => v === 1 || v === true
+19
View File
@@ -0,0 +1,19 @@
import { app } from "./app"
import { config } from "./config"
import { upgrade, websocket, type WsData } from "./ws"
const server = Bun.serve<WsData>({
port: config.port,
// 上传素材 / 图片
maxRequestBodySize: 50 * 1024 * 1024,
// AI 生成走 WebSocket,普通接口不会太久
idleTimeout: 120,
fetch(req, server) {
const res = upgrade(req, server)
if (res !== null) return res
return app.fetch(req)
},
websocket,
})
console.log(`webpreview server listening on http://localhost:${server.port}`)
+12
View File
@@ -0,0 +1,12 @@
import { config } from "./config"
// 同源请求,或来自白名单里的前端地址
export function isAllowedOrigin(origin: string | null, host: string | null) {
if (!origin) return true
if (config.allowedOrigins.includes(origin)) return true
try {
return new URL(origin).host === host
} catch {
return false
}
}
+271
View File
@@ -0,0 +1,271 @@
import { Hono } from "hono"
import { randomInt } from "node:crypto"
import { z } from "zod"
import { all, get, run, tx, now, iso, likeEscape } from "../db"
import {
Role,
authenticate,
login,
logout,
makePassword,
requireSuper,
requireUser,
setPassword,
} from "../auth"
import { HttpError, body, bool, intParam, orNotFound, pageQuery, pageWindow, query, type Env } from "../http"
const router = new Hono<Env>()
const roleEnum = z.enum([Role.SUPER, Role.ADMIN, Role.NORMAL])
// 只用 2-9 的数字,避免和字母 O/l 混淆
function generatePassword(length = 6) {
return Array.from({ length }, () => String(randomInt(2, 10))).join("")
}
async function insertUser(u: {
username: string
password: string
role?: string
classname?: string
email?: string
}) {
const res = run(
`INSERT INTO account_user
(password, last_login, is_superuser, username, email, is_staff, is_active, date_joined, role, raw_password, classname)
VALUES ($password, NULL, 0, $username, $email, 0, 1, $now, $role, $raw, $classname)`,
{
password: await makePassword(u.password),
username: u.username,
email: u.email ?? "",
now: now(),
role: u.role ?? Role.NORMAL,
raw: u.password.slice(0, 20),
classname: u.classname ?? "",
},
)
return Number(res.lastInsertRowid)
}
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),
}),
)
if (userExists(payload.username)) throw new HttpError(400, "用户已存在")
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)
if (!user) throw new HttpError(401, "账号密码错误")
login(c, user)
return c.json({ username: user.username, role: user.role })
})
router.post("/logout", (c) => {
requireUser(c)
logout(c)
return c.json({ message: "退出成功" })
})
router.get("/profile", (c) => {
const user = c.get("user")
if (user) return c.json({ username: user.username, role: user.role })
return c.json({ username: "", role: Role.NORMAL })
})
router.get("/list", (c) => {
requireSuper(c)
const q = query(
c,
pageQuery.extend({
username: z.string().default(""),
role: z.string().optional(),
}),
)
const where = [`username LIKE $username ESCAPE '\\'`]
const params: Record<string, unknown> = { username: likeEscape(q.username) }
if (q.role) {
where.push(`role = $role`)
params.role = q.role
}
const whereSql = where.join(" AND ")
const { limit, offset } = pageWindow(q)
const rows = all(
`SELECT id, username, raw_password, role, classname, date_joined, last_login, is_active
FROM account_user WHERE ${whereSql} ORDER BY id DESC LIMIT $limit OFFSET $offset`,
{ ...params, limit, offset },
)
const count = get<{ n: number }>(
`SELECT COUNT(*) AS n FROM account_user WHERE ${whereSql}`,
params,
)!.n
return c.json({
items: rows.map((u) => ({
id: u.id,
username: u.username,
raw_password: u.role !== Role.SUPER ? u.raw_password : "",
role: u.role,
classname: u.classname,
date_joined: iso(u.date_joined),
last_login: iso(u.last_login),
is_active: bool(u.is_active),
})),
count,
})
})
router.post("/batch", async (c) => {
requireSuper(c)
const payload = await body(
c,
z.object({ names: z.array(z.string()), classname: z.string() }),
)
const names = payload.names.map((n) => n.trim()).filter(Boolean)
const usernames = names.map((n) => "web" + payload.classname + n)
if (new Set(usernames).size !== usernames.length) {
throw new HttpError(400, "名单中有重复的名字,创建失败")
}
if (usernames.some(userExists)) {
throw new HttpError(400, "有些用户已经存在,创建失败")
}
const users = await Promise.all(
usernames.map(async (username) => {
const raw = generatePassword()
return { username, raw, password: await makePassword(raw) }
}),
)
const ts = now()
tx(() => {
for (const u of users) {
run(
`INSERT INTO account_user
(password, last_login, is_superuser, username, email, is_staff, is_active, date_joined, role, raw_password, classname)
VALUES ($password, NULL, 0, $username, '', 0, 1, $now, 'normal', $raw, $classname)`,
{ password: u.password, username: u.username, now: ts, raw: u.raw, classname: payload.classname },
)
}
})
return c.json({ message: "批量创建成功" })
})
// 新建单个用户(替代原来 Django Admin 的新增页面)
router.post("/create", async (c) => {
requireSuper(c)
const payload = await body(
c,
z.object({
username: z.string().trim().min(1, "用户名不能为空").max(150),
password: z.string().min(6, "密码至少 6 位").max(20, "密码最多 20 位"),
role: roleEnum.default(Role.NORMAL),
classname: z.string().trim().max(50).default(""),
}),
)
if (userExists(payload.username)) throw new HttpError(400, "用户已存在")
const id = await insertUser(payload)
return c.json({ id, message: "创建成功" })
})
// 修改用户(替代原来 Django Admin 的修改页面)
router.put("/:id{[0-9]+}", async (c) => {
requireSuper(c)
const id = intParam(c, "id")
const payload = await body(
c,
z.object({
username: z.string().trim().min(1, "用户名不能为空").max(150).optional(),
password: z.string().max(20, "密码最多 20 位").optional(),
role: roleEnum.optional(),
classname: z.string().trim().max(50).optional(),
}),
)
const user = orNotFound(
get<{ id: number; username: string; role: string }>(
`SELECT id, username, role FROM account_user WHERE id = $id`,
{ id },
),
"查无此人",
)
if (user.role === Role.SUPER) throw new HttpError(400, "不能在这里修改超级管理员")
if (payload.password && payload.password.length < 6) {
throw new HttpError(400, "密码至少 6 位")
}
if (payload.username && payload.username !== user.username && userExists(payload.username)) {
throw new HttpError(400, "用户名已存在")
}
run(
`UPDATE account_user SET
username = COALESCE($username, username),
role = COALESCE($role, role),
classname = COALESCE($classname, classname)
WHERE id = $id`,
{
id,
username: payload.username ?? null,
role: payload.role ?? null,
classname: payload.classname ?? null,
},
)
if (payload.password) await setPassword(id, payload.password)
return c.json({ message: "修改成功" })
})
router.get("/:id{[0-9]+}", (c) => {
requireSuper(c)
const u = orNotFound(
get<{ is_active: number }>(
`SELECT id, username, role, classname, is_active FROM account_user WHERE id = $id`,
{ id: intParam(c, "id") },
),
"查无此人",
)
return c.json({ ...u, is_active: bool(u.is_active) })
})
router.put("/active/:id", (c) => {
requireSuper(c)
const id = intParam(c, "id")
const user = orNotFound(
get<{ username: string; is_active: number }>(
`SELECT username, is_active FROM account_user WHERE id = $id`,
{ id },
),
"查无此人",
)
const active = !user.is_active
run(`UPDATE account_user SET is_active = $active WHERE id = $id`, { active, id })
if (!active) run(`DELETE FROM auth_session WHERE user_id = $id`, { id })
return c.json({ message: `${user.username} ${active ? "解封" : "封号"}成功` })
})
router.get("/classes", (c) => {
const rows = all<{ classname: string }>(
`SELECT DISTINCT classname FROM account_user WHERE classname > '' ORDER BY classname`,
)
return c.json(rows.map((r) => r.classname))
})
router.get("/names", (c) => {
const { classname } = query(c, z.object({ classname: z.string() }))
const prefix = "web" + classname
const rows = all<{ username: string }>(
`SELECT username FROM account_user
WHERE classname = $classname AND substr(username, 1, $len) = $prefix
ORDER BY username`,
{ classname, prefix, len: prefix.length },
)
return c.json(rows.map((u) => ({ name: u.username.slice(prefix.length), username: u.username })))
})
export default router
+137
View File
@@ -0,0 +1,137 @@
import { Hono } from "hono"
import { mkdir, rm } from "node:fs/promises"
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"
// 与 Django 的 get_valid_filename 一致:空格换成下划线,去掉除字母数字、-、_、. 以外的字符
export function validFilename(name: string) {
const s = name.trim().replaceAll(" ", "_").replace(/[^-\p{L}\p{N}_.]/gu, "")
if (!s || s === "." || s === "..") throw new HttpError(400, `无效的文件名:${name}`)
return s
}
// 解析 media 下的相对路径,防止越出 media 目录
export function mediaPath(relative: string) {
const root = config.mediaRoot
const full = resolve(root, relative)
if (full !== root && !full.startsWith(root + sep)) throw new HttpError(400, "无效的路径")
return full
}
async function saveFile(relative: string, file: File) {
let target = relative
// 同名文件已存在时加随机后缀(Django 存储的行为)
while (existsSync(mediaPath(target))) {
const ext = extname(relative)
const suffix = Math.random().toString(36).slice(2, 9)
target = relative.slice(0, relative.length - ext.length) + "_" + suffix + ext
}
const full = mediaPath(target)
await mkdir(dirname(full), { recursive: true })
await Bun.write(full, file)
return target
}
async function formFile(c: Ctx, field: string) {
const form = await c.req.formData().catch(() => null)
const value = form?.get(field)
if (!(value instanceof File)) {
throw new ValidationError([{ loc: ["body", field], msg: "缺少文件" }])
}
return { form: form!, file: value }
}
// ── 任务素材 ──────────────────────────────────────────────────────────────────
export const assets = new Hono<Env>()
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
JOIN task_${kind} x ON x.task_ptr_id = t.id WHERE t.display = $display`,
{ display },
),
)
}
const assetOut = (a: { name: string; file: string }) => ({
name: a.name,
url: config.mediaUrl + a.file,
})
for (const kind of ["challenge", "tutorial"] as const) {
// 挑战素材管理员可改,教程素材仅超管可改
const requireEditor: (c: Ctx) => User = kind === "challenge" ? requireAdmin : requireSuper
assets.get(`/${kind}/:display`, (c) => {
const task = findTask(kind, intParam(c, "display"))
const rows = all<{ name: string; file: string }>(
`SELECT name, file FROM task_taskasset WHERE task_id = $id ORDER BY id`,
{ id: task.id },
)
return c.json(rows.map(assetOut))
})
assets.post(`/${kind}/:display`, async (c) => {
requireEditor(c)
const task = findTask(kind, intParam(c, "display"))
const { form, file } = await formFile(c, "file")
const name = form.get("name")
if (typeof name !== "string" || !name) {
throw new ValidationError([{ loc: ["body", "name"], msg: "缺少文件名" }])
}
const existing = get<{ id: number; file: string }>(
`SELECT id, file FROM task_taskasset WHERE task_id = $task_id AND name = $name`,
{ task_id: task.id, name },
)
if (existing?.file) await rm(mediaPath(existing.file), { force: true })
const stored = await saveFile(
`tasks/${task.task_type}/${task.display}/${validFilename(name)}`,
file,
)
if (existing) {
run(`UPDATE task_taskasset SET file = $file WHERE id = $id`, { file: stored, id: existing.id })
} else {
run(`INSERT INTO task_taskasset (name, file, task_id) VALUES ($name, $file, $task_id)`, {
name,
file: stored,
task_id: task.id,
})
}
return c.json(assetOut({ name, file: stored }))
})
assets.delete(`/${kind}/:display/:name`, async (c) => {
requireEditor(c)
const task = findTask(kind, intParam(c, "display"))
const asset = orNotFound(
get<{ id: number; file: string }>(
`SELECT id, file FROM task_taskasset WHERE task_id = $task_id AND name = $name`,
{ task_id: task.id, name: c.req.param("name") },
),
)
if (asset.file) await rm(mediaPath(asset.file), { force: true })
run(`DELETE FROM task_taskasset WHERE id = $id`, { id: asset.id })
return c.json({ message: "删除成功" })
})
}
// ── 编辑器里插入图片 ──────────────────────────────────────────────────────────
export const upload = new Hono<Env>()
upload.post("/", async (c) => {
requireSuper(c)
const { file } = await formFile(c, "image")
const filename = crypto.randomUUID() + extname(file.name)
await mkdir(config.mediaRoot, { recursive: true })
await Bun.write(join(config.mediaRoot, filename), file)
return c.json({ url: config.mediaUrl + filename })
})
+197
View File
@@ -0,0 +1,197 @@
import { Hono } from "hono"
import { z } from "zod"
import { all, get, run, tx, iso, uuidOut } from "../db"
import { Role, isStaff, requireUser } from "../auth"
import { HttpError, intParam, orNotFound, qs, query, uuidParam, type Env } from "../http"
import { deleteSubmissions } from "../services/cascade"
import {
classifyConversationMessages,
classifyMessagesBatch,
inBackground,
} from "../services/classifier"
import { getPrecedingUserMessage, type MessageRow } from "../services/prompt"
const router = new Hono<Env>()
router.get("/conversations", (c) => {
const user = requireUser(c)
const q = query(c, z.object({ task_id: qs.opt(qs.int), user_id: qs.opt(qs.int) }))
const where: string[] = []
const params: Record<string, unknown> = {}
// 普通用户只能看自己的
if (user.role === Role.NORMAL) {
where.push("c.user_id = $user_id")
params.user_id = user.id
} else if (q.user_id) {
where.push("c.user_id = $user_id")
params.user_id = q.user_id
}
if (q.task_id) {
where.push("c.task_id = $task_id")
params.task_id = q.task_id
}
const rows = all(
`SELECT c.id, c.user_id, u.username, c.task_id, t.title AS task_title, c.is_active, c.created,
(SELECT COUNT(*) FROM prompt_message m WHERE m.conversation_id = c.id) AS message_count
FROM prompt_conversation c
JOIN account_user u ON u.id = c.user_id
JOIN task_task t ON t.id = c.task_id
${where.length ? "WHERE " + where.join(" AND ") : ""}
ORDER BY message_count DESC, c.created DESC`,
params,
)
return c.json(
rows.map((r) => ({
id: uuidOut(r.id),
user_id: r.user_id,
username: r.username,
task_id: r.task_id,
task_title: r.task_title,
is_active: !!r.is_active,
message_count: r.message_count,
created: iso(r.created),
})),
)
})
router.get("/conversations/:conversation_id/messages", (c) => {
const user = requireUser(c)
const id = uuidParam(c, "conversation_id")
const conv = orNotFound(
get<{ user_id: number }>(`SELECT user_id FROM prompt_conversation WHERE id = $id`, { id }),
)
if (user.role === Role.NORMAL && conv.user_id !== user.id) return c.json([])
const messages = all<MessageRow>(
`SELECT * FROM prompt_message WHERE conversation_id = $id ORDER BY created, id`,
{ id },
)
return c.json(
messages.map((m) => ({
id: m.id,
role: m.role,
source: m.source,
content: m.content,
code_html: m.code_html,
code_css: m.code_css,
code_js: m.code_js,
prompt_level: m.prompt_level,
created: iso(m.created),
})),
)
})
// 当前用户在某任务下的历史对话轮次:用户提示词 + 紧随其后的 assistant 消息中的页面代码
router.get("/history/:task_id", (c) => {
const user = requireUser(c)
const taskId = intParam(c, "task_id")
const messages = all<MessageRow>(
`SELECT m.* FROM prompt_message m JOIN prompt_conversation c ON c.id = m.conversation_id
WHERE c.user_id = $user_id AND c.task_id = $task_id
ORDER BY m.conversation_id, m.created, m.id`,
{ user_id: user.id, task_id: taskId },
)
const byConv = Map.groupBy(messages, (m) => m.conversation_id)
const items: { key: [string, number]; item: object }[] = []
for (const msgs of byConv.values()) {
msgs.forEach((userMsg, idx) => {
if (userMsg.role !== "user") return
let asst: MessageRow | undefined
for (const reply of msgs.slice(idx + 1)) {
if (reply.role === "user") break
if (reply.role === "assistant") {
asst = reply
break
}
}
if (!asst) return
items.push({
key: [userMsg.created, userMsg.id],
item: {
user_message_id: userMsg.id,
assistant_message_id: asst.id,
submission_id: uuidOut(asst.submission_id),
source: userMsg.source,
prompt: userMsg.content,
prompt_level: userMsg.prompt_level,
code_html: asst.code_html,
code_css: asst.code_css,
code_js: asst.code_js,
created: iso(userMsg.created),
},
})
})
}
items.sort((a, b) =>
a.key[0] < b.key[0] ? -1 : a.key[0] > b.key[0] ? 1 : a.key[1] - b.key[1],
)
return c.json(items.map((i) => i.item))
})
// 对一个对话里的用户消息做层级分类(管理员,后台执行)
router.post("/conversations/:conversation_id/classify", (c) => {
const user = requireUser(c)
if (!isStaff(user)) throw new HttpError(403, "没有权限")
const id = uuidParam(c, "conversation_id")
const { force } = query(c, z.object({ force: qs.opt(qs.bool).default(false) }))
orNotFound(get(`SELECT 1 FROM prompt_conversation WHERE id = $id`, { id }))
inBackground(classifyConversationMessages(id, force))
return c.json({ message: "开始分类" })
})
// 批量分类所有(或指定任务)对话的用户消息(管理员,后台执行)
router.post("/classify-batch", (c) => {
const user = requireUser(c)
if (!isStaff(user)) throw new HttpError(403, "没有权限")
const q = query(
c,
z.object({ task_id: qs.opt(qs.int), force: qs.opt(qs.bool).default(false) }),
)
const where = ["m.role = 'user'"]
const params: Record<string, unknown> = {}
if (q.task_id) {
where.push("c.task_id = $task_id")
params.task_id = q.task_id
}
if (!q.force) where.push("m.prompt_level IS NULL")
const ids = all<{ id: number }>(
`SELECT m.id FROM prompt_message m JOIN prompt_conversation c ON c.id = m.conversation_id
WHERE ${where.join(" AND ")} ORDER BY m.created, m.id`,
params,
).map((r) => r.id)
inBackground(classifyMessagesBatch(ids))
return c.json({ message: `开始分类 ${ids.length} 条消息`, count: ids.length })
})
// 删除一轮对话(assistant 消息 + 它前面的用户消息)以及关联的提交,只能删自己的
router.delete("/messages/:message_id/pair", (c) => {
const user = requireUser(c)
const asst = orNotFound(
get<MessageRow & { owner_id: number }>(
`SELECT m.*, c.user_id AS owner_id FROM prompt_message m
JOIN prompt_conversation c ON c.id = m.conversation_id
WHERE m.id = $id AND m.role = 'assistant'`,
{ id: intParam(c, "message_id") },
),
)
if (asst.owner_id !== user.id && user.role !== Role.SUPER) {
throw new HttpError(403, "只能删除自己的消息")
}
if (asst.submission_id && user.role !== Role.SUPER) {
const used = get(
`SELECT 1 WHERE EXISTS (SELECT 1 FROM submission_rating WHERE submission_id = $id)
OR EXISTS (SELECT 1 FROM submission_submissionaward WHERE submission_id = $id)`,
{ id: asst.submission_id },
)
if (used) throw new HttpError(400, "该消息关联的提交已被评分或获奖,无法删除")
}
const userMsg = getPrecedingUserMessage(asst)
tx(() => {
if (userMsg) run(`DELETE FROM prompt_message WHERE id = $id`, { id: userMsg.id })
run(`DELETE FROM prompt_message WHERE id = $id`, { id: asst.id })
if (asst.submission_id) deleteSubmissions("id = $id", { id: asst.submission_id })
})
return c.json({ deleted: true, submission_deleted: !!asst.submission_id })
})
export default router
+343
View File
@@ -0,0 +1,343 @@
import { Hono } from "hono"
import { z } from "zod"
import { all, get, run, tx, now, iso, uuidHex, uuidOut } from "../db"
import { requireAdmin, requireUser } from "../auth"
import { HttpError, ValidationError, body, bool, intParam, orNotFound, uuidParam, type Ctx, type Env } from "../http"
import { buildPromptRounds, type MessageRow } from "../services/prompt"
const router = new Hono<Env>()
const ITEM_ORDERINGS = ["manual", "awarded_at", "score", "view_count"] as const
// 作品排序方式 → ORDER BY
const ORDER_BY: Record<string, string> = {
manual: "sa.sort_order, sa.id",
awarded_at: "sa.awarded_at DESC, sa.sort_order, sa.id",
score: "s.score DESC, sa.sort_order, sa.id",
view_count: "s.view_count DESC, sa.sort_order, sa.id",
}
const HAS_CHAIN = `EXISTS (SELECT 1 FROM prompt_message m WHERE m.submission_id = s.id) AS has_prompt_chain`
const ITEM_SELECT = `SELECT sa.id, sa.submission_id, sa.sort_order, sa.awarded_at, sa.is_stale,
u.username, t.title AS task_title, t.display AS task_display,
s.score, s.view_count, s.html, s.css, s.js, ${HAS_CHAIN}
FROM submission_submissionaward sa
JOIN submission_submission s ON s.id = sa.submission_id
JOIN account_user u ON u.id = s.user_id
JOIN task_task t ON t.id = s.task_id`
function itemManageOut(r: any) {
return {
id: r.id,
submission_id: uuidOut(r.submission_id),
username: r.username,
task_title: r.task_title,
task_display: r.task_display,
score: r.score,
view_count: r.view_count,
sort_order: r.sort_order,
awarded_at: iso(r.awarded_at),
has_prompt_chain: bool(r.has_prompt_chain),
is_stale: bool(r.is_stale),
}
}
const loadItem = (id: number) => get(`${ITEM_SELECT} WHERE sa.id = $id`, { id })
function awardManageOut(a: any) {
return {
id: a.id,
name: a.name,
description: a.description,
sort_order: a.sort_order,
is_active: bool(a.is_active),
item_ordering: a.item_ordering,
item_count: a.item_count,
}
}
const AWARD_SELECT = `SELECT a.*, (SELECT COUNT(*) FROM submission_submissionaward sa WHERE sa.award_id = a.id) AS item_count
FROM submission_award a`
const awardIn = z.object({
name: z.string(),
description: z.string().default(""),
sort_order: z.number().int().default(0),
is_active: z.boolean().default(true),
item_ordering: z.string().default("manual"),
})
async function parseAward(c: Ctx) {
const p = await body(c, awardIn)
if (!(ITEM_ORDERINGS as readonly string[]).includes(p.item_ordering)) {
throw new HttpError(400, "无效的作品排序方式")
}
return p
}
function assertNameFree(name: string, exceptId = 0) {
if (get(`SELECT 1 FROM submission_award WHERE name = $name AND id != $id`, { name, id: exceptId })) {
throw new HttpError(400, "奖项名称已存在")
}
}
// ── 前台作品展 ────────────────────────────────────────────────────────────────
router.get("/", (c) => {
requireUser(c)
const awards = all(`SELECT * FROM submission_award WHERE is_active = 1 ORDER BY sort_order`)
const result = []
for (const award of awards) {
const items = all(
`${ITEM_SELECT} WHERE sa.award_id = $id ORDER BY ${ORDER_BY[award.item_ordering] ?? ORDER_BY.manual}`,
{ id: award.id },
)
if (!items.length) continue
result.push({
id: award.id,
name: award.name,
description: award.description,
item_ordering: award.item_ordering,
items: items.map((r) => ({
submission_id: uuidOut(r.submission_id),
username: r.username,
task_title: r.task_title,
task_display: r.task_display,
score: r.score,
view_count: r.view_count,
html: r.html,
css: r.css,
js: r.js,
has_prompt_chain: bool(r.has_prompt_chain),
})),
})
}
return c.json(result)
})
// ── 管理 ──────────────────────────────────────────────────────────────────────
router.get("/manage/awards", (c) => {
requireAdmin(c)
return c.json(all(`${AWARD_SELECT} ORDER BY a.sort_order, a.id`).map(awardManageOut))
})
router.post("/manage/awards", async (c) => {
requireAdmin(c)
const p = await parseAward(c)
assertNameFree(p.name)
const ts = now()
const res = run(
`INSERT INTO submission_award (created, modified, name, description, sort_order, is_active, item_ordering)
VALUES ($ts, $ts, $name, $description, $sort_order, $is_active, $item_ordering)`,
{ ts, ...p },
)
return c.json(awardManageOut(get(`${AWARD_SELECT} WHERE a.id = $id`, { id: Number(res.lastInsertRowid) })))
})
router.put("/manage/awards/:award_id", async (c) => {
requireAdmin(c)
const id = intParam(c, "award_id")
const p = await parseAward(c)
orNotFound(get(`SELECT 1 FROM submission_award WHERE id = $id`, { id }))
assertNameFree(p.name, id)
run(
`UPDATE submission_award SET name = $name, description = $description, sort_order = $sort_order,
is_active = $is_active, item_ordering = $item_ordering, modified = $ts
WHERE id = $id`,
{ id, ts: now(), ...p },
)
return c.json(awardManageOut(get(`${AWARD_SELECT} WHERE a.id = $id`, { id })))
})
router.delete("/manage/awards/:award_id", (c) => {
requireAdmin(c)
const id = intParam(c, "award_id")
orNotFound(get(`SELECT 1 FROM submission_award WHERE id = $id`, { id }))
tx(() => {
run(`DELETE FROM submission_submissionaward WHERE award_id = $id`, { id })
run(`DELETE FROM submission_award WHERE id = $id`, { id })
})
return c.json({ message: "删除成功" })
})
router.get("/manage/submissions/:submission_id", (c) => {
requireAdmin(c)
const id = uuidParam(c, "submission_id")
const s = orNotFound(
get(
`SELECT s.id, u.username, t.title AS task_title, t.display AS task_display,
s.score, s.view_count, ${HAS_CHAIN}
FROM submission_submission s
JOIN account_user u ON u.id = s.user_id
JOIN task_task t ON t.id = s.task_id
WHERE s.id = $id`,
{ id },
),
)
return c.json({
submission_id: uuidOut(s.id),
username: s.username,
task_title: s.task_title,
task_display: s.task_display,
score: s.score,
view_count: s.view_count,
has_prompt_chain: bool(s.has_prompt_chain),
})
})
router.get("/manage/awards/:award_id/items", (c) => {
requireAdmin(c)
const award = orNotFound(
get<{ id: number; item_ordering: string }>(`SELECT id, item_ordering FROM submission_award WHERE id = $id`, {
id: intParam(c, "award_id"),
}),
)
const items = all(
`${ITEM_SELECT} WHERE sa.award_id = $id ORDER BY ${ORDER_BY[award.item_ordering] ?? ORDER_BY.manual}`,
{ id: award.id },
)
return c.json(items.map(itemManageOut))
})
router.post("/manage/awards/:award_id/items", async (c) => {
requireAdmin(c)
const awardId = intParam(c, "award_id")
const p = await body(c, z.object({ submission_id: z.string(), sort_order: z.number().int().default(0) }))
const submissionId = uuidHex(p.submission_id)
if (!submissionId) throw new ValidationError([{ loc: ["body", "submission_id"], msg: "需要 UUID" }])
orNotFound(get(`SELECT 1 FROM submission_award WHERE id = $id`, { id: awardId }))
orNotFound(get(`SELECT 1 FROM submission_submission WHERE id = $id`, { id: submissionId }))
if (
get(`SELECT 1 FROM submission_submissionaward WHERE award_id = $a AND submission_id = $s`, {
a: awardId,
s: submissionId,
})
) {
throw new HttpError(400, "该作品已在奖项中")
}
const ts = now()
const res = run(
`INSERT INTO submission_submissionaward (created, modified, sort_order, awarded_at, award_id, submission_id, is_stale)
VALUES ($ts, $ts, $sort_order, $ts, $award_id, $submission_id, 0)`,
{ ts, sort_order: p.sort_order, award_id: awardId, submission_id: submissionId },
)
return c.json(itemManageOut(loadItem(Number(res.lastInsertRowid))))
})
router.put("/manage/items/:item_id", async (c) => {
requireAdmin(c)
const id = intParam(c, "item_id")
const { sort_order } = await body(c, z.object({ sort_order: z.number().int().default(0) }))
orNotFound(get(`SELECT 1 FROM submission_submissionaward WHERE id = $id`, { id }))
run(`UPDATE submission_submissionaward SET sort_order = $sort_order, modified = $ts WHERE id = $id`, {
sort_order,
ts: now(),
id,
})
return c.json(itemManageOut(loadItem(id)))
})
router.delete("/manage/items/:item_id", (c) => {
requireAdmin(c)
const id = intParam(c, "item_id")
orNotFound(get(`SELECT 1 FROM submission_submissionaward WHERE id = $id`, { id }))
run(`DELETE FROM submission_submissionaward WHERE id = $id`, { id })
return c.json({ message: "删除成功" })
})
// 把作品展条目切换到该学生该挑战的最新提交
router.put("/manage/items/:item_id/refresh", (c) => {
requireAdmin(c)
const id = intParam(c, "item_id")
const item = orNotFound(
get<{ user_id: number; task_id: number; submission_id: string; award_id: number }>(
`SELECT s.user_id, s.task_id, sa.submission_id, sa.award_id
FROM submission_submissionaward sa JOIN submission_submission s ON s.id = sa.submission_id
WHERE sa.id = $id`,
{ id },
),
)
const latest = get<{ id: string }>(
`SELECT id FROM submission_submission WHERE user_id = $user_id AND task_id = $task_id
ORDER BY created DESC LIMIT 1`,
{ user_id: item.user_id, task_id: item.task_id },
)
const target = latest?.id ?? item.submission_id
if (
target !== item.submission_id &&
get(`SELECT 1 FROM submission_submissionaward WHERE award_id = $a AND submission_id = $s`, {
a: item.award_id,
s: target,
})
) {
throw new HttpError(400, "最新提交已在该奖项中")
}
run(
`UPDATE submission_submissionaward SET submission_id = $sid, is_stale = 0, modified = $ts WHERE id = $id`,
{ sid: target, ts: now(), id },
)
return c.json(itemManageOut(loadItem(id)))
})
// ── 作品详情 ──────────────────────────────────────────────────────────────────
function assertAwarded(submissionId: string) {
if (
!get(
`SELECT 1 FROM submission_submissionaward sa JOIN submission_award a ON a.id = sa.award_id
WHERE sa.submission_id = $id AND a.is_active = 1`,
{ id: submissionId },
)
) {
throw new HttpError(404, "作品不存在或未授奖")
}
}
router.get("/:submission_id", (c) => {
requireUser(c)
const id = uuidParam(c, "submission_id")
assertAwarded(id)
const s = orNotFound(
get(
`SELECT s.id, u.username, t.title AS task_title, t.display AS task_display,
s.score, s.view_count, s.html, s.css, s.js, ${HAS_CHAIN}
FROM submission_submission s
JOIN account_user u ON u.id = s.user_id
JOIN task_task t ON t.id = s.task_id
WHERE s.id = $id`,
{ id },
),
)
const awards = all<{ name: string }>(
`SELECT a.name FROM submission_submissionaward sa JOIN submission_award a ON a.id = sa.award_id
WHERE sa.submission_id = $id AND a.is_active = 1 ORDER BY sa.sort_order, sa.id`,
{ id },
)
return c.json({
submission_id: uuidOut(s.id),
username: s.username,
task_title: s.task_title,
task_display: s.task_display,
score: s.score,
view_count: s.view_count,
html: s.html,
css: s.css,
js: s.js,
awards: awards.map((a) => a.name),
has_prompt_chain: bool(s.has_prompt_chain),
})
})
router.get("/:submission_id/prompt-chain", (c) => {
requireUser(c)
const id = uuidParam(c, "submission_id")
assertAwarded(id)
orNotFound(get(`SELECT 1 FROM submission_submission WHERE id = $id`, { id }))
const msg = get<MessageRow>(`SELECT * FROM prompt_message WHERE submission_id = $id`, { id })
if (!msg) throw new HttpError(404, "该作品没有关联提示词链")
return c.json(buildPromptRounds(msg))
})
export default router
+558
View File
@@ -0,0 +1,558 @@
import { Hono } from "hono"
import { z } from "zod"
import { all, get, run, tx, now, iso, likeEscape, newUuidHex, uuidHex, uuidOut } from "../db"
import { Role, isStaff, requireSuper, requireUser } from "../auth"
import {
HttpError,
ValidationError,
body,
intParam,
notFound,
orNotFound,
pageQuery,
pageWindow,
qs,
query,
type Env,
uuidParam,
} from "../http"
import { deleteSubmissions } from "../services/cascade"
import { updateSubmissionScore } from "../services/score"
import { classifyMessage, inBackground } from "../services/classifier"
import { buildGradebook, gradebookCsvRows, toCsv } from "../services/gradebook"
import {
buildPromptRounds,
createConversation,
getActiveConversation,
getPrecedingUserMessage,
insertMessage,
type MessageRow,
} from "../services/prompt"
import { pyRound } from "../services/pyround"
import showcase from "./showcase"
const router = new Hono<Env>()
router.route("/showcase", showcase)
// 当天(UTC 零点起)的评分次数,与原实现保持一致
function todayRatingCount(userId: number) {
const start = new Date()
start.setUTCHours(0, 0, 0, 0)
const end = new Date(start.getTime() + 86_400_000)
const fmt = (d: Date) => d.toISOString().slice(0, 19).replace("T", " ") + ".000000"
return get<{ n: number }>(
`SELECT COUNT(*) AS n FROM submission_rating
WHERE user_id = $id AND created >= $start AND created <= $end`,
{ id: userId, start: fmt(start), end: fmt(end) },
)!.n
}
const SUBMISSION_SELECT = `SELECT s.id, s.user_id, u.username, s.task_id, t.display AS task_display,
t.title AS task_title, t.task_type, s.score, s.html, s.css, s.js, s.flag, s.zone,
s.view_count, s.created, s.modified,
(SELECT r.score FROM submission_rating r WHERE r.user_id = $me AND r.submission_id = s.id LIMIT 1) AS my_score`
const SUBMISSION_FROM = `FROM submission_submission s
JOIN account_user u ON u.id = s.user_id
JOIN task_task t ON t.id = s.task_id`
function submissionOut(s: any) {
return {
id: uuidOut(s.id),
userid: s.user_id,
username: s.username,
task_id: s.task_id,
task_display: s.task_display,
task_title: s.task_title,
task_type: s.task_type,
score: s.score,
my_score: s.my_score || 0,
html: s.html,
css: s.css,
js: s.js,
flag: s.flag,
zone: s.zone,
submit_count: s.submit_count || 0,
view_count: s.view_count,
created: iso(s.created),
modified: iso(s.modified),
}
}
const hasRatingsOrAwards = (submissionId: string) =>
!!get(
`SELECT 1 WHERE EXISTS (SELECT 1 FROM submission_rating WHERE submission_id = $id)
OR EXISTS (SELECT 1 FROM submission_submissionaward WHERE submission_id = $id)`,
{ id: submissionId },
)
// ── 创建提交 ──────────────────────────────────────────────────────────────────
router.post("/", async (c) => {
const user = requireUser(c)
const p = await body(
c,
z.object({
task_id: z.number().int(),
html: z.string().nullable().optional(),
css: z.string().nullable().optional(),
js: z.string().nullable().optional(),
prompt: z.string().nullable().optional(),
message_id: z.number().int().nullable().optional(), // 关联的 assistant message
}),
)
const task = orNotFound(
get<{ id: number; task_type: string }>(`SELECT id, task_type FROM task_task WHERE id = $id`, {
id: p.task_id,
}),
)
if (task.task_type === "creation") throw new HttpError(400, "自由创作不需要提交")
const code = { html: p.html ?? null, css: p.css ?? null, js: p.js ?? null }
const { submissionId, newUserMsgId } = tx(() => {
let manualAsstMsgId: number | null = null
let linkedMsg: MessageRow | null = null
let newUserMsgId: number | null = null
if (p.prompt) {
const convId =
getActiveConversation(user.id, task.id) ?? createConversation(user.id, task.id, false)
newUserMsgId = insertMessage({
conversation_id: convId,
role: "user",
content: p.prompt,
source: "manual",
})
manualAsstMsgId = insertMessage({
conversation_id: convId,
role: "assistant",
content: "",
code_html: code.html,
code_css: code.css,
code_js: code.js,
source: "manual",
})
} else if (p.message_id) {
linkedMsg = get<MessageRow>(
`SELECT m.* FROM prompt_message m JOIN prompt_conversation c ON c.id = m.conversation_id
WHERE m.id = $id AND m.role = 'assistant' AND c.user_id = $user_id AND c.task_id = $task_id`,
{ id: p.message_id, user_id: user.id, task_id: task.id },
)
if (!linkedMsg) {
console.warn(
`create_submission: message_id ${p.message_id} not found for user ${user.id} task ${task.id}`,
)
} else {
newUserMsgId = getPrecedingUserMessage(linkedMsg)?.id ?? null
}
}
// 同一条 AI 消息重复提交时更新原来的提交,而不是再建一条
if (linkedMsg?.submission_id) {
run(
`UPDATE submission_submission SET html = $html, css = $css, js = $js WHERE id = $id`,
{ ...code, id: linkedMsg.submission_id },
)
return { submissionId: linkedMsg.submission_id, newUserMsgId }
}
const id = newUuidHex()
const ts = now()
run(
`INSERT INTO submission_submission
(id, created, modified, score, html, css, js, task_id, user_id, flag, raw_score, zone, view_count)
VALUES ($id, $ts, $ts, 0, $html, $css, $js, $task_id, $user_id, NULL, 0, NULL, 0)`,
{ id, ts, ...code, task_id: task.id, user_id: user.id },
)
const msgToLink = manualAsstMsgId ?? linkedMsg?.id
if (msgToLink) {
run(`UPDATE prompt_message SET submission_id = $sid WHERE id = $mid`, {
sid: id,
mid: msgToLink,
})
}
// 该学生该任务之前进入作品展的提交标记为“有更新”
run(
`UPDATE submission_submissionaward SET is_stale = 1
WHERE is_stale = 0 AND submission_id != $id AND submission_id IN
(SELECT id FROM submission_submission WHERE user_id = $user_id AND task_id = $task_id)`,
{ id, user_id: user.id, task_id: task.id },
)
return { submissionId: id, newUserMsgId }
})
// 只对新增的提示词做层级分类
if (
newUserMsgId !== null &&
!get(`SELECT 1 FROM prompt_message WHERE id = $id AND prompt_level IS NOT NULL`, {
id: newUserMsgId,
})
) {
inBackground(classifyMessage(newUserMsgId))
}
return c.json({ id: uuidOut(submissionId) })
})
// ── 列表 ──────────────────────────────────────────────────────────────────────
const listQuery = pageQuery.extend({
task_id: qs.opt(qs.int),
task_type: qs.opt(z.enum(["tutorial", "challenge"])),
username: qs.opt(z.string()),
user_id: qs.opt(qs.int),
flag: qs.opt(z.enum(["red", "blue", "green", "yellow", "any"])),
zone: qs.opt(z.enum(["featured", "low", "pending"])),
score_min: qs.opt(qs.num),
score_max_exclusive: qs.opt(qs.num),
score_lt_threshold: qs.opt(qs.num),
ordering: qs.opt(z.string()),
grouped: qs.opt(qs.bool).default(true),
})
router.get("/", (c) => {
const user = requireUser(c)
const f = query(c, listQuery)
const where: string[] = []
const params: Record<string, unknown> = {}
if (f.task_id) {
orNotFound(get(`SELECT 1 FROM task_task WHERE id = $id`, { id: f.task_id }))
where.push("s.task_id = $task_id")
params.task_id = f.task_id
} else if (f.task_type) {
where.push("t.task_type = $task_type")
params.task_type = f.task_type
}
if (f.username) {
where.push(`u.username LIKE $username ESCAPE '\\'`)
params.username = likeEscape(f.username)
}
if (f.user_id) {
where.push("s.user_id = $user_id")
params.user_id = f.user_id
}
if (f.flag === "any") where.push("s.flag IS NOT NULL")
else if (f.flag) {
where.push("s.flag = $flag")
params.flag = f.flag
}
if (f.zone) {
where.push("s.zone = $zone")
params.zone = f.zone
}
if (f.score_lt_threshold !== undefined) {
where.push("s.score < $score_lt")
params.score_lt = f.score_lt_threshold
} else {
if (f.score_min !== undefined) {
where.push("s.score >= $score_min")
params.score_min = f.score_min
}
if (f.score_max_exclusive !== undefined) {
where.push("s.score < $score_max")
params.score_max = f.score_max_exclusive
}
}
if (f.grouped) {
// 分组模式:每个 (user, task) 只保留最新一条
where.push(`s.id = (SELECT s3.id FROM submission_submission s3
WHERE s3.user_id = s.user_id AND s3.task_id = s.task_id ORDER BY s3.created DESC LIMIT 1)`)
}
const orderings: Record<string, string> = {
"-score": "s.score DESC",
score: "s.score ASC",
"-created": "s.created DESC",
}
const orderBy = orderings[f.ordering ?? ""] ?? "s.created DESC"
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""
const { limit, offset } = pageWindow(f)
const items = all(
`${SUBMISSION_SELECT},
(SELECT COUNT(*) FROM submission_submission s2
WHERE s2.user_id = s.user_id AND s2.task_id = s.task_id) AS submit_count
${SUBMISSION_FROM} ${whereSql}
ORDER BY ${orderBy} LIMIT $limit OFFSET $offset`,
{ ...params, me: user.id, limit, offset },
)
const count = get<{ n: number }>(`SELECT COUNT(*) AS n ${SUBMISSION_FROM} ${whereSql}`, params)!.n
return c.json({ items: items.map(submissionOut), count })
})
router.get("/by-user-task", (c) => {
const me = requireUser(c)
const q = query(c, z.object({ user_id: qs.int, task_id: qs.int }))
const rows = all(
`${SUBMISSION_SELECT} ${SUBMISSION_FROM}
WHERE s.user_id = $user_id AND s.task_id = $task_id ORDER BY s.created DESC`,
{ me: me.id, user_id: q.user_id, task_id: q.task_id },
)
return c.json(rows.map(submissionOut))
})
// 随机返回一个待打分的其他同学的提交(AI 生成期间的随手打分弹窗)
router.get("/random-for-rating", (c) => {
const user = requireUser(c)
const q = query(c, z.object({ exclude_id: qs.opt(z.string()) }))
let excludeId: string | null = null
if (q.exclude_id) {
excludeId = uuidHex(q.exclude_id)
if (!excludeId) throw new ValidationError([{ loc: ["query", "exclude_id"], msg: "需要 UUID" }])
}
if (user.role === Role.NORMAL && todayRatingCount(user.id) >= 30) return c.json(null)
const base = `SELECT s.id, u.username, t.title AS task_title, t.display AS task_display,
t.task_type, s.html, s.css, s.js
${SUBMISSION_FROM}
WHERE 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 pick =
get(
`${base} AND (SELECT COUNT(*) FROM submission_rating r WHERE r.submission_id = s.id) < 5
ORDER BY RANDOM() LIMIT 1`,
params,
) ?? get(`${base} ORDER BY RANDOM() LIMIT 1`, params)
if (!pick) return c.json(null)
return c.json({
submission_id: uuidOut(pick.id),
username: pick.username,
task_title: pick.task_title,
task_display: pick.task_display,
task_type: pick.task_type,
html: pick.html,
css: pick.css,
js: pick.js,
})
})
router.delete("/flags", (c) => {
const user = requireUser(c)
if (!isStaff(user)) throw new HttpError(403, "没有权限")
const res = run(`UPDATE submission_submission SET flag = NULL WHERE flag IS NOT NULL`)
return c.json({ cleared: res.changes })
})
// ── 统计 ──────────────────────────────────────────────────────────────────────
router.get("/stats/:task_id", (c) => {
const user = requireUser(c)
if (!isStaff(user)) throw new HttpError(403, "没有权限")
const taskId = intParam(c, "task_id")
const { classname } = query(c, z.object({ classname: qs.opt(z.string()) }))
orNotFound(get(`SELECT 1 FROM task_task WHERE id = $id`, { id: taskId }))
const classes = all<{ classname: string }>(
`SELECT DISTINCT classname FROM account_user
WHERE role = 'normal' AND classname != '' ORDER BY classname`,
).map((r) => r.classname)
// 学生范围:普通用户,可按班级过滤
const students = all<{ id: number; username: string; classname: string }>(
`SELECT id, username, classname FROM account_user
WHERE role = 'normal' ${classname ? "AND classname = $classname" : ""}
ORDER BY classname, username`,
classname ? { classname } : {},
)
const studentIds = new Set(students.map((s) => s.id))
const subs = all<{ id: string; user_id: number; created: string; score: number; flag: string | null }>(
`SELECT id, user_id, created, score, flag FROM submission_submission WHERE task_id = $id`,
{ id: taskId },
)
const studentSubs = subs.filter((s) => studentIds.has(s.user_id))
const submittedIds = new Set(studentSubs.map((s) => s.user_id))
const latest = new Map<number, (typeof subs)[number]>()
const subCount = new Map<number, number>()
for (const s of studentSubs) {
const cur = latest.get(s.user_id)
if (!cur || s.created > cur.created) latest.set(s.user_id, s)
subCount.set(s.user_id, (subCount.get(s.user_id) ?? 0) + 1)
}
// 平均分:每人最新一次提交中分数 > 0 的
const scored = [...latest.values()].filter((s) => s.score > 0)
const averageScore = scored.length
? pyRound(scored.reduce((a, s) => a + s.score, 0) / scored.length, 2)
: null
// 未评分:提交过但该任务下所有提交都没人打分
const ratedIds = new Set(
all<{ user_id: number }>(
`SELECT DISTINCT s.user_id FROM submission_rating r
JOIN submission_submission s ON s.id = r.submission_id WHERE s.task_id = $id`,
{ id: taskId },
).map((r) => r.user_id),
)
const tag = (s: { username: string; classname: string }) => ({
username: s.username,
classname: s.classname,
})
const dist = { count_1: 0, count_2: 0, count_3: 0, count_4_plus: 0 }
for (const n of subCount.values()) {
if (n === 1) dist.count_1++
else if (n === 2) dist.count_2++
else if (n === 3) dist.count_3++
else dist.count_4_plus++
}
// 标记统计:该任务所有提交(不按学生过滤)
const flagStats = { red: 0, blue: 0, green: 0, yellow: 0 }
for (const s of subs) {
if (s.flag && s.flag in flagStats) flagStats[s.flag as keyof typeof flagStats]++
}
const topViewed = classname
? all(
`SELECT s.id, s.view_count, u.username, u.classname ${SUBMISSION_FROM}
WHERE s.task_id = $id AND u.role = 'normal' AND u.classname = $classname
ORDER BY s.view_count DESC LIMIT 5`,
{ id: taskId, classname },
)
: all(
`SELECT s.id, s.view_count, u.username, u.classname ${SUBMISSION_FROM}
WHERE s.task_id = $id AND u.role = 'normal'
ORDER BY s.view_count DESC LIMIT 5`,
{ id: taskId },
)
const unrated = students.filter((s) => submittedIds.has(s.id) && !ratedIds.has(s.id))
return c.json({
submitted_count: submittedIds.size,
unsubmitted_count: students.length - submittedIds.size,
average_score: averageScore,
unrated_count: unrated.length,
unsubmitted_users: students.filter((s) => !submittedIds.has(s.id)).map(tag),
unrated_users: unrated.map(tag),
submission_count_distribution: dist,
flag_stats: flagStats,
classes,
top_viewed: topViewed.map((s) => ({
username: s.username,
classname: s.classname,
view_count: s.view_count,
submission_id: uuidOut(s.id),
})),
})
})
// ── 成绩册 ────────────────────────────────────────────────────────────────────
const gradebookQuery = z.object({
classname: z.string().default(""),
task_type: qs.opt(z.enum(["tutorial", "challenge"])),
username: qs.opt(z.string()),
include_all_tasks: qs.opt(qs.bool).default(false),
})
router.get("/gradebook", (c) => {
requireSuper(c)
return c.json(buildGradebook(query(c, gradebookQuery)))
})
router.get("/gradebook/export", (c) => {
requireSuper(c)
const gb = buildGradebook(query(c, gradebookQuery))
const filename = encodeURIComponent(`gradebook-${gb.classname}.csv`)
return c.body("" + toCsv(gradebookCsvRows(gb)), 200, {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename*=UTF-8''${filename}`,
})
})
// ── 单个提交 ──────────────────────────────────────────────────────────────────
router.delete("/:id", (c) => {
const user = requireUser(c)
const id = uuidParam(c, "id")
const sub = orNotFound(
get<{ user_id: number }>(`SELECT user_id FROM submission_submission WHERE id = $id`, { id }),
)
if (sub.user_id !== user.id && user.role !== Role.SUPER) {
throw new HttpError(403, "只能删除自己的提交")
}
if (user.role !== Role.SUPER && hasRatingsOrAwards(id)) {
throw new HttpError(400, "该提交已被评分或获奖,无法删除")
}
tx(() => {
// 关联的助手消息会随提交一起删除,它前面那条用户消息也一起删掉
const asst = get<MessageRow>(`SELECT * FROM prompt_message WHERE submission_id = $id`, { id })
const userMsg = asst ? getPrecedingUserMessage(asst) : null
deleteSubmissions("id = $id", { id })
if (userMsg) run(`DELETE FROM prompt_message WHERE id = $id`, { id: userMsg.id })
})
return c.json({ message: "删除成功" })
})
router.get("/:id/prompt-chain", (c) => {
requireUser(c)
const id = uuidParam(c, "id")
orNotFound(get(`SELECT 1 FROM submission_submission WHERE id = $id`, { id }))
const msg = get<MessageRow>(`SELECT * FROM prompt_message WHERE submission_id = $id`, { id })
if (!msg) throw new HttpError(404, "该提交没有关联提示词链")
return c.json(buildPromptRounds(msg))
})
router.get("/:id", (c) => {
const user = requireUser(c)
const id = uuidParam(c, "id")
const s = orNotFound(get(`${SUBMISSION_SELECT} ${SUBMISSION_FROM} WHERE s.id = $id`, { me: user.id, id }))
return c.json(submissionOut(s))
})
// 增加浏览次数(仅在全屏预览时调用)
router.post("/:id/view", (c) => {
requireUser(c)
const id = uuidParam(c, "id")
const res = run(`UPDATE submission_submission SET view_count = view_count + 1 WHERE id = $id`, { id })
if (!res.changes) notFound("提交不存在")
return c.json({ ok: true })
})
router.put("/:id/score", async (c) => {
const user = requireUser(c)
const id = uuidParam(c, "id")
const { score } = await body(c, z.object({ score: z.number().int() }))
if (score <= 0) throw new HttpError(400, "分数不能为零")
orNotFound(get(`SELECT 1 FROM submission_submission WHERE id = $id`, { id }))
const exists = get(`SELECT 1 FROM submission_rating WHERE user_id = $user_id AND submission_id = $id`, {
user_id: user.id,
id,
})
if (exists) return c.json({ message: "你已经给这个提交打过分了" })
if (user.role === Role.NORMAL && todayRatingCount(user.id) >= 30) {
throw new HttpError(400, "普通用户每天最多只能评分30次。")
}
tx(() => {
run(
`INSERT INTO submission_rating (score, created, submission_id, user_id)
VALUES ($score, $ts, $id, $user_id)`,
{ score, ts: now(), id, user_id: user.id },
)
updateSubmissionScore(id)
})
return c.json({ message: "打分成功" })
})
router.put("/:id/flag", async (c) => {
const user = requireUser(c)
if (!isStaff(user)) throw new HttpError(403, "没有权限")
const id = uuidParam(c, "id")
const { flag } = await body(
c,
z.object({ flag: z.enum(["red", "blue", "green", "yellow"]).nullable().default(null) }),
)
orNotFound(get(`SELECT 1 FROM submission_submission WHERE id = $id`, { id }))
run(`UPDATE submission_submission SET flag = $flag WHERE id = $id`, { flag, id })
return c.json({ flag })
})
export default router
+417
View File
@@ -0,0 +1,417 @@
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 { deleteTask } from "../services/cascade"
const TASK_COLUMNS = `t.id, t.created, t.modified, t.display, t.title, t.content, t.task_type,
t.is_public, t.example_html, t.example_css, t.example_js`
function taskOut(t: any) {
return {
id: t.id,
task_ptr: t.id,
created: iso(t.created),
modified: iso(t.modified),
display: t.display,
title: t.title,
content: t.content,
task_type: t.task_type,
is_public: bool(t.is_public),
example_html: t.example_html,
example_css: t.example_css,
example_js: t.example_js,
}
}
function insertTask(type: string, p: {
display: number
title: string
content: string
is_public: boolean
example_html?: string | null
example_css?: string | null
example_js?: string | null
}) {
const ts = now()
const res = run(
`INSERT INTO task_task (created, modified, display, title, content, task_type, is_public, example_html, example_css, example_js)
VALUES ($ts, $ts, $display, $title, $content, $type, $is_public, $example_html, $example_css, $example_js)`,
{
ts,
type,
display: p.display,
title: p.title,
content: p.content,
is_public: p.is_public,
example_html: p.example_html ?? null,
example_css: p.example_css ?? null,
example_js: p.example_js ?? null,
},
)
return Number(res.lastInsertRowid)
}
function updateTask(id: number, p: {
title: string
content: string
is_public: boolean
example_html?: string | null
example_css?: string | null
example_js?: string | null
}) {
run(
`UPDATE task_task SET modified = $ts, title = $title, content = $content, is_public = $is_public,
example_html = $example_html, example_css = $example_css, example_js = $example_js
WHERE id = $id`,
{
id,
ts: now(),
title: p.title,
content: p.content,
is_public: p.is_public,
example_html: p.example_html ?? null,
example_css: p.example_css ?? null,
example_js: p.example_js ?? null,
},
)
}
function togglePublic(id: number) {
const t = get<{ display: number; title: string; is_public: number }>(
`SELECT display, title, is_public FROM task_task WHERE id = $id`,
{ id },
)!
const isPublic = !t.is_public
run(`UPDATE task_task SET is_public = $p, modified = $ts WHERE id = $id`, {
p: isPublic,
ts: now(),
id,
})
return { message: `【${t.display}】${t.title} 已${isPublic ? "公开" : "隐藏"}` }
}
const exampleFields = {
example_html: z.string().nullable().optional(),
example_css: z.string().nullable().optional(),
example_js: z.string().nullable().optional(),
}
// ── 教程 ──────────────────────────────────────────────────────────────────────
export const tutorial = new Hono<Env>()
const findTutorialId = (display: number) =>
get<{ id: number }>(
`SELECT t.id FROM task_task t JOIN task_tutorial x ON x.task_ptr_id = t.id
WHERE t.display = $display`,
{ display },
)?.id
tutorial.get("/list", (c) => {
requireSuper(c)
const rows = all(
`SELECT t.display, t.title, t.is_public FROM task_task t
JOIN task_tutorial x ON x.task_ptr_id = t.id ORDER BY t.display`,
)
return c.json(rows.map((r) => ({ ...r, is_public: bool(r.is_public) })))
})
tutorial.get("/display", (c) => {
const rows = all<{ display: number }>(
`SELECT t.display FROM task_task t JOIN task_tutorial x ON x.task_ptr_id = t.id
WHERE t.is_public = 1 ORDER BY t.display`,
)
return c.json(rows.map((r) => r.display))
})
tutorial.get("/:display", (c) => {
const row = orNotFound(
get(
`SELECT ${TASK_COLUMNS} FROM task_task t JOIN task_tutorial x ON x.task_ptr_id = t.id
WHERE t.display = $display`,
{ display: intParam(c, "display") },
),
)
return c.json(taskOut(row))
})
tutorial.post("/", async (c) => {
requireSuper(c)
const p = await body(
c,
z.object({
display: z.number().int(),
title: z.string(),
content: z.string(),
is_public: z.boolean().default(false),
...exampleFields,
}),
)
const id = findTutorialId(p.display)
if (id) {
updateTask(id, p)
return c.json({ message: "更新成功" })
}
tx(() => {
const newId = insertTask("tutorial", p)
run(`INSERT INTO task_tutorial (task_ptr_id) VALUES ($id)`, { id: newId })
})
return c.json({ message: "创建成功" })
})
tutorial.put("/public/:display", (c) => {
requireSuper(c)
const id = orNotFound(findTutorialId(intParam(c, "display")))
return c.json(togglePublic(id))
})
tutorial.delete("/:display", (c) => {
requireSuper(c)
const id = findTutorialId(intParam(c, "display"))
if (id) tx(() => deleteTask(id))
return c.json({ message: "删除成功" })
})
// ── 挑战 ──────────────────────────────────────────────────────────────────────
export const challenge = new Hono<Env>()
const CHALLENGE_SELECT = `SELECT ${TASK_COLUMNS}, x.score, x.pass_score, x.author_id,
COALESCE(u.username, '') AS author_name
FROM task_task t
JOIN task_challenge x ON x.task_ptr_id = t.id
LEFT JOIN account_user u ON u.id = x.author_id`
const findChallenge = (display: number) =>
get<{ id: number; author_id: number | null }>(
`SELECT t.id, x.author_id FROM task_task t JOIN task_challenge x ON x.task_ptr_id = t.id
WHERE t.display = $display`,
{ display },
)
function challengeSlim(r: any) {
return {
display: r.display,
title: r.title,
score: r.score,
pass_score: r.pass_score,
is_public: bool(r.is_public),
author_name: r.author_name,
}
}
challenge.get("/list", (c) => {
requireAdmin(c)
return c.json(all(`${CHALLENGE_SELECT} ORDER BY t.display`).map(challengeSlim))
})
challenge.get("/display", (c) => {
const user = c.get("user")
const rows = all(`${CHALLENGE_SELECT} WHERE t.is_public = 1 ORDER BY t.display DESC`)
const submitted = new Set(
user
? all<{ task_id: number }>(
`SELECT DISTINCT task_id FROM submission_submission WHERE user_id = $id`,
{ id: user.id },
).map((r) => r.task_id)
: [],
)
return c.json(rows.map((r) => ({ ...challengeSlim(r), submitted: submitted.has(r.id) })))
})
challenge.get("/:display", (c) => {
const r = orNotFound(
get(`${CHALLENGE_SELECT} WHERE t.display = $display`, {
display: intParam(c, "display"),
}),
)
return c.json({
...taskOut(r),
score: r.score,
pass_score: r.pass_score,
author: r.author_id,
author_name: r.author_name,
})
})
challenge.post("/", async (c) => {
const user = requireAdmin(c)
const p = await body(
c,
z.object({
display: z.number().int(),
title: z.string(),
content: z.string(),
score: z.number().int().default(0),
is_public: z.boolean().default(false),
...exampleFields,
}),
)
const existing = findChallenge(p.display)
if (existing) {
tx(() => {
updateTask(existing.id, p)
run(
`UPDATE task_challenge SET score = $score, author_id = COALESCE(author_id, $author)
WHERE task_ptr_id = $id`,
{ id: existing.id, score: p.score, author: user.id },
)
})
return c.json({ message: "更新成功" })
}
tx(() => {
const id = insertTask("challenge", p)
run(
`INSERT INTO task_challenge (task_ptr_id, score, pass_score, author_id)
VALUES ($id, $score, NULL, $author)`,
{ id, score: p.score, author: user.id },
)
})
return c.json({ message: "创建成功" })
})
challenge.put("/public/:display", (c) => {
requireSuper(c)
const found = orNotFound(findChallenge(intParam(c, "display")))
return c.json(togglePublic(found.id))
})
challenge.delete("/:display", (c) => {
requireAdmin(c)
const found = findChallenge(intParam(c, "display"))
if (found) tx(() => deleteTask(found.id))
return c.json({ message: "删除成功" })
})
// ── 自由创作 ──────────────────────────────────────────────────────────────────
export const creation = new Hono<Env>()
const CREATION_SELECT = `SELECT t.id, t.display, t.title, t.content, t.created, t.modified,
x.html, x.css, x.js, x.owner_id, u.username AS owner_name
FROM task_task t
JOIN task_creation x ON x.task_ptr_id = t.id
JOIN account_user u ON u.id = x.owner_id`
function creationOut(r: any) {
return {
task_id: r.id,
display: r.display,
title: r.title,
content: r.content,
html: r.html,
css: r.css,
js: r.js,
owner_name: r.owner_name,
created: iso(r.created),
modified: iso(r.modified),
}
}
function cleanTitle(title: string) {
title = title.trim()
if (!title) throw new HttpError(400, "主题名称不能为空")
return [...title].slice(0, 100).join("")
}
function getOwned(user: User, display: number) {
const r = orNotFound(get(`${CREATION_SELECT} WHERE t.display = $display`, { display }))
if (r.owner_id !== user.id && user.role !== Role.SUPER) {
throw new HttpError(403, "只能操作自己的创作")
}
return r
}
const loadCreation = (id: number) => get(`${CREATION_SELECT} WHERE t.id = $id`, { id })
const creationIn = z.object({ title: z.string(), content: z.string().default("") })
creation.get("/mine", (c) => {
const user = requireUser(c)
const rows = all(`${CREATION_SELECT} WHERE x.owner_id = $id ORDER BY t.created DESC`, {
id: user.id,
})
return c.json(
rows.map((r) => ({
display: r.display,
title: r.title,
content: r.content,
created: iso(r.created),
modified: iso(r.modified),
has_code: Boolean(r.html || r.css || r.js),
})),
)
})
creation.post("/", async (c) => {
const user = requireUser(c)
const p = await body(c, creationIn)
const title = cleanTitle(p.title)
const id = tx(() => {
// 序号在所有创作中自增
const last = get<{ display: number }>(
`SELECT t.display FROM task_task t JOIN task_creation x ON x.task_ptr_id = t.id
ORDER BY t.display DESC LIMIT 1`,
)
const id = insertTask("creation", {
display: (last?.display ?? 0) + 1,
title,
content: p.content,
is_public: false,
})
run(
`INSERT INTO task_creation (task_ptr_id, html, css, js, owner_id) VALUES ($id, '', '', '', $owner)`,
{ id, owner: user.id },
)
return id
})
return c.json(creationOut(loadCreation(id)))
})
creation.get("/:display", (c) => {
const user = requireUser(c)
return c.json(creationOut(getOwned(user, intParam(c, "display"))))
})
creation.put("/:display", async (c) => {
const user = requireUser(c)
const r = getOwned(user, intParam(c, "display"))
const p = await body(c, creationIn)
run(`UPDATE task_task SET title = $title, content = $content, modified = $ts WHERE id = $id`, {
title: cleanTitle(p.title),
content: p.content,
ts: now(),
id: r.id,
})
return c.json(creationOut(loadCreation(r.id)))
})
creation.put("/:display/code", async (c) => {
const user = requireUser(c)
const r = getOwned(user, intParam(c, "display"))
const p = await body(
c,
z.object({
html: z.string().default(""),
css: z.string().default(""),
js: z.string().default(""),
}),
)
tx(() => {
run(`UPDATE task_creation SET html = $html, css = $css, js = $js WHERE task_ptr_id = $id`, {
...p,
id: r.id,
})
run(`UPDATE task_task SET modified = $ts WHERE id = $id`, { ts: now(), id: r.id })
})
return c.json(creationOut(loadCreation(r.id)))
})
creation.delete("/:display", (c) => {
const user = requireUser(c)
const r = getOwned(user, intParam(c, "display"))
tx(() => deleteTask(r.id))
return c.json({ message: "删除成功" })
})
+28
View File
@@ -0,0 +1,28 @@
import { run } from "../db"
// Django 的 on_delete=CASCADE 是在应用层做的,这里手动按依赖顺序删除。
// 调用方负责包在事务里。
export function deleteSubmissions(where: string, params: Record<string, unknown>) {
const ids = `SELECT id FROM submission_submission WHERE ${where}`
run(`DELETE FROM submission_rating WHERE submission_id IN (${ids})`, params)
run(`DELETE FROM submission_submissionaward WHERE submission_id IN (${ids})`, params)
run(`DELETE FROM prompt_message WHERE submission_id IN (${ids})`, params)
run(`DELETE FROM submission_submission WHERE ${where}`, params)
}
export function deleteTask(taskId: number) {
const p = { task_id: taskId }
deleteSubmissions("task_id = $task_id", p)
run(
`DELETE FROM prompt_message WHERE conversation_id IN
(SELECT id FROM prompt_conversation WHERE task_id = $task_id)`,
p,
)
run(`DELETE FROM prompt_conversation WHERE task_id = $task_id`, p)
run(`DELETE FROM task_taskasset WHERE task_id = $task_id`, p)
run(`DELETE FROM task_tutorial WHERE task_ptr_id = $task_id`, p)
run(`DELETE FROM task_challenge WHERE task_ptr_id = $task_id`, p)
run(`DELETE FROM task_creation WHERE task_ptr_id = $task_id`, p)
run(`DELETE FROM task_task WHERE id = $task_id`, p)
}
+79
View File
@@ -0,0 +1,79 @@
import { all, get, run } from "../db"
import { complete, llmConfigured } from "./llm"
const CLASSIFY_SYSTEM_PROMPT = `你是一个教育评估专家。根据布鲁姆认知分类学,分析以下学生在前端学习中发送给AI助手的一条提示词,判断该提示词所体现的认知层级。
层级定义:
- L1 记忆:能背诵HTML标签语法(例:"帮我写一个按钮")
- L2 理解:能解释flex布局原理(例:"为什么这里不居中?")
- L3 应用:能独立搭建页面结构(例:"用flex做导航栏,间距16px")
- L4 分析:能定位跨浏览器兼容性bug(例:"Safari中margin失效,原因?")
- L5 评价:能对比并选择方案(例:"对比Grid与Flex方案优劣")
- L6 创造:能设计并实现原创交互作品(例:"设计夜间/日间切换效果")
只返回一个数字(1-6),不要解释。`
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
async function callLlm(content: string): Promise<number | null> {
if (!llmConfigured()) return null
try {
const text = await complete(
[
{ role: "system", content: CLASSIFY_SYSTEM_PROMPT },
{ role: "user", content },
],
{ maxTokens: 10, timeoutMs: 30_000 },
)
const match = /[1-6]/.exec(text)
if (!match) {
console.warn(`classify: unexpected LLM response '${text}'`)
return null
}
return Number(match[0])
} catch (e) {
console.error("classify LLM call failed:", e)
return null
}
}
export async function classifyMessage(messageId: number): Promise<number | null> {
const msg = get<{ content: string }>(
`SELECT content FROM prompt_message WHERE id = $id AND role = 'user'`,
{ id: messageId },
)
if (!msg) return null
const level = await callLlm(msg.content)
if (level !== null) {
run(`UPDATE prompt_message SET prompt_level = $level WHERE id = $id`, {
level,
id: messageId,
})
}
return level
}
export async function classifyConversationMessages(conversationId: string, force = false) {
const rows = all<{ id: number }>(
`SELECT id FROM prompt_message
WHERE conversation_id = $id AND role = 'user' ${force ? "" : "AND prompt_level IS NULL"}
ORDER BY created, id`,
{ id: conversationId },
)
for (const { id } of rows) {
await classifyMessage(id)
await sleep(300)
}
}
export async function classifyMessagesBatch(ids: number[]) {
for (const id of ids) {
await classifyMessage(id)
await sleep(500)
}
}
// 后台执行,不阻塞请求
export function inBackground(p: Promise<unknown>) {
p.catch((e) => console.error("background task failed:", e))
}
+306
View File
@@ -0,0 +1,306 @@
import { all } from "../db"
import { uuidOut } from "../db"
import { HttpError } from "../http"
import { pyRound, pySum } from "./pyround"
export type GradebookTaskType = "tutorial" | "challenge"
type Grade = "A" | "B" | "C" | "D" | "E"
export interface GradebookFilters {
classname: string
task_type?: GradebookTaskType
username?: string
include_all_tasks?: boolean
}
interface GradebookTask {
id: number
display: number
title: string
task_type: string
submitted_count: number
coverage: number
included: boolean
}
interface GradebookRow {
user_id: number
username: string
classname: string
rank: number
grade: Grade
assessment_grade: Grade
assessment_score: number
top3_score: number
scores: Record<number, { score: number; submitted: boolean; submission_id: string | null }>
tutorial_total: number
challenge_total: number
total_score: number
average_score: number | null
submitted_task_count: number
missing_task_count: number
}
export interface Gradebook {
classname: string
classes: string[]
task_count: number
included_task_count: number
student_count: number
coverage_threshold_count: number
tasks: GradebookTask[]
rows: GradebookRow[]
}
const score = (value: number | null | undefined) => pyRound(value || 0, 2)
// Python 字符串比较(按码点),不能用 localeCompare
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
function gradeForRank(rank: number, n: number): Grade {
if (rank <= Math.ceil(0.3 * n)) return "A"
if (rank <= Math.ceil(0.7 * n)) return "B"
if (rank <= Math.ceil(0.9 * n)) return "C"
if (rank <= Math.ceil(0.95 * n)) return "D"
return "E"
}
// 考核等级:A10% B20% C40% D20% E10%,按最高三次提交均分排名
function assessmentGradeForRank(rank: number, n: number): Grade {
if (rank <= Math.ceil(0.1 * n)) return "A"
if (rank <= Math.ceil(0.3 * n)) return "B"
if (rank <= Math.ceil(0.7 * n)) return "C"
if (rank <= Math.ceil(0.9 * n)) return "D"
return "E"
}
// 考核分数:各等级对应的分数段(高分→低分),段内按名次线性铺开;最高分封顶 99
const ASSESSMENT_SCORE_BANDS: Record<Grade, [number, number]> = {
A: [92, 99],
B: [84, 91],
C: [76, 83],
D: [68, 75],
E: [60, 67],
}
function assessmentScore(indexInGrade: number, gradeSize: number, grade: Grade) {
const [low, high] = ASSESSMENT_SCORE_BANDS[grade]
if (gradeSize <= 1) return high
// 段内第 1 名拿段顶,最后 1 名拿段底
return pyRound(high - (indexInGrade / (gradeSize - 1)) * (high - low))
}
export function buildGradebook(filters: GradebookFilters): Gradebook {
const classname = filters.classname?.trim() ?? ""
if (!classname) throw new HttpError(400, "请选择班级")
const classes = all<{ classname: string }>(
`SELECT DISTINCT classname FROM account_user
WHERE role IN ('normal', 'admin') AND classname != '' ORDER BY classname`,
).map((r) => r.classname)
const students = all<{ id: number; username: string; classname: string }>(
`SELECT id, username, classname FROM account_user
WHERE role IN ('normal', 'admin') AND classname = $classname
ORDER BY username, id`,
{ classname },
)
const studentCount = students.length
const coverageThreshold = studentCount ? Math.ceil(studentCount * 0.5) : 0
const typeFilter = filters.task_type ? `AND t.task_type = $task_type` : ""
const typeParams = filters.task_type ? { task_type: filters.task_type } : {}
const taskRows = all<{ id: number; display: number; title: string; task_type: string; submitted_count: number }>(
`SELECT t.id, t.display, t.title, t.task_type, COUNT(DISTINCT s.user_id) AS submitted_count
FROM submission_submission s
JOIN task_task t ON t.id = s.task_id
JOIN account_user u ON u.id = s.user_id
WHERE u.role IN ('normal', 'admin') AND u.classname = $classname ${typeFilter}
GROUP BY t.id`,
{ classname, ...typeParams },
)
taskRows.sort(
(a, b) =>
(a.task_type === "tutorial" ? 0 : 1) - (b.task_type === "tutorial" ? 0 : 1) ||
a.display - b.display ||
a.id - b.id,
)
const allTasks: GradebookTask[] = taskRows.map((t) => ({
id: t.id,
display: t.display,
title: t.title,
task_type: t.task_type,
submitted_count: t.submitted_count,
coverage: studentCount ? score(t.submitted_count / studentCount) : 0,
included: studentCount > 0 && t.submitted_count >= coverageThreshold,
}))
const tasks = allTasks.filter((t) => filters.include_all_tasks || t.included)
const visibleTaskIds = new Set(tasks.map((t) => t.id))
const includedTaskCount = tasks.filter((t) => t.included).length
// 每个 (学生, 任务) 取最高分的提交;同分取最新
const best = new Map<string, { id: string; score: number }>()
if (studentCount && visibleTaskIds.size) {
const subs = all<{ id: string; user_id: number; task_id: number; score: number }>(
`SELECT s.id, s.user_id, s.task_id, s.score
FROM submission_submission s JOIN account_user u ON u.id = s.user_id
WHERE u.role IN ('normal', 'admin') AND u.classname = $classname
ORDER BY s.user_id, s.task_id, s.score DESC, s.created DESC, s.id DESC`,
{ classname },
)
for (const s of subs) {
if (!visibleTaskIds.has(s.task_id)) continue
const key = `${s.user_id}:${s.task_id}`
if (!best.has(key)) best.set(key, s)
}
}
let rows: GradebookRow[] = students.map((student) => {
const scores: GradebookRow["scores"] = {}
let tutorialTotal = 0
let challengeTotal = 0
const submittedScores: number[] = []
let submittedTaskCount = 0
let missingTaskCount = 0
for (const task of tasks) {
const sub = best.get(`${student.id}:${task.id}`)
const cellScore = sub ? score(sub.score) : 0
scores[task.id] = {
score: cellScore,
submitted: !!sub,
submission_id: sub ? uuidOut(sub.id) : null,
}
if (!task.included) continue
if (sub) {
submittedTaskCount++
submittedScores.push(cellScore)
} else {
missingTaskCount++
}
if (task.task_type === "tutorial") tutorialTotal += cellScore
else challengeTotal += cellScore
}
tutorialTotal = score(tutorialTotal)
challengeTotal = score(challengeTotal)
const top3 = submittedScores.sort((a, b) => b - a).slice(0, 3)
const top3Score = top3.length ? score(pySum(top3) / top3.length) : 0
const totalScore = score(tutorialTotal + challengeTotal)
return {
user_id: student.id,
username: student.username,
classname: student.classname,
rank: 0,
grade: "E",
assessment_grade: "E",
assessment_score: 60,
top3_score: top3Score,
scores,
tutorial_total: tutorialTotal,
challenge_total: challengeTotal,
total_score: totalScore,
average_score: includedTaskCount ? score(totalScore / includedTaskCount) : null,
submitted_task_count: submittedTaskCount,
missing_task_count: missingTaskCount,
}
})
rows.sort((a, b) => b.total_score - a.total_score || cmp(a.username, b.username))
rows.forEach((row, i) => {
row.rank = i + 1
row.grade = gradeForRank(i + 1, studentCount)
})
// 考核等级:按最高三次提交均分单独排名后定级(不改变上面的总分排名)
const assessmentOrder = [...rows].sort(
(a, b) => b.top3_score - a.top3_score || cmp(a.username, b.username),
)
assessmentOrder.forEach((row, i) => {
row.assessment_grade = assessmentGradeForRank(i + 1, studentCount)
})
// 考核分数:在各等级段内按名次线性铺开到对应分数段
const groups = new Map<Grade, GradebookRow[]>()
for (const row of assessmentOrder) {
const g = groups.get(row.assessment_grade) ?? []
g.push(row)
groups.set(row.assessment_grade, g)
}
for (const [grade, group] of groups) {
group.forEach((row, i) => {
row.assessment_score = assessmentScore(i, group.length, grade)
})
}
const username = filters.username?.trim().toLowerCase() ?? ""
if (username) rows = rows.filter((r) => r.username.toLowerCase().includes(username))
return {
classname,
classes,
task_count: tasks.length,
included_task_count: includedTaskCount,
student_count: studentCount,
coverage_threshold_count: coverageThreshold,
tasks,
rows,
}
}
function csvNumber(value: number) {
if (Number.isInteger(value)) return String(value)
return pyRound(value, 2).toFixed(2).replace(/0+$/, "").replace(/\.$/, "")
}
const taskTypeLabel = (t: string) => (t === "tutorial" ? "教程" : "挑战")
export function gradebookCsvRows(gb: Gradebook): (string | number)[][] {
const rows: (string | number)[][] = [
[
"排名",
"平时等级",
"考核等级",
"考核分数",
"用户名",
"班级",
...gb.tasks.map((t) => `${taskTypeLabel(t.task_type)}${t.display}-${t.title}`),
"教程合计",
"挑战合计",
"总分",
"平均分",
"已提交任务数",
"未提交任务数",
],
]
for (const row of gb.rows) {
rows.push([
row.rank,
row.grade,
row.assessment_grade,
row.assessment_score,
row.username,
row.classname,
...gb.tasks.map((t) => csvNumber(row.scores[t.id]!.score)),
csvNumber(row.tutorial_total),
csvNumber(row.challenge_total),
csvNumber(row.total_score),
row.average_score === null ? "" : csvNumber(row.average_score),
row.submitted_task_count,
row.missing_task_count,
])
}
return rows
}
// 与 Python csv 模块默认行为一致:含逗号/引号/换行时加引号,行尾 \r\n
export function toCsv(rows: (string | number)[][]) {
const cell = (v: string | number) => {
const s = String(v)
return /[",\r\n]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s
}
return rows.map((r) => r.map(cell).join(",")).join("\r\n") + "\r\n"
}
+202
View File
@@ -0,0 +1,202 @@
import { config } from "../config"
export const SYSTEM_PROMPT = `你是一个网页生成助手。根据用户的需求描述,生成网页代码。
规则:
1. 使用一个 \`\`\`html 代码块返回所有代码
2. HTML 代码只需要 body 内的内容,不需要完整的 HTML 文档结构
3. CSS 样式写在 <style> 标签内,JavaScript 写在 <script> 标签内,都放在代码块里
4. 用中文回复,先简要说明你做了什么,然后给出代码
5. 在已有代码基础上修改时,返回完整的修改后代码,不要只返回片段
6. 由于任何外部链接都被屏蔽,使用纯 HTML、CSS 和 JS 实现功能,不要依赖外部库
输出格式示例(必须严格遵守,只用一个代码块):
好的,我为你创建了一个点击按钮变色的示例。
\`\`\`html
<style>
button { padding: 8px 16px; }
</style>
<button id="btn">点击我</button>
<script>
document.getElementById('btn').onclick = function() {
this.style.background = 'red';
};
</script>
\`\`\``
export const GUIDANCE_SYSTEM_PROMPT = `你是一个提示词写作教练,帮助学生写出清晰、具体的网页需求描述。
判断标准——满足以下条件视为"够好":
- 有明确主题(例如:登录页、计时器、商品卡片列表)
- 至少包含以下一项具体描述:颜色/布局/风格、交互行为(按钮点击、动画等)、页面内容(文字、数量、图标)
判断为"不够好"的典型情况:
- 目标太泛,如"做一个好看的页面"
- 只有主题,完全没有任何视觉、交互或内容描述
规则:
1. 如果提示词不够好,用 1-2 个启发性问题引导学生补充细节,不要直接给出答案
2. 如果提示词已经够好,以 [READY] 开头回复,简短夸奖学生并说明可以生成了
3. 用中文回复,语气鼓励,简洁明了
4. 使用 Markdown 语法高亮关键词,优先突出 **主题**、**视觉**、**交互**、**内容**、**可以生成** 等重点
5. 如果回复以 [READY] 开头,[READY] 不要加粗,必须保持原始文本
6. 不要生成任何代码`
const DEFAULT_MODEL = "deepseek-v4-flash"
const MODEL_ALIASES: Record<string, string> = {
"deepseek-v4-flash-thinking": DEFAULT_MODEL,
}
const NON_THINKING_MODELS = new Set(["deepseek-v4-flash"])
// 走火山方舟(ARK)接口的模型
const ARK_MODELS = new Set(["doubao-seed-2-0-lite-260215"])
export interface ChatMessage {
role: "system" | "user" | "assistant"
content: string
}
interface CompletionOptions {
model?: string
stream: boolean
maxTokens?: number
signal?: AbortSignal
timeoutMs?: number
}
export const llmConfigured = () => !!config.llmApiKey
function buildRequest(messages: ChatMessage[], opts: CompletionOptions) {
const requested = opts.model || DEFAULT_MODEL
const resolved = MODEL_ALIASES[requested] ?? requested
const ark = ARK_MODELS.has(resolved)
const body: Record<string, unknown> = {
model: resolved,
messages,
stream: opts.stream,
}
if (opts.maxTokens) body.max_tokens = opts.maxTokens
if (NON_THINKING_MODELS.has(resolved) && !(requested in MODEL_ALIASES)) {
body.thinking = { type: "disabled" }
}
const apiKey = ark ? config.arkApiKey : config.llmApiKey
if (!apiKey) throw new Error("未配置模型接口密钥")
const baseUrl = (ark ? config.arkBaseUrl : config.llmBaseUrl).replace(/\/$/, "")
const signals = [AbortSignal.timeout(opts.timeoutMs ?? 120_000)]
if (opts.signal) signals.push(opts.signal)
return fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
signal: AbortSignal.any(signals),
})
}
async function ensureOk(res: Response) {
if (res.ok) return
const text = await res.text().catch(() => "")
throw new Error(`Error code: ${res.status} - ${text.slice(0, 500)}`)
}
// 读取 OpenAI 兼容接口的 SSE 流,逐段产出文本
async function* streamCompletion(
messages: ChatMessage[],
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
}
}
}
export function streamChat(history: ChatMessage[], model?: string, signal?: AbortSignal) {
return streamCompletion([{ role: "system", content: SYSTEM_PROMPT }, ...history], model, signal)
}
export function streamGuidance(history: ChatMessage[], signal?: AbortSignal) {
return streamCompletion(
[{ role: "system", content: GUIDANCE_SYSTEM_PROMPT }, ...history],
undefined,
signal,
)
}
export async function complete(
messages: ChatMessage[],
opts: { model?: string; maxTokens?: number; timeoutMs?: number } = {},
): Promise<string> {
const res = await buildRequest(messages, {
model: opts.model ?? config.llmModel,
stream: false,
maxTokens: opts.maxTokens,
timeoutMs: opts.timeoutMs,
})
await ensureOk(res)
const json: any = await res.json()
return json.choices?.[0]?.message?.content ?? ""
}
export interface ExtractedCode {
html: string | null
css: string | null
js: string | null
}
// 从 AI 回复中提取代码:支持单个 html 代码块(新)或分开的 html/css/js 代码块(旧)
export function extractCode(text: string): ExtractedCode {
const result: ExtractedCode = { html: null, css: null, js: null }
const pattern = /```(html|css|js|javascript|typescript|ts|jsx|tsx)\s*\n([\s\S]*?)```/gi
for (const [, rawLang, code] of text.matchAll(pattern)) {
let lang = rawLang!.toLowerCase()
if (["javascript", "typescript", "ts", "jsx", "tsx"].includes(lang)) lang = "js"
const key = lang as keyof ExtractedCode
if (key in result && result[key] === null) result[key] = code!.trim()
}
// 单个 HTML 代码块:把 <style>/<script> 的内容拆出来,并从 html 中去掉
if (result.html && result.css === null && result.js === null) {
let html = result.html
const style = /<style[^>]*>([\s\S]*?)<\/style>/i.exec(html)
if (style) {
result.css = style[1]!.trim()
html = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
}
const script = /<script[^>]*>([\s\S]*?)<\/script>/i.exec(html)
if (script) {
result.js = script[1]!.trim()
html = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
}
result.html = html.trim()
}
return result
}
export function parseGuidanceResponse(full: string): [string, boolean] {
if (full.startsWith("[READY]")) {
return [full.slice("[READY]".length).replace(/^\n+/, ""), true]
}
return [full, false]
}
+126
View File
@@ -0,0 +1,126 @@
import { all, get, run, now, newUuidHex } from "../db"
import { Role, type User } from "../auth"
export interface MessageRow {
id: number
conversation_id: string
role: string
source: string
content: string
code_html: string | null
code_css: string | null
code_js: string | null
created: string
prompt_level: number | null
submission_id: string | null
}
// 自由创作只有创作者本人(和超管)能对话,其他任务对所有登录用户开放
export function canAccessTask(user: User, taskId: number) {
const row = get<{ owner_id: number }>(
`SELECT owner_id FROM task_creation WHERE task_ptr_id = $id`,
{ id: taskId },
)
if (!row) return true
return row.owner_id === user.id || user.role === Role.SUPER
}
// 当前用户在该任务下消息最多的那个对话
export function getActiveConversation(userId: number, taskId: number) {
return get<{ id: string }>(
`SELECT c.id FROM prompt_conversation c
WHERE c.user_id = $user_id AND c.task_id = $task_id
ORDER BY (SELECT COUNT(*) FROM prompt_message m WHERE m.conversation_id = c.id) DESC,
c.created DESC
LIMIT 1`,
{ user_id: userId, task_id: taskId },
)?.id
}
export function createConversation(userId: number, taskId: number, isActive = true) {
const id = newUuidHex()
const ts = now()
run(
`INSERT INTO prompt_conversation (id, created, modified, is_active, task_id, user_id)
VALUES ($id, $ts, $ts, $active, $task_id, $user_id)`,
{ id, ts, active: isActive, task_id: taskId, user_id: userId },
)
return id
}
export function getOrCreateActiveConversation(userId: number, taskId: number) {
return getActiveConversation(userId, taskId) ?? createConversation(userId, taskId)
}
export function insertMessage(m: {
conversation_id: string
role: string
content: string
source?: string
code_html?: string | null
code_css?: string | null
code_js?: string | null
submission_id?: string | null
}): number {
const res = run(
`INSERT INTO prompt_message
(conversation_id, role, source, content, code_html, code_css, code_js, created, submission_id)
VALUES ($conversation_id, $role, $source, $content, $code_html, $code_css, $code_js, $created, $submission_id)`,
{
conversation_id: m.conversation_id,
role: m.role,
source: m.source ?? "conversation",
content: m.content,
code_html: m.code_html ?? null,
code_css: m.code_css ?? null,
code_js: m.code_js ?? null,
created: now(),
submission_id: m.submission_id ?? null,
},
)
return Number(res.lastInsertRowid)
}
export function getPrecedingUserMessage(asst: Pick<MessageRow, "id" | "conversation_id" | "created">) {
return get<MessageRow>(
`SELECT * FROM prompt_message
WHERE conversation_id = $conv AND role = 'user'
AND (created < $created OR (created = $created AND id < $id))
ORDER BY created DESC, id DESC LIMIT 1`,
{ conv: asst.conversation_id, created: asst.created, id: asst.id },
)
}
export function buildPromptRounds(source: MessageRow) {
let messages = all<MessageRow>(
`SELECT * FROM prompt_message WHERE conversation_id = $conv ORDER BY created, id`,
{ conv: source.conversation_id },
)
let idx = messages.findIndex((m) => m.id === source.id)
if (idx < 0) idx = messages.length - 1
messages = messages.slice(0, idx + 1)
const rounds = []
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]!
if (msg.role !== "user") continue
let reply: MessageRow | undefined
for (const r of messages.slice(i + 1)) {
if (r.role === "user") break
if (r.role === "assistant") {
reply = r
break
}
}
rounds.push({
question: msg.content,
source: msg.source,
prompt_level: msg.prompt_level,
assistant_msg_id: reply?.id ?? null,
html: reply?.code_html ?? null,
css: reply?.code_css ?? null,
js: reply?.code_js ?? null,
})
}
return rounds
}
+27
View File
@@ -0,0 +1,27 @@
/**
* 与 Python 的 round(x, n) 结果一致:按浮点数的精确值舍入,正好在中间时取偶数(银行家舍入)。
* Number#toFixed 同样按精确值舍入,只是“正好在中间”时向上取,所以只需单独处理这种情况。
* 十进制 n 位小数的精确中点必然形如 奇数 / 2^(n+1),据此判断。
*/
export function pyRound(x: number, ndigits = 0): number {
const t = x * 2 ** (ndigits + 1)
if (Number.isInteger(t) && Math.abs(t) % 2 === 1) {
const scale = 10 ** ndigits
const floor = Math.floor(x * scale)
return (floor % 2 === 0 ? floor : floor + 1) / scale
}
return Number(x.toFixed(ndigits))
}
/** 与 Python 3.12+ 的 sum() 一致:对浮点数做 Neumaier 补偿求和 */
export function pySum(values: Iterable<number>): number {
let sum = 0
let c = 0
for (const x of values) {
const t = sum + x
if (Math.abs(sum) >= Math.abs(x)) c += sum - t + x
else c += x - t + sum
sum = t
}
return c && Number.isFinite(c) ? sum + c : sum
}
+51
View File
@@ -0,0 +1,51 @@
import { all, get, run } from "../db"
import { Role } from "../auth"
import { pySum } from "./pyround"
const ROLE_WEIGHT: Record<string, number> = {
[Role.SUPER]: 0.5,
[Role.ADMIN]: 0.3,
}
function zoneFor(n: number, score: number): string | null {
if (n < 5) return "pending"
if (score >= 4.0) return "featured"
if (score < 3.0) return "low"
return null
}
/**
* 重新计算提交的分数:按角色加权平均得到 raw_score,
* 再用贝叶斯平均(C=3,全站平均分做先验)平滑得到 score,并更新分区。
*/
export function updateSubmissionScore(submissionId: string) {
const ratings = all<{ score: number; role: string }>(
`SELECT r.score, u.role FROM submission_rating r
JOIN account_user u ON u.id = r.user_id
WHERE r.submission_id = $id ORDER BY r.id`,
{ id: submissionId },
)
const n = ratings.length
let rawScore = 0
let score = 0
if (n > 0) {
const weights = ratings.map((r) => ROLE_WEIGHT[r.role] ?? 0.2)
rawScore = pySum(ratings.map((r, i) => r.score * weights[i]!)) / pySum(weights)
const C = 3
const mean = get<{ avg: number | null }>(
`SELECT AVG(raw_score) AS avg FROM submission_submission
WHERE raw_score > 0 AND id != $id`,
{ id: submissionId },
)?.avg
const globalMean = mean || rawScore
score = (C * globalMean + n * rawScore) / (C + n)
}
run(
`UPDATE submission_submission
SET raw_score = $raw, score = $score, zone = $zone WHERE id = $id`,
{ raw: rawScore, score, zone: zoneFor(n, score), id: submissionId },
)
}
+225
View File
@@ -0,0 +1,225 @@
import type { Server, ServerWebSocket, WebSocketHandler } from "bun"
import { config } from "./config"
import { all, run, iso, uuidOut } from "./db"
import { parseCookie, userFromSessionKey, type User } from "./auth"
import { canAccessTask, getOrCreateActiveConversation, insertMessage } from "./services/prompt"
import {
extractCode,
parseGuidanceResponse,
streamChat,
streamGuidance,
type ChatMessage,
} from "./services/llm"
import { isAllowedOrigin } from "./origin"
type Kind = "prompt" | "guidance"
export interface WsData {
kind: Kind
user: User
taskId: number
conversationId: string
// 正在等待 AI 回复的用户消息;对话中断时删掉,避免留下没有回复的提问
pendingUserMessageId: number | null
abort: AbortController
closed: boolean
// 串行处理消息(与 Django Channels 的行为一致)
queue: Promise<void>
// 引导对话只在本次连接内保留上下文
guidanceHistory: ChatMessage[]
}
type WS = ServerWebSocket<WsData>
const PATH_RE = /^\/ws\/(prompt|guidance)\/(\d+)\/?$/
// 处理 WebSocket 升级请求;返回 undefined 表示已升级,返回 Response 表示拒绝或不是 ws 路径
export function upgrade(req: Request, server: Server<WsData>): Response | undefined | null {
const url = new URL(req.url)
const match = PATH_RE.exec(url.pathname)
if (!match) return null
if (!isAllowedOrigin(req.headers.get("origin"), req.headers.get("host"))) {
return new Response("Forbidden", { status: 403 })
}
const user = userFromSessionKey(parseCookie(req.headers.get("cookie"), config.sessionCookie))
const taskId = Number(match[2])
if (!user || !canAccessTask(user, taskId)) return new Response("Forbidden", { status: 403 })
const ok = server.upgrade(req, {
data: {
kind: match[1] as Kind,
user,
taskId,
conversationId: "",
pendingUserMessageId: null,
abort: new AbortController(),
closed: false,
queue: Promise.resolve(),
guidanceHistory: [],
},
})
return ok ? undefined : new Response("Upgrade failed", { status: 400 })
}
function send(ws: WS, data: unknown) {
if (!ws.data.closed) ws.send(JSON.stringify(data))
}
function dropPendingUserMessage(ws: WS) {
if (ws.data.pendingUserMessageId !== null) {
run(`DELETE FROM prompt_message WHERE id = $id`, { id: ws.data.pendingUserMessageId })
ws.data.pendingUserMessageId = null
}
}
function conversationHistory(conversationId: string) {
return all<{
id: number
role: string
content: string
code_html: string | null
code_css: string | null
code_js: string | null
submission_id: string | null
created: string
}>(
`SELECT id, role, content, code_html, code_css, code_js, submission_id, created
FROM prompt_message WHERE conversation_id = $id AND source = 'conversation'
ORDER BY created, id`,
{ id: conversationId },
)
}
async function streamToClient(ws: WS, chunks: AsyncIterable<string>) {
let full = ""
for await (const chunk of chunks) {
full += chunk
send(ws, { type: "stream", content: chunk })
}
return full
}
async function handlePrompt(ws: WS, data: any) {
if ((data.type ?? "message") === "new_conversation") return
const prompt = String(data.content ?? "").trim()
const model = String(data.model ?? "")
if (!prompt) return
ws.data.pendingUserMessageId = insertMessage({
conversation_id: ws.data.conversationId,
role: "user",
content: prompt,
})
try {
const history: ChatMessage[] = conversationHistory(ws.data.conversationId).map((m) => ({
role: m.role as ChatMessage["role"],
content: m.content,
}))
let full: string
try {
full = await streamToClient(ws, streamChat(history, model, ws.data.abort.signal))
} catch (e) {
send(ws, { type: "error", content: `AI 服务出错:${e instanceof Error ? e.message : e}` })
return
}
if (ws.data.closed) return
const code = extractCode(full)
const messageId = insertMessage({
conversation_id: ws.data.conversationId,
role: "assistant",
content: full,
code_html: code.html,
code_css: code.css,
code_js: code.js,
})
ws.data.pendingUserMessageId = null
send(ws, { type: "complete", code, message_id: messageId })
} finally {
dropPendingUserMessage(ws)
}
}
async function handleGuidance(ws: WS, data: any) {
const prompt = String(data.content ?? "").trim()
if (!prompt) return
ws.data.pendingUserMessageId = insertMessage({
conversation_id: ws.data.conversationId,
role: "user",
content: prompt,
source: "guidance",
})
ws.data.guidanceHistory.push({ role: "user", content: prompt })
try {
let full: string
try {
full = await streamToClient(ws, streamGuidance(ws.data.guidanceHistory, ws.data.abort.signal))
} catch (e) {
send(ws, { type: "error", content: `AI 服务出错:${e instanceof Error ? e.message : e}` })
return
}
if (ws.data.closed) return
const [clean, isReady] = parseGuidanceResponse(full)
ws.data.guidanceHistory.push({ role: "assistant", content: clean })
const messageId = insertMessage({
conversation_id: ws.data.conversationId,
role: "assistant",
content: clean,
source: "guidance",
})
ws.data.pendingUserMessageId = null
send(ws, { type: "complete", message_id: messageId, is_ready: isReady })
} finally {
dropPendingUserMessage(ws)
}
}
export const websocket: WebSocketHandler<WsData> = {
// AI 生成可能要一两分钟,期间没有客户端消息
idleTimeout: 960,
sendPings: true,
open(ws) {
const d = ws.data
d.conversationId = getOrCreateActiveConversation(d.user.id, d.taskId)
if (d.kind === "guidance") {
send(ws, { type: "init", messages: [] })
return
}
send(ws, {
type: "init",
conversation_id: uuidOut(d.conversationId),
messages: conversationHistory(d.conversationId).map((m) => ({
id: m.id,
role: m.role,
content: m.content,
code:
m.role === "assistant" ? { html: m.code_html, css: m.code_css, js: m.code_js } : null,
submitted: m.role === "assistant" ? m.submission_id !== null : null,
created: iso(m.created),
})),
})
},
message(ws, raw) {
let data: any
try {
data = JSON.parse(String(raw))
} catch {
return
}
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))
},
close(ws) {
ws.data.closed = true
// 客户端断开时停止向模型请求,未完成的提问会在 finally 中删除
ws.data.abort.abort()
dropPendingUserMessage(ws)
},
}