18 KiB
麦洛学习三端架构:learner desktop / ops / server
事实基线:2026-08-15
当前物理形态仍是一份 OpenMAIC Next.js 应用,通过部署角色封住大型课程运营能力; learner desktop、ops、server 尚未拆成三个独立应用。本文区分“已经实现的能力边界” 与“后续机械拆分”,不把目标态写成已上线事实。
1. 核心结论
- learner desktop 保留原 OpenMAIC 单课件生成、编辑与互动课堂,不是纯静态播放器。
- 大型课程只由 ops 制作:主 Agent 先给出全局框架与每模块 generationPrompt,人工确认后, 才按顺序复用原单课件生成管线。
- 发布不复制或重写课堂内核。server 把持久化 classroom 冻结为完整 bundle; learner 严格验证课程清单、版本、哈希与 bundle 完整性后,仍进入原 Stage。
- 原课堂的 Agent、ChatArea/Roundtable、Scene/Action、HTML 互动、Quiz、PBL、白板、 讲解音频和媒体都是受保护能力,不在本次三端改造中另造简化版。
- 大型课程 manifest 的模块身份是 coursewareId + coursewareVersion + contentHash。 单独重发某模块不会改变已经发布的课程版本。
- 当前 courseware、bundle、course framework、course manifest 都是文件仓库, 发布只是单进程内的补偿事务,没有跨进程隔离性,只适合单写 Node.js 实例。 Postgres 事务与私有对象存储仍是公网生产上线前的硬前置。
2. 当前形态与能力矩阵
部署角色取值为 all、ops、server、learner。生产环境未配置角色时默认 learner, 开发和测试环境未配置时默认 all。角色边界目前主要保护 manage_courses, 并不等同于已经完成物理路由裁剪。
| 能力 | learner desktop | ops | server | 当前事实 |
|---|---|---|---|---|
| 普通文本生成单课件 | 主入口 | 同构应用内仍可用 | 承载当前后台 job | POST /api/generate-classroom |
| 材料、Interactive、职教单课件 | 主入口 | 同构应用内仍可用 | 提供生成 API | 经 generationSession 进入原 /generation-preview |
| 单课件编辑、播放 | 是 | 用于审阅模块 | 否 | 均复用原 /classroom/{id} 与 Stage |
| 原课堂 Agent/讨论/白板/PBL/音频 | 是 | 审阅时是 | 提供模型和数据 API | 没有 learner 专用简化内核 |
| 创建、重生成、确认大型课程 | 否 | 是 | 否 | /courses 与 /api/courses/** 受角色和会话保护 |
| 主 Agent 课程框架 | 否 | 是 | 运行时当前仍同进程 | stage 为 course-framework |
| classroom 转 frozen bundle | 否 | 发起并审阅 | 接收整批冻结 ZIP 并事务发布 | lib/server/classroom-courseware-publish.ts + /api/internal/course-publish |
| courseware 注册、精确版本下载 | 只读 | 写入 | 主责 | /api/coursewares/** |
| course manifest 读取 | 只读 | 发布 | 主责 | /api/learn/courses/** |
| 学习大型课程 | 是 | 可验收 | 提供静态清单与 bundle | /learn/course/{id} → /learn/{coursewareId} → Stage |
| 发布凭据 | 无 | ACCESS_CODE 会话;跨实例使用服务端 token | 校验 Bearer token | 浏览器不再读取发布 token |
已实现的安全边界
- OPENMAIC_DEPLOYMENT_ROLE 和 NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE 共同决定服务端能力与 客户端入口显示;公开变量只承载非敏感角色名。
- 生产 ops 必须配置 ACCESS_CODE。middleware 保护运营路径, 每个 /api/courses/** route 还会再次执行 requireOpsAccess,校验 HMAC cookie。
- ACCESS_CODE 会话带签发时间和 TTL,默认 7 天;cookie Max-Age 与 TTL 一致, 中间件与 Node route 都会拒绝过期或未来时间的 token。登录只接受同源 application/json,实际请求体上限 8 KiB,失败尝试受有界进程内限流。
- 用 cookie 授权的 ops 写请求还必须通过 Origin 同源校验。反向代理部署应配置 OPS_PUBLIC_ORIGIN 作为 canonical origin;该校验不信任 X-Forwarded-Host。
- 同源 ops 发布课件时使用已认证会话;独立 server 接收 POST /api/coursewares 时才校验 COURSEWARE_PUBLISH_TOKEN Bearer token。
- COURSEWARE_PUBLISH_TOKEN 是服务端变量。旧的 NEXT_PUBLIC_COURSEWARE_PUBLISH_TOKEN 不再是配置项,也不得重新引入浏览器。
- learner 角色隐藏大型课程入口,直接访问运营 API 返回 403,访问运营页面会回到首页。
- server 角色是无头数据/API 服务:所有页面返回 404,且不暴露 /api/courses/、/api/ops/ 和 /api/access-code/**;未鉴权的媒体代理、provider 验证、视频导出、开发态 persistence、usage 与全局 job 列表也会 404。 内部整课发布仍只认 Bearer token。
- server 对全局 job 列表同时拒绝 GET/HEAD,并在规范化尾斜杠后对内部发布 执行 POST-only 方法边界。
- 远程发布后,ops 以当前 publication receipt 补足本地 registry 的 source 反查; 发布与 job/source 删除共用单进程互斥并在冲突时返回 409。该回执不是历史索引, 重生后旧 source 的永久保留和多副本互斥仍是后续持久化契约。
- SSRF 边界已拦截 RFC6598
100.64.0.0/10(含阿里云 metadata 地址); 专用 server 不暴露/api/proxy-media。若未来在公网 learner/all 重新开放, 必须先实现 DNS 解析 pin 和逐跳 redirect 复核。
尚未实现的隔离
- 三种角色仍从同一 Next.js 构建产物启动,生成、课堂和多数 API 文件仍共处一个进程。
- deployment role 已对明确非课堂必需端点做部分 deny,但它仍不是完整 learner identity/capability allowlist。
- learner desktop、ops、server 的独立域名和独立包尚未机械拆出;ops → server 的整批 frozen bundle + schema-v2 manifest 提交接口已封装为 server-only HTTP 契约。
- 因 NEXT_PUBLIC_* 在构建期内联,严格的 ops/learner UI 隔离应分别构建。
- learner 用户身份、classroom/job 的 owner、资源级授权与课程权益尚未实现。 因此不得把当前匿名访问原 Chat、生成、TTS、搜索或其他高成本 API 描述为已安全开放;公网部署必须先完成身份、owner、授权和额度设计。
3. 受保护课堂内核
本改造的边界是“外围编排、权限、冻结发布、目录和存储”,不是重写课堂。以下代码和契约 应视为受保护基线:
- components/stage.tsx
- components/edit/PlaybackChromeRoot.tsx
- components/chat/**
- components/roundtable/**
- components/scene-renderers/**
- lib/playback/**
- lib/chat/**
- lib/action/**
- lib/whiteboard/**
- lib/pbl/**
- 原 Stage / Scene / Action DSL 与 /classroom/{id} 播放链路
必须保持的运行时不变式:
- learner 打开的模块仍是 /classroom → Stage → PlaybackChromeRoot → SceneRenderer。
- Stage 内原 ChatArea/Roundtable 是唯一课堂问答入口;learner 外层不再挂第二套 AssistantPanel。
- 冻结包保留完整 Agent roster、persona、priority、voiceConfig、voiceDesign, 以及 discussion/multiAgent 引用。
- Stage 初始白板、场景白板、四类 Scene、教学 Action、Quiz、PBL 设计模板、 HTML iframe、媒体和 speech 音频均随包保存。
- 静态目录与 bundle 下载是零 LLM 热路径;进入原课堂后,原教师/助教、动态评分和 PBL 教学运行时仍可按原设计调用模型。不得再把 learner 描述为“整个学习过程零 LLM”。
4. 生成数据流
4.1 learner desktop 单课件双路径
首页需求
├─ 纯文本普通模式
│ └─ POST /api/generate-classroom
│ └─ 持久化 classroom job
│ └─ 原 generateClassroom 管线
│ └─ 原子写入 classroom
└─ 有材料,或开启 Interactive Mode / 职教模式
└─ 文件写入浏览器 document store
└─ sessionStorage.generationSession
└─ /generation-preview
└─ 原前台单课件生成管线
分流条件位于 app/page.tsx:
- courseMaterials 非空、interactiveMode 为 true 或 vocationalTestMode 为 true 时走前台;
- 其他普通需求走可关闭页面的后台 job;
- 前台适配器只保存浏览器拥有的文件并恢复原生成会话,不复制场景生成逻辑。
4.2 ops 大型课程生成
POST /api/courses
→ 主 Agent 生成 CourseFramework
→ status = framework_ready
→ 运营人工审查和确认
→ POST /api/courses/{id}/start
→ 模块 1 调用原 classroom job
→ outputDigest + semanticHash
→ 模块 2 注入模块 1 的实际产出与 continuityInputRefs
→ 按序继续,直到全部成功
主 Agent 的框架契约包括:
- targetAudience、courseGoals、languageDirective;
- terminology、teachingStyle、difficultyProgression、assessmentStrategy;
- 每模块 generationPrompt、learningObjectives、incomingKnowledge、 outgoingKnowledge、excludedTopics、prerequisites 和 estimatedMinutes。
第二层生成具有以下约束:
- 每个模块都复用 createClassroomGenerationJob + runClassroomGenerationJob;
- 同一课程同一时刻只有一个 framework、整课或单模块 run;
- generationPromptSnapshot 保存实际使用的主 Agent 提示词;
- outputDigest 从持久化 classroom 的四类 Scene 与 Action 中确定性提取, semanticHash 标识有界语义产出;digest v2 同时保存 sourceRevisionHash,对完整 持久化 classroom JSON 做规范化哈希,覆盖 HTML 脚本、样式、布局和媒体等变化;
- 后续模块使用前序实际 outputDigest,而不是只相信原计划;
- 任一模块失败即暂停后续模块,修复或重试成功后才顺序续跑;
- 大课模块强制 interactiveMode=true、enableTTS=true;
- Interactive Mode 若没有至少一个非空 HTML 的 interactive Scene,会在原子持久化前失败。
5. 发布数据流
独立部署时,ops 在本地冻结完全部模块后,用一个 multipart 请求把 metadata 与全部 ZIP 发往固定的 /api/internal/course-publish。server 只接受 server-only COURSEWARE_PUBLISH_TOKEN Bearer 认证;公开 bundle URL 由 COURSEWARE_PUBLIC_BASE_URL 派生,不使用请求 Host。未配置远程 origin 时的同源 fallback 走同一批量发布契约。
已完成 CourseRecord
→ validateCoursePublishSnapshot
- 模块顺序、成功状态、classroom 可读
- outputDigest 语义哈希与 sourceRevisionHash 都未漂移
- continuityInputRefs 精确匹配前序实际输出
- 每模块至少一个非空互动 HTML
→ 每个 persisted classroom 冻结为 unpublished courseware
- Agent/persona/voice
- Stage/Scene whiteboards
- Scene/Action/Quiz/PBL
- 内联 HTML 与媒体
- speech audioRef 指向包内音频
→ 再次校验整个来源快照未变化
→ 本批精确模块版本切为 published
→ 提交 schemaVersion=2 的 CourseManifest
classroom → frozen bundle 的服务端适配器会:
- 把本地媒体或经过 SSRF 检查的远端媒体转成内容稳定引用;
- 拒绝 blob URL、未解析生成占位符、空媒体、缺失讲解音频和外链互动资产;
- 把 PBL 学习者运行态剥离,只发布可重复开始的设计模板;
- 把 speech 的 audioId/audioUrl 改写成 bundle 内 audioRef;
- 要求可携带且至少有 teacher 的 Agent roster;
- 用确定性 SHA-256 内容哈希登记不可变版本。
发布期间模块先以 unpublished 暂存。每条大课 registry record 私有记录 courseId/moduleIndex; 即使状态已 promotion 为 published,公共 catalog/detail/download 仍要求某个已提交 schema-v2 manifest 精确 pin 该 id/version/hash。因此 manifest 前不存在 learner 可见窗口。若来源二次 校验或 manifest 提交失败,本批版本回到 unpublished,旧的已发布版本不受影响。当前这是 单进程事务式补偿与可见性门禁,不是数据库事务。
幂等判断不只比较 latest manifest 与 incoming contentHash,还会读取对应存储对象并复用完整
单课件发布门禁检查内部 id/version/hash、重算 hash、complete 与 portable 资源。文件缺失或
bundle.json 被篡改时不会返回幂等成功。contentHash v2 明确忽略 manifest.json
中的 exportedAt,所以远程和同源 fallback 对相同源重复发布都返回同一
manifest;无 contentHashVersion 标记的历史包仍按 v1 原始字节哈希验证,
learner 仅对能通过精确 registry 和 v1 哈希校验的真正旧包启用兼容路径。
当前没有隐式“强制新版本”语义。
ops 只在 server 回执的模块 id/version/contentHash 与本地冻结包逐项一致后 写入 publication receipt。回执还固定每模块 sourceRevisionHash;查询发布状态时会 重读当前 classroom 并比较该哈希,源课堂任何完整修改都使回执 fail closed。
6. 学习数据流与精确版本
GET /api/learn/courses/{courseId}?version={manifestVersion}
→ 只接受 schemaVersion=2 且每模块有 id + version + hash
→ /learn/{coursewareId}?version={v}&hash={h}
→ 查询 registry 的精确版本
→ 核对 registry id/version/hash
→ 下载 bundle
→ 核对 bundle 内 id、声明 hash、重新计算 hash、complete=true
→ 物化 Agent/音频/媒体/课堂文档
→ 本地 stage id = learn_{id}_v{version}_{hash}
→ /classroom/{stageId}?learner=1
→ 原 Stage
物化后的发布课堂没有 outlines,因此课堂页不会恢复场景或媒体生成;若物化中途失败, 文档、音频、媒体和资产池会补偿回滚。独立单课件入口可以先解析 latest,但缓存身份同样 包含最终解析出的 version 与 contentHash;大型课程入口始终使用 manifest 精确 pin。
7. legacy manifest 重发策略
旧 singleton manifest 可以只读并在仓库中保留,但它没有可靠的模块版本和内容哈希:
- learner API 对 schemaVersion 非 2 或任一模块缺少 pin 的记录返回 409;
- 禁止把“当前 latest”回填成“历史上当时使用的版本”,因为无法证明它是原始事实;
- 运营端应从原 CourseRecord 重新执行发布校验并重发课程;
- 如旧模块没有 continuityInputRefs、outputDigest、互动 HTML 或持久化音频, 从最早不满足门禁的模块开始顺序重生成,再发布;
- 新发布生成下一 manifest 版本并精确 pin 每个模块;旧版本继续保留用于审计, 但仍不可供 learner 打开。
8. 当前存储与扩展边界
| 数据 | 当前实现 | 当前限制 | 目标替换 |
|---|---|---|---|
| classroom | data/classrooms + 本地 media/audio | 本机文件 | 数据库元数据 + 对象存储 |
| classroom jobs | data/classroom-jobs + 内存 runner | 重启中断,靠记录自愈后续跑 | 持久队列/worker |
| CourseRecord | data/course-frameworks | 进程内锁 | Postgres 行锁/事务 |
| courseware metadata | data/coursewares 历史 JSON | 进程内版本锁 | Postgres 唯一键 (id, version) |
| frozen bundle bytes | data/courseware-bundles | 本地文件 | 对象存储 put-if-absent + manifest exact-pin 后 CDN |
| CourseManifest | data/course-manifests 历史 JSON | 进程内版本锁 | Postgres 唯一键 (course_id, version) |
| learner 物化缓存 | 浏览器 document store / IndexedDB | 单设备 | 可保留缓存,进度另走用户存储 |
文件写入使用临时文件 + rename/link,并有进程内 mutex;它只能防同一 Node.js 进程并发。 多个 server 实例、多个容器或共享卷不能依靠这些 Map 锁分配唯一版本,因此当前服务端必须 单实例写入。失败时的 unpublished 回退是应用层补偿,并不提供数据库事务的 隔离性或崩溃一致性。DATABASE_URL 和 ASSET_S3_BUCKET 已服务于通用 document/runtime/asset 持久化抽象,但尚未替换 courseware/manifest/bundle 这组仓库,不能据此宣称发布层已上 Postgres/S3;数据库事务、唯一约束和私有对象存储是扩容或公网上线前的硬前置。
9. 后续机械拆分顺序
- 先冻结契约和测试:bundle、manifest v2、CourseRecord、protected Stage 回归保持不变。
- 抽共享契约包:仅移动纯类型、哈希、序列化和 import 适配器,不改 Scene/Action 语义。
- 先拆 server:搬 courseware/manifest repo、下载与只读 API;复用已有内部整批提交接口, 让现有 ops 通过 HTTP 适配器调用。
- 把文件 repo 替换为 Postgres + 对象存储后,再允许 server 多实例。
- 再拆 learner desktop:整体搬首页双路径、generation-preview、tasks、learn、classroom 以及受保护课堂依赖;只改 import 和 API base URL,不复制 Stage。
- 最后拆 ops:搬 courses UI、course-framework runner、发布校验与 classroom 冻结发起端; 审阅仍复用同一受保护课堂包。
- 最后删除 all 兼容角色和同源直写分支,启用按应用路由 allowlist。
详细执行与回滚门槛见 deployment-3-tier.md。
10. 验收边界与已知 Chat 基线漂移
三端改造相关的广泛受保护课堂回归曾执行 130 个文件、1,294 个测试: 128 个文件、1,291 个测试通过。稳定复现的 3 个失败位于既有 Pi Chat director 契约:
- tests/lib/chat/pi/prompts.test.ts: keeps concept and mechanism questions teacher-led before student reactions
- tests/lib/chat/pi/route-cue-user.test.ts: hands successful web evidence to one child and clears it before later delegations
- tests/lib/chat/pi/route-cue-user.test.ts: does not leak consumed web evidence after the selected child fails
第一项是当前 prompt 的 teacher + user 规则与旧 assistant fallback 断言漂移;后两项是 route fixture 对 URL 出现在 child prompt 字符串中的旧断言,与当前 evidence attachment 路径漂移。本轮大型课程、冻结发布和三端文档未修改 lib/chat/** 或 /api/chat/pi。 这些失败只作为受保护基线记录,不应在本改造中修改 Chat 内核来“顺手修绿”。