Files
web/CLAUDE.md
T
xuyueandClaude Opus 5.5 26827e31d5
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
feat: 登录改用 JWT(access token + refresh token),不再用 cookie
- 登录返回 15 分钟的 access token(HS256,密钥 JWT_SECRET,未配置则每次启动随机)
  和 14 天的 refresh token;请求带 Authorization: Bearer,WebSocket 用 ?token=
- refresh token 只存 sha256,放在新表 auth_refresh_token(删除旧的 auth_session);
  POST /account/refresh 换新的 access token,退出、改密码、封号时作废
- 带了无效或过期的 token 一律 401,前端据此刷新:快过期时提前刷新、并发请求只刷新一次,
  401 时刷新后重试一次,refresh token 也失效才退出登录
- 不再用 cookie,去掉 CORS credentials 和写请求的 Origin 校验

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:10:55 +08:00

8.7 KiB
Raw Blame History

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 has a Bun backend (server/) and a Vue 3 frontend (client/) in one repository, 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 to ws.ts, everything else to the Hono app
    • src/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/tx helpers using $name params, time (now(), iso()) and UUID (uuidHex(), uuidOut()) conversions
    • src/auth.ts — Django-compatible password hashing; JWT login, no cookies: POST /account/login returns a 15-min HS256 access_token (sent as Authorization: Bearer, signed with JWT_SECRET, random per process if unset) and a 14-day opaque refresh_token (only its sha256 is stored, in auth_refresh_token; POST /account/refresh trades 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), prompt
    • src/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/pySum reproduce Python's round() and sum() 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); messages per connection are processed serially; on disconnect the pending user message is deleted and the LLM request aborted
    • scripts/ — one-off CLIs: migrate-from-postgres.ts, create-superuser.ts, classify-prompts.ts, recalculate-scores.ts
    • Roles: super, admin, normal. Task model is multi-table: task_task + one of task_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-legacy only 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 URLs
    • src/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 home
    • src/router.ts — Vue Router with auth guards
    • src/store/ — Reactive state (plain Vue refs, not Vuex/Pinia); prompt.ts / guidance.ts own the WebSockets
    • src/pages/, src/components/ — user management uses components/dashboard/UserFormModal.vue (there is no Django Admin anymore)
  • Caddyfile — serves SPA from build/ and /media, proxies /api* and /ws* to the backend

  • docker-compose.yaml — webpreview-web (Caddy) and webpreview-api (Bun); webpreview-db (Postgres) exists only under the migrate profile for the one-time data migration

  • .github/workflows/deploy.yaml — on push to main: build client with Bun, rsync to server build/, then git pull && docker compose up -d --build on the server

Development Commands

./dev.sh                 # backend (bun --watch) + frontend together

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 install          # Install deps (bun.lock)
bun run start        # Dev server (Vite, run on the Bun runtime) on :3000
bun run build        # Production build (bun run build:staging for the school server)
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)

docker compose up -d --build
docker compose run --rm webpreview-api bun scripts/create-superuser.ts <username> <password>

Key Technical Details

  • Schema changes: there is no migration framework. Edit the DDL in src/db.ts for new tables; for altering existing tables add an idempotent step guarded by PRAGMA user_version
  • SQL style: raw SQL with named params ($id), strict: true so params are passed without the $ prefix ({ id }). Wrap multi-statement writes in tx(). Deleting tasks/submissions must go through services/cascade.ts
  • Queries block the whole server: bun:sqlite is synchronous in a single process, so a slow query stalls every HTTP request and WebSocket. Request-path queries must take milliseconds; batch work goes in scripts/. Never await inside tx() (it would commit early). Code with no await runs atomically, so read-check-write needs no locking; races only happen across an await
  • Wide rows: unlike Postgres (TOAST), SQLite keeps html/css/js and prompt_message.content/code_* inline, and Django's column order puts user_id, task_id, flag, zone, raw_score after 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 (select rowids with ORDER BY ... LIMIT), then fetch full rows for just that page (see GET /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_submission must be covered by submission_submission_slim; compare rowid rather than id (id is a separate index). The planner only has stats via PRAGMA optimize (run on open and hourly), and may pick a poor single-column index — check new queries with EXPLAIN QUERY PLAN (no SCAN of big tables, ideally COVERING INDEX); a unary + (+s.zone = $zone) stops a column's index from being used. CREATE INDEX IF NOT EXISTS matches by name only, so a changed index needs a new name (drop the old one in a user_version step)
  • Types: booleans are 0/1; timestamps are text compared as strings, so only write values from now() (never CURRENT_TIMESTAMP / datetime('now')); text sorts by code point; ORDER BY ... ASC puts NULLs first (Postgres: last), so add NULLS LAST where it matters; no ILIKE / 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 take page / page_size (default 10, max 100) and return { items, count }
  • Task display field: integer used as URL slug for tutorials/challenges/creations (unique per task type), not the DB id
  • 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