




《互联网软件开发技术与实践》· 第 2 课
当代码可以由模型生成,工程师最核心的交付物随之转移:不再是代码本身,而是那份把需求说清楚、且可以判定是否达标的规约。代码,只是它的衍生物。
主讲人:张齐勋
单位:北京大学
日期:9 月 16 日(第 2 次课)
会议:腾讯会议 301-524-976
2026 年秋
以课程表为例:把一句话交给模型,页面很快就能打开。但“看起来做完了”与“真的做完了”是两回事——差别在于规约是否明确。
帮我做一个高一(3)班的本周课程表网页,好看一点就行。 有星期一到星期五,每天几节课。 做一个能直接打开的页面就可以。
第一轮氛围编程 · 典型产出
示意参考 · 含时段、教师、教室等
课表不要写在页面里,要存进数据库。我改库里的课程名,刷新网页必须变。 每节课还要显示教室。 不要登录,不要做成教务系统。
注意“教室”这一需求:直到第三轮才出现,且仅存在于聊天记录中。接下来,我们将同一份课表需求写入 openspec/changes/…/ 下的四份文件。
范围随对话持续扩张,大量时间用于纠正模型的自行发挥。
同一句话,更换模型后得到的字段、数据与边界处理各不相同。
约定只存在于聊天记录,他人接手时需要重新复述全部上下文。
sspku-my-course-demo 中,用 OpenSpec 为课表生成四份规约文件。编码实现属于第 3 课的内容。
本部分演示路线:init → /opsx-propose 课表 → 逐份 Review 四件套原文(样板:add-weekly-class-schedule)。
课表 change · 四件套(本课样板)
openspec/changes/add-weekly-class-schedule/
├── proposal.md Why · What Changes · Non-goals
├── design.md Flask SSR · schedule_entries
├── tasks.md §1 骨架 … §5 validate
└── specs/weekly-class-schedule/spec.md
与 2.3 Propose 产出、课表样板页 中的原文完全一致,稍后逐行对照
spec Scenario → Apply 阶段代码(示意)
@app.get("/")
def schedule():
rows = fetch_entries() # schedule_entries
return render_template(
"schedule.html", rows=rows)
spec:Refresh reflects database updates ——修改数据库中的 course_name,刷新后页面必须显示新名称
注意顺序:先有左侧规约,tasks.md §3–4 再让模型写出 Flask 代码与页面
统一意图Why · 范围 · Non-goals
## Why 课表数据进 SQLite, 勿写死 HTML。 ## Non-goals - 无登录 / 无管理端 - 不对接教务
技术决策组件·数据流·design
Decision 2: SSR GET / 查 SQLite, Jinja 渲染 schedule.html 库: data/timetable.db 表: schedule_entries
可验收规约行为·数据·接口
Scenario: Course name changed in database - WHEN 改 course_name - AND reload / - THEN 显示新名称
任务拆解加载 Markdown·按序实现
## 3. 课表页面 - [ ] GET / + schedule.html ## 4. 刷新与一致性 - [ ] sqlite3 改库手验 ## 5. 文档 - [ ] README + validate
openspec init
openspec init 后:仓里多了什么
┌────────────────────────────────────────────────────────────────┐ │ sspku-my-course-demo(新仓库) │ │ │ ├── README.md ← 给人看:用途、结构、下一步 │ └── openspec/ │ ├── config.yaml ← 给 AI 看:context / rules │ ├── specs/ ← 能力规约(长期真相源) │ └── changes/ ← 每次变更的规划与归档 └────────────────────────────────────────────────────────────────┘
changes/ 里。2.2 将演示如何让模型生成这些文件。
/opsx-propose,把课表需求描述清楚,由模型在 changes/ 下生成文件。先认识 Cursor 中的三类入口。.trae/skills),但机制一致。| 类型 | 作用 | Cursor 里常见位置 / 用法 |
|---|---|---|
| 规则 | 长期约束:遵循什么约定、禁止什么行为 | 演示仓:README + openspec/config.yaml;学期仓另加项目规则文件(AGENTS.md、CLAUDE.md 等,第四部分) |
| 技能 | 封装好的工作流——读取哪些文件、按何种步骤产出四件套 | .cursor/skills/<名>/SKILL.md;在技能面板选用,或在对话中指名 openspec-propose |
| 命令 | 对话框中的快捷入口,底层调用上述技能 | .cursor/commands/ 下的 Markdown 文件;输入 / 选择 opsx-propose(以 init 后为准) |
/opsx-propose)负责发起流程,文件负责固化结果——所有细节写入 proposal.md / design.md / spec.md / tasks.md 之中,而非停留在对话里。/opsx-propose
我想在这个项目里加“本班一周课表”:打开页面就能看,不用登录。 课表数据要进数据库(SQLite 就行),不要写死在 HTML 里。我如果改库里的课程名,刷新网页必须跟着变。
变更:add-weekly-class-schedule
位置:openspec/changes/add-weekly-class-schedule/
sqlite3 或 SQL 直接改库,不提供管理后台| 已创建产物 | 说明 |
|---|---|
proposal.md | 动机、范围、Non-goals 与新能力 weekly-class-schedule |
specs/weekly-class-schedule/spec.md | 免登录访问、SQLite 为数据来源、刷新反映库变更、条目字段模型 |
design.md | Flask SSR、库路径 data/timetable.db、表 schedule_entries、脚本 scripts/init_db.py |
tasks.md | 骨架 → 库 → 页面 → 改库手验 → README → validate |
| 写什么 | 课表 change(演示仓) | CampusClaw 当堂(第三部分) |
|---|---|---|
| 动机与范围 | proposal.md · Why / Non-goals | 登录、隔离、上传入库;非目标含检索/对话 |
| 技术方案 | design.md · Flask SSR、schedule_entries | Browser / App / DB、Compose 部署节 |
| 可验收行为 | specs/weekly-class-schedule/spec.md | delta Scenario:权限、跨班、/health 等 |
| 实现顺序 | tasks.md §1–5 + validate | 逐条小任务 + 加载清单;当堂 [ ] |
| IDE 入口 | .cursor/commands · /opsx-propose | 同技能链;Propose 后 Review |
| 章节 | 课表示例 | 学生自检 |
|---|---|---|
| Why | 统一课表入口;数据进库、改库刷新生效 | □ 能一句话说清用户与痛点 |
| What Changes | Web 应用 + SQLite + README 运行说明 | □ 交付物列全 |
| Non-goals | 无登录/管理端/教务对接/生产部署 | □ 非目标写全,防范围膨胀 |
| Capabilities | weekly-class-schedule 须查库、刷新可见 | □ 与 spec 目录名一致 |
| Impact | 新建 app 目录、requirements.txt、README 运行说明 | □ 与 design / tasks 一致 |
## Why 本班需要一处统一的课表入口:打开即可查看当周安排,无需登录。课表内容应持久化在数据库中,便于通过改库更新课程信息并在刷新页面后立即生效,而不是把课程写死在 HTML 里。 ## What Changes - 新增可本地运行的 Web 应用:默认页展示“本班一周课表”(周一至周日列或等价周视图)。 - 使用 SQLite 存储课表条目(课程名、星期、时间段等);提供初始化/示例数据脚本。 - 提供 HTTP API 或直接服务端渲染,使每次页面加载读取当前数据库内容。 - 更新 `README.md`:如何安装依赖、初始化数据库、启动服务并访问页面。 ## Non-goals(非目标) - 不做登录、权限、多班级/多租户。 - 不做课表编辑后台(维护者用 SQLite 客户端或 SQL 改数据即可)。 - 不做换课、提醒、导出 iCal、与教务系统对接。 - 不做生产级部署与高可用(课程 demo 本地运行即可)。 ## Capabilities ### New Capabilities - `weekly-class-schedule`:访客在无认证情况下查看本班一周课表;展示内容 MUST 来自 SQLite,且在数据库更新后刷新页面可见最新课程名等信息。 ### Modified Capabilities (无) ## Impact - 新建应用源码目录(见 design.md)、SQLite 数据库文件路径、依赖清单(如 `requirements.txt`)。 - `README.md` 增加应用运行说明(不改变 `project-documentation` 既有 requirement 的语义,仅补充内容)。
schedule_entries)。
GET / 查库 → schedule.html)。每次刷新都重新查库,proposal 里说“每次加载读取当前库”,靠的就是这个。data/timetable.db、表 schedule_entries、种子脚本 scripts/init_db.py,细节在 Decision 3–4。proposal.md;本地 127.0.0.1:5000,仅启动本地服务,不涉及 Docker——CampusClaw 的 Compose 部署属于另一个 change。Browser → GET /
Flask → SELECT … FROM schedule_entries ORDER BY weekday
→ render_template("schedule.html", …)
SQLite ← data/timetable.db(改 course_name 后 reload 即验收)
## Context
仓库目前仅有 OpenSpec 与 README,无应用代码。动机见 `proposal.md`。目标是在本地起一个简单 Web 服务,SQLite 为唯一课表数据源。
## Goals / Non-Goals
**Goals:**
- 单页课表视图 + SQLite 持久化 + 刷新即见库内最新数据。
- 依赖少、便于课程 demo 一键启动(文档写清命令)。
**Non-Goals:**
- 见 `proposal.md` 非目标(无登录、无管理端、不对接教务)。
## Decisions
### Decision 1: Python + Flask + 标准库 `sqlite3`
使用 Flask 提供 HTTP,SQLite 通过 Python 标准库访问,依赖仅 Flask(`requirements.txt`)。
**备选:** Node + Express —— 同样可行;本 demo 优先 Python 以降低课程环境差异。
### Decision 2: 服务端渲染(SSR)为主
`GET /` 在服务端查询 SQLite,用 Jinja2 模板渲染 HTML 表格/网格。每次完整刷新都会重新查库,自然满足“改库后刷新可见”。
**备选:** 静态页 + `GET /api/schedule` JSON —— 也可满足 spec,但 SSR 更少 moving parts,首屏无需额外 fetch。
### Decision 3: 数据模型与文件位置
- 库文件:`data/timetable.db`(可 `.gitignore`,由 seed 脚本生成)。
- 表 `schedule_entries`:`id` INTEGER PK,`weekday` INTEGER 1–7(1=周一)、`start_time` TEXT `HH:MM`、`end_time` TEXT `HH:MM`、`course_name` TEXT NOT NULL;可选 `location` TEXT。
- 脚本 `scripts/init_db.py`:建表并插入示例本班数据。
### Decision 4: 应用布局
app/
__init__.py # create_app(), 注册路由
db.py # get_connection(), fetch_entries()
templates/
schedule.html # 一周课表视图
scripts/
init_db.py
data/
timetable.db # 本地生成
requirements.txt
run.py # flask run 入口(或 python -m flask)
### Decision 5: 本地运行约定
- 默认 `http://127.0.0.1:5000/`
- README 说明:`pip install -r requirements.txt` → `python scripts/init_db.py` → `python run.py`
## Risks / Trade-offs
- [并发写库与 Flask dev server] → demo 只读访问为主;维护者改库时短暂锁表可接受。
- [时间格式未统一时区] → 仅存本地 `HH:MM` 字符串,不涉及跨时区。
## Migration Plan
全新仓库(Greenfield):首次运行 `init_db.py` 创建库,没有旧数据需要迁移。
## Open Questions
(无——技术栈与 SSR 方案已足以拆分 tasks。)
specs/weekly-class-schedule/spec.md;delta 标题 ADDED Requirements,Requirement 的名字用英文——这是 OpenSpec 的默认写法。| 课表要覆盖的能力 | Requirement 名(样板) | Scenario 要点 |
|---|---|---|
| 打开即见、无登录 | Anonymous weekly schedule page | WHEN 打开 `/` → THEN 一周布局;无登录提示 |
| 必须查库 | Schedule content is loaded from SQLite | 有数据则展示;空库仍 200 |
| 改库刷新 | Refresh reflects database updates | 改 course_name 后 reload 可见 |
| 数据模型 | Timetable entry data model | weekday + 起止时间 + 课程名可展示 |
tasks.md §4.1
## Purpose 让本班成员通过浏览器查看存储在 SQLite 中的一周课表,无需登录;页面展示始终反映数据库当前内容。 ## ADDED Requirements ### Requirement: Anonymous weekly schedule page The system MUST serve a default web page that displays the class weekly timetable without requiring authentication. #### Scenario: Visitor opens the app root - **WHEN** 用户在浏览器打开应用根路径(如 `/`) - **THEN** 页面展示标题或说明,表明为“本班一周课表” - **AND** 页面展示周一至周日(或等价的一周七列/七区)的课表布局 #### Scenario: No login prompt - **WHEN** 未携带任何会话或凭证的用户访问课表页 - **THEN** 系统 MUST NOT 要求登录、注册或输入密码即可查看课表 ### Requirement: Schedule content is loaded from SQLite The system MUST read timetable entries from a SQLite database at request time; course names and other displayed fields MUST NOT be hardcoded only in static HTML as the sole source of truth. #### Scenario: Page reflects database rows - **WHEN** 数据库中存在若干课表条目(含课程名、星期、开始/结束时间) - **THEN** 打开或刷新课表页时,页面 MUST 展示这些条目中的课程名与时间安排 #### Scenario: Empty database - **WHEN** 数据库中没有任何课表条目 - **THEN** 页面 MUST 正常加载并提示暂无课程或展示空课表,而不是返回未处理的服务器错误
### Requirement: Refresh reflects database updates The system MUST reflect updates made directly in SQLite after the user refreshes the page. #### Scenario: Course name changed in database - **WHEN** 维护者在 SQLite 中将某条目的课程名修改为新的名称 - **AND** 用户在同一浏览器中刷新课表页(完整页面 reload) - **THEN** 页面 MUST 显示更新后的课程名 ### Requirement: Timetable entry data model Each timetable entry stored in SQLite MUST include at least: weekday (Monday–Sunday), start time, end time, and course name. #### Scenario: Valid entry is displayable - **WHEN** 条目包含合法的 weekday、start time、end time 与 course name - **THEN** 该条目 MUST 出现在对应 weekday 的课表视图中,并显示课程名与时间范围
Flask + init_db.py + schedule_entries。动手前先读 design 和 spec 里的数据模型。
GET / 查库渲染页面,再手动改一次库做验收。动手前读 spec 全文。
README 应保证他人可从零启动;openspec validate 要一次通过。
verify:。[ ]。勾选发生在第 3 课的 Apply 阶段。
## 1. 项目骨架 - [ ] 1.1 添加 `requirements.txt`(Flask)与 `run.py` 启动入口 — verify: `pip install -r requirements.txt` 成功 - [ ] 1.2 创建 `app/` 包(`create_app`、配置 SQLite 路径 `data/timetable.db`)— verify: 导入 `app` 无报错 ## 2. 数据库 - [ ] 2.1 实现 `scripts/init_db.py`:建表 `schedule_entries` 并插入至少 3 条示例课 — verify: 运行后 `data/timetable.db` 存在且 `sqlite3 data/timetable.db "SELECT course_name FROM schedule_entries"` 有结果 - [ ] 2.2 实现 `app/db.py`:按 weekday 排序读取全部条目 — verify: 单元测试或临时脚本打印条目数与库一致 ## 3. 课表页面 - [ ] 3.1 实现 `GET /`:查库并渲染 `templates/schedule.html`(周一至周日、课程名、时间)— verify: 启动服务后浏览器打开 `/` 可见示例课程 - [ ] 3.2 空库与错误路径:无数据时友好空状态 — verify: 清空表后刷新仍 200 且有空课表提示 ## 4. 刷新与数据一致性 - [ ] 4.1 手动验证:用 `sqlite3` 修改某条 `course_name` 后刷新页面显示新名称 — verify: 与 spec“Refresh reflects database updates”一致 ## 5. 文档与收尾 - [ ] 5.1 更新 `README.md`:安装、init_db、启动、访问 URL — verify: 按 README 步骤可从零打开课表页 - [ ] 5.2 运行 `openspec validate add-weekly-class-schedule --strict` — verify: 无 error
openspec/changes/add-weekly-class-schedule/ 四份文件可被查看、修改与 diffopenspec validate 校验的规约。第三部分起:请各位使用同一套工具,独立完成 CampusClaw 的 change,注意与课表仓库区分。
工具与仓库都要明确区分。OpenSpec 官方五步中,本课只走前两步,但要求做扎实。
| 对比项 | 课表练习仓 | CampusClaw 学期仓 |
|---|---|---|
| 用途 | 演示流程:一句话如何转化为四份规约 | 本课交付所在:CampusClaw 需求规约 |
| 文件夹名 | sdd-lab-timetable(课后可自建) | campusclaw-<组名>(当堂新建) |
| IDE 打开 | 可不打开 | 必须打开,本课所有操作均在此进行 |
| 当堂产物 | 无 | openspec/changes/…/ 下的四份文件 |
openspec/changes/<change>/ 中进行规划;完成后归档,合并进 openspec/specs/,成为后续开发的依据。
campusclaw-mygroup/
├── AGENTS.md ← 项目规则:无规约不写代码(视工具而定)
├── README.md ← 三行说明:价值、场景、本学期不做
└── openspec/
├── config.yaml ← 给 AI 看的上下文与规则
├── specs/ ← 长期真相源,Archive 后并入
└── changes/ ← 每次变更的四份文件
openspec/;项目规则文件名随所用工具而定,可自行新建(如 AGENTS.md、CLAUDE.md)。openspec init --tools <你的工具>,其中 --tools 参数指明所用工具,命令模板将被安装到对应目录(trae / cursor / claude / codex / agents)。/,若能看到 openspec 相关命令,即表示安装成功。
Explore明确待办事项,可选
Propose生成四份 Markdown 初稿
Review修订 Markdown,直到每条可判定
Apply第 3 课——按 tasks 实现代码
Archive第 3 课——归档进 specs/
openspec/changes/add-class-timetable/ ├── proposal.md ← 为什么做、范围、Non-goals ├── design.md ← 技术决策、数据流 ├── specs/ │ └── class-timetable/ │ └── spec.md ← delta:Requirement + Scenario └── tasks.md ← 实现顺序 checklist,当堂仍为 [ ]
changes/archive/ 归档目录,规约合并进 openspec/specs/。后续变更均从这份最新规约出发。
建学期仓在 IDE 中打开仓库根目录
Propose写登录、隔离、上传入库
Review按清单互相检查,改到每条都能判定
新对话引用 spec 原文验收
演示到此为止。接下来请各位为自己的学期项目撰写第一份需求规约。
openspec/changes/<change>/ 下的四份文件[ ],本课不执行 Apply
node -v 不低于 v20。npm install -g @fission-ai/openspec@latest 安装完成后,openspec --version 能输出版本号即表示安装成功。README三行说明:价值、场景、不做
规则文件无规约不写代码
change四份 Markdown:proposal 至 tasks
tasks实现顺序,当堂全留空
mkdir -p ~/campusclaw-mygroup cd ~/campusclaw-mygroup git init # 新建项目规则文件 AGENTS.md:无规约不写代码(名称随工具而定) openspec init --tools cursor # 换成你的工具:trae / claude / codex / agents
New-Item -ItemType Directory -Force "$HOME\campusclaw-mygroup" | Out-Null Set-Location "$HOME\campusclaw-mygroup" git init # 新建项目规则文件 AGENTS.md:无规约不写代码(名称随工具而定) openspec init --tools cursor
campusclaw-mygroup,而非课程仓库,更不是课表练习仓 sdd-lab-timetable。npx @fission-ai/openspec@latest init --tools cursor,效果相同。openspec/config.yaml、openspec/changes/ 与 README 三行说明;项目规则文件按所用工具放置(如 AGENTS.md)。
撰写时不要自由发挥:这四项一条都不能少,也不要额外增加内容。
/opsx-propose 项目加一个变更:用户登录、角色权限、班级隔离,教师可以把教学材料上传进知识库。 - 教师、学生都能用账号密码登录;没登录访问受保护页面,会被引导到登录页; - 学生不能上传材料,调用上传接口必须被拒绝; - 班级是数据边界:A 班的人访问 B 班材料要被拒绝;过滤放在服务端,不靠前端隐藏按钮; - 教师上传的材料要写入知识库,本班材料列表能查到这条记录。 密码必须哈希存储,禁止明文;密钥只放服务端环境变量。启动方式用 Docker Compose,应用提供 GET /health。
第 39 页 Propose 出来的只是初稿,常缺 Scenario、Non-goals 或 tasks 分节。请按下表逐项核对,也可以和相邻同学互查;有缺项就用下一页的补全提示词改 Markdown。下文 Ex 与 Review 对照一份补全后的终稿。change 目录名以你仓内为准(演示为 add-auth-rbac-class-knowledge)。
| 文件 | 应有内容(逐项核对打勾) |
|---|---|
| README + proposal | README 三行;proposal 含 Why / What / Capabilities(如 auth-upload)/ Non-goals;What 含登录、角色、隔离、上传入库、预置样本、Compose 与 /health |
| specs/…/spec.md | 登录、角色、隔离、上传、预置、密码哈希、Compose/health 等 Requirement;每条带可判定 Scenario |
| spec 格式 | 英文骨架 ADDED Requirements / Requirement: / Scenario:;Scenario 可用 WHEN/THEN/AND 或 当/那么/且 |
| design.md | 技术栈、会话与密码哈希、查询层班级隔离、上传入库数据流、数据模型、Compose 与 healthcheck |
| tasks.md | 分节 checklist(骨架→种子→登录→隔离→上传→Compose→validate);每条 verify;checkbox 全 [ ] |
上一页核对出缺项时:新建对话,把下面这段提示词整段粘贴进去(路径里的 change 名要改成你自己的目录)。演示用的就是这同一段提示词。下文 Ex 与 Review 对照补全后的终稿。
初稿已生成,但内容不全。请只修改 openspec/changes/add-auth-rbac-class-knowledge 下的 markdown,不要写业务代码。 请补全: 1. spec.md:登录、角色、班级隔离、上传入库、预置数据、密码哈希、Compose/health 等 Requirement 及 Scenario(含跨班被拒、学生上传 403、上传后列表可查)。 2. design.md:会话与密码、Flask/SQLite(或等价栈)、服务端班级过滤、上传数据流、Compose 与 GET /health。 3. tasks.md:按实现顺序分节,每条带 verify;最后一节含 openspec validate --strict;checkbox 保持 [ ]。 4. proposal:Non-goals 含检索问答、对话助手、作业提交与批改、SSO/生产 HA 等本课不做项。 改完列出:改了哪些文件、每处补了什么。
add-auth-rbac-class-knowledge。
## Why CampusClaw 是面向中小学的教研智能体,覆盖学情分析、备课、命题、课堂与教研协同,教研材料与知识库是其能力底座。当前材料分散在群文件与网盘中,教师难以检索复用,班级之间也缺少数据边界。本变更先建立**身份、角色与班级隔离**,让教师登录后上传并管理本班材料,学生仅可查看本班内容,为后续检索与智能问答打基础。 ## What Changes - **用户登录**:教师、学生两类角色;账号密码登录;未登录访问受保护页面时引导至登录页。 - **角色权限**:教师可上传与管理本班材料;学生对本班材料只读;学生调用上传接口 MUST 被拒绝(如 HTTP 403)。 - **班级隔离**:班级为数据边界;所有业务查询在服务端按会话中的班级过滤;A 班用户不得读取 B 班材料(含直接访问与列表越权)。 - **材料上传与知识库入库**:教师上传后解析并写入知识库,材料关联本班;本班材料列表查库展示,上传后可见新记录。 - **预置样本数据**:班级 A/B、教师 A、学生 A1/B1,以及两班可区分的材料标题;建立班级、用户、讲义、作业、助手、技能六类核心数据结构并写入种子数据。 - **安全与运行**:密码 MUST 哈希存储,禁止明文;会话/签名密钥仅通过服务端环境变量注入;标准启动方式为 Docker Compose;应用提供 `GET /health`。 - **文档**:README 三行说明(价值、场景、不做)与 Docker 启动方式;新增 `docker-compose.yml`、`.env.example`。 ## Capabilities ### New Capabilities - `auth-upload`:登录与会话、按角色授权、按班级隔离、教师上传材料入库、预置数据、密码哈希与会话密钥、Docker Compose 与 `/health` 等可验收行为。 ### Modified Capabilities (无——`openspec/specs/` 尚无主规格。) ## Impact - 新建 Web 应用与 API(登录、材料列表、教师上传、健康检查)。 - 新建 SQLite(或等价)持久层:用户、班级、材料、知识库条目及六类域表与种子数据脚本。 - 项目根增加 `Dockerfile`、`docker-compose.yml`、`.env.example`;依赖清单(如 `requirements.txt`)。 - 可运行实现按 `tasks.md` 在 Apply 阶段完成;本 change 以规约四件套为交付物。 ## Non-goals(非目标) 本课/本 change **明确不做**以下内容(避免范围膨胀;表结构可预留字段,但不验收): - **检索与问答**:不做 RAG、全文检索、智能问答、基于知识库的对话。 - **对话助手**:不做聊天界面、Agent 编排、技能包运行时。 - **作业流程**:不做作业提交、批改、成绩、审计与错题本。 - **身份与企业集成**:不做多校/多租户、OAuth/OIDC、SSO、短信/邮箱验证码登录。 - **生产运维**:不做 K8s、多副本高可用、完整 CI/CD 流水线;标准启动为 Docker Compose 本地/演示环境即可。 - **前端替代服务端安全**:不以隐藏按钮、禁用表单作为权限或班级隔离的唯一手段。
## Context 仓库根已有 README 三行说明与 OpenSpec change 四件套;尚无业务应用代码。动机见 `proposal.md`;可验收行为见 `specs/auth-upload/spec.md`。Apply 阶段在本仓库实现 CampusClaw 最小可运行栈。 ## Goals / Non-Goals **Goals:** - 登录可用,会话携带 `role` 与 `class_id`。 - 权限在服务端可判定(学生上传 403)。 - 班级隔离不可通过改前端绕过(查询与按 ID 访问均校验班级)。 - 教师上传链路:落盘 → 解析 → 双表入库 → 列表查库可见。 - `docker compose up` 一键启动;`GET /health` 供探活;密钥与 DB 路径可配置。 **Non-Goals:** - 见 `proposal.md` Non-goals(检索问答、对话、作业、SSO、生产 HA 等)。 ---
## 会话与密码 ### 会话模型 - 采用 **Flask 服务端会话**(signed cookie):登录成功后设置 HttpOnly、`SameSite=Lax` 的 session cookie。 - Session payload 至少含:`user_id`、`role`(`teacher` | `student`)、`class_id`。 - 签名密钥 **`SECRET_KEY` 仅从环境变量读取**(Compose `env_file: .env`);禁止在源码中写死生产密钥。 - 受保护路由使用统一前置逻辑(装饰器或 `before_request`):无有效 session → 页面 `302` 至 `/login`;API 返回 `401` JSON。 ### 密码存储 - 用户表字段 `password_hash`(禁止 `password` 明文列)。 - 种子脚本与(若有)改密逻辑使用 **bcrypt**(或 argon2);登录时用库提供的 verify(如 `werkzeug.security.check_password_hash` 或 `bcrypt.checkpw`)。 - 比较 SHOULD 使用恒定时间语义,避免时序侧信道(依赖库默认行为即可)。 ### 登出 - `POST /logout` 或 `GET /logout` 清除 session,重定向登录页(实现细节,spec 未强制路径名)。 --- ## 技术栈:Flask + SQLite | 层次 | 选择 | 说明 | | --- | --- | --- | | Web | Flask 3.x | 路由、模板 SSR 材料列表/登录页;上传与 JSON API 同进程 | | DB | SQLite 3 | 单文件 `data/app.db`,标准库 `sqlite3` 或薄封装 | | 密码 | bcrypt / werkzeug | `requirements.txt` 显式列出 | | 运行 | gunicorn(Compose 内)或 `flask run`(本地 dev) | Compose 生产式入口推荐 gunicorn 绑定 `0.0.0.0:8080` | **备选栈:** Node + Express + better-sqlite3 —— 行为等价即可,本 design 按 Python 栈写 tasks;若换栈须同步改 tasks 命令但 spec 不变。 ### 模块划分(建议) ``` app/ __init__.py # create_app(), SECRET_KEY 校验 auth.py # login/logout, login_required materials.py # 列表、按 id、upload knowledge.py # parse_file + insert knowledge_entries db.py # get_conn, 带 class_id 的查询 helper templates/ # login.html, materials.html scripts/init_db.py ``` --- ## 服务端班级隔离 隔离 **MUST 实现在服务端**,不得仅隐藏前端按钮。 ### 列表与集合查询 - 所有 `SELECT` 材料/知识库条目时 **强制** `WHERE class_id = :session_class_id`。 - 封装为 `list_materials(class_id)` 等函数,业务路由禁止传入客户端可控的 `class_id` 覆盖会话(管理员场景本 change 无)。 ### 按 ID 访问单条资源 1. `SELECT` by `id`(可不带 class 条件以检测越权)。 2. 若行不存在 → `404`。 3. 若 `row.class_id != session['class_id']` → **`403` 或 `404`(择一,在 README 固定)**,响应体不得含他班标题/正文/路径。 ### 上传与写入 - 新记录的 `class_id` **只取自 session**,忽略表单中的 class 字段(若有则丢弃或拒绝)。 - 教师仅能在本会话班级写入;不存在「代传 B 班」API。 ### 测试关注点 - 学生 A1 列表不见 B 班标题;教师 A 用 B 班 material id 访问被拒;与 spec 三个班级 Scenario 一一对应。 ---
## 上传与入库数据流
```
Client (teacher, multipart/form-data)
POST /api/materials/upload 或 POST /materials/upload
│
├─ login_required + role == teacher else 403
├─ 校验文件扩展名/大小(MVP: .txt, .md)
├─ 保存 uploads/{class_id}/{uuid}_{safe_filename}
├─ parse → plain text(失败则删文件,返回 400,无 DB 行)
├─ BEGIN TRANSACTION
│ INSERT materials (title, class_id, file_path, uploaded_by, ...)
│ INSERT knowledge_entries (material_id, class_id, body_text, ...)
├─ COMMIT
└─ 201 { "material_id": ... }
Client GET /materials (或 /api/materials)
│
├─ login_required
├─ rows = list_materials(session.class_id) # 仅本班
└─ 200 HTML 或 JSON
```
- **标题**:默认取自文件名或表单字段;须在本班列表可展示。
- **知识库**:MVP 将解析全文或摘要写入 `body_text`;后续 RAG 不在本 change。
- **学生**:同一 GET 列表 API,无 POST 上传路由权限。
---
## Docker Compose 与 GET /health
### 文件约定
- `Dockerfile`:多阶段或单阶段安装依赖、复制 `app/`、`scripts/`;`CMD` 启动 gunicorn 或等价。
- `docker-compose.yml`:
- service `app`,`build: .`,`ports: ["8080:8080"]`(或与 README 一致)。
- `volumes`: `./data:/app/data`,`./uploads:/app/uploads`。
- `env_file: .env`;`environment` 可覆盖 `SECRET_KEY`。
- `healthcheck`: 例如 `curl -f http://127.0.0.1:8080/health` 或 `wget -qO- ...`。
- `.env.example`:列出 `SECRET_KEY=` 占位与说明;**不提交真实密钥**。
### 启动与初始化
- 容器 entrypoint 或首次启动:若 `data/app.db` 不存在则运行 `python scripts/init_db.py`(建表 + 种子 A/B 班、用户、样本材料)。
- README 步骤:`cp .env.example .env` → 填 `SECRET_KEY` → `docker compose up --build` → 打开 `http://localhost:8080/login`。
### GET /health
- 路由:`GET /health`,**无需登录**。
- 响应:`200`,`Content-Type: application/json`,body 如 `{"status":"ok"}`。
- 可选:检测 SQLite 文件可读;失败时返回 `503`(本 change 可简化为进程存活即 ok)。
- Compose `healthcheck` 依赖此端点,避免「容器 up 但应用未就绪」。
---
## Decisions(摘要)
| # | 决策 | 备选 |
| --- | --- | --- |
| 1 | Flask + SQLite | Node + Express |
| 2 | Signed cookie session | JWT |
| 3 | bcrypt 密码哈希 | PBKDF2-only |
| 4 | 单库 + class_id 过滤 | 每班独立库 |
| 5 | MVP 解析 txt/md | PDF 管道后置 |
## Risks / Trade-offs
- [PDF 解析复杂] → 首版仅 txt/md;失败场景 spec 已要求无脏数据。
- [403 vs 404 跨班] → 在 README 与 tasks 验收中固定一种。
- [Flask dev server] → Compose 内用 gunicorn。
## Migration Plan
从零起步:由 `init_db.py` 建表与种子,无旧数据需要迁移。实现顺序见 `tasks.md`(数据 → 登录 → 隔离 → 上传 → Compose → validate)。
## Open Questions
(无。)
## Purpose 让教师与学生以各自角色登录 CampusClaw,以班级作为数据边界隔离教学材料;教师上传的内容解析后写入知识库,本班成员通过列表查看;越权上传、跨班访问与未登录访问在服务端被拒绝。 ## ADDED Requirements ### Requirement: 用户登录 系统 MUST 提供教师(teacher)、学生(student)两类角色的账号密码登录;登录成功后 MUST 建立服务端会话;未登录用户访问受保护页面 MUST 被引导到登录页,且 MUST NOT 泄露任何业务数据。 #### Scenario: 教师与学生登录成功 - **WHEN** 用户使用有效账号与密码发起登录(预置账号含教师 A、学生 A1、学生 B1) - **THEN** 登录 MUST 成功并建立服务端会话 - **AND** 会话中 MUST 记录用户标识、角色(teacher 或 student)与所属班级(如班级 A 或 B) #### Scenario: 错误密码登录失败 - **WHEN** 用户提供存在用户名但错误密码 - **THEN** 登录 MUST 失败 - **AND** 系统 MUST NOT 建立有效会话 - **AND** 响应 MUST NOT 暗示密码哪一位错误以外的敏感信息(如不返回密码哈希) #### Scenario: 未登录访问受保护页面 - **WHEN** 未携带有效会话的用户请求受保护页面(如材料列表)或受保护 API - **THEN** 对页面请求 MUST 重定向到登录页 - **AND** 对 API 请求 MUST 返回未授权(如 HTTP 401) - **AND** 响应 MUST NOT 包含任何本班或他班材料标题、正文或文件路径 ### Requirement: 角色权限 系统 MUST 按会话中的角色授权:教师可上传与管理本班材料;学生对本班材料只读;学生调用上传或管理接口 MUST 被拒绝。 #### Scenario: 学生上传被拒绝(403) - **WHEN** 以 student 角色(如学生 A1)的有效会话向材料上传接口提交文件 - **THEN** 系统 MUST 拒绝请求并返回 HTTP 403(或等价语义的错误码) - **AND** `materials` 与知识库相关表 MUST 无新增或变更记录 - **AND** 上传目录 MUST 无因该请求产生的新文件(或事务回滚后不可见) #### Scenario: 教师上传被允许 - **WHEN** 以 teacher 角色(如教师 A)的有效会话向材料上传接口提交支持的文件 - **THEN** 系统 MUST 接受请求并执行入库流程(见「材料上传与知识库入库」) - **AND** 返回 MUST 表示成功(如 HTTP 201)并含新材料标识 #### Scenario: 学生仅只读本班列表 - **WHEN** 学生 A1 登录后打开本班材料列表 - **THEN** 系统 MUST 展示本班可读材料 - **AND** 页面或 API MUST NOT 提供学生可用的上传或删除本班材料的写操作入口(服务端仍 MUST 拒绝写 API,见上一 Scenario)
### Requirement: 班级隔离 班级是数据边界。所有材料、知识库及相关业务查询 MUST 在服务端按会话中的班级标识过滤;A 班用户 MUST NOT 读取、修改或删除 B 班材料;前端隐藏按钮 MUST NOT 作为满足本要求的唯一手段。 #### Scenario: 跨班按 ID 访问材料被拒绝 - **WHEN** 班级 A 的用户(学生 A1 或教师 A)通过 URL、API 路径参数或请求体指定 B 班某条材料的 ID 发起访问 - **THEN** 系统 MUST 拒绝访问(HTTP 403 或 404,实现择一并在文档中固定) - **AND** 响应 MUST NOT 返回 B 班材料的标题、正文片段、文件路径或存储键 #### Scenario: 跨班枚举列表不得泄露 B 班 - **WHEN** 学生 A1 登录后请求材料列表(含分页、搜索参数为空或任意合法值) - **THEN** 返回集合中每一条记录的班级归属 MUST 均为班级 A - **AND** MUST NOT 出现 B 班预置材料的可区分标题(如标题中含「B 班」的样本) #### Scenario: 材料列表仅含本班记录 - **WHEN** 教师 A 登录后打开本班材料列表 - **THEN** 列表中每一条记录 MUST 归属于班级 A - **AND** 列表数据 MUST 来自数据库查询且查询条件含班级 A 的过滤 ### Requirement: 材料上传与知识库入库 教师上传教学材料后,系统 MUST 将文件落盘、解析文本(或等价内容)并写入知识库记录;材料与知识库条目 MUST 关联上传者所属班级;本班材料列表 MUST 从数据库查询展示;上传成功后刷新列表 MUST 可见新记录。 #### Scenario: 上传后知识库与列表可查 - **WHEN** 教师 A 通过上传接口提交一份合法文件(如 `.md` 或 `.txt`)且解析成功 - **THEN** 知识库(或 `knowledge_entries` 等价表)中 MUST 出现与该文件对应的新记录 - **AND** 该记录 MUST 关联班级 A 与对应材料主键 - **AND** 材料表 MUST 新增一条标题可辨的记录 - **AND** 教师 A 刷新本班材料列表时 MUST 看到该条新记录 #### Scenario: 上传后学生本班可见只读 - **WHEN** 教师 A 成功上传新材料后,学生 A1 刷新本班材料列表 - **THEN** 学生 A1 MUST 能在列表中看到该条新材料 - **AND** 学生 A1 对该材料 MUST 仍为只读(不得通过 API 修改或再次上传覆盖) #### Scenario: 上传失败时不产生脏数据 - **WHEN** 上传过程因不支持格式、空文件或解析失败而终止 - **THEN** 系统 MUST 返回明确错误(如 HTTP 400) - **AND** MUST NOT 在材料表或知识库中留下不完整记录 - **AND** MUST NOT 保留孤立的未关联文件(或 MUST 在失败时清理已写文件)
### Requirement: 预置核心数据 系统 MUST 建立班级、用户、讲义、作业、助手、技能六类核心数据结构(表或等价实体),并预置可验收的样本数据,以支持登录、班级隔离与上传验收。 #### Scenario: 种子数据满足双班与用户 - **WHEN** 首次执行数据库初始化或种子脚本(含 Compose 首次启动时的 entrypoint) - **THEN** 库中 MUST 存在班级 A、班级 B - **AND** MUST 存在教师 A(归属班级 A)、学生 A1(归属 A)、学生 B1(归属 B) - **AND** 各用户 MUST 具备可登录的用户名与已哈希的密码字段 #### Scenario: 两班材料标题可区分 - **WHEN** 种子脚本执行完成 - **THEN** MUST 存在至少一条明确归属 A 班的材料标题(如含「A 班」) - **AND** MUST 存在至少一条明确归属 B 班的材料标题(如含「B 班」) - **AND** 六类核心结构中讲义、作业、助手、技能表 MUST 已创建(可各含 0~1 条占位行,不要求本 change 验收业务功能) ### Requirement: 密码哈希与会话密钥 系统 MUST 使用单向密码哈希算法存储用户密码,禁止明文;会话签名密钥(如 `SECRET_KEY`)MUST 仅通过服务端环境变量或 Compose 注入,MUST NOT 硬编码在源码或提交到版本库。 #### Scenario: 库中无明文密码 - **WHEN** 直接查询用户表中密码相关字段(如 `password_hash`) - **THEN** 字段值 MUST NOT 等于任何已知预置账号的明文口令 - **AND** MUST 可识别为哈希格式(如 bcrypt `$2` 前缀或 argon2 标识) #### Scenario: 登录校验使用哈希比较 - **WHEN** 用户使用正确明文密码登录 - **THEN** 系统 MUST 通过哈希验证通过并建立会话 - **WHEN** 同一用户使用错误密码登录 - **THEN** 验证 MUST 失败且不建立会话 #### Scenario: 缺少会话密钥时服务不得静默使用默认值 - **WHEN** 生产/Compose 配置未提供会话签名必需的环境变量 - **THEN** 应用启动 MUST 失败或使用文档明确禁止的不安全默认值;`.env.example` MUST 列出所需变量名 ### Requirement: Docker Compose 部署与健康检查 系统 MUST 以 Docker Compose 作为标准启动方式;应用 MUST 提供 `GET /health`;数据库文件与上传目录 MUST 通过 volume 持久化,以便容器重建后数据仍在。 #### Scenario: Compose 启动后可访问 - **WHEN** 操作者按 README 复制 `.env.example` 并执行 `docker compose up --build`(或文档等价命令)直至 healthcheck 通过 - **THEN** 浏览器 MUST 可访问登录页 URL - **AND** `GET /health` MUST 返回 HTTP 200 且 body 表明服务可用(如 JSON `status: ok`) #### Scenario: health 不依赖登录态 - **WHEN** 未携带会话 cookie 的请求访问 `GET /health` - **THEN** MUST 仍返回成功响应 - **AND** MUST NOT 重定向到登录页 #### Scenario: 重建容器后数据仍在 - **WHEN** 已存在预置用户、材料或教师上传产生的记录后,执行 `docker compose down` 再 `docker compose up` 且未删除数据 volume - **THEN** 预置班级与用户 MUST 仍可登录 - **AND** 已上传材料与知识库记录 MUST 仍可通过本班列表或 SQL 查询到
## 1. 仓库与文档(T1) - [ ] 1.1 确认仓根 README 含价值/场景/不做三行说明 — verify: 打开 `README.md` 可见三行且与 proposal Non-goals 一致 - [ ] 1.2 添加 `requirements.txt`、应用入口(如 `run.py` 或 gunicorn 模块路径)与 `app/` 包骨架 — verify: `pip install -r requirements.txt` 后 `python -c "import app"` 无报错 ## 2. 数据模型与种子(T2) - [ ] 2.1 实现 `scripts/init_db.py`:创建班级、用户、讲义、作业、助手、技能、材料、知识库(或等价)表 — verify: 执行脚本后 `data/app.db` 存在且 `.schema` 含上述表名 - [ ] 2.2 种子:班级 A/B、教师 A、学生 A1/B1;A/B 班各至少一条可区分标题的材料;用户密码写入 `password_hash` — verify: `sqlite3 data/app.db "SELECT username, role FROM users"` 含 teacher_a、student_a1、student_b1 - [ ] 2.3 实现 `app/db.py`(连接、参数化查询、按 `class_id` 列表材料)— verify: 临时脚本对 class A 调用列表函数返回条数 ≥ 1 且不含 B 班标题 ## 3. 登录、会话与密码(T3) - [ ] 3.1 实现 `/login` 页面与 POST 登录逻辑;session 写入 `role`、`class_id`;`SECRET_KEY` 仅来自环境变量 — verify: 教师 A、学生 A1 登录后会话字段正确;未设置 `SECRET_KEY` 时启动行为与 design 一致 - [ ] 3.2 实现登出与 `login_required`:未登录访问 `/materials` 重定向登录页 — verify: 清 cookie 后 GET `/materials` 为 302 至 `/login` 且响应体无材料数据 - [ ] 3.3 密码 verify 走哈希;错误密码登录失败 — verify: 错误口令无法建立 session;库中 password 字段非明文 ## 4. 班级隔离(T4) - [ ] 4.1 材料列表 API/页面仅查询 `session.class_id` — verify: 学生 A1 登录后列表仅含 A 班标题,不出现 B 班样本标题 - [ ] 4.2 按 material id 访问时校验 `class_id` — verify: 教师 A 请求 B 班材料 id 返回 403 或 404(与 README 一致)且响应无 B 班正文/标题 - [ ] 4.3 确认路由不接受客户端传入的 `class_id` 覆盖会话 — verify: 篡改 query/body 中的 class 仍只能看到本班或越权被拒
## 5. 角色权限与上传入库(T5) - [ ] 5.1 学生 POST 上传返回 403;DB 与 uploads 无新增 — verify: 学生 A1 上传后 `materials`/`knowledge_entries` 行数与上传前相同 - [ ] 5.2 教师上传:落盘 → 解析 txt/md → 同事务写入 materials + knowledge_entries,`class_id` 来自 session — verify: 教师 A 上传后两表各增 1 行且 class 为 A - [ ] 5.3 上传成功后本班列表可见;学生 A1 刷新列表可见新条但仍无法上传 — verify: 教师上传后 A1 列表含新标题;A1 POST 上传仍 403 - [ ] 5.4 解析失败或非法扩展名返回 4xx 且无脏数据 — verify: 上传 `.exe` 或故意损坏文件后无孤立 DB 行 ## 6. Docker Compose 与 GET /health(T6) - [ ] 6.1 添加 `Dockerfile`、`docker-compose.yml`、`.env.example`;挂载 `./data`、`./uploads` — verify: `docker compose up --build -d` 后容器 running - [ ] 6.2 实现 `GET /health` 无需登录;compose 配置 healthcheck — verify: `curl -sf http://localhost:8080/health` 返回 200 JSON - [ ] 6.3 容器 entrypoint/启动时自动 init_db(若库不存在)— verify: 删 `data/app.db` 后 compose up 仍可登录预置账号 - [ ] 6.4 `docker compose down` 再 `up`(不删 volume)后预置与上传数据仍在 — verify: 上传一条后再 down/up,列表仍含该条 - [ ] 6.5 README 补充 Compose 启动步骤、登录 URL、health URL、`.env` 说明 — verify: 按 README 从零可打开登录页并 curl health ## 7. 规约与收尾 - [ ] 7.1 运行 `openspec validate add-auth-rbac-class-knowledge --strict` — verify: 命令 exit 0 且无 error - [ ] 7.2 手工对照 spec 关键 Scenario:跨班被拒、学生上传 403、上传后列表可查 — verify: 记录三条验收结果(通过/失败)于 PR 或实验报告
proposal.md → specs/…/spec.md → tasks.md。| 文件 | 与相邻同学对照(打勾自检) |
|---|---|
| README | 三行说明写全:价值主张、核心场景、本学期不做 |
| proposal.md | 目标含登录、角色权限、班级隔离、上传入库;非目标含检索问答、对话、作业提交 |
| design.md · 会话与密码 | 写清用会话还是 Token;密码哈希存储,禁止明文 |
| design.md · 隔离与上传 | 班级过滤放在服务端;上传链路写到“文件 → 解析 → 入库 → 列表” |
| design.md · 部署节 | 写明 Docker Compose 启动、volume 持久化与 GET /health |
请只修改 openspec/changes/<change>/ 下的 markdown,不要写代码。 我发现三处问题,请逐条修改: 1. design.md 没写班级隔离在哪一层实现。请补:所有业务查询在服务端按会话中的班级过滤,前端隐藏按钮不算满足。 2. proposal 的非目标不全。请补:注册与找回密码、技能开关。 3. spec 的班级隔离只有 Requirement、没有 Scenario。请补跨班访问被拒绝的 Scenario,用当 / 且 / 那么写。 改完告诉我每处改了哪个文件、改了什么。
| Requirement 主题 | Scenario 必须能判定(打勾自检) |
|---|---|
| 用户登录 | 教师、学生登录成功;未登录被引导到登录页 |
| 角色权限 | 学生调用上传返回 403;教师上传被允许 |
| 班级隔离 | A 班用户读 B 班材料被拒;列表只含本班记录 |
| 上传与入库 | 上传后知识库有记录,刷新列表可见 |
| 六类核心数据 | 班级 A / B、教师 A、学生 A1 / B1、两班材料都能查到 |
| Docker 部署 | 按 README 起 Compose 可访问;/health 返回成功;重建容器数据仍在 |
规则文件、README 三行说明、四份规约均已就位——且不含任何代码。
先准备数据,再实现登录,再做隔离。顺序不可颠倒,且每条都必须可验证。
上传入库是核心环节——教师可上传、学生被拒绝。T6 的 Compose 与 /health 属于第 3 课。
[ ]。
不要猜。请阅读本仓库的 openspec/(含 changes 与 specs)、README.md 与项目规则文件。 1. 列出本变更的硬性要求(至少 3 条),每条给出对应的文件路径。 2. A 班的学生能否查看 B 班的材料?请引用 spec 原文说明。 3. 教师上传材料后,知识库与材料列表分别怎么变化?请引用 spec 原文。 不要改任何代码。
你刚才的回答没有引用 spec 原文。请打开 openspec/changes/<change>/specs/ 下的 spec.md, 把“班级隔离”“材料上传与知识库入库”两个 Requirement 的 Scenario 原文贴出来, 再据此回答:跨班访问会被拒绝吗?依据是哪一条 Scenario?
| 要做的事 | 技能名 | Claude 常见斜杠命令 |
|---|---|---|
| Explore(可选) | openspec-explore | 思考模式,不写文件 |
| 提案 | openspec-propose | /opsx-propose |
| 实现(课后) | openspec-apply-change | /opsx-apply CHANGE |
| 改计划 | openspec-update-change | /opsx-update(以 init 提示为准) |
| 归档 | openspec-archive-change | /opsx-archive |
openspec/**/*.md,口头约定无效。
一份可验收的需求规约——覆盖登录、角色权限、班级隔离、材料上传入库,四份文件已提交于学期仓。
没有编写任何业务代码,checkbox 全部为空。Apply 与 Archive 留待第 3 课。
基于同一仓库与同一份 tasks——登录 → 班级隔离 → 上传入库 → Compose 与 /health,逐条实现、逐条验收。





第 2 课 · SDD 与 CampusClaw 规约