# 麦洛学习三端部署与运维 > 事实基线: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= OPS_PUBLIC_ORIGIN=https://ops.example.com COURSEWARE_PUBLISH_TOKEN= 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= 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/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,不具有数据库事务的跨进程隔离性、 原子提交或崩溃一致性。 当前生产约束: 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 + --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 内核或放宽测试来掩盖它们。