6 个任务:monorepo 骨架与契约包、drizzle schema 生成剪枝、题目样本导入、 Hono 题目接口、搬入 ojnext、端到端串通。 相对设计文档两处偏离已说明:出口标准改为本地 PG + 生产题目样本(比原设想强, 能在浏览器端到端验证);不写测试(遵循项目既定策略)。 额外补 Task 3 样本数据 —— 本地库只有结构无数据,否则无法验证出口标准。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
24 KiB
阶段 1:monorepo 骨架 —— 实施计划
给执行者: 用
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
{
"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.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
{
"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 "✅ 无框架表"
预期:
- 生成的表数 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表的两个特殊列被正确生成
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.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
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
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/web,bun 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/web(Bun 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/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" 之外新增:
"/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 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 workspaces(Task 1)、drizzle-kit pull 并剪掉 django_*(Task 2)、拷贝 ojnext 进 apps/web(Task 5)、出口标准"能从真实库读出一道题"(Task 4 + Task 6)—— 均已覆盖。额外补了 Task 3(样本数据),因为本地库只有结构没有数据,不导样本无法验证出口标准。
与设计文档的偏离:
- 出口标准原文是"能从真实库读出一道题",当时假设本机无数据库。现在改为本地 PostgreSQL 16 + 生产导出的题目样本,比原设想更强(能在浏览器里端到端看到)。
- 不写测试 —— 遵循项目既定策略,用"跑起来看输出"替代。
类型一致性: 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 管)。