40 KiB
Findings & Decisions
Requirements
- 麦洛教育目标形态包含用户桌面端、运营端和后台数据服务。
- 用户端保留原单课件生成 Agent,并使用运营端发布的大型连续课程。
- 大型课程制作仅开放给运营端。
- 主 Agent 围绕课程目标拆解全局框架,并为每个子模块生成提示词;人工确认后串行调用原单课件生成 Agent。
- 发布后的每个模块完整保留原互动课堂、HTML 代码互动、原教师/助教、多 Agent 讨论、Quiz、PBL、白板和音频。
- 不新增或重写课堂交互内核;改造集中在编排、权限、发布、目录和服务端外围。
Research Findings
-
2026-08-16 用户正式确认:平台账号是课程资源、购买权益和跨设备学习记录的永久主体;设备身份只用于持有证明,未登录游客数据必须支持显式迁移到账号。
-
当前 generation job store 的
updateClassroomGenerationJob可用任意 patch 覆盖或清空ownerPrincipalId,create 又始终省略 owner;因此先要把 owner 绑定从普通更新中剥离成单独的 compare-and-set/显式迁移操作。 -
当前 observe helper 故意只返回
void,无法直接复用作 enforcement。最小安全演进应新增独立的 evaluate/enforce helper,保留 shadow 路径原响应,同时由显式环境模式决定是否 fail closed;不能偷偷把现有 observe 改成返回授权结果。 -
在真实 session/device-proof provider 未接入前,production resolver 必须继续返回
unavailable;可先让创建路由在解析到 authenticated principal 时原子写 owner,并在 enforcement 模式下对 unavailable/anonymous/legacy-unowned 返回稳定错误,默认仍保持 shadow 兼容。 -
generation job 的 POST/GET-list 目前没有身份测试;list 在非 server 角色会返回全部 job。required enforcement 竖切必须同时处理 create、list、detail、cancel、resume、delete,不能只保护单资源路由而留下全局枚举。
-
大型课程 runner 直接调用 job store 创建运营内部 job;owner 绑定参数必须保持可选,避免把 ops 编排误当 learner 账号资源。公开 API 创建才从已验证 principal 派生 owner/guest binding。
-
required 模式若先从全局 job 文件中截取
limit=20再按 owner 过滤,会让活跃多用户场景中的本人任务被其他用户的新任务挤出结果;正确顺序必须是“归属过滤 → 时间排序 → 用户 limit”。文件仓库阶段可在 required 模式读取全集后过滤,未来数据库迁移应下推为带 owner 索引的查询。 -
持久化 JSON 可能被旧版本或人工操作写成 owner+guest 同时存在;授权分类与迁移都必须将这种双归属视为损坏并 fail closed,不能因为先检查 owner 就忽略 guest。
-
owner 字段除运行时防御外还应从普通 update patch 的 TypeScript 类型中移除;显式迁移函数是唯一允许写归属字段的代码路径。
-
Phase 7 已落 provider-neutral 授权底座:
RequestPrincipal不绑定具体登录供应商,当前生产 resolver 恒为unavailable且不读取任何身份 header;纯 owner policy 对 legacy/unknown 给出 indeterminate,对 ops/service 身份不提供隐式越权。 -
generation job 的 detail/cancel/delete/resume 已接 observe-only seam,observer 只返回
void且隔离同步/异步异常,现有状态码与响应体不依赖授权 decision;ownerPrincipalId为可选字段,旧 JSON 仍可读取,新任务目前不会伪造 owner。 -
独立审查未发现 observe-only 竖切阻断问题;未来接真实身份/遥测时必须增加超时或有界异步投递,并在创建资源时原子绑定不可被普通更新清空的 owner。
-
Phase 7 完成全量出站链路分级:proxy-media、Azure Voices、MinerU Cloud 验证已 DNS pin;专用 server 已隐藏 provider verify/probe,但 image/video/TTS/voice/ASR/PDF/extract 及 LLM BYOK 生成入口仍可能把客户端 baseUrl 交给二次解析的 fetch/SDK。
-
旧
validateUrlForSSRF()是“先解析校验、后由调用方重新解析连接”,仍有 DNS rebinding TOCTOU;ALLOW_LOCAL_NETWORKS又同时放开 operator self-hosted 与不可信客户端 URL,必须拆成 server-managed public、operator-private 和 learner-BYOK 三类信任策略。 -
当前最直接的链式 SSRF 是 provider 返回的二次 URL:MinerU Cloud 的 upload URL/ZIP URL、Qwen TTS 的 audio URL会被直接请求;这些 URL 即使首个 API endpoint 是公网,也必须再次执行 public allowlist/DNS pin/redirect/大小限制。
-
自托管 model probe/PDF、企业 HTTP proxy 和 AI SDK 流式调用不能直接替换成 GET-only public helper;需要支持任意 method/body/stream 的 secure transport,或由可信 egress proxy 执行目的地址策略。
-
fetchPinnedPublicUrl已扩展为有界 GET/PUT:只接受静态可测量 body,禁止覆盖 Host/Content-Length/Transfer-Encoding,上传前先做 50 MiB 默认上限,仍以单次 DNS 解析固定 Host/SNI 和连接地址。 -
MinerU 预签名 PUT 与结果 ZIP GET、Qwen TTS 返回音频 GET、AliDoc OSS 图片 GET 均已逐资源重新 pin;不把首个 API Bearer 转发给二次 URL,拒绝重定向并限制请求/响应体。
-
dedicated server 现通过中央 provider access policy 禁止 unmanaged client key/baseUrl;所有统一 LLM/TTS/ASR/PDF/image/video/web-search resolver 只接受运营配置。桌面/all、ops、learner BYOK 和运营配置的 Ollama/private self-hosted 保持原行为;无网络
unpdf是明确例外。 -
当前已有两阶段大课骨架:框架生成后停在
framework_ready,人工确认后按顺序创建并运行 classroom generation job。 -
CourseModuleSpec目前只有标题、说明、目标、前置条件和时长,没有主 Agent 输出的模块级generationPrompt。 -
runner 仅把说明、目标、前置条件和泛化连续性提示拼成 requirement;没有传递
languageDirective,也不读取前序模块实际产出。 -
大课模块复用了服务端单课件生成管线,发布后的学习路径最终仍进入原
<Stage>。 -
learner 模式在原 Stage 外额外挂载了自包含
AssistantPanel,形成与原 ChatArea 并行的第二套助教。 -
大课后台生成没有使用前台 Interactive Mode 的专用 outline prompt,因此可能生成很少或没有 HTML 互动场景。
-
首页在材料、互动模式或职教模式开启时只 toast 后 return,没有进入仍存在的
/generation-preview。 -
/courses页面和/api/courses/**缺少运营角色服务端鉴权;发布 Token 通过NEXT_PUBLIC_*暴露到浏览器。 -
大型课程 manifest 只保存
coursewareId,没有固定模块版本和contentHash。 -
当前目录没有 Git 仓库;已有定向测试曾达到 14 个文件、83 个测试通过,i18n 检查仍有多语言缺失键。
-
middleware.ts只在配置ACCESS_CODE时校验 HMAC cookie;未配置时全部请求放行,且页面请求即使未认证也继续进入前端。 -
首页
startBackgroundGeneration在存在课程材料、互动模式或职教模式时于app/page.tsx:169仅 toast 后返回;否则 POST/api/generate-classroom。 -
/generation-preview通过sessionStorage['generationSession']恢复输入,说明首页恢复前台流程必须先写入兼容的 generation session,再导航,不能只做裸router.push。 -
app/classroom/[id]/page.tsx先渲染原<Stage>,随后仅在 learner query 下额外挂载AssistantPanel;删除该 import/条件挂载即可恢复原课堂助教唯一性,不需触碰 Stage。 -
首页大型课程入口目前无条件渲染;
lib/config/feature-flags.ts还通过NEXT_PUBLIC_COURSEWARE_PUBLISH_TOKEN决定浏览器侧发布能力,课程构建页直接读取并发送该公开变量。 -
前台生成会话的最小必需结构由
app/generation-preview/types.ts定义:sessionId、requirements、pdfText、currentStep,并可带documentSources、图片、outline 与 preview phase。 -
无材料时可直接写入
requirements.requirement/language/webSearch/interactiveMode/vocationalTestMode等状态;有材料时必须先用storeDocumentBlob持久化每个 Blob,再写入含storageKey的有序documentSources。 -
现有
home-to-generation与full-happy-pathE2E 仍断言普通首页提交进入/generation-preview,但当前产品决定应调整为:普通无材料模式走后台任务,材料/互动/职教模式走前台预览。 -
isCoursewarePublishEnabled()和getCoursewarePublishToken()当前均依赖NEXT_PUBLIC_COURSEWARE_PUBLISH_TOKEN;课程构建页把它作为 Bearer header 发回同源 API,不能作为运营安全边界。 -
UserRequirements已原生支持webSearch、interactiveMode与taskEngineMode;职教开关应映射到taskEngineMode,而不是另造 session 字段。 -
generation-preview 的 active steps 会在
documentSources存在且pdfText为空时自动执行材料解析;storeDocumentBlob(File)返回 IndexedDB storage key,正好用于首页会话构造。 -
语言由
useI18n()提供locale;UserRequirements类型本身没有language字段,当前生成语言主要由服务端 outline 结果的languageDirective决定,因此首页无需为了恢复链路新增未使用的 language 字段。 -
部署边界现采用双层校验:服务实例角色决定是否具备
manage_courses,ops 生产环境还必须由现有 HMACACCESS_CODEcookie 认证;生产未配置角色默认 learner。 -
COURSEWARE_PUBLISH_TOKEN已收回服务端:同源 ops 发布由角色+ACCESS_CODE 会话授权,独立 server 部署仍用 Bearer Token 接收远端 ops 发布。 -
readCourseRecord目前直接把 JSON 断言为CourseRecord,没有 schema migration;新增框架字段必须在读取时规范化,或让所有消费者都安全 fallback,否则旧文件会在 runner 访问新字段时失效。 -
validateCourseFramework目前只校验标题、语言、摘要和 4–15 个模块;模块索引会重排,时长会夹在 5–60 分钟,但主 Agent 输出没有课程目标、连续性契约、模块生成提示词或知识边界。 -
runFrameworkPhase手工复制模块记录,只保存标题与说明;应复用emptyModuleRecord并保存生成提示词快照,确保之后框架再生成或模型输出变化时有可审计输入。 -
buildModuleRequirement当前硬编码 15–25 分钟,忽略estimatedMinutes,也没有拼入languageDirective;这是 Phase 2 可以在大课编排层修复的明确数据丢失点。 -
Phase 2 已把新模型输出和旧存量分成两条边界:新输出严格要求课程目标、连续性契约、模块提示词和知识边界;旧 JSON 只在
read/list时确定性规范化,读取不会主动改写磁盘。 -
运营确认页已改用共享
CourseDetail类型,并在确认前展示目标学习者、语言、整课目标、四项连续性契约及每模块知识边界/生成提示词。 -
course runner 原先只按 run key 去重,不同 key 的整课生成与单模块重试可以并发;现在 runner 以 courseId 做互斥,所有 start/resume/regenerate 入口也会在调度前检查并立即登记运行。
-
后台
GenerateClassroomInput目前没有interactiveMode,构造的UserRequirements也只有 requirement;因此大课 runner 即使复用单课件管线,也只能进入 package 的普通requirements-to-outlinesprompt。 -
原前台 Interactive Mode 是 app 层
INTERACTIVE_OUTLINES模板(目标 70% widgets);package 的generateSceneOutlinesFromRequirements负责统一解析/补 id。最小复用方式是在服务端仅替换传给该函数的 outlineaiCallprompt,继续复用其解析与后续 content/action/persist 管线。 -
classroom job 成功记录只保存 classroomId 和 scenesCount;runner 可从服务端持久化 classroom JSON 读取四类 Scene 实际内容,生成有界、确定性的 output digest,再把此前成功模块的 digest 注入下一模块 requirement,无需改课堂运行时。
-
Phase 3 已在模块成功后从持久化 classroom 提取四类 Scene 的实际语义证据,保存
semanticHash;后续模块记录continuityInputRefs并只在所有前序模块有成功实际产出时继续。 -
全课程流水线现在遇到模块失败即暂停;失败模块修复后,同一个课程 run 会按顺序继续剩余 pending 模块,避免在知识断层上生成后续课件。
-
后台 Interactive Mode 复用原 app prompt 模板,只替换 outline AI call,仍走 package 的解析、content、actions 和原子持久化;持久化前强制至少一个
interactiveScene 具有非空 HTML。 -
output digest 会过滤 script/style、data URI 和 learner runtime,并保留所有前序模块的哈希索引;详细 evidence 受 12k 上限约束且优先近期模块。
-
Phase 4 漂移根因是 course manifest 只保存
coursewareId,learner 又查询 latest;正确不可变身份必须是coursewareId + coursewareVersion + contentHash,传输 URL 只能由该身份派生。 -
文件型 courseware 与 manifest 原先都允许同版本覆盖或覆盖 latest;现已改成历史记录/append-only,并在单进程发布锁中完成版本分配和保存。多实例仍需要数据库唯一键或跨进程事务作为后续部署约束。
-
服务端生成的课堂素材位于
data/classrooms/<id>/{media,audio},Scene 中却保留/api/classroom-media/...URL;冻结发布必须先把这些 URL 解析成字节和稳定 bundle ref,不能让 learner 依赖运营端源文件。 -
原序列化同时保存
audioRef与audioUrl,而播放器优先 URL;冻结包必须在已有audioRef时删除远端 URL,才能真正离线且不漂移。 -
大课发布要求每条 speech 有持久化音频,因此大型课程生成统一启用 TTS;缺少 provider/字节时允许运营预览,但发布门禁失败并指出缺失资源。
-
Agent persona/voice 与 Stage 初始 whiteboard 原先未完整冻结;它们属于发布数据契约而非课堂运行时逻辑,已在外围序列化/导入和 server publisher 中补齐。
-
“冻结学习包”只禁止 outline/scene/media 的课程生成调用;原 ChatArea/Roundtable、动态评分和 PBL 教学运行时仍可正常调用模型。
-
受保护核心广泛回归的 3 个失败属于既有 Chat director 契约内部漂移,隔离重跑仍稳定复现:当前 director prompt 明确写“课堂只有 teacher + user”,但旧测试仍期待 assistant/student fallback;web evidence 的源码具备一次性 take/attachment 路径,但这两条 route fixture 没有把 URL带入 child turn。Phase 4 未修改
lib/chat/**或/api/chat/pi,因此按用户边界记录而不在本改造中修写 Chat 内核。 -
现有 Playwright 已覆盖 learner 部署拒绝 ops、首页普通后台任务和互动/材料前台单课件路径,但没有大型课程的真实发布→manifest→精确模块 URL;Phase 5 应优先用无 LLM 的 Vitest/route 集成夹具补这条数据边界,再用少量浏览器 smoke 验证 UI 导航。
-
Playwright 原先在 development 启动服务且未声明部署角色;development 按设计 fallback 为
all,导致 learner 边界用例与其环境互相矛盾。E2E 默认服务现显式固定为 learner;未来 ops 浏览器测试应使用独立 project/server 配置。 -
learner 角色下
/不保证提前打开MAIC-Database;classroom-interaction.spec.ts的旧 seed helper 直接indexedDB.open()后在缺表时同步抛错却没有 reject,Promise 因而悬挂到超时。该失败属于 E2E fixture 假设(注释仍写 DB v8,而当前已 v17),可在测试层显式初始化 Dexie/捕获事务创建错误,不需改课堂运行时。 -
课堂文档的当前权威存储是
BrowserDocumentStore使用的maic-documentsv1(stages/scenes/outlines);Quiz、Playback 和 iframe E2E 已按该契约显式 seed。classroom-interaction.spec.ts仍写入旧MAIC-Database的stageOutlines,应仅在测试夹具中迁移到前者。 -
Phase 5 已有无 LLM 的真实文件仓储集成证据:两模块冻结发布生成 schema v2 精确 pin manifest;中途冻结失败会回滚已暂存模块且 learner 端不可见;manifest v1 在模块独立发布 v2 后仍精确指向 v1。
-
三端最终边界审计表明:当前闭环仅在“同进程、单副本、共享本地文件库”条件下成立。Docker 未传构建期公开角色;server 尚非无头 allowlist;ops → server 没有跨进程 manifest 事务接口;文件库+内存锁不支持多实例/Vercel。
-
learner 可见 manifest 目前暴露与源 classroom 相同的
coursewareId,而 raw/api/classroom没有资源权限;多用户共享 server 下 job 列表/读取/取消/删除也没有 owner。这两项属于正式上线前必须建立的资源级边界。 -
已定位的可独立修复外围漂移还包括:learner
/tasks把/api/courses403 与正常 job 绑在同一Promise.all;首页无/learn目录入口;/publish未纳入 ops gating;上游成功模块重生成不会级联失效下游;浏览器重发的 ZIP 内部 version 可能仍为 1。 -
合并 Playwright 单 worker 回归已在干净 learner dev server 上 11/11 通过:先前的 iframe 场景标题和 generation redirect 失败不可隔离复现,与并行干扰/残留 server 一致,不应对原 Stage 代码做“修复”。
-
源 classroom 与公开 module id 可以在外围完整解耦:新发布使用
course_{courseId}_module_{index}稳定公开 id,registry 私有保存sourceClassroomId,公开 summary 不返回;learner/server 可用该私有关联拒绝 raw classroom 旁路,同时兼容拦截旧的 shared-id 记录。 -
源 classroom 反查是授权路径,不能复用“公开列表跳过损坏 JSON”的宽容语义;文件 repo 因此增加了严格
hasPublishedSource反查,任一 registry 文件损坏时 raw source 请求 fail closed。 -
浏览器 packager 无法无竞态地预知 next version;可行契约是 server 在 per-courseware lock 内分配版本,仅改写被 content hash 明确排除的
bundle.json.meta.version,再保存 ZIP。learner 必须同时校验 registry、包内 version 和 hash。 -
上游模块重生成不能仅修目标模块;当前做法是一次原子清空
N..end的所有尝试/输出字段并将课程置为 generating,随后顺序重建;失败会保留后续 pending,既有 resume 可续跑。 -
NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE必须在pnpm build之前注入;只在容器运行时设置会造成权限与 UI 不一致。Docker 现在对 learner/ops/server 分别构建,并在运行层固定相同角色。 -
learner 冻结课堂的“不再生成”应是路由外层明确策略,而不只依赖 bundle 恰好没有 outlines;现在 learner mode 即使遇到异常缓存也不调 Scene/媒体生成,作者课堂恢复逻辑不变。
-
跨进程发布的可复用边界已定位:ops 可用
packagePersistedClassroom(..., version: 1)生成完整 ZIP 模板;server 的publishCourseware会在每个 courseware 锁内分配真实版本、重写 bundle.json version 并重验 hash/完整性,因此运输层不应复制这些门禁。 -
当前大课发布 route 的事务边界全在 ops 进程:本地写 unpublished courseware、二次验证源快照、切 published 再写 manifest。独立部署必须把“metadata + 全部 ZIP”作为一次 server-only 批量请求,并在 server 一个 course 互斥边界内复用
publishCourseware、publishCourse和 status rollback。 -
现有
/api/coursewares只能单 ZIP multipart 上传,已有COURSEWARE_MAX_UPLOAD_BYTES单模块上限和 Bearer 校验;大课远程 transport 需在此之上再增加整批 Content-Length/实际 File size 上限,不能仅信任客户头。 -
文件 repo 可以把本批版本状态补偿为
unpublished,bundle byte store 也支持删除单个版本;当前 manifest repo 是全局实例,为做真实隔离仓库测试,publishCourse需增加可选CourseManifestRepo注入,生产默认仍用原全局 repo。 -
runCoursePublishExclusive已提供每 course 单进程互斥;server 批量接口可直接复用。它不是跨副本锁,因此文档仍必须保留“多实例上线前迁到数据库事务”限制。 -
远程发布的安全重试可不新增独立 idempotency 表:server 在 staging 前解析/重算全部 incoming ZIP hash,再将 latest schema-v2 manifest 的课程 metadata 与 module index/id/title/description/hash 精确比较;完全一致时直接返回旧 manifest,超时重发不会产生空转版本。
-
ops 详情当前只查本地 courseware repo 并比较私有
sourceClassroomId;独立 server 下必然误显示“未发布”。最小闭环是在 opsCourseRecord保存一份精确 publication receipt(manifest version + 每模块 source classroom/digest + id/version/hash),详情仅在 receipt 与当前源身份一致时显示已发布;从任意模块开始重生时清除整课 receipt。 -
ACCESS_CODE 原先的 HMAC 会话没有过期语义、登录限流或 Cookie 写请求的 Origin 检查;现在 Node/Edge 共用同一时效契约(默认 7 天),登录使用有界单进程限流,只在显式信任代理头时按客户 IP 分桶。
-
server角色不能只是“隐藏运营入口”;它必须是无头服务。当前 middleware 已将全部非 API 页面以直接 404 隐藏,并将 courses/ops/access-code 信任域从 server API 面移除;其余课堂/生成 API 仍等待 owner 契约,不应被误宣称为已完成多用户安全化。 -
项目已有
capBodyStream,可在formData()之前包裹请求 stream,因此整批上传能同时防 Content-Length 谎报和 chunked 无上限缓冲;解析后仍逐模块检查 File.size。 -
internal server 接口不应用 request Host 生成 registry
bundleUrl,否则会把 ops 访问的内网 hostname 固化给 learner;生产必须用严格 origin 形式的COURSEWARE_PUBLIC_BASE_URL。 -
publication receipt 存在时必须 fail closed:若当前 classroom/digest 与 receipt 不符,不能再 fallback 到本地 latest
sourceClassroomId;只有完全没有 receipt 的 legacy/shared-FS 记录可用旧查询兼容。 -
除模块级
invalidateCourseModulesFrom外,整个框架重生路径会直接将framework/modules置空;该原子更新也必须同时清除 publication receipt,避免新框架尚未产生时运营端仍持有旧发布身份。 -
新冻结包使用
contentHashVersion: 2:哈希规范化时忽略仅表示导出时刻的manifest.exportedAt,因此相同课堂字节在网络超时后重新打包仍能命中 server 幂等判断;无版本字段的历史包继续按原始 v1 规则验证,避免破坏已发布内容。 -
ops → server 已使用一次 multipart 传输 metadata+全部 ZIP;remote/local 共用同一 commit 内核,同内容重复发布均返回原 manifest,响应丢失不会制造空转版本。
-
大课模块的 registry 记录私有保留
courseId/moduleIndex/sourceClassroomId;公开 catalog/detail/download 只在 schema-v2 manifest 包含精确 id/version/hash pin 后可见。 -
发布回执新增全量 canonical classroom
sourceRevisionHash,不再仅靠会丢弃 script/style/media/action 细节的语义摘要判断当前性;HTML 脚本单独变化也会使发布状态失效。 -
classroom/courseware 文件库已在路由、facade、record store 和 byte store 多层校验 id/version 与 resolved path;同版本幂等命中前会读回实际 ZIP,核对完整性、内部身份、私有归属和精确字节,缺失/损坏不再返回假成功。
-
OPS_PUBLIC_ORIGIN是 cookie-authenticated 运营写请求的 canonical Origin;X-Forwarded-Host即使用于可信 IP 限流,也不再能扩充 CSRF 允许域。 -
server 端其余课堂/单课件 API 仍需要 learner identity/entitlement 契约;不能为了隐藏高成本 API 而直接禁用原 Chat、Quiz、PBL、TTS 或用户端单课件生成。
-
/api/proxy-media不是原课堂教学运行时必需合约,且存在 DNS-rebinding 窗口;server 角色现已对其直接 404,同时 SSRF IP 分类补齐 RFC6598100.64.0.0/10(含阿里云100.100.100.200)。其他用户控制 URL 的服务端 fetch 仍应在正式身份契约阶段统一切换到 pinned-resolution safe fetch。 -
server 无头边界已额外隐藏 provider 探测/验证、MP4 export、开发态 persistence、usage 和无 owner 的全局 job 列表;单课件 POST 与原课堂运行时端点保留,等待 learner capability。
-
generation job DELETE 原先可绕过 raw classroom 读取保护删掉已发布源;现在会先做 fail-closed published-source 反查,命中时 job 与 classroom 都保留。
-
QA/TTS 进程内限流默认不信任 XFF/X-Real-IP,仅在
LEARNER_RATE_LIMIT_TRUST_PROXY_HEADERS=true且入口覆写头时启用;活跃 key 采用硬上限+共享 overflow bucket,不再无界增长或每次全表 reap。 -
manifest metadata/hash 相同不足以证明一次重试仍可用;幂等返回前必须读取 exact bundle bytes,并复用完整单课件 publish gate 验证内部 id/version/hash、重算 hash、complete 与 portable 资源。对象丢失或只篡改未参与 hash 的
bundle.json都必须拒绝旧幂等命中并生成新版本。 -
文件补偿事务不能提供数据库隔离,但大课 record 的私有 courseId/moduleIndex 可以建立最小可见性门禁:catalog、detail、download 只有在任一已提交 schema-v2 manifest 精确 pin 该 id/version/hash 时才公开,因此逐模块 promotion 到 manifest commit 之间不会泄漏给 learner。
-
同源共享文件 fallback 已复用与远程 server 相同的批量 transaction helper,远近端现在对相同 metadata + 内容都幂等;显式重复点击也是 no-op,当前没有 forceNewVersion 契约。
-
Phase 6 已完成可在单实例文件仓库边界内可靠验证的三端隔离、跨进程发布、冻结内容身份、源 revision 和高风险 server deny;数据库事务、对象存储、共享锁/限流及 learner owner 不是同一层面的“收尾”,已独立迁入生产化阶段。
-
专用
role=server已通过 404 隔离/api/proxy-media并补齐100.64.0.0/10,但learner/all公网部署仍需要连接级 DNS pin:仅在校验阶段解析域名不足以抵御 fetch 时的二次解析和重定向变址。 -
身份机制不能替代 SSRF 防护;safe-fetch 可在不决定账号模型的前提下先落地。job/classroom owner、课堂运行时调用额度与单课件生成权益则会改变公开/登录产品体验,必须在身份方案确定后实施。
-
项目已直接依赖
undici,可通过自定义 dispatcher/连接 lookup 把请求连接绑定到预先验证的 IP,同时保留 URL hostname 供 Host 与 TLS SNI 使用;现有/api/proxy-media的逐跳 URL 校验仍使用全局 fetch,尚未绑定到验证结果。 -
当前依赖中没有 NextAuth/Better Auth/Lucia/Clerk/JWT/Jose 等正式账号或 Session 库,只有
pg和面向运行时/文档/资产的存储包;现有PERSISTENCE_DEV_TOKEN + x-learner-key已在源码中明确标注为开发态、客户端可伪造,不能升级命名后当作 learner 身份。 -
现成 Postgres document/runtime/asset stores 可以承载学习状态,但没有 users/devices/sessions/entitlements/resource ownership schema;正式身份必须新增独立 auth 数据模型,并由服务端 claim 派生 learner principal,不能继续信任浏览器提交的 learnerKey。
-
learner 身份的推荐终态不是账号 Session 与设备 capability 二选一:账号内部 UUID 是权益、跨设备和找回主体;桌面端用系统浏览器 OAuth 2.1 Authorization Code + PKCE,并用设备密钥/DPoP 证明令牌持有;打开课堂时再签发 5–15 分钟、绑定
coursewareId + version + contentHash的 runtime grant。 -
RequestPrincipal应统一承载 user/device/ops/service 四类主体,owner、learnerKey 和配额主体都只能从服务端 claim 派生。job/classroom/render/voice/draft 必须服务端写 owner;跨 owner 查询统一 404,随机 ID 不能代替授权。 -
课堂 runtime grant 只约束外围 API:Chat/QA/Quiz/PBL/TTS 先校验 exact classroom/courseware 资源、action scope、entitlement 与 quota,再进入现有 handler;不需要修改 Stage、ChatArea/Roundtable、Quiz/PBL 或播放内核。
-
当前
/api/qa按 coursewareId 读取 latest,未来必须改为 grant 固定的 version/hash;现有大课 manifest exact pin 不能在动态答疑层被 latest 查询绕过。 -
身份实施前仍需产品确认:正式发布是否强制登录/保留邀请码游客;桌面技术形态与登录渠道;是否首版多租户;目录/manifest/bundle 是否公开或需 entitlement;离线下载不可撤回的接受度;BYOK 是否开放及是否经过平台;学习进度是否需要跨设备/教师/证书级可信。
-
无需等待产品选择的安全不变量是:内部 UUID 主体、服务端派生 owner、短期 exact-resource grant、动态高成本 API fail closed、事务配额预留、旧
legacy_unowned数据不自动认领、原课堂内核保持不变。 -
server 精确路径策略不能只看 GET:Next.js 可从 GET 派生 HEAD,而尾斜杠会绕过 未规范化的精确比较。当前全局 job 列表已同时隐藏 GET/HEAD,内部发布在路径 规范化后仍只允许 POST。Azure voices 列表属于 provider 探测,也不再暴露于专用 server。
-
remote ops 不挂载 server courseware registry,因此已发布 source 保护必须同时查当前
CourseRecord.publication回执。回执与 registry 反查都是授权路径,损坏时 fail closed。 -
发布最终复核与 receipt 落盘之间存在 source DELETE TOCTOU;当前用同进程多 source 原子 claim 与单 source delete claim 互斥,冲突返回 409。这与其他内存锁一样不跨副本。
-
当前 publication receipt 会在重生时清除;旧已发布 source 是否必须永久保留没有 产品契约,本轮不新增历史索引。跨进程提交后本地 receipt 写失败/响应丢失的窗口也只能 由持久 idempotency/outbox 与数据库事务最终消除。
-
courseware record/bundle 之外,course manifest 文件仓库也必须在自身边界校验 id 和 safe positive version,并只解析配置根目录的直接子文件;不能只依赖当前 HTTP 路由的上游校验。
-
新的 public URL fetch 边界采用“每跳单次
dns.lookup(all)→ 任一非 global-unicast 即整跳拒绝 → Undici Agent lookup 只返回已验证集合 → 手动重定向重新建 Agent”的连接级契约;URL hostname 保持不变,因此 Host/TLS SNI 不需要改写为 IP。 -
public media policy 明确忽略全局
ALLOW_LOCAL_NETWORKS,拒绝凭据 URL、IPv4 mapped IPv6、CGNAT/metadata、文档/benchmark/multicast/reserved 与 IPv6 transition/documentation 范围;内部自托管 provider 的例外不能外溢成匿名公网代理。 -
/api/proxy-media已改为 8 KiB 声明+实际 JSON 流上限、最多 5 次逐跳 pinned redirect、25 MiB transport/读取上限和泛化错误。连接 dispatcher 只在响应体读取/取消后关闭,避免释放过早又不遗留 socket。 -
远端发布的 ops 进程没有 server 端 courseware registry,因此“是否为已发布 source”的删除保护不能只查本地 registry;现以 current
CourseRecord.publication.modules[].sourceClassroomId作为现有远端回执 fallback,并对损坏回执 fail closed。框架/模块重生成清掉 current receipt 后是否永久保留旧 source,仍是历史审计产品语义,不在本批擅自增加永久索引。 -
发布从课堂快照、冻结、远端提交到回执落盘期间,与 job DELETE 共享按 source classroomId 的同步 claim;这样删除无法在最后 revalidate 与 receipt 写入之间穿过。该 gate 仍是单 Node 进程语义,多实例要由数据库 CAS/锁取代。
-
专用 server 对 ownerless 全局 job list 的隐藏必须同时覆盖 GET 与 Next 自动派生的 HEAD;内部 publish 也必须在带尾斜杠时维持“仅 POST”。无客户端调用的
/api/azure-voices属 provider 探测,已从专用 server 面隐藏。 -
course manifest 文件仓储现在也在自身边界校验 courseId、safe-positive version 与 direct-child 路径,不再只依赖上游 route/transaction;这与 courseware record/ZIP 的多层路径防护一致。
-
outbound 初盘显示剩余风险不是一个入口:provider probe、Azure voices、PDF/ASR/TTS、image/video adapter、文档解析、模型 SDK 和素材下载都有 URL/baseUrl fetch。必须区分客户端 URL 与运营配置的 managed URL;后者可能是私网自托管服务,不能被 public-only policy 静默破坏。
-
Azure voices 与 MinerU Cloud 这类明确公网、单次 GET 且拒绝重定向的路径可直接复用 pinned helper;image/video/voice/AI SDK 涉及 POST、轮询、provider fallback 或 SDK 自己的 fetch 注入,应按 adapter 设计逐批迁移,不能用一次全局替换冒险。
-
/api/provider/probe-models原本也支持ALLOW_LOCAL_NETWORKS下的本地自托管模型。正式方案要么在身份保护后用 operator 精确 host allowlist + pinned connection,要么保持专用 server deny;不应为了 SSRF 修复把桌面自托管能力无提示删除,也不能继续用“允许任意私网”的全局开关。 -
身份供应商未定不妨碍先建立纯策略,但必须显式区分
anonymous与resolver_unavailable:当前没有 learner authenticator,后者不能伪装成游客。ownership 决策只接受服务端 owner/principal/capability,不从 IP、ACCESS_CODE、anon:*、x-learner-key或 body 推导。 -
observe-only authz seam 只有在接入至少一个真实资源时才有价值;首个选择 generation job,可让旧记录的 owner missing 与未来 owner match/mismatch 被结构化观察,同时保持所有现有 status/body 不变。observer 不能返回 allow,抛错也不得影响请求。
-
永久权益主体仍需用户确认。推荐以正式账号内部 UUID 为永久 owner,设备 capability 仅作为账号绑定持有证明或可迁移游客身份;具体手机号/邮箱/微信/SSO 供应商可以以后再选。
-
public pinned helper 已最小支持调用方提供认证头,同时拒绝覆盖 Host,保持原 URL hostname 驱动 Host/SNI。Azure voices 与 MinerU Cloud probe 已迁移;它们拒绝重定向,因此订阅 key/Bearer 不会跨 host 转发。
-
为保留原自托管能力,本批没有把 model probe、PDF self-hosted、AliDocMind SDK 强制切成 public-only;AI SDK 与 image/video adapter 也未动。这些调用点仍需“public pinned / operator exact private allowlist / managed internal”三类明确策略后再迁移。
-
账号 owner 传递到课堂的最小边界已确认:
generateClassroom的服务端 options 可承载 owner,job runner 从已落盘 job 读取 owner 后传入;不应把 owner 放进客户端可控的GenerateClassroomInput。 -
当前
PersistedClassroomData没有归属字段,/api/classroom的 POST/GET 也没有资源级授权;发布可见性门仅保护“已发布原始课堂”,不能代替 owner 校验。 -
课堂归属必须保存在 Stage/Scene 之外的持久化包装中,避免改动原互动课堂 DSL 与播放内核。
-
课堂重复 POST 是一个隐含的 owner 篡改面:对已存在课堂必须先按旧 owner 授权,并保留旧归属;只有首次持久化才从当前服务端 principal 创建 owner/guest 绑定。
-
生成课堂必须以已落盘 job 的 owner 为权威来源,不再从长任务结束时的请求上下文推导;当前 runner 已按此传递。
2026-08-16 生成任务恢复故障发现
- Python 普通课件 job
Xjk5UcLtOx在本次检查时仍可推进到scene 5/10、已完成 4 个场景;说明当前后端没有持续挂死,用户点击“继续”确实启动了新的生成尝试。 - 初一英语大型课程
S9V3Mm0AWy的模块 1 job 在本地时间 12:09 左右开始,最后一次模型使用记录在 12:17:44;当前 Next 进程启动于 12:19:07,之后旧 runner 不再更新该 job,12:43 左右读时被判为Stale job: process may have restarted during generation。这能证明“进程重启/runner 失联”是本次大课失败的直接机制,但不能单凭现有日志区分崩溃和人工重启。 readClassroomGenerationJob的 stale 判断只比较updatedAt,并返回内存中的 failed 视图;它不知道当前进程是否仍持有活跃 runner,也没有 heartbeat/lease,因此长模型调用超过 30 分钟时可能误报,进程重启时又只能在下次读取才暴露。runClassroomGenerationJob的runningJobs、runningControllers和大课runningRuns都是进程内 Map;磁盘只保存状态和原始输入,不保存 runner 接管 token 或可恢复执行上下文。generateClassroom的 Stage、outlines 和generatedScenes都在内存中;正式persistClassroom只在所有场景、媒体/TTS 阶段结束后调用,取消或进程终止不会留下正式 classroom,也没有场景级 checkpoint。- 普通 job 的 resume 路由与 runner 注释明确规定“从 persisted input 重新开始”;大型课程 resume 只跳过
succeeded模块。故障发生在模块 1 时,后续模块尚未成功,用户看到的就是整门大课重跑,而不是框架被重新生成。 - 课程 reconciliation 把 stale/failed classroom job 变成
module.status = failed并清空jobId;随后普通 resume 创建新 job,旧 job 的任何中间产出没有引用入口。这是大课无法复用模块内 checkpoint 的第二个结构性缺口。 - 恢复协议必须把“逻辑 job、attempt/lease、checkpoint”和“正式课堂可见性”分开:checkpoint 用于恢复,正式 classroom 仍保持完成后原子发布;旧任务没有 checkpoint 时才退化为从头重跑。
Technical Decisions
| Decision | Rationale |
|---|---|
| 第一批先做权限、入口和 learner 外围收敛 | 都是 P0 边界问题,可在不碰课堂内核的情况下独立验证 |
| 权限同时区分部署能力与请求用户角色 | 仅隐藏 UI 或仅靠环境变量不足以保护运营 API |
| 平行 AssistantPanel 从课堂外层取消挂载 | 恢复原 ChatArea/Roundtable 的唯一性,无需修改课堂内核 |
| 大课连续性字段采用兼容性 fallback | 已有文件型课程记录不能因新增字段失效 |
| 新框架字段在边界处规范化,runner 只消费规范化结果 | 避免把旧数据兼容判断散落到生成管线和 UI |
generationPrompt 由主 Agent 生成并在模块记录中保留快照 |
满足主 Agent 拆解职责,并让每次子课件生成输入可追溯 |
| 同一课程同一时间只允许一个生成 run | 保证模块顺序和快照语义,避免恢复、整课流水线与单模块重试互相覆盖 |
| 新大课 manifest 只接受精确 version/hash pins | 防止模块单独重发后旧大课静默切到 latest |
| legacy 无 pin manifest 要求运营重新发布 | 历史记录没有保存当时版本,不能把当前 latest 伪装为原始事实 |
| 服务端课堂先冻结成自包含 bundle,再提交课程 manifest | learner 不依赖运营端媒体 URL,同时仍通过原 import → Stage 链路上课 |
| 大课冻结发布强制 TTS、Agent roster 与非空互动 HTML | 发布后保证原有语音、教师人格和 HTML 互动能力可重放 |
Protected Runtime Evidence
components/stage.tsx装配PlaybackChromeRoot与InteractiveIframeHost。components/stage/scene-renderer.tsx是 slide/quiz/interactive/pbl 的唯一分派器。components/edit/PlaybackChromeRoot.tsx装配 ActionEngine、PlaybackEngine、Canvas、Roundtable 和 ChatArea。InteractiveIframeHost.tsx、iframe pool 和 widget messaging 维持 HTML iframe 保活及安全契约。lib/bundle/serialize.ts与 import builder 已保留 scene content、actions、scene whiteboards 和 multiAgent 字段。
Issues Encountered
| Issue | Resolution |
|---|---|
| DeepSeek Harness 首次任务未进入工具清单 | 重启桌面应用后已成功加载并完成只读自检 |
| 无 Git 历史,现有定制改动均为未版本化状态 | 使用计划文件记录范围,限制并行任务触及不同文件,并逐批验证 |
| 用户要求停止使用 DeepSeek 算力 | 后续不再调用 Harness;主线程完成实现与本地验证 |
Resources
- 产品与部署文档:
../docs/ - 大课编排:
lib/course-framework/ - 服务端单课件生成:
lib/server/classroom-generation.ts - 课程运行时入口:
app/classroom/[id]/page.tsx - 原课堂:
components/stage.tsx、components/edit/PlaybackChromeRoot.tsx - 发布与学习包:
lib/bundle/、lib/course-manifest-repo/