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

21 KiB
Raw Blame History

麦洛学习三端部署与运维

事实基线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 内核或放宽测试来掩盖它们。