fix: 修复注册和上传素材在 await 前后的并发竞态;补充 SQLite 写法说明
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
- 注册 / 新建用户 / 批量建用户:先算密码哈希,再同步地查重和插入, 同名并发请求返回 400 而不是撞唯一约束变成 500 - 上传任务素材改用 INSERT ... ON CONFLICT DO UPDATE - CLAUDE.md 补充同步阻塞、行内大文本列、索引、类型差异等注意事项 - 忽略根目录的 db.sqlite3(线上数据备份) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e6a3da14b0
commit
ceba1c68f2
4 files changed
+17
-12
No files matched your search
@@ -5,6 +5,7 @@
|
|||||||
/media/
|
/media/
|
||||||
/redis/
|
/redis/
|
||||||
/sqlite/
|
/sqlite/
|
||||||
|
/db.sqlite3*
|
||||||
|
|
||||||
# 本地工具目录
|
# 本地工具目录
|
||||||
.agents/
|
.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`
|
- **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`
|
- **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 }`
|
- **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`
|
- **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
|
- **Submission / conversation IDs**: UUIDs in the API, 32-char hex in the DB
|
||||||
|
|||||||
@@ -30,12 +30,15 @@ async function insertUser(u: {
|
|||||||
classname?: string
|
classname?: string
|
||||||
email?: string
|
email?: string
|
||||||
}) {
|
}) {
|
||||||
|
// 先算哈希(异步),再同步地查重 + 插入:中间没有 await,并发注册同名不会撞唯一约束变成 500
|
||||||
|
const password = await makePassword(u.password)
|
||||||
|
if (userExists(u.username)) throw new HttpError(400, "用户已存在")
|
||||||
const res = run(
|
const res = run(
|
||||||
`INSERT INTO account_user
|
`INSERT INTO account_user
|
||||||
(password, last_login, is_superuser, username, email, is_staff, is_active, date_joined, role, raw_password, classname)
|
(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)`,
|
VALUES ($password, NULL, 0, $username, $email, 0, 1, $now, $role, $raw, $classname)`,
|
||||||
{
|
{
|
||||||
password: await makePassword(u.password),
|
password,
|
||||||
username: u.username,
|
username: u.username,
|
||||||
email: u.email ?? "",
|
email: u.email ?? "",
|
||||||
now: now(),
|
now: now(),
|
||||||
@@ -59,7 +62,6 @@ router.post("/register", async (c) => {
|
|||||||
password: z.string().min(6).max(20),
|
password: z.string().min(6).max(20),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (userExists(payload.username)) throw new HttpError(400, "用户已存在")
|
|
||||||
await insertUser(payload)
|
await insertUser(payload)
|
||||||
return c.json({ message: "创建成功" })
|
return c.json({ message: "创建成功" })
|
||||||
})
|
})
|
||||||
@@ -148,6 +150,8 @@ router.post("/batch", async (c) => {
|
|||||||
)
|
)
|
||||||
const ts = now()
|
const ts = now()
|
||||||
tx(() => {
|
tx(() => {
|
||||||
|
// 算哈希期间可能有人建了同名用户,插入前在同一个同步事务里再查一次
|
||||||
|
if (usernames.some(userExists)) throw new HttpError(400, "有些用户已经存在,创建失败")
|
||||||
for (const u of users) {
|
for (const u of users) {
|
||||||
run(
|
run(
|
||||||
`INSERT INTO account_user
|
`INSERT INTO account_user
|
||||||
@@ -172,7 +176,6 @@ router.post("/create", async (c) => {
|
|||||||
classname: z.string().trim().max(50).default(""),
|
classname: z.string().trim().max(50).default(""),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (userExists(payload.username)) throw new HttpError(400, "用户已存在")
|
|
||||||
const id = await insertUser(payload)
|
const id = await insertUser(payload)
|
||||||
return c.json({ id, message: "创建成功" })
|
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)}`,
|
`tasks/${task.task_type}/${task.display}/${validFilename(name)}`,
|
||||||
file,
|
file,
|
||||||
)
|
)
|
||||||
if (existing) {
|
// 上面 await 期间可能有同名上传先写入了,用 upsert 而不是按 existing 选 INSERT / UPDATE
|
||||||
run(`UPDATE task_taskasset SET file = $file WHERE id = $id`, { file: stored, id: existing.id })
|
run(
|
||||||
} else {
|
`INSERT INTO task_taskasset (name, file, task_id) VALUES ($name, $file, $task_id)
|
||||||
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,
|
{ name, file: stored, task_id: task.id },
|
||||||
file: stored,
|
)
|
||||||
task_id: task.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return c.json(assetOut({ name, file: stored }))
|
return c.json(assetOut({ name, file: stored }))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user