Deploy / deploy (build, debian, 22) (push) Canceled after 0s
- 登录返回 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>
82 lines
8.7 KiB
Markdown
82 lines
8.7 KiB
Markdown
# 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
|
||
|
||
```bash
|
||
./dev.sh # backend (bun --watch) + frontend together
|
||
```
|
||
|
||
### Server (from `server/`)
|
||
```bash
|
||
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/`)
|
||
```bash
|
||
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)
|
||
```bash
|
||
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 `rowid`s 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
|