Files
OJ2/docs/plans/2026-08-06-phase1-skeleton.md
yuetsh 39ad431e31 docs: 阶段 1 骨架实施计划
6 个任务:monorepo 骨架与契约包、drizzle schema 生成剪枝、题目样本导入、
Hono 题目接口、搬入 ojnext、端到端串通。

相对设计文档两处偏离已说明:出口标准改为本地 PG + 生产题目样本(比原设想强,
能在浏览器端到端验证);不写测试(遵循项目既定策略)。
额外补 Task 3 样本数据 —— 本地库只有结构无数据,否则无法验证出口标准。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:36:44 -06:00

676 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 阶段 1monorepo 骨架 —— 实施计划
> **给执行者:** 用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans` 逐任务实施。步骤用 `- [ ]` 复选框跟踪。
**目标:** 建起 `OJ2` 的 Bun workspaces 骨架,`bun dev` 能起来,并从本地 PostgreSQL 真实读出一道题、在浏览器里显示出来。
**架构:** 三个 workspace —— `apps/api`Bun + Hono + Drizzle`apps/web`(由 ojnext 原样拷入,仅换 API 层)、`packages/contract`Zod schema前后端唯一真相源。数据库结构由 `drizzle-kit pull` 从本地 PostgreSQL introspect 生成,剪掉 Django 框架表。本阶段只打通"读一道题"这一条最薄的链路,不碰认证、不碰判题。
**技术栈:** Bun 1.3.11、Hono、Drizzle ORM 0.45.2 / drizzle-kit 0.31.10、Zod、Vue 3 + Vite沿用 ojnext
## 全局约束
- **不修改 `/home/xuyue/Projects/OJ/OnlineJudge/``/home/xuyue/Projects/OJ/ojnext/` 任何文件。** 两个旧仓库全程冻结,回滚路径依赖于此。`apps/web` 是**拷贝**,不是移动,不带 git 历史。
- **不写测试。** 项目既定策略(根 `CLAUDE.md`Do not write new tests。本计划用"跑起来看输出"替代测试环节。
- **PostgreSQL 固定 16。** 生产是 16.10,本地 compose 用 `postgres:16-alpine`(实测 16.14)。**不要换 17/18** —— 开发环境用了生产不支持的特性,要到切换那天才炸。
- **前端必须兼容旧版 Chrome< 94。** 学校机房电脑的浏览器版本低ojnext 里的 `mermaid-legacy` 等 fallback 依赖是为此存在的,**搬运时一个都不能删**`vite.config.ts` 的 build target 也不能提高。
- 本地依赖服务:`docker compose -f docker/compose.dev.yml up -d`PostgreSQL 在 **5433**、Redis 在 **6380**(端口特意错开本机默认)。
- 数据库连接串:`postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge`
- 本地库结构已灌好:**34 张表**27 业务 + 7 Django 框架),与 `docs/specs/schema.sql` 差集为 0。
- 端点清单已定案:新后端需实现 **110 个**端点,见 `docs/specs/endpoint-inventory.md`。本阶段只实现其中 1 个。
- Docker 命令若报 permission denied说明当前 shell 不在 `docker` 组,用 `newgrp docker <<'EOF' … EOF` 包一层;重启终端后即可直接用。
---
## 文件结构
| 文件 | 职责 |
|---|---|
| `package.json` | 根 workspace 定义 + 顶层脚本 |
| `bunfig.toml` | Bun 配置 |
| `tsconfig.base.json` | 三个 workspace 共享的 TS 编译选项 |
| `packages/contract/package.json` / `src/index.ts` | Zod schema 出口,前后端共同依赖 |
| `packages/contract/src/problem.ts` | 题目相关的 Zod schema 与类型 |
| `apps/api/package.json` / `src/index.ts` | Hono 应用入口 |
| `apps/api/src/db/schema.ts` | `drizzle-kit pull` 生成后剪枝的表定义 |
| `apps/api/src/db/index.ts` | Drizzle 客户端单例 |
| `apps/api/src/routes/problem.ts` | 题目路由 |
| `apps/api/drizzle.config.ts` | drizzle-kit 配置 |
| `apps/web/**` | 由 ojnext 拷入,仅改 API 层 |
| `docs/specs/sample-data.sql` | 从生产导出的题目样本(不含用户数据) |
---
## Task 1: monorepo 骨架与共享契约包
**Files:**
- Create: `package.json``bunfig.toml``tsconfig.base.json`
- Create: `packages/contract/package.json``packages/contract/tsconfig.json``packages/contract/src/index.ts``packages/contract/src/problem.ts`
**Interfaces:**
- Produces: workspace 名 `@oj2/contract`,导出 `problemSummarySchema``ProblemSummary`(后续任务的前后端都从这里 import
- [ ] **Step 1: 写根 `package.json`**
```json
{
"name": "oj2",
"private": true,
"type": "module",
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"dev": "bun run --filter '*' dev",
"dev:api": "bun run --filter '@oj2/api' dev",
"dev:web": "bun run --filter '@oj2/web' dev",
"db:up": "docker compose -f docker/compose.dev.yml up -d",
"db:down": "docker compose -f docker/compose.dev.yml down"
},
"devDependencies": {
"typescript": "^5.7.0",
"@types/bun": "latest"
}
}
```
- [ ] **Step 2: 写 `bunfig.toml`**
```toml
[install]
# workspace 内部依赖走本地链接,不去 registry 找
linker = "hoisted"
```
- [ ] **Step 3: 写 `tsconfig.base.json`**
```json
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext", "DOM"],
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"types": ["bun"]
}
}
```
- [ ] **Step 4: 写 `packages/contract/package.json`**
```json
{
"name": "@oj2/contract",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": { ".": "./src/index.ts" },
"dependencies": {
"zod": "^4.0.0"
}
}
```
- [ ] **Step 5: 写 `packages/contract/tsconfig.json`**
```json
{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}
```
- [ ] **Step 6: 写 `packages/contract/src/problem.ts`**
字段照着本地库 `problem` 表的真实列取。只放本阶段用得到的几个,不要把整张表铺开 —— YAGNI。
```typescript
import { z } from "zod"
/** 题目列表项。字段取自 problem 表,只含列表页需要的列。 */
export const problemSummarySchema = z.object({
id: z.number().int(),
_id: z.string(), // 展示用编号,与自增 id 不同
title: z.string(),
difficulty: z.string(),
submissionNumber: z.number().int(),
acceptedNumber: z.number().int(),
})
export type ProblemSummary = z.infer<typeof problemSummarySchema>
```
- [ ] **Step 7: 写 `packages/contract/src/index.ts`**
```typescript
export * from "./problem"
```
- [ ] **Step 8: 安装并确认 workspace 被识别**
```bash
cd /home/xuyue/Projects/OJ/OJ2
bun install
```
预期:输出里能看到 workspace 解析,`node_modules/@oj2/contract` 是指向 `packages/contract` 的符号链接。核实:
```bash
ls -l node_modules/@oj2/
```
预期看到 `contract -> ../../packages/contract`
- [ ] **Step 9: 提交**
```bash
cd /home/xuyue/Projects/OJ/OJ2
git add package.json bunfig.toml tsconfig.base.json packages/ bun.lock
git commit -m "feat(阶段1): monorepo 骨架与 @oj2/contract 契约包"
```
---
## Task 2: 从本地库生成 Drizzle schema 并剪枝
**Files:**
- Create: `apps/api/package.json``apps/api/tsconfig.json``apps/api/drizzle.config.ts`
- Create: `apps/api/src/db/schema.ts`(由 drizzle-kit 生成后手工剪枝)
- Create: `apps/api/src/db/index.ts`
**Interfaces:**
- Consumes: 本地 PostgreSQL`postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge`
- Produces: `apps/api/src/db/schema.ts` 导出各表定义;`apps/api/src/db/index.ts` 导出 `db` 客户端单例
**先决条件**`docker compose -f docker/compose.dev.yml ps` 显示两个服务都是 healthy。不是的话先 `bun run db:up` 并等健康检查通过。
- [ ] **Step 1: 写 `apps/api/package.json`**
```json
{
"name": "@oj2/api",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --watch src/index.ts",
"db:pull": "drizzle-kit pull"
},
"dependencies": {
"@oj2/contract": "workspace:*",
"drizzle-orm": "^0.45.2",
"hono": "^4.0.0",
"postgres": "^3.4.0",
"zod": "^4.0.0"
},
"devDependencies": {
"drizzle-kit": "^0.31.10"
}
}
```
- [ ] **Step 2: 写 `apps/api/tsconfig.json`**
```json
{
"extends": "../../tsconfig.base.json",
"include": ["src", "drizzle.config.ts"]
}
```
- [ ] **Step 3: 写 `apps/api/drizzle.config.ts`**
```typescript
import { defineConfig } from "drizzle-kit"
export default defineConfig({
dialect: "postgresql",
schema: "./src/db/schema.ts",
out: "./src/db",
dbCredentials: {
url: process.env.DATABASE_URL ?? "postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge",
},
// Django 框架表不进新后端introspect 时直接排除
tablesFilter: ["!django_*", "!auth_*"],
})
```
- [ ] **Step 4: 安装依赖并 introspect**
```bash
cd /home/xuyue/Projects/OJ/OJ2
bun install
cd apps/api
bun run db:pull
```
- [ ] **Step 5: 核对生成结果**
```bash
cd /home/xuyue/Projects/OJ/OJ2/apps/api
echo "生成的表数: $(grep -c 'pgTable(' src/db/schema.ts)"
grep -oE 'export const \w+ = pgTable\("\w+"' src/db/schema.ts | sed 's/.*pgTable("//' | tr -d '"' | sort | grep -E '^(django_|auth_)' && echo "❌ 仍有框架表" || echo "✅ 无框架表"
```
预期:
- 生成的表数 **27**34 减去 7 张 Django 框架表:`auth_group``auth_group_permissions``auth_permission``django_content_type``django_dramatiq_task``django_migrations``django_session`
- 输出 `✅ 无框架表`
`tablesFilter` 没生效、27 对不上,改为生成后手工删掉那 7 个 `pgTable` 定义,并在 `schema.ts` 顶部注释写明删了哪些、为什么。
- [ ] **Step 6: 确认 `user` 表的两个特殊列被正确生成**
```bash
grep -nE 'rawPassword|raw_password|sessionKeys|session_keys' src/db/schema.ts
```
预期两个都在。`raw_password` 是**有意保留**的明文密码列(教师查学生密码的运维需求,见设计文档 7.1.1),不要因为"看起来不安全"就删掉。`session_keys` 是死字段,但本阶段不动它 —— 删除属于后续阶段的清理工作。
- [ ] **Step 7: 写 `apps/api/src/db/index.ts`**
```typescript
import { drizzle } from "drizzle-orm/postgres-js"
import postgres from "postgres"
import * as schema from "./schema"
const url = process.env.DATABASE_URL ?? "postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge"
const client = postgres(url)
export const db = drizzle(client, { schema })
export { schema }
```
- [ ] **Step 8: 提交**
```bash
cd /home/xuyue/Projects/OJ/OJ2
git add apps/api bun.lock
git commit -m "feat(阶段1): drizzle schema 从本地库生成并剪掉 Django 框架表"
```
---
## Task 3: 导入题目样本数据
**Files:**
- Create: `docs/specs/sample-data.sql`
**Interfaces:**
- Produces: 本地库里有若干条真实题目记录,供 Task 4 的接口读取
**只导题目和标签,不导用户。** 理由题目内容富文本、LaTeX、中文、特殊字符正是最容易撑爆新后端序列化的东西必须用真实数据而学生账号属于个人信息没有理由离开服务器 —— 本阶段也用不到用户数据。
`problem` 表有 `created_by_id` 外键指向 `user`,所以要先造一个占位用户,再导题目。
- [ ] **Step 1: 在服务器上导出题目样本**
登录生产服务器执行(容器名以实际 `docker-compose.yml` 为准):
```bash
docker exec oj-postgres psql -U onlinejudge -d onlinejudge -c \
"\copy (SELECT * FROM problem ORDER BY id LIMIT 20) TO STDOUT WITH CSV HEADER" > problems.csv
docker exec oj-postgres psql -U onlinejudge -d onlinejudge -c \
"\copy (SELECT * FROM problem_tag) TO STDOUT WITH CSV HEADER" > tags.csv
```
- [ ] **Step 2: 传回本机**
```bash
scp <服务器>:~/problems.csv <服务器>:~/tags.csv /tmp/
```
- [ ] **Step 3: 确认没有夹带用户数据**
```bash
head -1 /tmp/problems.csv
grep -icE 'pbkdf2_sha256|@[a-z]+\.(com|cn|net)' /tmp/problems.csv
```
预期:表头是 problem 表的列名;第二条计数为 **0**。非 0 说明导错了表,删掉重来。
- [ ] **Step 4: 造占位用户并导入**
```bash
cd /home/xuyue/Projects/OJ/OJ2
docker cp /tmp/problems.csv oj2-postgres:/tmp/problems.csv
docker cp /tmp/tags.csv oj2-postgres:/tmp/tags.csv
docker exec -i oj2-postgres psql -U onlinejudge -d onlinejudge <<'SQL'
-- 占位用户,仅为满足 problem.created_by_id 外键;密码是无意义的固定串
INSERT INTO "user" (id, password, username, admin_type, problem_permission, open_api, is_disabled, session_keys, raw_password)
VALUES (1, 'unusable', 'devadmin', 'Super Admin', 'All', false, false, '[]'::jsonb, 'devonly')
ON CONFLICT (id) DO NOTHING;
\copy problem_tag FROM '/tmp/tags.csv' WITH CSV HEADER
\copy problem FROM '/tmp/problems.csv' WITH CSV HEADER
SQL
```
- [ ] **Step 5: 核对**
```bash
docker exec oj2-postgres psql -U onlinejudge -d onlinejudge -tAc \
"SELECT count(*) || ' 道题, ' || count(DISTINCT difficulty) || ' 种难度' FROM problem"
docker exec oj2-postgres psql -U onlinejudge -d onlinejudge -tAc \
"SELECT _id || ' | ' || left(title, 30) FROM problem ORDER BY id LIMIT 3"
```
预期:能看到 20 道题、若干种难度,以及 3 条真实的题目编号和标题。
- [ ] **Step 6: 把导入脚本存档**
把 Step 4 里的 SQL不含 CSV 数据本身)写进 `docs/specs/sample-data.sql`,顶部注释说明 CSV 从哪来、为什么不导用户数据。**CSV 文件本身不入库** —— 题目内容是学校的教学资产,没必要进 git。在 `.gitignore` 加一行 `*.csv`
- [ ] **Step 7: 提交**
```bash
cd /home/xuyue/Projects/OJ/OJ2
git add docs/specs/sample-data.sql .gitignore
git commit -m "chore(阶段1): 题目样本导入脚本(不含用户数据)"
```
---
## Task 4: Hono 应用与题目列表接口
**Files:**
- Create: `apps/api/src/index.ts``apps/api/src/routes/problem.ts`
**Interfaces:**
- Consumes: `db``schema`Task 2`problemSummarySchema`Task 1、样本数据Task 3
- Produces: `GET http://localhost:3000/api/problems` 返回题目列表,形如 `{ "data": ProblemSummary[] }`
**响应格式说明**:阶段 0 已定案 API 重新设计,所以**不要**复刻旧后端的 `{error, data}` 格式。本阶段先用最朴素的 `{ data }`,完整契约留到阶段 3 铺开时定。
- [ ] **Step 1: 写 `apps/api/src/routes/problem.ts`**
```typescript
import { Hono } from "hono"
import { desc } from "drizzle-orm"
import { problemSummarySchema } from "@oj2/contract"
import { db, schema } from "../db"
export const problemRoutes = new Hono()
problemRoutes.get("/problems", async (c) => {
const rows = await db
.select({
id: schema.problem.id,
_id: schema.problem.id_,
title: schema.problem.title,
difficulty: schema.problem.difficulty,
submissionNumber: schema.problem.submissionNumber,
acceptedNumber: schema.problem.acceptedNumber,
})
.from(schema.problem)
.orderBy(desc(schema.problem.id))
.limit(20)
// 用契约校验schema 与实际数据对不上会在这里立刻炸,而不是传到前端才发现
const data = rows.map((r) => problemSummarySchema.parse(r))
return c.json({ data })
})
```
> **注意列名**`drizzle-kit pull` 会把 `problem._id` 这类下划线开头的列生成成什么标识符,取决于生成结果。**先看 `src/db/schema.ts` 里 `problem` 表的实际字段名**,再照着写上面的 `select`。`submission_number` / `accepted_number` 同理drizzle 通常转成 camelCase但以生成结果为准不要照抄本段。
- [ ] **Step 2: 写 `apps/api/src/index.ts`**
```typescript
import { Hono } from "hono"
import { problemRoutes } from "./routes/problem"
const app = new Hono()
app.get("/health", (c) => c.json({ ok: true }))
app.route("/api", problemRoutes)
export default {
port: 3000,
fetch: app.fetch,
}
```
- [ ] **Step 3: 起服务**
```bash
cd /home/xuyue/Projects/OJ/OJ2
bun run dev:api
```
- [ ] **Step 4: 验证(另开一个终端)**
```bash
curl -s http://localhost:3000/health
curl -s http://localhost:3000/api/problems | head -c 600
```
预期:
- `/health` 返回 `{"ok":true}`
- `/api/problems` 返回真实题目,能看到中文标题和真实的 `_id` 编号
若 Zod 校验报错,说明 `problemSummarySchema` 的字段类型与库里实际类型不符(常见:数字列被 postgres 驱动返回成字符串)。修 schema 或在 select 里转型,**不要**把校验去掉 —— 它在这里炸正是它的价值。
- [ ] **Step 5: 提交**
```bash
cd /home/xuyue/Projects/OJ/OJ2
git add apps/api/src
git commit -m "feat(阶段1): Hono 应用与题目列表接口,读通本地真实数据"
```
---
## Task 5: 把 ojnext 搬进 apps/web
**Files:**
- Create: `apps/web/**`(由 `/home/xuyue/Projects/OJ/ojnext` 拷贝)
- Modify: `apps/web/package.json`(改名、接入 workspace
**Interfaces:**
- Consumes: `@oj2/contract`
- Produces: workspace `@oj2/web``bun run dev:web` 能起 Vite 开发服务器
**这是搬运,不是重写。** 37k 行 Vue、217 个文件原样拷过来,本阶段**一行业务代码都不改**。API 层的替换留到阶段 3。
- [ ] **Step 1: 拷贝(排除 node_modules、.git、构建产物**
```bash
cd /home/xuyue/Projects/OJ
mkdir -p OJ2/apps/web
rsync -a --exclude=node_modules --exclude=.git --exclude=dist --exclude=package-lock.json \
ojnext/ OJ2/apps/web/
```
`package-lock.json` 排除掉,因为 workspace 统一由根目录的 `bun.lock` 管。
- [ ] **Step 2: 确认旧仓库未被动过**
```bash
cd /home/xuyue/Projects/OJ/ojnext && git status --porcelain | wc -l
```
预期:`0`。非 0 说明 rsync 方向写反了,立刻 `git checkout .` 恢复。
- [ ] **Step 3: 改 `apps/web/package.json` 的包名**
`"name": "oj-next"` 改成 `"name": "@oj2/web"`,并加上契约包依赖:
```json
"dependencies": {
"@oj2/contract": "workspace:*",
```
其余 38 个依赖和 11 个 devDependencies **一个都不要动**,尤其是 `mermaid-legacy` 之类为兼容旧版 Chrome 存在的 fallback 包。
- [ ] **Step 4: 装依赖**
```bash
cd /home/xuyue/Projects/OJ/OJ2
bun install
```
从 npm 换到 bun 解析同一批依赖,可能出现版本漂移。装完看有没有报错或大量警告。
- [ ] **Step 5: 确认能构建**
```bash
cd /home/xuyue/Projects/OJ/OJ2/apps/web
bun run build
```
预期:构建成功,产出 `dist/`。这一步是搬运是否成功的**唯一硬指标** —— 38 个依赖在 bun 下能否正常解析、Vite 能否跑通,都在这里见分晓。
失败的话不要动业务代码,先看是不是依赖解析问题;实在不行退回用 npm 管 `apps/web`Bun workspaces 允许某个包单独用别的包管理器,代价是失去统一 lockfile
- [ ] **Step 6: 确认旧版 Chrome 兼容没被破坏**
```bash
cd /home/xuyue/Projects/OJ/OJ2/apps/web
grep -nE '"target"|build:\s*\{' vite.config.ts | head
grep -c 'mermaid-legacy' package.json
```
预期:`vite.config.ts` 的 build target 与 ojnext 原文件完全一致(可 `diff` 对照 `/home/xuyue/Projects/OJ/ojnext/vite.config.ts`,应无差异);`mermaid-legacy` 计数 ≥ 1。
- [ ] **Step 7: 起开发服务器看一眼**
```bash
cd /home/xuyue/Projects/OJ/OJ2
bun run dev:web
```
浏览器打开 `http://localhost:5173`。此时 API 还指向旧后端(`vite.config.ts``/api` 代理到 `PUBLIC_OJ_URL`),页面可能报接口错误 —— **这是预期的**,本阶段不接线。只要页面框架能渲染出来就算过。
- [ ] **Step 8: 提交**
```bash
cd /home/xuyue/Projects/OJ/OJ2
git add apps/web bun.lock
git commit -m "feat(阶段1): 搬入 ojnext 为 apps/web未改业务代码"
```
---
## Task 6: 端到端串通
**Files:**
- Modify: `apps/web/vite.config.ts`(新增一条指向新 API 的代理)
- Create: `apps/web/src/oj/dev-problems.vue`(临时验证页,阶段 3 会删)
- Modify: `apps/web/src/routes.ts`(挂一条临时路由)
**Interfaces:**
- Consumes: `GET /api2/problems`Task 4 的接口,经 Vite 代理)、`ProblemSummary`Task 1 的类型)
**这一步的唯一目的是证明链路通了**:本地 PostgreSQL → Drizzle → Hono → 契约校验 → Vite 代理 → Vue 页面。用 `/api2` 前缀而不是 `/api`,是为了不影响现有页面继续指向旧后端。
- [ ] **Step 1: 在 `apps/web/vite.config.ts` 的 proxy 里加一条**
在现有的 `"/api"``"/public"``"/ws"` 之外新增:
```typescript
"/api2": {
target: "http://localhost:3000",
changeOrigin: true,
rewrite: (p: string) => p.replace(/^\/api2/, "/api"),
},
```
- [ ] **Step 2: 写临时验证页 `apps/web/src/oj/dev-problems.vue`**
```vue
<script setup lang="ts">
import { ref, onMounted } from "vue"
import type { ProblemSummary } from "@oj2/contract"
const problems = ref<ProblemSummary[]>([])
const error = ref("")
onMounted(async () => {
try {
const res = await fetch("/api2/problems")
const body = await res.json()
problems.value = body.data
} catch (e) {
error.value = String(e)
}
})
</script>
<template>
<div style="padding: 24px">
<h2>阶段 1 链路验证临时页阶段 3 删除</h2>
<p v-if="error" style="color: red">{{ error }}</p>
<p> {{ problems.length }} 道题</p>
<ul>
<li v-for="p in problems" :key="p.id">
{{ p._id }} {{ p.title }}{{ p.difficulty }}通过 {{ p.acceptedNumber }}/{{ p.submissionNumber }}
</li>
</ul>
</div>
</template>
```
- [ ] **Step 3: 在 `apps/web/src/routes.ts` 挂一条路由**
照该文件已有的路由写法,加一条 `path: "/dev-problems"` 指向上面这个组件。**照抄文件里现有条目的风格**,不要自创写法。
- [ ] **Step 4: 两个服务一起起**
```bash
cd /home/xuyue/Projects/OJ/OJ2
bun run db:up # 若依赖服务没在跑
bun run dev # 同时起 api 和 web
```
- [ ] **Step 5: 验证**
浏览器打开 `http://localhost:5173/dev-problems`
预期:页面列出 20 道**真实题目**,中文标题正常显示,编号和通过数都是库里的真实值。
这就是阶段 1 的出口标准。看到题目列表出来,说明本地 PostgreSQL → Drizzle → Hono → Zod 契约 → Vite 代理 → Vue 组件这条链路全线打通,且类型从数据库一路贯通到了前端组件(`ProblemSummary``.vue` 里有完整类型提示)。
- [ ] **Step 6: 提交**
```bash
cd /home/xuyue/Projects/OJ/OJ2
git add apps/web/vite.config.ts apps/web/src/oj/dev-problems.vue apps/web/src/routes.ts
git commit -m "feat(阶段1): 端到端串通,前端显示本地库真实题目"
```
---
## 阶段 1 完成标准
五项全部满足才算完成:
- [ ] `bun install``node_modules/@oj2/contract` 是指向 `packages/contract` 的符号链接
- [ ] `apps/api/src/db/schema.ts` 有 27 张表,无 `django_*` / `auth_*`
- [ ] `curl http://localhost:3000/api/problems` 返回真实题目数据
- [ ] `cd apps/web && bun run build` 构建成功,且 `vite.config.ts` 与 ojnext 原文件无差异
- [ ] 浏览器 `http://localhost:5173/dev-problems` 显示 20 道真实题目
---
## 自查记录
**规格覆盖:** 设计文档第 11 节阶段 1 列的四项 —— 建仓与 Bun workspacesTask 1`drizzle-kit pull` 并剪掉 `django_*`Task 2、拷贝 ojnext 进 `apps/web`Task 5、出口标准"能从真实库读出一道题"Task 4 + Task 6—— 均已覆盖。额外补了 Task 3样本数据因为本地库只有结构没有数据不导样本无法验证出口标准。
**与设计文档的偏离:**
1. 出口标准原文是"能从真实库读出一道题",当时假设本机无数据库。现在改为**本地 PostgreSQL 16 + 生产导出的题目样本**,比原设想更强(能在浏览器里端到端看到)。
2. 不写测试 —— 遵循项目既定策略,用"跑起来看输出"替代。
**类型一致性:** `ProblemSummary` 在 Task 1 定义、Task 4 用于后端校验、Task 6 用于前端类型标注,三处同源。`db` / `schema` 在 Task 2 定义、Task 4 消费。Task 4 Step 1 已显式提醒:`select` 的字段名必须以 `drizzle-kit pull` 的实际生成结果为准,不得照抄计划正文。
**已知风险:** Task 5 Step 5 的 `bun run build` 是本阶段唯一可能大面积失败的地方 —— ojnext 的 38 个依赖从 npm 换到 bun 解析可能漂移。计划里给了退路(该包单独用 npm 管)。