Compare commits
2
Commits
4b8109caaa
...
ceba1c68f2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ceba1c68f2 | ||
|
|
e6a3da14b0 |
No files matched your search
@@ -5,6 +5,7 @@
|
||||
/media/
|
||||
/redis/
|
||||
/sqlite/
|
||||
/db.sqlite3*
|
||||
|
||||
# 本地工具目录
|
||||
.agents/
|
||||
|
||||
@@ -67,6 +67,10 @@ docker compose run --rm webpreview-api bun scripts/create-superuser.ts <username
|
||||
|
||||
- **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
|
||||
|
||||
@@ -25,6 +25,9 @@ CREATE INDEX IF NOT EXISTS "submission_submission_task_id_91e9556b" ON "submissi
|
||||
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");
|
||||
-- 非 Django 原有:除 html/css/js 外的常用列。user_id 等列在表里排在 html/css/js 后面,
|
||||
-- 直接读表要先读完整段代码,所以列表、打分、统计等扫描都靠这个覆盖索引
|
||||
CREATE INDEX IF NOT EXISTS "submission_submission_slim" ON "submission_submission" ("user_id", "task_id", "created", "score", "flag", "zone", "raw_score", "id");
|
||||
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");
|
||||
@@ -57,6 +60,8 @@ export function openDb(path: string): Database {
|
||||
db.run("PRAGMA busy_timeout = 5000")
|
||||
db.run("PRAGMA foreign_keys = ON")
|
||||
db.run(SCHEMA)
|
||||
// SQLite 不会自动 ANALYZE,没有统计信息时规划器常挑错索引;0x10002 = 打开时只分析必要的表
|
||||
db.run("PRAGMA optimize=0x10002")
|
||||
return db
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { app } from "./app"
|
||||
import { config } from "./config"
|
||||
import { db } from "./db"
|
||||
import { upgrade, websocket, type WsData } from "./ws"
|
||||
|
||||
const server = Bun.serve<WsData>({
|
||||
@@ -16,4 +17,7 @@ const server = Bun.serve<WsData>({
|
||||
websocket,
|
||||
})
|
||||
|
||||
// 长期运行的连接按 SQLite 文档建议定期刷新统计信息
|
||||
setInterval(() => db.run("PRAGMA optimize"), 60 * 60 * 1000)
|
||||
|
||||
console.log(`webpreview server listening on http://localhost:${server.port}`)
|
||||
@@ -30,12 +30,15 @@ async function insertUser(u: {
|
||||
classname?: string
|
||||
email?: string
|
||||
}) {
|
||||
// 先算哈希(异步),再同步地查重 + 插入:中间没有 await,并发注册同名不会撞唯一约束变成 500
|
||||
const password = await makePassword(u.password)
|
||||
if (userExists(u.username)) throw new HttpError(400, "用户已存在")
|
||||
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),
|
||||
password,
|
||||
username: u.username,
|
||||
email: u.email ?? "",
|
||||
now: now(),
|
||||
@@ -59,7 +62,6 @@ router.post("/register", async (c) => {
|
||||
password: z.string().min(6).max(20),
|
||||
}),
|
||||
)
|
||||
if (userExists(payload.username)) throw new HttpError(400, "用户已存在")
|
||||
await insertUser(payload)
|
||||
return c.json({ message: "创建成功" })
|
||||
})
|
||||
@@ -148,6 +150,8 @@ router.post("/batch", async (c) => {
|
||||
)
|
||||
const ts = now()
|
||||
tx(() => {
|
||||
// 算哈希期间可能有人建了同名用户,插入前在同一个同步事务里再查一次
|
||||
if (usernames.some(userExists)) throw new HttpError(400, "有些用户已经存在,创建失败")
|
||||
for (const u of users) {
|
||||
run(
|
||||
`INSERT INTO account_user
|
||||
@@ -172,7 +176,6 @@ router.post("/create", async (c) => {
|
||||
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: "创建成功" })
|
||||
})
|
||||
|
||||
@@ -96,15 +96,12 @@ for (const kind of ["challenge", "tutorial"] as const) {
|
||||
`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,
|
||||
})
|
||||
}
|
||||
// 上面 await 期间可能有同名上传先写入了,用 upsert 而不是按 existing 选 INSERT / UPDATE
|
||||
run(
|
||||
`INSERT INTO task_taskasset (name, file, task_id) VALUES ($name, $file, $task_id)
|
||||
ON CONFLICT (task_id, name) DO UPDATE SET file = excluded.file`,
|
||||
{ name, file: stored, task_id: task.id },
|
||||
)
|
||||
return c.json(assetOut({ name, file: stored }))
|
||||
})
|
||||
|
||||
|
||||
@@ -234,13 +234,14 @@ router.get("/", (c) => {
|
||||
where.push("s.user_id = $user_id")
|
||||
params.user_id = f.user_id
|
||||
}
|
||||
if (f.flag === "any") where.push("s.flag IS NOT NULL")
|
||||
// flag / zone 前的一元 + 让 SQLite 不用这两列的单列索引(那样要回表读大行),改走 slim 覆盖索引
|
||||
if (f.flag === "any") where.push("+s.flag IS NOT NULL")
|
||||
else if (f.flag) {
|
||||
where.push("s.flag = $flag")
|
||||
where.push("+s.flag = $flag")
|
||||
params.flag = f.flag
|
||||
}
|
||||
if (f.zone) {
|
||||
where.push("s.zone = $zone")
|
||||
where.push("+s.zone = $zone")
|
||||
params.zone = f.zone
|
||||
}
|
||||
if (f.score_lt_threshold !== undefined) {
|
||||
@@ -258,7 +259,8 @@ router.get("/", (c) => {
|
||||
}
|
||||
if (f.grouped) {
|
||||
// 分组模式:每个 (user, task) 只保留最新一条
|
||||
where.push(`s.id = (SELECT s3.id FROM submission_submission s3
|
||||
// 用 rowid 而不是 id,这样子查询能完全走 submission_submission_slim 覆盖索引
|
||||
where.push(`s.rowid = (SELECT s3.rowid 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)`)
|
||||
}
|
||||
|
||||
@@ -271,12 +273,16 @@ router.get("/", (c) => {
|
||||
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""
|
||||
const { limit, offset } = pageWindow(f)
|
||||
|
||||
// 先只在索引上排序分页拿到这一页的 rowid,再读这几行的完整内容;
|
||||
// 否则排序时要把每条提交的 html/css/js 大行都读一遍
|
||||
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`,
|
||||
${SUBMISSION_FROM}
|
||||
WHERE s.rowid IN (SELECT s.rowid ${SUBMISSION_FROM} ${whereSql}
|
||||
ORDER BY ${orderBy} LIMIT $limit OFFSET $offset)
|
||||
ORDER BY ${orderBy}`,
|
||||
{ ...params, me: user.id, limit, offset },
|
||||
)
|
||||
const count = get<{ n: number }>(`SELECT COUNT(*) AS n ${SUBMISSION_FROM} ${whereSql}`, params)!.n
|
||||
@@ -305,20 +311,25 @@ router.get("/random-for-rating", (c) => {
|
||||
}
|
||||
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}
|
||||
// 只在索引上随机挑出 rowid,再读这一行的代码;否则每个候选都要读一遍 html/css/js
|
||||
const base = `SELECT s.rowid AS rid FROM submission_submission s
|
||||
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(
|
||||
const picked =
|
||||
get<{ rid: number }>(
|
||||
`${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)
|
||||
) ?? get<{ rid: number }>(`${base} ORDER BY RANDOM() LIMIT 1`, params)
|
||||
if (!picked) return c.json(null)
|
||||
const pick = get(
|
||||
`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.rowid = $rid`,
|
||||
{ rid: picked.rid },
|
||||
)!
|
||||
return c.json({
|
||||
submission_id: uuidOut(pick.id),
|
||||
username: pick.username,
|
||||
|
||||
Reference in new issue
Block a user