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

299 lines
18 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.

# 麦洛学习三端架构:learner desktop / ops / server
> 事实基线:2026-08-15
>
> 当前物理形态仍是一份 OpenMAIC Next.js 应用,通过部署角色封住大型课程运营能力;
> learner desktop、ops、server 尚未拆成三个独立应用。本文区分“已经实现的能力边界”
> 与“后续机械拆分”,不把目标态写成已上线事实。
## 1. 核心结论
1. learner desktop 保留原 OpenMAIC 单课件生成、编辑与互动课堂,不是纯静态播放器。
2. 大型课程只由 ops 制作:主 Agent 先给出全局框架与每模块 generationPrompt,人工确认后,
才按顺序复用原单课件生成管线。
3. 发布不复制或重写课堂内核。server 把持久化 classroom 冻结为完整 bundle;
learner 严格验证课程清单、版本、哈希与 bundle 完整性后,仍进入原 Stage。
4. 原课堂的 Agent、ChatArea/Roundtable、Scene/Action、HTML 互动、Quiz、PBL、白板、
讲解音频和媒体都是受保护能力,不在本次三端改造中另造简化版。
5. 大型课程 manifest 的模块身份是 coursewareId + coursewareVersion + contentHash。
单独重发某模块不会改变已经发布的课程版本。
6. 当前 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} 播放链路
必须保持的运行时不变式:
1. learner 打开的模块仍是
/classroom → Stage → PlaybackChromeRoot → SceneRenderer。
2. Stage 内原 ChatArea/Roundtable 是唯一课堂问答入口;learner 外层不再挂第二套
AssistantPanel。
3. 冻结包保留完整 Agent roster、persona、priority、voiceConfig、voiceDesign,
以及 discussion/multiAgent 引用。
4. Stage 初始白板、场景白板、四类 Scene、教学 Action、Quiz、PBL 设计模板、
HTML iframe、媒体和 speech 音频均随包保存。
5. 静态目录与 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. 后续机械拆分顺序
1. 先冻结契约和测试:bundle、manifest v2、CourseRecord、protected Stage 回归保持不变。
2. 抽共享契约包:仅移动纯类型、哈希、序列化和 import 适配器,不改 Scene/Action 语义。
3. 先拆 server:搬 courseware/manifest repo、下载与只读 API;复用已有内部整批提交接口,
让现有 ops 通过 HTTP 适配器调用。
4. 把文件 repo 替换为 Postgres + 对象存储后,再允许 server 多实例。
5. 再拆 learner desktop:整体搬首页双路径、generation-preview、tasks、learn、classroom
以及受保护课堂依赖;只改 import 和 API base URL,不复制 Stage。
6. 最后拆 ops:搬 courses UI、course-framework runner、发布校验与 classroom 冻结发起端;
审阅仍复用同一受保护课堂包。
7. 最后删除 all 兼容角色和同源直写分支,启用按应用路由 allowlist。
详细执行与回滚门槛见 deployment-3-tier.md。
## 10. 验收边界与已知 Chat 基线漂移
三端改造相关的广泛受保护课堂回归曾执行 130 个文件、1,294 个测试:
128 个文件、1,291 个测试通过。稳定复现的 3 个失败位于既有 Pi Chat director 契约:
1. tests/lib/chat/pi/prompts.test.ts:
keeps concept and mechanism questions teacher-led before student reactions
2. tests/lib/chat/pi/route-cue-user.test.ts:
hands successful web evidence to one child and clears it before later delegations
3. 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 内核来“顺手修绿”。