- prettier 点“整理”时才加载,编辑器页面按路由懒加载:首屏 JS 2345 KB → 766 KB - 随机评分先随机抽 64 个 rowid 挑选,抽不中再退回全表随机:6 万条提交时 50ms → 1ms - WebSocket 消息最大 64 KB;AI 回复中再发来的消息直接拒绝,不再排队 - 同一用户名 10 分钟失败 10 次、同一 IP 失败 100 次后暂停登录(返回 429,登录框显示提示) - 作品展的 iframe 加 loading="lazy" - 新增 ws.test.ts(正常生成、回复中再发消息、中途断开、握手失败)和随机评分、登录限流的测试 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9.9 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
WebPreview is a web-based tutorial/challenge platform where users view tutorials and submit HTML/CSS/JS code for challenges. It is a Bun workspaces monorepo: a Bun backend (server/, package webpreview-server) and a Vue 3 frontend (client/, package webpreview-client) share one root package.json and one bun.lock, deployed via Docker Compose with Caddy as reverse proxy.
The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1–2 GB). It keeps Django's exact database schema (table names, column types, UTC YYYY-MM-DD HH:MM:SS.ffffff timestamps, 32-char hex UUIDs, pbkdf2_sha256$... password hashes) so old data loads unchanged. The Django code is in git history before that change.
Architecture
-
server/— Bun + Hono HTTP API, Bun's native WebSocket,bun:sqlite(single SQLite file, WAL). No Redis, no Postgres.src/index.ts—Bun.serve: WebSocket upgrades go tows.ts, everything else to the Hono appsrc/app.ts— mounts routers under/api/<prefix>, CORS, auth middleware, error →{ detail }JSON (ninja-compatible; validation errors are 422)src/db.ts— schema (CREATE TABLE IF NOT EXISTS, same DDL as Django),all/get/run/txhelpers using$nameparams, time (now(),iso()) and UUID (uuidHex(),uuidOut()) conversionssrc/auth.ts— Django-compatible password hashing; JWT login, no cookies:POST /account/loginreturns a 15-min HS256access_token(sent asAuthorization: Bearer, signed withJWT_SECRET, random per process if unset) and a 14-day opaquerefresh_token(only its sha256 is stored, inauth_refresh_token;POST /account/refreshtrades it for a new access token, logout / password change / deactivation delete it). An invalid or expired Bearer token is a 401 even on public endpoints so the client knows to refresh.requireUser/requireAdmin/requireSuper(401 / 403)src/routes/—account,tasks(tutorial / challenge / creation),files(task assets + image upload),submission,showcase(mounted under/submission/showcase),promptsrc/services/—score.ts(rating weights super×0.5, admin×0.3, normal×0.2, then Bayesian smoothing with C=3 and zones),gradebook.ts,llm.ts(OpenAI-compatible streaming via fetch; DeepSeek default, ARK for doubao models),classifier.ts(Bloom-level L1–L6 prompt tagging, runs in background),cascade.ts(Django's CASCADE deletes, done by hand),pyround.ts(pyRound/pySumreproduce Python'sround()andsum()so scores match the old system bit-for-bit)src/ws.ts—/ws/prompt/<task_id>/?token=<access_token>(code generation chat) and/ws/guidance/<task_id>/?token=...(prompt coaching); one message per connection at a time (a message sent while the AI is still replying gets an error, payloads capped at 64 KB); on disconnect the pending user message is deleted and the LLM request abortedscripts/— one-off CLIs:create-superuser.ts,classify-prompts.ts,recalculate-scores.ts- Roles:
super,admin,normal. Task model is multi-table:task_task+ one oftask_tutorial/task_challenge/task_creation(task_ptr_id)
-
client/— Vue 3 + TypeScript SPA built with Vite; Bun is the package manager and runtime (no Node/npm)vite.config.ts— browser targets are Chrome ≥ 87 (lab machines run Chrome 105);@vitejs/plugin-legacyonly injects usage-based core-js polyfills, no legacy chunks- UI framework: Naive UI (auto-imported via
unplugin-vue-components) src/token.ts— tokens in localStorage; refreshes the access token shortly before expiry (one request at a time) and builds WebSocket URLssrc/api.ts— Axios-based API client; adds the Bearer header, on 401 refreshes once and retries, otherwise clears login and goes home; 403 alerts and goes homesrc/router.ts— Vue Router with auth guardssrc/store/— Reactive state (plain Vue refs, not Vuex/Pinia);prompt.ts/guidance.tsown the WebSocketssrc/pages/,src/components/— user management usescomponents/dashboard/UserFormModal.vue(there is no Django Admin anymore)
-
Caddyfile— serves SPA frombuild/and/media, proxies/api*and/ws*to the backend -
docker-compose.yaml—webpreview-web(Caddy) andwebpreview-api(Bun); the API reads the repo-root.envviaenv_file(server/.envis only for local dev). The one-time Postgres → SQLite migration (script,webpreview-dbservice,docs/migrate-to-bun.md) was removed after commitcdeb7c1; restore it from there if another server still needs the cutover -
.github/workflows/deploy.yaml— on push tomain: build client with Bun, rsync to serverbuild/, thengit pull && docker compose up -d --buildon the server
Development Commands
From the repo root (each script runs inside its workspace, via bun run --filter):
bun install # installs both workspaces into the single root bun.lock
bun run dev # backend (bun --watch, :8000) + frontend (Vite, :3000) together
bun run build # client production build (build:staging for the school server)
bun run test # server tests
bun run typecheck # server (tsc 7) + client (vue-tsc) typecheck
bun run fmt # format the client
Add a dependency from inside its workspace (cd client && bun add x); the lockfile is still the root one.
Server (from server/)
bun run dev # dev server on :8000 with auto-reload
bun test # API + unit tests (in-memory SQLite, LLM disabled)
bun run typecheck
bun scripts/create-superuser.ts <username> <password>
bun scripts/classify-prompts.ts [--task-id N] [--force] [--dry-run]
bun scripts/recalculate-scores.ts [--dry-run]
Config comes from server/.env (see server/.env.example); dev DB is server/data/db.sqlite3, uploads go to the repo-root media/.
Client (from client/)
bun run dev # Dev server (Vite, run on the Bun runtime) on :3000
bun run build # Production build (bun run build:staging for the school server)
bun run typecheck # vue-tsc; the client pins TypeScript 5.9 because vue-tsc can't run on TS 7 (the server uses TS 7)
bun fmt # Format with Bun's built-in formatter (config in bunfig.toml)
Frontend env vars used at build time: PUBLIC_BASE_URL, PUBLIC_WS_URL, PUBLIC_WEB_URL (dev proxy target for /media and /ws).
Docker (from project root)
server/Dockerfile builds with the repo root as context (so it can use the root bun.lock) and installs only the server workspace; .dockerignore is an allowlist, so new top-level server files must be added there.
docker compose up -d --build
docker compose run --rm webpreview-api bun scripts/create-superuser.ts <username> <password>
Back up production by copying sqlite/ (or sqlite3 sqlite/db.sqlite3 ".backup backup.sqlite3").
Key Technical Details
- Schema changes: there is no migration framework. Edit the DDL in
src/db.tsfor new tables; for altering existing tables add an idempotent step guarded byPRAGMA user_version - SQL style: raw SQL with named params (
$id),strict: trueso params are passed without the$prefix ({ id }). Wrap multi-statement writes intx(). Deleting tasks/submissions must go throughservices/cascade.ts - Queries block the whole server:
bun:sqliteis synchronous in a single process, so a slow query stalls every HTTP request and WebSocket. Request-path queries must take milliseconds; batch work goes inscripts/. Neverawaitinsidetx()(it would commit early). Code with noawaitruns atomically, so read-check-write needs no locking; races only happen across anawait - Wide rows: unlike Postgres (TOAST), SQLite keeps
html/css/jsandprompt_message.content/code_*inline, and Django's column order putsuser_id,task_id,flag,zone,raw_scoreafter them, so reading those columns from the table reads the whole code. List/scan queries must not select the big columns: page on indexes first (selectrowids withORDER BY ... LIMIT), then fetch full rows for just that page (seeGET /submission/,random-for-rating) - Indexes: SQLite uses one index per table for AND conditions (no BitmapAnd), so multi-column filters need a composite index (equality columns first, then range/sort). Hot scans on
submission_submissionmust be covered bysubmission_submission_slim; comparerowidrather thanid(idis a separate index). The planner only has stats viaPRAGMA optimize(run on open and hourly), and may pick a poor single-column index — check new queries withEXPLAIN QUERY PLAN(noSCANof big tables, ideallyCOVERING INDEX); a unary+(+s.zone = $zone) stops a column's index from being used.CREATE INDEX IF NOT EXISTSmatches by name only, so a changed index needs a new name (drop the old one in auser_versionstep) - Types: booleans are
0/1; timestamps are text compared as strings, so only write values fromnow()(neverCURRENT_TIMESTAMP/datetime('now')); text sorts by code point;ORDER BY ... ASCputs NULLs first (Postgres: last), so addNULLS LASTwhere it matters; noILIKE/DISTINCT ON - API compatibility: response shapes, status codes and trailing-slash tolerance (
strict: false) mirror the old django-ninja API, so the client needed no changes besides user management. Paginated endpoints takepage/page_size(default 10, max 100) and return{ items, count } - Task
displayfield: integer used as URL slug for tutorials/challenges/creations (unique per task type), not the DBid - Submission / conversation IDs: UUIDs in the API, 32-char hex in the DB
- Language/locale is Chinese; timestamps are stored in UTC and returned as ISO strings ending in
Z - Client build output is served by Caddy from
build/in production