Files
openmaic/docs/deployment-3-tier.md
2026-08-16 14:58:47 +08:00

427 lines
21 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.

# 麦洛学习三端部署与运维
> 事实基线2026-08-15
>
> 当前是一份 Next.js 16.1.2 代码、三种部署角色,不是三个已经拆开的应用。
> 本手册先说明当前可运行方式,再给出 server → learner desktop → ops 的机械拆分顺序。
## 1. 角色解析与真实保护范围
| 值 | 用途 | 生产默认 | 大型课程运营能力 |
|---|---|---|---|
| learner | learner desktop 公共表面 | 未配置角色时采用 | 无 |
| ops | 内部运营工作台 | 必须显式配置 | 有,且生产必须配置 ACCESS_CODE |
| server | 课件/课程清单服务端 | 必须显式配置 | 无;可用 Bearer token 接收课件 |
| all | 历史单体兼容 | 不建议生产使用 | 有 |
解析规则:
- 服务端读取 OPENMAIC_DEPLOYMENT_ROLE未设时才回退公开角色
- 客户端入口读取 NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE
- 生产未配置或配置非法时 fail closed 为 learner
- development/test 未配置时为 all保留上游单体开发体验
- NEXT_PUBLIC_* 是构建期值ops 与 learner 要做可靠 UI 隔离时应分别构建。
当前强制保护的范围:
- /courses、/courses/**、/api/courses、/api/courses/** 和 /api/ops/** 需要
manage_courses
- 生产 ops 若没有 ACCESS_CODE 返回 503
- 运营 API 除 middleware 外,还逐 route 调用 requireOpsAccess 校验 HMAC cookie
- learner 请求运营 API 返回 403访问运营页面回到首页
- server 是无头服务:所有非 API 页面直接返回 404/api/courses/**、
/api/ops/** 和 /api/access-code/** 也返回 API 404provider 探测/验证、
MP4 export、开发态 persistence、usage、全局 job 列表与 `/api/proxy-media`
也不在专用 server 公开面;
- 全局 job 列表的 GET/HEAD 都会返回 404
`/api/internal/course-publish` 只允许 POST尾斜杠不会绕过方法边界
- POST /api/coursewares 在 ops 同源部署校验 ACCESS_CODE 会话,在 server 部署校验
COURSEWARE_PUBLISH_TOKEN Bearer token在 learner 部署拒绝;
- 公开 courseware、course manifest 读取仍是服务端的静态数据 API。
远程发布成功后ops 通过当前 `CourseRecord.publication` 回执识别本地未挂载
registry 的已发布 source classroom并拒绝覆写或随 job 删除。发布与删除还
共用同进程 source 互斥,避免在最终复核与回执落盘之间竞态删除。该保护只覆盖
当前回执且不跨进程;重生清除回执后,旧已发布源是否永久保留尚未定义,
多实例仍需持久发布历史和分布式锁/事务。
重要限制:上述是针对明确非课堂必需端点的部分 deny不是完整的
learner identity/capability allowlist
物理拆分之前,还应在反向代理层只暴露该角色需要的路由。
learner 身份、classroom/job owner、资源级授权、课程权益与成本额度尚未完成。
无头 server 边界只隐藏页面和运营 API不代表其他生成、Chat、TTS、搜索等
高成本 API 已可安全向匿名公网开放。上线前必须先完成身份与授权设计,
并由网关执行最小 API allowlist。
## 2. 三端部署矩阵
| 项目 | learner desktop | ops | server |
|---|---|---|---|
| 角色 env | learner | ops | server |
| 面向用户 | 学习者/单课件作者 | 内部运营 | learner 与 ops |
| 单课件双路径 | 主能力 | 同构代码中仍存在 | 当前承载后台生成与模型 API |
| 大型课程创建/确认/重试 | 隐藏且拒绝 | 主能力 | 不提供运营 UI |
| 大课学习 | 主能力 | 验收可用 | 提供 manifest/bundle |
| 原 Stage | 学习和编辑 | 模块审阅 | 不渲染 |
| ACCESS_CODE | 可选全站访问码 | 生产必配 | 通常不配,发布走 token |
| COURSEWARE_PUBLISH_TOKEN | 不配 | 当前课程发布内部必配 | 跨实例发布必配 |
| LLM/TTS/媒体 provider | 单课件生成与原课堂按需 | 主 Agent、模块生成、TTS、媒体 | 仅其实际承载的模型端点按需 |
| 文件数据目录 | 可读课程缓存为浏览器数据 | classroom、job、framework | courseware、bundle、manifest |
| 多实例 | UI 可横向,但生成任务仍受状态约束 | 不可并发写文件 repo | 当前不可并发写文件 repo |
learner desktop 不是“无模型 key 的纯静态站”。它保留单课件生成和原课堂教学 Agent
当前同构部署可通过 server-configured provider 提供模型;物理 desktop 拆分后可改为调用
受控 server API。只有目录、manifest 和 bundle 下载属于零 LLM 热路径。
## 3. 环境变量
### 3.1 角色与鉴权
| 变量 | 作用 | learner | ops | server |
|---|---|---:|---:|---:|
| OPENMAIC_DEPLOYMENT_ROLE | 服务端角色all/ops/server/learner | 必配 | 必配 | 必配 |
| NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE | 同一非敏感角色名,控制 UI affordance | 必配 | 必配 | 可配 |
| ACCESS_CODE | HMAC 会话口令 | 可选 | 生产必配 | 可选 |
| ACCESS_CODE_SESSION_TTL_SECONDS | 会话 TTL默认 604800 秒7 天) | 按需 | 可配 | 不配 |
| OPS_PUBLIC_ORIGIN | ops 同源写校验的 canonical origin | 不配 | 反代部署必配 | 不配 |
| COURSEWARE_PUBLISH_TOKEN | 服务端发布凭据 | 不配 | 当前课程发布必配 | 跨实例接收必配 |
| COURSE_PUBLISH_SERVER_BASE_URL | 固定的跨进程发布 origin | 不配 | 独立部署必配 | 不配 |
| COURSEWARE_PUBLIC_BASE_URL | learner 可访问的课件公开 origin | 不配 | 不配 | 生产必配 |
不存在受支持的 NEXT_PUBLIC_COURSEWARE_PUBLISH_TOKEN。不要把发布密钥放入浏览器构建。
ACCESS_CODE cookie 使用 HMAC 签名并带签发时间Edge 中间件与 Node route 使用
同一 TTL 规则,拒绝过期或未来时间 token。POST /api/access-code/verify 只接受
同源 application/json对 Content-Length 和实际 chunked stream 都强制 8 KiB 上限,
并对失败尝试做有界进程内限流。所有使用该 cookie 的 ops 写操作也要通过
Origin 同源校验。进程内限流不能代替网关级的分布式限流。
同源校验不信任 X-Forwarded-Host如果 ops 位于反向代理后,必须把浏览器真实
origin例如 https://ops.example.com配置为 OPS_PUBLIC_ORIGIN。
### 3.2 生成与模型
| 变量 | 作用 | 备注 |
|---|---|---|
| MODEL_ROUTES | 按 stage 路由模型 | 至少按需配置 generate-classroom、course-framework、scene-content、scene-actions |
| 各 PROVIDER_API_KEY / BASE_URL / MODELS | LLM provider | 以 .env.example 为准 |
| 各 TTS_* | 讲解音频与原课堂语音 | 大课发布要求每个 speech 有持久化音频 |
| IMAGE_* / VIDEO_* | 可选媒体生成 | 未生成或无法解析的媒体会在发布时 fail closed |
| Web search provider 变量 | 可选框架研究/单课件联网 | 未配置时相应生成路径降级 |
| OPENMAIC_ENABLE_VOCATIONAL | 服务端职教 task-engine 门 | 客户端开关本身不是安全边界 |
| NEXT_PUBLIC_SHOW_VOCATIONAL_TEST_UI | 显示实验入口 | 仅 UI |
| NEXT_PUBLIC_PI_CHAT_ENABLED | 原 Pi 课堂 Chat 开关 | 属于受保护课堂能力 |
| OPENMAIC_ENABLE_PI_WEB_SEARCH | Pi Director 联网门 | 独立于 Pi Chat 开关 |
主 Agent 模型示例:
MODEL_ROUTES={"course-framework":"openai:gpt-4o-mini","generate-classroom":"openai:gpt-4o"}
实际 provider:model 格式应与当前 server provider 配置保持一致,不要照搬旧文档中的
provider/model 斜杠写法。
### 3.3 文件仓库
| 变量 | 默认目录 | 角色 |
|---|---|---|
| CLASSROOM_DATA_DIR | data/classrooms | learner/ops 当前生成服务 |
| CLASSROOM_JOBS_DIR | data/classroom-jobs | learner/ops 当前生成服务 |
| COURSE_FRAMEWORK_DIR | data/course-frameworks | ops |
| COURSEWARE_DATA_DIR | data/coursewares | server当前同源发布也直接写 |
| COURSEWARE_BUNDLE_DIR | data/courseware-bundles | server |
| COURSE_MANIFEST_DIR | data/course-manifests | server当前 ops 同进程提交 |
| COURSEWARE_MAX_UPLOAD_BYTES | 314572800 | server单模块 ZIP 上限 |
| COURSE_PUBLISH_MAX_UPLOAD_BYTES | 943718400 | ops/server整批 multipart 上限 |
| COURSE_PUBLISH_TIMEOUT_MS | 600000 | ops 调用 server 的超时 |
| COURSE_PUBLISH_ALLOW_INSECURE_HTTP | false | 仅可在受信私网显式开启 |
### 3.4 通用持久化
| 变量 | 作用 | 当前边界 |
|---|---|---|
| NEXT_PUBLIC_PERSISTENCE=1 | 浏览器改用 HTTP persistence backend | 构建期变量 |
| NEXT_PUBLIC_PERSISTENCE_TOKEN | 开发共享 token | 不提供用户隔离,不得作为公网生产鉴权 |
| PERSISTENCE_DEV_TOKEN | 与公开开发 token 配对 | 仅开发 |
| DATABASE_URL | 通用 document/runtime/asset Postgres backend | 没有替换 courseware/manifest repo |
| ASSET_S3_BUCKET | 通用 asset 字节存储 | 没有替换 frozen bundle byte store |
不要因为配置了 DATABASE_URL 或 ASSET_S3_BUCKET就把发布仓库描述为已经使用
Postgres/S3。
## 4. 当前启动方式
每个角色应使用独立构建目录或独立构建流水线,因为公开角色会在构建时内联。
### Docker Compose
Dockerfile 把 NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE 声明为 builder ARG/ENV
Compose 为 learner、ops、server 分别构建独立镜像上下文,并在运行时固定同值的
OPENMAIC_DEPLOYMENT_ROLE。不要只在 container 启动时改 NEXT_PUBLIC 角色;那不会重写已经内联的
浏览器 bundle。
# learner默认端口 3000
docker compose up --build learner
# ops默认端口 3102.env.local 必须有 ACCESS_CODE
docker compose --profile ops up --build ops
# server默认端口 3103
docker compose --profile server up --build server
可分别用 OPENMAIC_LEARNER_PORT、OPENMAIC_OPS_PORT、OPENMAIC_SERVER_PORT 改写宿主机端口。
`.env.local` 仍负责 provider key、ACCESS_CODE 和 COURSEWARE_PUBLISH_TOKENCompose 中的硬编码角色会
覆盖 env_file 中同名角色防止服务端权限与客户端入口不一致。learner、ops 和 server 默认使用
独立数据卷。ops 配置 COURSE_PUBLISH_SERVER_BASE_URL 后使用跨实例整批发布;
未配置时仅保留历史同进程共享文件库 fallback。
### 直接启动 Next.js
#### learner desktop 表面
OPENMAIC_DEPLOYMENT_ROLE=learner
NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE=learner
pnpm build
pnpm start -p 3101
若要使用单课件生成和原课堂模型能力,还需配置相应 provider 与 MODEL_ROUTES。
若只做发布课程的读取 smoke可不配置模型但这不代表完整 learner 产品无需模型。
#### ops
OPENMAIC_DEPLOYMENT_ROLE=ops
NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE=ops
ACCESS_CODE=<strong-secret>
OPS_PUBLIC_ORIGIN=https://ops.example.com
COURSEWARE_PUBLISH_TOKEN=<server-only-secret>
COURSE_PUBLISH_SERVER_BASE_URL=https://course-server.example.com
pnpm build
pnpm start -p 3102
ops 还需主 Agent、单课件生成、TTS 和媒体 provider以及可持久化的 classroom、
job、framework 数据卷。
#### server
OPENMAIC_DEPLOYMENT_ROLE=server
NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE=server
COURSEWARE_PUBLISH_TOKEN=<same-server-only-secret>
COURSEWARE_PUBLIC_BASE_URL=https://learn.example.com
COURSEWARE_DATA_DIR=/srv/openmaic/coursewares
COURSEWARE_BUNDLE_DIR=/srv/openmaic/courseware-bundles
COURSE_MANIFEST_DIR=/srv/openmaic/course-manifests
pnpm build
pnpm start -p 3103
### 跨进程大课发布契约
ops 在本地 persisted classroom 上执行完整冻结和二次快照校验,然后向固定路径
POST /api/internal/course-publish 发送一个 multipart 请求metadata JSON 加全部模块 ZIP。
该路由只在 server/all 角色开放,即使 server 环境意外带有 ACCESS_CODE也只认
COURSEWARE_PUBLISH_TOKEN Bearer不认 ops cookie。
server 在一个 course 单进程互斥边界内:
1. 对全部 incoming ZIP 运行与单课件发布相同的完整门禁;幂等命中前还会读取 exact 已存
ZIP复核存在、内部 id/version/hash、重算 hash 与 complete缺失或篡改会发布新版本
不会返回伪成功;
2. 调用原 publishCourseware 逐模块分配/重写版本并以 unpublished 暂存;
3. 全部通过后切换 published再调用原 publishCourse 写 schema-v2 manifest
4. 大课 courseware record 私有携带 courseId/moduleIndex公共 catalog、detail、download
只有在任一已提交 schema-v2 manifest 包含 exact id/version/hash pin 时才可见,因此
promote 到 manifest 提交之间以及失败补偿期间均返回 404/不进入列表;
5. 任一失败将本批状态补偿回 unpublished
6. ops 只在回执的 module id/version/hash 与本地 ZIP 逐项一致后写入 publication receipt
receipt 还保存每模块的 sourceRevisionHash。查询状态会重读完整 classroom
并比较该哈希;任意源修改或模块重生都使 receipt 失效。
contentHash v2 明确忽略 manifest.json 的 exportedAt。相同 metadata + 课堂内容的
远程超时重试、重复点击,以及同源共享文件 fallback 都幂等返回既有
manifest。无 contentHashVersion 的历史包仍使用 v1 原始字节哈希learner 的旧内部
version 兼容只对能通过精确 registry 与重算 v1 hash 的真正旧包生效。若要
强制产生新版本,必须先改变课程内容,当前契约没有 `forceNewVersion` 开关。
安全约束:目的地由 server-only COURSE_PUBLISH_SERVER_BASE_URL 固定,不接受浏览器输入;
URL 禁止凭据/path/query/fragmentfetch 禁止 redirect非 loopback HTTP 默认拒绝。server 在
formData 缓冲前对实际 stream 封顶,且解析后再校验每个 File.size。公开 bundleUrl 仅由
COURSEWARE_PUBLIC_BASE_URL 派生,不使用内网 request Host。失败响应稳定包含 errorCode、phaserequest/
staging/commit/rollback、可选 moduleIndex 和 details。
未配置 COURSE_PUBLISH_SERVER_BASE_URL 时,当前单仓运行仍使用同源共享文件库,但复用同一
批量事务/幂等/visibility helper该 fallback 是迁移兼容,不是多实例事务。
## 5. 文件仓库为何只能单实例
当前正确性依赖以下进程内状态:
- courseware publishLocks
- CourseRecord courseLocks
- CourseManifest manifestLocks
- 大课 runningRuns、AbortController 和当前 classroom job 集合;
- 版本号先读历史、再加一、再写回的 read-modify-write。
文件层虽使用原子 renamebundle 使用 link 实现 create-if-absent但 Map mutex 只在单进程内
有效。两个容器可能同时读到相同 nextVersion或覆盖彼此刚写的 JSON history。
当前“事务”是失败后把本批记录补偿回 unpublished不具有数据库事务的跨进程隔离性、
原子提交或崩溃一致性。
当前生产约束:
1. courseware、manifest、framework 的写入实例都保持 1
2. 不用多副本共享 NFS 来伪装数据库事务;
3. 发布和生成期间避免滚动重启;
4. 备份整个版本历史 JSON 与 bundle 目录,并保持二者一致;
5. classroom/course runner 重启后需要通过记录自愈,再由运营点击继续/重试。
这些限制意味着:只要计划公网生产上线或多副本扩容,下述 Postgres 事务、
唯一约束与私有对象存储就是硬前置,不是可选性能优化。
未来多实例的最低实现:
- Postgres 表为 courseware_versions 和 course_manifest_versions 建唯一键;
- 在同一事务内锁定 course/courseware、分配 next version、写 metadata 与状态;
- bundle 写对象存储key 包含 id/version/hash使用 put-if-absent对象在 manifest commit
前必须保持私有CDN/签名 URL 也必须执行等价的 exact-pin visibility gate
- manifest 只在全部对象与 metadata 可读后提交;
- 下载 URL 指向对象存储/CDN
- 生成任务进入持久队列worker 使用租约/幂等键;
- 失败补偿只改变本批 staged 记录,不覆盖历史已发布版本。
## 6. legacy manifest 重发操作
识别方式:
- GET /api/learn/courses/{id} 返回 409
- 错误为 legacy course manifest must be republished
- 文件可能是旧 singleton也可能已经进入 history envelope但记录本身没有
schemaVersion=2 或模块缺 version/hash。
安全重发流程:
1. 保留旧 manifest 文件并备份,不直接编辑旧版本。
2. 在 ops 打开对应 CourseRecord确认 framework 和模块 classroom 仍存在。
3. 运行发布校验;如提示 MODULE_OUTPUT_UNVERIFIED、CONTINUITY_UNVERIFIED、
CONTINUITY_STALE、INTERACTIVE_HTML_MISSING 或缺音频/媒体,从最早失败模块开始
按顺序重生成。
4. 人工审阅修复后的模块,再由 ops 执行“发布课程”。
5. 新发布会生成下一 manifest 版本schemaVersion=2每模块固定
coursewareId + coursewareVersion + contentHash。
6. 用精确 manifest version 访问 learner 页进行 smoke。
7. 旧无 pin 版本保留审计,但继续拒绝 learner不要把当前 latest 手工写成旧历史 pin。
若原 CourseRecord/classroom 已丢失,不能伪造历史身份。应按新课程重新生成、审阅、发布。
## 7. 机械拆分顺序与每步门槛
### Step 0冻结基线
- 固定 bundle format、manifest schema v2、CourseRecord compat 和 protected core 清单;
- 保存当前针对性测试结果;
- 不在拆分 PR 中改变 Chat、Stage、Scene、Action、PBL 或白板语义。
回滚门槛:任何 learner bundle 无法进入原 Stage立即停止拆分。
### Step 1共享契约
- 移动 bundle types/hash/serialize、course manifest types、API DTO
- 把环境读取留在应用 adapter纯契约包不读取 process.env
- ops、server、learner 同时消费同一版本,禁止复制类型。
回滚门槛:同一 fixture 的 contentHash 或 imported document 发生变化。
### Step 2先拆 server
- 搬 app/api/coursewares/**、app/api/learn/courses/**、
lib/courseware-repo、lib/course-manifest-repo 和 bundle byte store
- 新增只接受 server token 的 course manifest 提交接口;
- 现有 ops 先通过 HTTP adapter 调新 server保留同源 adapter 作为短期回滚;
- reader 先迁、writer 后迁,确认旧 manifest 409 策略一致。
回滚门槛:精确 version/hash 查询、不可变下载头或发布门禁不一致。
### Step 3先完成 DB/对象存储,再扩 server
- 文件 repo 双写只用于迁移核对,不作为长期架构;
- 对比记录数、版本历史、hash 与对象字节;
- 切读后保留文件只读备份,确认稳定再停双写;
- 未完成事务和唯一键前保持单实例。
### Step 4再拆 learner desktop
- 整体搬 app/page、generation-preview、tasks、learn、classroom 和其依赖;
- 原 Stage 与 protected core 作为一个共享包/工作区依赖整体复用,不 fork
- 只替换 API base URL、鉴权 adapter 与文件路径;
- 保留首页普通后台与材料/Interactive/职教前台双路径。
回滚门槛:出现第二套助教、发布课堂带 outlines、或 learner 不再进入原 Stage。
### Step 5最后拆 ops
- 搬 courses UI、course-framework、publish-validation、module-digest 和发布发起端;
- classroom 生成/审阅仍使用同一单课件 agent 与 protected core
- 浏览器只持 ACCESS_CODE 会话,不持 server token
- 完成 ops → server 的 bundle 与 manifest transport。
### Step 6收口
- 删除 all 的生产支持和同源 repo 直写;
- 给每个 app 配置显式 route allowlist、独立构建和最小 provider key
- 做一次 legacy 重发、并发发布、失败回滚和旧 manifest 固定版本演练。
## 8. 验收命令
在 /Users/inmanw/项目/麦洛学习/OpenMAIC 执行。
### 类型与格式
pnpm exec tsc --noEmit
pnpm exec prettier ../docs/architecture-3-tier.md ../docs/deployment-3-tier.md ../docs/large-course-mode.md ../docs/handover.md --check
### 三端、大课、冻结发布定向回归
pnpm exec vitest run \
tests/config/deployment-role.test.ts \
tests/server/ops-access.test.ts \
tests/generation/foreground-session.test.ts \
tests/server/classroom-outline-mode.test.ts \
tests/server/classroom-generation-retry.test.ts \
tests/course-framework \
tests/bundle \
tests/courseware
### learner/ops 浏览器边界
pnpm exec playwright test \
e2e/tests/home-to-generation.spec.ts \
e2e/tests/deployment-role-boundary.spec.ts \
--project=chromium
Playwright 默认 webServer 已显式固定 learner 角色。若本机已有 3002 端口的旧 dev server
先确认它的角色,避免 reuseExistingServer 让边界测试误连 all/ops。
### 构建
pnpm build
### 读路径压测
node scripts/load-test-reads.mjs + --base http://localhost:3103 + --courseware <published-id> + --concurrency 20 + --requests 400
## 9. 已知受保护 Chat 基线漂移
隔离诊断命令:
pnpm exec vitest run \
tests/lib/chat/pi/director-tool-wiring.test.ts \
tests/lib/chat/pi/route-cue-user.test.ts \
tests/lib/chat/pi/prompts.test.ts
当前结果为 3 files 中 40 passed / 3 failed失败正好是
- promptskeeps concept and mechanism questions teacher-led before student reactions
- route-cue-userhands successful web evidence to one child and clears it before later delegations
- route-cue-userdoes not leak consumed web evidence after the selected child fails
它们属于既有 protected Chat director 的 prompt/evidence 断言漂移,本次三端改造未触及
lib/chat/** 或 /api/chat/pi。验收报告应原样记录这 3 项;不要在本改造中修改 Chat
内核或放宽测试来掩盖它们。