21 KiB
麦洛学习三端部署与运维
事实基线: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 404;provider 探测/验证、
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_TOKEN;Compose 中的硬编码角色会
覆盖 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 单进程互斥边界内:
- 对全部 incoming ZIP 运行与单课件发布相同的完整门禁;幂等命中前还会读取 exact 已存 ZIP,复核存在、内部 id/version/hash、重算 hash 与 complete;缺失或篡改会发布新版本, 不会返回伪成功;
- 调用原 publishCourseware 逐模块分配/重写版本并以 unpublished 暂存;
- 全部通过后切换 published,再调用原 publishCourse 写 schema-v2 manifest;
- 大课 courseware record 私有携带 courseId/moduleIndex;公共 catalog、detail、download 只有在任一已提交 schema-v2 manifest 包含 exact id/version/hash pin 时才可见,因此 promote 到 manifest 提交之间以及失败补偿期间均返回 404/不进入列表;
- 任一失败将本批状态补偿回 unpublished;
- 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/fragment,fetch 禁止 redirect;非 loopback HTTP 默认拒绝。server 在 formData 缓冲前对实际 stream 封顶,且解析后再校验每个 File.size。公开 bundleUrl 仅由 COURSEWARE_PUBLIC_BASE_URL 派生,不使用内网 request Host。失败响应稳定包含 errorCode、phase(request/ 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。
文件层虽使用原子 rename,bundle 使用 link 实现 create-if-absent,但 Map mutex 只在单进程内 有效。两个容器可能同时读到相同 nextVersion,或覆盖彼此刚写的 JSON history。 当前“事务”是失败后把本批记录补偿回 unpublished,不具有数据库事务的跨进程隔离性、 原子提交或崩溃一致性。
当前生产约束:
- courseware、manifest、framework 的写入实例都保持 1;
- 不用多副本共享 NFS 来伪装数据库事务;
- 发布和生成期间避免滚动重启;
- 备份整个版本历史 JSON 与 bundle 目录,并保持二者一致;
- 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。
安全重发流程:
- 保留旧 manifest 文件并备份,不直接编辑旧版本。
- 在 ops 打开对应 CourseRecord,确认 framework 和模块 classroom 仍存在。
- 运行发布校验;如提示 MODULE_OUTPUT_UNVERIFIED、CONTINUITY_UNVERIFIED、 CONTINUITY_STALE、INTERACTIVE_HTML_MISSING 或缺音频/媒体,从最早失败模块开始 按顺序重生成。
- 人工审阅修复后的模块,再由 ops 执行“发布课程”。
- 新发布会生成下一 manifest 版本,schemaVersion=2,每模块固定 coursewareId + coursewareVersion + contentHash。
- 用精确 manifest version 访问 learner 页进行 smoke。
- 旧无 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;失败正好是:
- prompts:keeps concept and mechanism questions teacher-led before student reactions
- route-cue-user:hands successful web evidence to one child and clears it before later delegations
- route-cue-user:does not leak consumed web evidence after the selected child fails
它们属于既有 protected Chat director 的 prompt/evidence 断言漂移,本次三端改造未触及 lib/chat/** 或 /api/chat/pi。验收报告应原样记录这 3 项;不要在本改造中修改 Chat 内核或放宽测试来掩盖它们。