chore: 数据已迁到 SQLite,删掉 Postgres 迁移脚本、webpreview-db 服务和迁移文档
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
school 服务器以后要切换时,从 cdeb7c1 里找回(deploy.yaml 里写了)。
备份方法挪到 CLAUDE.md。
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cdeb7c18ef
commit
dc91b9b5db
5 files changed
+5
-225
No files matched your search
@@ -18,7 +18,8 @@ jobs:
|
|||||||
- name: debian
|
- name: debian
|
||||||
build_command: build
|
build_command: build
|
||||||
remote_port: 22
|
remote_port: 22
|
||||||
# school 暂时停用,需要时恢复(并先按 docs/migrate-to-bun.md 切换那台服务器)
|
# school 暂时停用,需要时恢复。那台还没从 Postgres 迁到 SQLite:迁移脚本、webpreview-db 服务和
|
||||||
|
# 步骤文档已删除,从提交 cdeb7c1 里找回(git show cdeb7c1:docs/migrate-to-bun.md)
|
||||||
# - name: school
|
# - name: school
|
||||||
# build_command: build:staging
|
# build_command: build:staging
|
||||||
# remote_port: 8822
|
# remote_port: 8822
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1
|
|||||||
- `src/routes/` — `account`, `tasks` (tutorial / challenge / creation), `files` (task assets + image upload), `submission`, `showcase` (mounted under `/submission/showcase`), `prompt`
|
- `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/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
|
- `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`
|
- `scripts/` — one-off CLIs: `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`)
|
- 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)
|
- **`client/`** — Vue 3 + TypeScript SPA built with **Vite**; Bun is the package manager and runtime (no Node/npm)
|
||||||
@@ -31,7 +31,7 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1
|
|||||||
- `src/pages/`, `src/components/` — user management uses `components/dashboard/UserFormModal.vue` (there is no Django Admin anymore)
|
- `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
|
- **`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
|
- **`docker-compose.yaml`** — `webpreview-web` (Caddy) and `webpreview-api` (Bun); the API reads the repo-root `.env` via `env_file` (`server/.env` is only for local dev). The one-time Postgres → SQLite migration (script, `webpreview-db` service, `docs/migrate-to-bun.md`) was removed after commit `cdeb7c1`; restore it from there if another server still needs the cutover
|
||||||
- **`.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
|
- **`.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
|
## Development Commands
|
||||||
@@ -72,6 +72,7 @@ Frontend env vars used at build time: `PUBLIC_BASE_URL`, `PUBLIC_WS_URL`, `PUBLI
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
docker compose run --rm webpreview-api bun scripts/create-superuser.ts <username> <password>
|
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
|
## Key Technical Details
|
||||||
|
|
||||||
|
|||||||
@@ -23,16 +23,3 @@ services:
|
|||||||
- ./sqlite:/app/data
|
- ./sqlite:/app/data
|
||||||
- ./media:/app/media
|
- ./media:/app/media
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# 只在从旧版 Postgres 迁移数据时使用:
|
|
||||||
# docker compose --profile migrate up -d webpreview-db
|
|
||||||
# docker compose run --rm webpreview-api bun scripts/migrate-from-postgres.ts
|
|
||||||
webpreview-db:
|
|
||||||
image: postgres:17-alpine
|
|
||||||
container_name: webpreview-db
|
|
||||||
profiles: ["migrate"]
|
|
||||||
volumes:
|
|
||||||
- ./data:/var/lib/postgresql/data
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
restart: "no"
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# 服务器切换到 Bun 后端(一次性)
|
|
||||||
|
|
||||||
后端从 Django + Postgres + Redis 换成了 Bun + SQLite,前后端也合并成了一个仓库
|
|
||||||
(`git.xuyue.cc:xuyue/web.git` / `github.com/yuetsh/web`)。
|
|
||||||
每台服务器(debian、school)都要按下面的步骤做一次,之后 push 到 `main` 就会自动部署。
|
|
||||||
|
|
||||||
切换时所有人需要重新登录一次(旧的登录会话在 Redis 里,不迁移)。密码不受影响。
|
|
||||||
|
|
||||||
## 0. GitHub 仓库设置
|
|
||||||
|
|
||||||
在 `yuetsh/web` 的 Settings → Secrets and variables → Actions 里添加和旧仓库一样的 `KEY`、`HOST`。
|
|
||||||
|
|
||||||
## 1. 备份
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /root/WebPreview
|
|
||||||
docker compose exec webpreview-db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' > /root/webpreview-$(date +%F).sql
|
|
||||||
```
|
|
||||||
|
|
||||||
`data/`(Postgres 数据目录)在整个过程中不会被修改,出问题可以随时回退。
|
|
||||||
|
|
||||||
## 2. 把目录变成新仓库的 clone
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /root/WebPreview
|
|
||||||
git init -b main
|
|
||||||
git remote add origin git@git.xuyue.cc:xuyue/web.git
|
|
||||||
git fetch origin
|
|
||||||
git reset origin/main # 只建立 git 记录,不改动现有文件
|
|
||||||
git checkout -- . # 用仓库里的 docker-compose.yaml、Caddyfile 等覆盖旧文件
|
|
||||||
mv api /root/webpreview-api-old # 旧的 Django 后端,确认没问题后可删
|
|
||||||
git status # 应该是干净的(.env、data/、build/、media/ 都被忽略)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. 迁移数据(Postgres → SQLite)
|
|
||||||
|
|
||||||
找一个没人用的时间做,先停掉旧后端,避免迁移期间有新数据写进 Postgres:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker stop webpreview-api
|
|
||||||
docker compose --profile migrate up -d webpreview-db # 旧库本来就在运行的话不会有变化
|
|
||||||
docker compose build webpreview-api
|
|
||||||
docker compose run --rm --no-deps webpreview-api bun scripts/migrate-from-postgres.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
脚本会逐表打印行数并做外键检查,最后输出 `导入完成` 才算成功。数据库文件在 `./sqlite/db.sqlite3`。
|
|
||||||
`.env` 里原有的 `POSTGRES_*` 变量就是脚本要用的,不需要改。
|
|
||||||
|
|
||||||
## 4. 启动新服务
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d --remove-orphans # 启动 Caddy + Bun,顺带删掉不再需要的 Redis 容器
|
|
||||||
docker compose --profile migrate stop webpreview-db
|
|
||||||
docker stats --no-stream # webpreview-api 应该在几十 MB
|
|
||||||
```
|
|
||||||
|
|
||||||
打开网站登录确认一下。之后就可以 `docker compose --profile migrate rm webpreview-db`,
|
|
||||||
`data/` 目录建议留一段时间再删(属主是容器里的 postgres 用户,需要 root 才能删)。
|
|
||||||
|
|
||||||
## 回退
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose down
|
|
||||||
mv /root/webpreview-api-old api
|
|
||||||
git show 47d4679:docker-compose.yaml > docker-compose.yaml
|
|
||||||
git show 47d4679:Caddyfile > Caddyfile
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
## 以后的日常操作
|
|
||||||
|
|
||||||
- 部署:push 到 `main`,GitHub Actions 会构建前端并在服务器上 `git pull && docker compose up -d --build`
|
|
||||||
- 新建超级管理员:`docker compose exec webpreview-api bun scripts/create-superuser.ts <用户名> <密码>`
|
|
||||||
- 补标注提示词层级:`docker compose exec webpreview-api bun scripts/classify-prompts.ts`
|
|
||||||
- 备份:复制 `sqlite/` 目录即可(或 `sqlite3 sqlite/db.sqlite3 ".backup backup.sqlite3"`)
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
/**
|
|
||||||
* 把旧 Django 项目的 Postgres 数据导入 SQLite(一次性迁移)。
|
|
||||||
*
|
|
||||||
* POSTGRES_HOST=... POSTGRES_DB=... POSTGRES_USER=... POSTGRES_PASSWORD=... \
|
|
||||||
* bun scripts/migrate-from-postgres.ts [--force]
|
|
||||||
*
|
|
||||||
* 目标库为 DB_PATH(默认 data/db.sqlite3)。目标库已有数据时需要 --force(会先清空)。
|
|
||||||
* 会话不迁移,所有人需要重新登录一次。
|
|
||||||
*/
|
|
||||||
import { SQL } from "bun"
|
|
||||||
import { config } from "../src/config"
|
|
||||||
import { db } from "../src/db"
|
|
||||||
|
|
||||||
// 按外键依赖顺序
|
|
||||||
const TABLES = [
|
|
||||||
"account_user",
|
|
||||||
"task_task",
|
|
||||||
"task_tutorial",
|
|
||||||
"task_challenge",
|
|
||||||
"task_creation",
|
|
||||||
"task_taskasset",
|
|
||||||
"submission_submission",
|
|
||||||
"submission_rating",
|
|
||||||
"submission_award",
|
|
||||||
"submission_submissionaward",
|
|
||||||
"prompt_conversation",
|
|
||||||
"prompt_message",
|
|
||||||
]
|
|
||||||
|
|
||||||
const force = process.argv.includes("--force")
|
|
||||||
const env = process.env
|
|
||||||
for (const key of ["POSTGRES_HOST", "POSTGRES_DB", "POSTGRES_USER", "POSTGRES_PASSWORD"]) {
|
|
||||||
if (!env[key]) {
|
|
||||||
console.error(`缺少环境变量 ${key}`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pg = new SQL({
|
|
||||||
hostname: env.POSTGRES_HOST,
|
|
||||||
port: Number(env.POSTGRES_PORT ?? 5432),
|
|
||||||
database: env.POSTGRES_DB,
|
|
||||||
username: env.POSTGRES_USER,
|
|
||||||
password: env.POSTGRES_PASSWORD,
|
|
||||||
})
|
|
||||||
|
|
||||||
const existing = db.query(`SELECT COUNT(*) AS n FROM account_user`).get() as { n: number }
|
|
||||||
if (existing.n > 0 && !force) {
|
|
||||||
console.error(`目标库 ${config.dbPath} 已有数据,加 --force 会清空后重新导入`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const quote = (name: string) => `"${name.replaceAll('"', '""')}"`
|
|
||||||
|
|
||||||
// 按 Postgres 的列类型转换成 Django 在 SQLite 中的存储格式
|
|
||||||
function selectExpr(column: string, pgType: string) {
|
|
||||||
const c = quote(column)
|
|
||||||
switch (pgType) {
|
|
||||||
case "timestamp with time zone":
|
|
||||||
return `to_char(${c} AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US') AS ${c}`
|
|
||||||
case "timestamp without time zone":
|
|
||||||
return `to_char(${c}, 'YYYY-MM-DD HH24:MI:SS.US') AS ${c}`
|
|
||||||
case "uuid":
|
|
||||||
return `replace(${c}::text, '-', '') AS ${c}`
|
|
||||||
case "boolean":
|
|
||||||
return `${c}::int AS ${c}`
|
|
||||||
case "bigint":
|
|
||||||
return `${c}::text AS ${c}`
|
|
||||||
default:
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const counts: Record<string, number> = {}
|
|
||||||
const rowsByTable: Record<string, Record<string, unknown>[]> = {}
|
|
||||||
|
|
||||||
for (const table of TABLES) {
|
|
||||||
const sqliteColumns = (db.query(`PRAGMA table_info(${quote(table)})`).all() as { name: string }[]).map(
|
|
||||||
(c) => c.name,
|
|
||||||
)
|
|
||||||
const pgColumns = await pg`
|
|
||||||
SELECT column_name, data_type FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = ${table}`
|
|
||||||
if (!pgColumns.length) throw new Error(`Postgres 中没有表 ${table}`)
|
|
||||||
const pgTypes = new Map<string, string>(pgColumns.map((c: any) => [c.column_name, c.data_type]))
|
|
||||||
const missing = sqliteColumns.filter((c) => !pgTypes.has(c))
|
|
||||||
if (missing.length) throw new Error(`Postgres 表 ${table} 缺少列:${missing.join(", ")}`)
|
|
||||||
|
|
||||||
const select = sqliteColumns.map((c) => selectExpr(c, pgTypes.get(c)!)).join(", ")
|
|
||||||
const rows: Record<string, unknown>[] = await pg.unsafe(`SELECT ${select} FROM ${quote(table)}`)
|
|
||||||
for (const row of rows) {
|
|
||||||
for (const c of sqliteColumns) {
|
|
||||||
if (pgTypes.get(c) === "bigint" && row[c] !== null) row[c] = Number(row[c])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rowsByTable[table] = rows
|
|
||||||
console.log(`读取 ${table}: ${rows.length} 行`)
|
|
||||||
}
|
|
||||||
await pg.close()
|
|
||||||
|
|
||||||
db.transaction(() => {
|
|
||||||
db.run("PRAGMA defer_foreign_keys = ON")
|
|
||||||
for (const table of [...TABLES].reverse()) db.run(`DELETE FROM ${quote(table)}`)
|
|
||||||
db.run(`DELETE FROM auth_refresh_token`)
|
|
||||||
for (const table of TABLES) {
|
|
||||||
const rows = rowsByTable[table]!
|
|
||||||
if (!rows.length) {
|
|
||||||
counts[table] = 0
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const columns = Object.keys(rows[0]!)
|
|
||||||
const insert = db.prepare(
|
|
||||||
`INSERT INTO ${quote(table)} (${columns.map(quote).join(", ")})
|
|
||||||
VALUES (${columns.map((c) => "$" + c).join(", ")})`,
|
|
||||||
)
|
|
||||||
for (const row of rows) insert.run(row as any)
|
|
||||||
counts[table] = rows.length
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
// 校验
|
|
||||||
let ok = true
|
|
||||||
for (const table of TABLES) {
|
|
||||||
const n = (db.query(`SELECT COUNT(*) AS n FROM ${quote(table)}`).get() as { n: number }).n
|
|
||||||
if (n !== counts[table]) ok = false
|
|
||||||
console.log(`${n === counts[table] ? "✓" : "✗"} ${table}: ${n}`)
|
|
||||||
}
|
|
||||||
const fk = db.query(`PRAGMA foreign_key_check`).all()
|
|
||||||
if (fk.length) {
|
|
||||||
ok = false
|
|
||||||
console.error("外键检查失败:", fk.slice(0, 10))
|
|
||||||
}
|
|
||||||
console.log(ok ? `\n导入完成:${config.dbPath}` : "\n导入有问题,请检查上面的输出")
|
|
||||||
process.exit(ok ? 0 : 1)
|
|
||||||
Reference in new issue
Block a user