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

24 KiB
Raw Blame History

阶段 1monorepo 骨架 —— 实施计划

给执行者:superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实施。步骤用 - [ ] 复选框跟踪。

目标: 建起 OJ2 的 Bun workspaces 骨架,bun dev 能起来,并从本地 PostgreSQL 真实读出一道题、在浏览器里显示出来。

架构: 三个 workspace —— apps/apiBun + Hono + Drizzleapps/web(由 ojnext 原样拷入,仅换 API 层)、packages/contractZod 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.mdDo 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 -dPostgreSQL 在 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.jsonbunfig.tomltsconfig.base.json
  • Create: packages/contract/package.jsonpackages/contract/tsconfig.jsonpackages/contract/src/index.tspackages/contract/src/problem.ts

Interfaces:

  • Produces: workspace 名 @oj2/contract,导出 problemSummarySchemaProblemSummary(后续任务的前后端都从这里 import

  • Step 1: 写根 package.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
[install]
# workspace 内部依赖走本地链接,不去 registry 找
linker = "hoisted"
  • Step 3: 写 tsconfig.base.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
{
  "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
{
  "extends": "../../tsconfig.base.json",
  "include": ["src"]
}
  • Step 6: 写 packages/contract/src/problem.ts

字段照着本地库 problem 表的真实列取。只放本阶段用得到的几个,不要把整张表铺开 —— YAGNI。

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
export * from "./problem"
  • Step 8: 安装并确认 workspace 被识别
cd /home/xuyue/Projects/OJ/OJ2
bun install

预期:输出里能看到 workspace 解析,node_modules/@oj2/contract 是指向 packages/contract 的符号链接。核实:

ls -l node_modules/@oj2/

预期看到 contract -> ../../packages/contract

  • Step 9: 提交
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.jsonapps/api/tsconfig.jsonapps/api/drizzle.config.ts
  • Create: apps/api/src/db/schema.ts(由 drizzle-kit 生成后手工剪枝)
  • Create: apps/api/src/db/index.ts

Interfaces:

  • Consumes: 本地 PostgreSQLpostgres://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
{
  "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
{
  "extends": "../../tsconfig.base.json",
  "include": ["src", "drizzle.config.ts"]
}
  • Step 3: 写 apps/api/drizzle.config.ts
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
cd /home/xuyue/Projects/OJ/OJ2
bun install
cd apps/api
bun run db:pull
  • Step 5: 核对生成结果
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 "✅ 无框架表"

预期:

  • 生成的表数 2734 减去 7 张 Django 框架表:auth_groupauth_group_permissionsauth_permissiondjango_content_typedjango_dramatiq_taskdjango_migrationsdjango_session
  • 输出 ✅ 无框架表

tablesFilter 没生效、27 对不上,改为生成后手工删掉那 7 个 pgTable 定义,并在 schema.ts 顶部注释写明删了哪些、为什么。

  • Step 6: 确认 user 表的两个特殊列被正确生成
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
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: 提交
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 为准):

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: 传回本机
scp <服务器>:~/problems.csv <服务器>:~/tags.csv /tmp/
  • Step 3: 确认没有夹带用户数据
head -1 /tmp/problems.csv
grep -icE 'pbkdf2_sha256|@[a-z]+\.(com|cn|net)' /tmp/problems.csv

预期:表头是 problem 表的列名;第二条计数为 0。非 0 说明导错了表,删掉重来。

  • Step 4: 造占位用户并导入
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: 核对
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: 提交
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.tsapps/api/src/routes/problem.ts

Interfaces:

  • Consumes: dbschemaTask 2problemSummarySchemaTask 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
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.tsproblem 表的实际字段名,再照着写上面的 selectsubmission_number / accepted_number 同理drizzle 通常转成 camelCase但以生成结果为准不要照抄本段。

  • Step 2: 写 apps/api/src/index.ts
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: 起服务
cd /home/xuyue/Projects/OJ/OJ2
bun run dev:api
  • Step 4: 验证(另开一个终端)
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: 提交
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/webbun run dev:web 能起 Vite 开发服务器

这是搬运,不是重写。 37k 行 Vue、217 个文件原样拷过来,本阶段一行业务代码都不改。API 层的替换留到阶段 3。

  • Step 1: 拷贝(排除 node_modules、.git、构建产物
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: 确认旧仓库未被动过
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",并加上契约包依赖:

  "dependencies": {
    "@oj2/contract": "workspace:*",

其余 38 个依赖和 11 个 devDependencies 一个都不要动,尤其是 mermaid-legacy 之类为兼容旧版 Chrome 存在的 fallback 包。

  • Step 4: 装依赖
cd /home/xuyue/Projects/OJ/OJ2
bun install

从 npm 换到 bun 解析同一批依赖,可能出现版本漂移。装完看有没有报错或大量警告。

  • Step 5: 确认能构建
cd /home/xuyue/Projects/OJ/OJ2/apps/web
bun run build

预期:构建成功,产出 dist/。这一步是搬运是否成功的唯一硬指标 —— 38 个依赖在 bun 下能否正常解析、Vite 能否跑通,都在这里见分晓。

失败的话不要动业务代码,先看是不是依赖解析问题;实在不行退回用 npm 管 apps/webBun workspaces 允许某个包单独用别的包管理器,代价是失去统一 lockfile

  • Step 6: 确认旧版 Chrome 兼容没被破坏
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: 起开发服务器看一眼
cd /home/xuyue/Projects/OJ/OJ2
bun run dev:web

浏览器打开 http://localhost:5173。此时 API 还指向旧后端(vite.config.ts/api 代理到 PUBLIC_OJ_URL),页面可能报接口错误 —— 这是预期的,本阶段不接线。只要页面框架能渲染出来就算过。

  • Step 8: 提交
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/problemsTask 4 的接口,经 Vite 代理)、ProblemSummaryTask 1 的类型)

这一步的唯一目的是证明链路通了:本地 PostgreSQL → Drizzle → Hono → 契约校验 → Vite 代理 → Vue 页面。用 /api2 前缀而不是 /api,是为了不影响现有页面继续指向旧后端。

  • Step 1: 在 apps/web/vite.config.ts 的 proxy 里加一条

在现有的 "/api""/public""/ws" 之外新增:

        "/api2": {
          target: "http://localhost:3000",
          changeOrigin: true,
          rewrite: (p: string) => p.replace(/^\/api2/, "/api"),
        },
  • Step 2: 写临时验证页 apps/web/src/oj/dev-problems.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: 两个服务一起起
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: 提交
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 installnode_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 1drizzle-kit pull 并剪掉 django_*Task 2、拷贝 ojnext 进 apps/webTask 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 管)。