CYBERSWAT · DEVELOPMENT

把第一份作品交出去

开发部新生入门30天 · 公开初稿 · 2026-10-11

先读这里

开发部新生入门30天 · 从一行程序到可交接的系统

2026-10-11 · 公开初稿。三十个学习单元已提供正文与教学例子,可以先注册、阅读、练习、提交问题;真实读者的耗时与理解尚未验证,反馈会推动后续修订。技术检查不代替学员过关。

你先做一个小东西:终端里的虚构任务清单。它没有漂亮界面,但别人拿到文件后能照说明运行、看懂完成数、改一项再验证。随后它长出浏览器页面、HTTP服务、数据库与双合成身份;最后补测试、运行与交接。新概念从旧例遇到的问题里出现,不要求一开始装好所有框架。

“30天”表示学习单元,不是速成保证或连续打卡。一天可以拆开,已会内容可以跳读,但出口证据不能跳。手机适合阅读,运行练习需要可安装工具的电脑;没有Docker时可先学前半册并反馈环境卡点,不凭阅读记录认定数据库或部署已完成。

怎样使用这本书

先读场景,预测结果,自己运行;把输出与预期对照,再做修改练习。报错留脱敏原文,一次改变一个条件。首周预计每天20分钟读、40分钟练,只是容量安排,真实耗时未知;后续全栈和AI长章可以分多次,不强求一个晚上。

第一周写代码由本人完成,不用AI生成/补全答案;概念、报错可以问定向解释,但实际改动与验证由你做。后续指定协作练习允许AI,仍需自审差异、测试和参与说明,独立理解小改不外包。本书不要求购买模型或服务器,两个AI专章可用教材候选先练审阅。

三十单元的地图

阶段 从什么问题开始 会留下什么
01–07 工作台与Git 文件在哪里、谁执行、错在哪里、别人如何跑 脚本、三张排错卡、Git历史、README、复现
08–14 JS/TS与浏览器 值如何运算、规则如何拆、事件如何改变页面 类型检查、模块、异步、小页面修改与验证
15–21 HTTP与全栈 请求怎样进入服务、资料怎样保存、谁能操作 Vue/Nest/PG台账、迁移、双合成身份与权限
22–25 测试与交接 哪些已证明、失败查哪层、环境怎样重建 测试、故障卡、容器、恢复、契约与README
26–27 AI协作两章 怎样给边界、审差异、验证Agent行动 任务卡、反例、参与记录、可审阅协作过程
28–30 候选与下一步 哪个版本可交、如何撤回、接下什么任务 发布候选证据、复盘、下一任务卡

本册覆盖D0–D2与D3首关的学习材料,完整D3维护、D4真实模块与D5带人需更长项目与真人记录,不在第30天自动授予。详细见全册自查。两个部门共用清楚的教材写法和可核验基础方法;开发部专业主线是程序、浏览器、服务、资料与交付,AI部的数据/模型实验另学,层级不互换。

从一开始守住资料边界

只用虚构或获准公开资料。密钥、口令、私人信息、真实警务资料不进练习仓库、不发公网AI、不贴反馈。别人代码文字标来源;没做的写未验证。误提交秘密时停止传播并联系维护者处置,删当前文件不等于清历史。

学习记录是本人的进度,不是成绩。没有实际复核者时写待验收;自己先演练有价值,公开发布也可先收反馈,但不替另一个人签名。每次更新应有版本、日期与具体变化;留意弹出的更新摘要,旧阅读记录与新修订分开看。

发现问题请带章节、版本、目标、步骤、预期/实际与脱敏错误。可以先报一个最小卡点,不必等学完整本。我们会保留修订记录,让你知道哪些增加了、哪些纠正了、哪些影响已有练习。现在从第一行开始,把一份别人接得住的作品逐步做出来。

01 · 跑起第一行

Day 1 · 工作台与第一行输出

今天留下两个东西:hello.ts 和一份环境记录。预计读 20 分钟、练 40 分钟;不要求你已经会编程。遇到安装问题,先记原文,不要连续换三种安装方法。

三个东西各干什么

编辑器(editor)用来写文件,推荐熟悉的代码编辑器;终端(terminal)接收命令;Node.js负责在电脑上执行 JavaScript,也能运行本册用到的简单 TypeScript。它们是不同的角色:在编辑器里保存了文件,不代表程序已经运行。

从 Node 官方下载页 选择适合本机系统的 Node 24.x 受维护补丁版本,从 Git 官方安装入口 选择对应系统;核查于 2026-10-11。按安装说明完成后,关闭并重开终端。若不确定系统架构,先请助教确认,不随意安装多套版本。Windows 可用 PowerShell,macOS/Linux 可用系统终端。在终端运行:

node --version
git --version

应看到两个版本号。本册技术验证使用 Node 24.11.1;这是实测版本,不是“目前最新”的声明。若找不到 node,先确认安装完成、终端已重开,再把完整消息与系统信息记下来。不要通过随手关闭系统防护或复制未知脚本解决。

建自己的练习目录

打开你准备放练习的目录,在终端依次运行。下面三条在 PowerShell 与 bash 都可用:

mkdir dev-first-week
cd dev-first-week
pwd

pwd 显示当前位置。用编辑器打开这个目录,新建 hello.ts,注意不是 hello.ts.txt。先自己输入以下两行,再保存:

const message: string = '今天,我把程序跑起来了。'
console.log(message)

回到该目录的终端:

node hello.ts

预期输出:

今天,我把程序跑起来了。

const 为一个值起名字,message 是名字,string 表示文字类型;console.log 把值打印出来。单引号必须成对;行末不写分号在这里也可以。现在不需要背语法大全,只要你能找到文字在哪里、程序从哪一行执行到哪一行。

Node 在这里移除可以擦掉的类型标注后执行,并没有检查类型是否正确。D1 会加独立类型检查;不要据此认为 .ts 文件运行成功就保证所有类型正确。Node 官方说明,访问于 2026-10-11。

换一句话,确认你真的改到了文件

把文字改成“今天,我会保存再运行。”,保存,再运行同一条命令。应看到新句子。若仍是旧句子,先看编辑器是否有未保存标记,再确认终端所在目录;不要先重装 Node。

现象 下一步
提示找不到命令 重开终端,检查安装和 PATH;记录完整错误
找不到 hello.ts 运行 pwd;列目录 ls;检查后缀与保存位置
没输出但没报错 确认运行的是刚保存的文件,里面有 console.log
引号附近报语法错 检查是否输入了中文弯引号、是否少了另一半

今天交什么

保留 hello.ts;新建 notes.md,写系统、Node/Git 版本、运行命令、预期与实际输出、一个你遇到的卡点。不要贴电脑账户名等多余个人信息。写代码由你自己完成,概念可问解释;不懂的词标出来,比装作全懂更有用。

自问三句:编辑器和 Node 有什么区别?保存与运行有什么区别?终端在哪个目录?能够用自己的话回答,再继续 Day 2。

02 · 文件与路径

Day 2 · 文件在哪里,程序就从哪里开始

今天把一句话变成一份任务清单。前置是 Day 1 能运行;预计读 20 分钟、练 40 分钟。所有任务都是虚构练习,不填写自己的真实日程或任何业务资料。

先知道自己站在哪

目录可以套目录,文件有路径。工作目录(working directory)是你敲命令时所在的位置;相对路径从这里计算。程序文件的位置与工作目录不一定相同,Day 4 会故意让它们不同,观察会发生什么。

在 dev-first-week 中执行:

pwd
ls

你应能看到 hello.ts。cd .. 去上一级,cd dev-first-week 再回来。路径含空格时给它加引号;教学目录采用简单英文名是为了减少变量,不代表系统不能使用中文路径。

建 summary.ts

用编辑器新建 summary.ts,输入下面的完整例子。先按样例输入再理解,不调用 AI 代写。

type Task = { title: string; done: boolean }

const tasks: Task[] = [
  { title: '准备虚构资料', done: true },
  { title: '检查页面文字', done: false },
  { title: '整理交接说明', done: false },
]

for (const task of tasks) {
  const mark = task.done ? '[x]' : '[ ]'
  console.log(mark + ' ' + task.title)
}

const completed = tasks.filter(task => task.done).length
console.log('已完成 ' + completed + '/' + tasks.length + ' 项')

运行:

node summary.ts

预期:

[x] 准备虚构资料
[ ] 检查页面文字
[ ] 整理交接说明
已完成 1/3 项

只解释完成今天任务所需的语法

Task 描述一条数据有文字标题和真假状态;boolean 只有 true/false 两种。Task[] 是多条同类数据组成的数组(array)。方括号包住数组,花括号包住一条任务。

for...of 逐条取出任务。? : 根据状态在两个标记中选一个。filter 保留符合条件的项,length 数个数。最后一行把完成数与总数拼成文字。类型、数组和函数会在后续系统学;今天先把每行与输出对应起来。

这份程序的输入直接写在文件里,运行结果只显示在终端。它没有数据库,不会在你打字时自动修改状态;关掉终端不会“保存操作”,因为今天根本没有输入操作。能说清没有什么能力,也是读懂程序的一部分。

做一次预测再验证

先别修改文件,在 notes.md 写:第二条的 done 变成 true 后,哪两行输出会改变?然后自己改、保存、运行,比对预测。为了给 Day 3 留一次明确的变化,现在把第二条恢复为 false 并保存;下一节用 Git 记录修改。

卡点 排查
Unexpected token 看错误附近的括号、引号、逗号;一次改一处
未完成项也被计入 看是否把 false 写成了字符串 'false';非空字符串在这个条件里会被当作真,可能导致多算。Node 直接运行不会替你做类型检查
改了没有变化 检查保存、目录、文件名;运行 ls 确认

今天留下 summary.ts、一次“预测/实际”对照。能指着数据、循环和最后一行说明各自职责,就可以进入 Day 3。延伸的 JS/TS 关系见 TypeScript 官方入门,访问于 2026-10-11。

03 · 用 Git 留下改动

Day 3 · 把改动记录成别人看得懂的历史

今天不加新功能。你要让“我改过了”变成一条可检查的记录。前置是 hello.ts、summary.ts 和 Day 2 的初始输出;预计读 20 分钟、练 40 分钟。

Git 记的是哪些变化

工作目录里是正在编辑的文件;暂存区(staging area)是你选定要记录的变化;提交(commit)把它们存成一个版本。git add 不会把文件发到网上,git commit 也只发生在本地。Day 5 才讲远端。

只在你新建的 dev-first-week 练习目录执行:

git init
git branch -M main
git status

如果 Git 首次提交时说不知道你是谁,在当前练习仓库设置你愿意用于学习记录的名字与邮箱,不要照搬别人的身份;不用 --global 就不会改所有仓库。远端公开前可使用平台提供的隐私邮箱。

git config user.name "你的显示名"
git config user.email "你的隐私邮箱"

先写忽略规则,再选文件

在编辑器里创建 .gitignore,内容如下。首周不需要 .env,这里先预防以后不小心加入:

.env
.env.*
!.env.example
node_modules/
.DS_Store
*.log

现在逐个选文件,不用不看内容的“一次全加”:

git add hello.ts summary.ts .gitignore
git diff --cached
git commit -m "建立虚构任务清单"

git diff --cached 是检查即将提交的内容。第一次提交时新文件全部出现是正常的。密钥、真实数据、个人信息不该在里面;忽略规则不是安全扫描器,也不会自动清除已经提交的文件。

做第二次提交

把第二条任务的 done 改成 true。先运行 node summary.ts,应显示两条 [x] 和 已完成 2/3 项。再检查:

git diff
git add summary.ts
git diff --cached
git commit -m "将文字检查标记为完成"
git log --oneline
git status

diff 中 - 是旧内容,+ 是新内容,不是让你手工复制的符号。日志应有两条有意义的记录;不是提交越多越好,而是另一人能理解为什么发生变化。

如果暂存错文件,可以用 git restore --staged 文件名 撤出暂存,工作文件仍在。现在不要尝试清空历史、强推或 reset --hard;第一周只需要理解并审阅自己的变化。

今天交什么

保留日志、一次差异说明和“运行前/运行后”结果。第三次有意义的提交可以是下一天的排错资料或 Day 6 文档,不需要为凑次数改空格。你应能回答:保存、暂存、提交、推送分别发生在哪里?

今天代码修改仍由你完成。可以请 AI 解释某条 Git 命令,但先用 git help、状态与实际差异核实,不让它替你执行看不懂的破坏性操作。延伸阅读 Pro Git:Git Basics,访问于 2026-10-11。

04 · 读懂报错

Day 4 · 读懂报错

2026-10-11 公开初稿;保留原样章教学主线并修正表述。下面是虚构练习场景,使用独立的 error-demo 子目录。示例需在该目录运行;不同 Node 版本的栈帧行号可能不同。


今天你将得到什么

一句话:把报错从"我完了"变成"它在告诉我哪里不对"。 产出物:3 张「病历卡」(后面会讲这是什么)。 预计耗时:读 20 分钟 + 练 40 分钟。 前置:Day 1–3 完成,电脑上能跑 node -v 并已有本地 Git 仓库。


一个场景

第 3 天晚上 11 点,你按教程敲完最后一行,回车。

屏幕刷出一大段红色的字。你往上翻,找不到"错误"两个字到底在哪一行;往下看,最后一行写着 Node.js v24.11.1,看起来也不像错误。

你截了个图,发到群里:

「报错了,怎么办」

然后你盯着屏幕等回复。十分钟过去,没人说话。你开始怀疑两件事:是不是我电脑有问题, 以及是不是我太笨了。

仅凭这条报错,不能得出那两种结论。现在能确认的是:程序给出了错误线索,我们需要把它读出来。 而且——它给了你一个别人帮你的机会,而你刚才那句话,把这个机会丢掉了。


正文

报错是这世上最诚实的老师

先把一件事说清楚:报错不是失败,是信息。

改代码的人里,最难教的一类是"报错看不懂就乱改"——删掉这行、注释那行、重装一遍依赖, 最后程序不报错了,但也不工作了,而且没人知道为什么。他们不是不努力,是把报错当成了敌人。

换一个看法:你写的每一行代码,计算机都严格按字面执行,它不会猜你的意思。 它做不到的时候,就会停下来告诉你:我读到第 6 行,这里访问了不存在的属性,得到 undefined,我没法继续从它里面取属性。

这句话,就是一次报错。

报错的解剖:先从三处找线索

看一个真实的报错(来自一次真机运行,你的行号与路径会不同):

TypeError: Cannot read properties of undefined (reading '0')
    at Object.<anonymous> (~/error-demo/index.js:6:31)
    at Module._compile (node:internal/modules/cjs/loader:1761:14)
    at Object..js (node:internal/modules/cjs/loader:1893:10)
    at Module.load (node:internal/modules/cjs/loader:1481:32)
    at node:internal/main/run_main_module:33:47

别被长度吓到。只需要读三处:

读哪 内容 它告诉你
第 1 行 TypeError: Cannot read properties of undefined 什么错:你在从 undefined(未取得值)里取属性
第一段里带你自己文件名的那行 at Object.<anonymous> (~/error-demo/index.js:6:31) 在哪错:index.js 第 6 行第 31 列
第 1 行的括号里 (reading '0') 错在取什么:你在取下标 0

下面那几行 node:internal/... 是运行时调用链。通常先看自己的栈帧;若自己的代码无误,再查依赖和运行时,不能仅凭前缀断言它们永远没有问题。 新手最常见的误读就是:从最后一行开始看,然后跑去研究 run_main_module 是什么。 从第一行往下读,找到第一个属于你的文件名,停。

注意:报错的具体文字、行号、路径会随环境与版本变化,你不必和这里一模一样—— 要学的是读法,不是背下来。

一个能跑起来的完整例子

空讲没用。我们造一个真实的错误,再亲手修掉它。

文件树(在 dev-first-week 中新建 error-demo;就两个文件,不需要 package.json):

error-demo/
├── data.json
└── index.js

data.json:

{
  "user": { "id": 1, "name": "同学甲" }
}

index.js:

const fs = require('node:fs')

const raw = fs.readFileSync('./data.json', 'utf8')
const data = JSON.parse(raw)

console.log('你好,' + data.users[0].name)

在 error-demo 目录里运行:

node index.js

你会拿到上面那个 TypeError。现在按刚才的三处读法走一遍:

  1. 什么错:从 undefined 里取属性;
  2. 在哪错:index.js 第 6 行;
  3. 错在取什么:下标 0。

第 6 行写的是 data.users[0].name。于是问题变成一句话:data.users 的值是 undefined。

回头看 data.json——里面只有 user(单数),没有 users(复数)。 data.users 取不到东西,是 undefined;从一个 undefined 里取 [0],就炸了。

修法就很清楚了:

console.log('你好,' + data.user.name)

再跑一次:

你好,同学甲

注意这个过程里我们做了什么:我们没有"试一下看看",我们是先确认它在哪一层出错 (数据层,不是前端、不是接口、不是依赖),再动手改那一层。 这就是后续 Web 开发要继续用的分层归因。

另一类错误:环境层

顺手认识第二种。如果你在别的目录里运行同一个命令:

cd ..
node error-demo/index.js

得到的是:

Error: ENOENT: no such file or directory, open './data.json'

读法一样:ENOENT = "没有这个文件"。./data.json 是相对路径——它相对于 你敲命令时所在的目录,不是相对于 index.js 所在的目录。 这一次是相对路径与启动位置不一致。实际项目也可能需要修改路径处理代码;先验证工作目录,再决定改启动说明还是程序。

报错读不懂怎么办

三步,按顺序:

  1. 先读三处(什么错 / 在哪错 / 错在取什么)。先把看懂的部分和仍不明白的部分分开。
  2. 看不懂术语就用报错原文去搜——TypeError Cannot read properties of undefined 整句复制, 不要只搜"报错"。
  3. 还不懂,就带着完整信息去问(Day 4 到 Day 30 都适用,参见 AI 手册 12.2 最小可复现):
  4. 我想做什么(一句话)
  5. 我敲了什么命令、在哪个目录
  6. 完整的报错原文(复制文字,不要截图,不要只写"报错了")

试一试(2 分钟):把 data.json 里 user 改成 users 数组, 看看报错变成什么样——你会发现它换了一种错法。报错会变,说明它在跟着你的代码走。


工程价值观 · 先复现,再动手

主张 不要瞎改。先让问题稳定重现,再定位在哪一层,最后才动手
反面 看不懂报错就到处改,越改越乱;AI 说这么改就改,没读懂就提交
虚构反例 一个同学为了消掉一个报错,删了 3 行、注释了 2 行、重装了依赖,报错消失了——功能也没了。第二天没人(包括他自己)说得清昨晚改了什么。
检验 你能稳定复现这个 bug 吗?你知道自己改的是哪一层吗?

为什么排在第 4 天:因为在你还什么都不会的时候,"乱试"很容易成为应对焦虑的动作—— 也正因如此,它是这 30 天里最容易养成的坏习惯。今天多花十分钟学读报错, 是为了后面 26 天不靠碰运气。


【与 AI 一起做】

今天可以问 AI 定向解释,也可以查文档或请人解释;不要求账号或付费。实际修改仍由你完成。

今天不许出现的提示词:

❌「帮我改好」 ❌「这段代码报错了,给我正确的」

今天要这样用(照抄,把方括号换成你的内容):

我在学 Node.js,刚写了一段代码。
我想做的事:[读取 data.json,打印用户名]
我敲的命令和所在目录:[node index.js,在 error-demo 目录下]
完整报错:
[粘贴脱敏后的完整相关报错;先去掉秘密与私人信息,不改关键错误上下文]

请先不要给我改好的完整代码。
请回答三个问题:
1. 这个报错属于哪一类(环境 / 语法 / 类型 / 接口)?
2. 报错指向我的第几行,那一行在做什么?
3. 我应该自己去改哪里?

为什么要这样问:你要的是读懂,不是修好。 AI 可能很快给出一份代码,但它不能替你建立"看到报错知道去哪"的肌肉记忆。 这种判断要通过自己的预测、定位与复验逐步建立——而 D0 阶段,你是最需要建立自己的判断的时候(AI协作专章继续讨论)。

今天还要做一件事:AI 给你的任何判断,先自己验证一遍再接受。 它说"错在第 6 行",你就去数第 6 行;它说"是数据层问题",你就去打印 data 看看。 校准信任从第四天就开始练。


【这一步最常在哪里断】

卡点 为什么断 怎么办
只发"报错了"三个字 别人拿不到任何可用信息,只能反问,来回三轮你就自己放弃了 贴出脱敏后的完整相关报错+命令+目录
从最后一个报错开始改 一个错误常引发连锁报错;后面那些是"果",不是"因" 先定位最早的相关错误,再结合调用链确认原因
中文标点 / 全角符号 中文引号 “ 和英文引号 " 计算机眼里是两个东西;路径中空格、编码或工具配置也可能影响定位 代码里的符号一律切到英文输入法;新练习可先选简单英文路径减少变量;这不代表系统不支持中文
删掉报错的那行让它"不报错" 报错消失 ≠ 问题解决,功能已经悄悄坏了 报错消失前,先问"它原本要做什么"
截图问人 别人没法复制你的报错去搜 一律复制文字

今天交什么

3 张「病历卡」。造三个不同的错误(至少包含一个环境/路径错误、一个类型错误)。第三个可以在独立文件 syntax-error.js 写下不完整的 console.log(,运行观察 SyntaxError,补齐为 console.log("已修复") 后重跑,每张卡填四行:

病历卡 #1
报错原文(完整粘贴):
我的判断(哪一层?第几行?):
我改了什么:
结果(好了 / 变了 / 还是不行):

自检:如果第 3 张卡的"我的判断"那一行你还是写不出来——没关系,如实写"判断不出"。 这比编一个像样的答案有用;把缺口说明白,下一位才能帮你。

打卡 ☐


明天见

明天练习远端仓库:可以使用已授权的托管账号,也可以先用本机裸仓库完成推送和克隆。但先说一件事: 上面那张病历卡,先别删。 30 天后你会回来重读它。


⛔ 红线提醒:密钥、口令、token 不进代码库;练习只用虚构数据,真实警务数据与个人信息不贴进公网 AI。

首周文件约定:三张卡写入 error-cards.md,卡中的路径脱去个人目录信息;只保留教学文件。这里的 TypeError 原始示例会故意失败;修复后应按新预期验证,并在 README 注明当前状态,别把原始错误版的非零退出码误报为整个首作失败。回到 dev-first-week 后,node summary.ts 仍应显示 2/3。

05 · 推送与克隆

Day 5 · 远端不是你的工作目录

今天拿到一份新的仓库副本。前置是本地 Git 已有提交;预计读 20 分钟、练 40 分钟。网络不可达时有纯本地路线,效果要如实标明。

先分清三个位置

你编辑的是本地工作目录;本地 .git 保存提交;远端(remote)是另一个仓库的位置。push 发送本地提交,clone 从仓库建立新副本。给别人发某个文件,不等于交出了可复现版本;复制整个目录也不能自动证明 README 没漏步骤。

路线 A:你已有可用的远端账号

在 GitHub 或学校允许的平台新建自己的练习仓库,按页面提示设置隐私;先不要选择自动生成 README,以免新手第一步就遇到两套初始历史。复制它给出的仓库地址。回到本地:

git status
git log --oneline

确认要发送的提交都是虚构资料。按平台页面给你的准确地址执行下面形式,将 YOUR-REPO-URL 整体换成你的地址:

git remote add origin YOUR-REPO-URL
git remote -v
git push -u origin main

按平台官方登录流程认证,不把密码或 token 拼到 URL 里,不把凭据发群里。若已经有 origin,先看 git remote -v 的地址再处理,别盲目覆盖。推送失败先记完整错误,区分认证、地址和网络;不要一上来强推。

路线 B:没有账号或当前网络不可用

在自己的练习目录执行以下命令,建立一个只在本机的裸仓库(bare repository),它没有供你编辑的工作文件,用于模拟提交的去向:

git init --bare ../practice-remote.git
git remote add practice ../practice-remote.git
git push -u practice main

它练的是同一套 Git 传输概念,但没有证明你会访问公网平台。在记录里写“本地远端已验证,平台认证/推送待补”;后续有条件再补,不编一个不存在的仓库链接。

用 clone 检查交出去的是什么

回到上一级,在新的目录名建立副本。用路线 B 时:

cd ..
git clone --branch main practice-remote.git dev-first-week-copy
cd dev-first-week-copy
node summary.ts

路线 A 则把 practice-remote.git 换成真实仓库地址。预期仍是两条完成任务和 已完成 2/3 项。如果原目录运行正常、副本缺文件,检查缺的文件是否提交了;不能用“我电脑上有”解释交付缺件。

今天先别在副本继续写后面的章节。回到自己的原目录 dev-first-week;下一天整理 README,然后提交并再次推送。两个目录名字很像,开工前用 pwd 确认。

今天交什么

一条实际 push 的结果、一个实际 clone 的结果、新目录运行输出和未完成项。输出脱去个人账号/目录等不必要信息。若两条路线都没通,记录卡点并求助,不标完成。

你要解释:删掉工作目录是否会删除远端?尚未 commit 的变化会不会被 push?能回答后,再读 Day 6。Git 原理可查 Pro Git:Working with Remotes,访问于 2026-10-11。

06 · 写清运行步骤

Day 6 · 把脑子里的步骤写出来

今天只完成一个目标:另一位读者不问你,就知道如何运行。前置是自己的原目录与 Day 5 副本;预计读 20 分钟、练 40 分钟。

一句“直接运行”缺了什么

它没说用哪个程序、哪个版本、在哪个目录、文件叫什么,也没说怎样判断结果正确。你记得这些,是因为刚亲手做过。下一位维护者可能只看到一个压缩包;说明必须从那个人的位置出发。

在原练习目录新建 README.md。下面是可直接按自己实际情况填写的结构;内容不需要漂亮,需要是真的。

# 虚构任务清单

用途:显示三条虚构教学任务及完成数量。

## 环境
- Node 24.x;我的实际验证版本:填写 node --version 的输出
- Git;我的实际验证版本:填写 git --version 的输出
- 不需要 npm install、模型 Key 或数据库。
- Node 运行此 TS 例子不代表通过了静态类型检查。

## 运行
在含 summary.ts 的仓库目录执行:
node summary.ts

## 预期
[x] 准备虚构资料
[x] 检查页面文字
[ ] 整理交接说明
已完成 2/3 项

## 修改与验证
把第三条 done 从 false 改成 true,应显示已完成 3/3 项。
验完恢复或提交新状态;这里的预期同步更新。

## 文件
summary.ts:主程序;hello.ts:首日练习。
error-cards.md:三个故意错误、判断、修复和结果。
.gitignore:不进入提交的路径规则。

## 限制
数据写在代码里,没有界面、文件保存、登录或数据库。
error-demo 用于故意报错和修复;注明当前文件是错误版还是修复版。

## 参与和来源
练习依据《开发部新生入门30天》第一周。
本人修改:具体写一项。
AI 使用:未用 / 只询问哪些概念、如何验证(如实填写)。

## 验证记录
日期、系统、命令、预期、实际结果、未验证事项。

不要把“填写”留在最后交付物里;没做的项目写未验证。你的任务状态如果与这里不同,改成真实预期。README 不是对未来的许诺,而是对这份版本的说明。

按读者的路径试一次

先审阅并提交文件:

git add README.md error-cards.md error-demo/data.json error-demo/index.js
git diff --cached
git commit -m "补充运行说明和排错记录"

若你还没有 error-cards.md,先回 Day 4 完成,别创建空文件只为命令成功。再推送到自己实际使用的远端。重新 clone 到另一个尚不存在的目录,按 README 执行;不要从旧目录补一个没提交的文件然后假装一次通过。

这轮若需要口头解释或补文件,在记录中写下来,再修文档或提交。学会交接的过程,就是把“我以为别人知道”逐个找出来。

不能复制的差异

AI 手册同样讲 README,但示例有 Python、模型 Key 和外部依赖。我们复用它的交付方法,不照搬那套环境。延伸阅读 AI 手册 5.7。

今天交一份真实 README 和复现记录。代码不变也可以有有价值的提交;“别人能接住”本来就是工程的一部分。

07 · 交付与小改

Day 7 · 交出去,再改一个小要求

前六天的文件现在应能组成一份作品。今天不加花哨功能,把一次复现和一次小改完整做完。预计读 15 分钟、练 45 分钟;真实验收时长以试读记录为准。

先整理文件

你的练习目录至少有以下文件。.git/ 是 Git 管理的内部目录,不手改它;可能还有你自己保留的笔记。

dev-first-week/
├── .gitignore
├── hello.ts
├── summary.ts
├── README.md
├── error-cards.md
└── error-demo/
    ├── data.json
    └── index.js

error-demo 是报错与修复实验,README 应说明当前文件是错误版还是修复版,以及它与主程序不同。检查 Git 状态,确认你打算交付的文件已提交。不要把“提交数达到三次”当成目标;检查每次改动是否能说明目的。

首作的运行基线

若你不确定最终 summary.ts 应长什么样,这里列出本周基线。请先与自己文件对照找差异,不用整段覆盖自己的尝试。

type Task = { title: string; done: boolean }

const tasks: Task[] = [
  { title: '准备虚构资料', done: true },
  { title: '检查页面文字', done: true },
  { title: '整理交接说明', done: false },
]

for (const task of tasks) {
  const mark = task.done ? '[x]' : '[ ]'
  console.log(mark + ' ' + task.title)
}

const completed = tasks.filter(task => task.done).length
console.log('已完成 ' + completed + '/' + tasks.length + ' 项')

运行 node summary.ts,预期最后一行是 已完成 2/3 项。除了最后一行,也看三条任务标记;只修统计数字而不修数据不是完成需求。

三步验证

第一步,干净目录。 按 Day 5/6 再 clone 一份,严格照 README 运行。先自己演练可以发现文档缺漏;有另一位复核者时请对方照做。没找到复核者就写“自检通过,独立复核待完成”,不能替对方签名。

第二步,讲回来。 不读现成解说,用三到五句说清:数据在哪、程序如何选标记、完成数如何算、没有哪些功能。再解释一次 TypeError 或路径错,指出当时如何验证自己的判断。

第三步,小改。 在原目录把第三条改成完成,先写预测再运行。应显示三条 [x] 和 已完成 3/3 项。解释为什么循环不用增加一行。检查差异,选择保留新状态并同步 README,或恢复基线;不要留下 README 与代码互相矛盾。

这些步骤的目的不是考背诵;它们让别人看见你能理解和维护哪一部分。首周编码由本人完成,理解型问题可以问解释。用 AI 的地方写明范围和你的验证,不需要提交含私人内容的完整聊天。

交付记录

作品:虚构任务清单
版本:填写提交标识;没有远端也保留本地记录
环境:实际系统、Node/Git版本
运行:目录、命令、实际输出
排错:三张卡是否齐全;仍不理解什么
小改:预测、差异、运行结果
AI参与:无 / 定向解释的内容与验证
复核:本人自检 / 实际复核者;未做的写未做
下一步:一个具体问题

对照首周自查。接下来从Day 8补齐JS/TS与浏览器,再到Web全栈;今天通过首作不等于已经会写带登录的网站。你现在有了一份能跑、能解释、能交接的小作品,这足够作为下一段学习的起点。

08 · 值与转换

Day 8 · 屏幕上的“2”未必是数字

任务清单将来要接收表单。传来的数量看起来是2,程序却可能把它拼成21。今天先不做页面,弄清文字和数字怎样参与运算。在新目录 dev-foundations/day08 新建下面文件;首周仓库保留,不覆盖。

文件:values.ts

const entered = '2'
const count = Number(entered)
console.log(entered + 1)
console.log(count + 1)
console.log(typeof entered, typeof count)
console.log(Boolean('false'), Boolean(false))
console.log(Number.isFinite(Number('不是数字')))

运行 node values.ts,依次应得到 21、3、string number、true false、false。先写自己的预测,再与输出比。+遇到文字会拼接;Number尝试转换数字,失败得到特殊数值NaN,所以还要检查 Number.isFinite。空文字和空格会转换成0;如果业务不接受空输入,必须先检查 trim()。

string是文字,number是数字,boolean是真假。'false'是一段非空文字,在真假判断里会被视为真;false才是假。null常表示有意留空,undefined常见于没有返回值、没有该属性或没找到数组项,两者都不能自动说明业务上该做什么。

类型和执行是两次检查

const禁止把名字重新指向另一个值,但不把对象内部冻结;let允许重新赋值。类型标注给开发工具信息,实际运算仍遵循JavaScript。Day11会加独立类型检查。不要给每个值加 as number 假装转换;断言只影响检查器,不会把文字变成数字。

把第一行分别改成 '0'、''、' '、'2个'。一次只改一个值,记录转换结果及是否应该接受。再写规则:只接受非空、转换后有限且不小于0的整数,用 Number.isInteger 检查。不要先删掉非数字字符,那会把坏输入变成另一个输入。

排错与交付

出现21时看运算两边真实类型,别只盯显示的字符。出现NaN时查原始输入;value === NaN检查不出它,应查 Number.isNaN 或有限性。今天交程序、四组预测/实际和接受规则。可问AI解释单个运算,但先留自己的预测,让观察仍由你完成。

09 · 函数与边界

Day 9 · 把一段规则交给函数

两处都要统计完成数时,复制循环很快出问题:改了这一份,忘了另一份。函数(function)把输入、处理和输出放在一个边界里。今天把统计与标题检查分开,完整文件如下。

文件:functions.ts

type Task = { title: string; done: boolean }
function summary(tasks: Task[]): string {
  const completed = tasks.filter(task => task.done).length
  return `${completed}/${tasks.length}`
}
function titleOf(value: unknown): string {
  if (typeof value !== 'string' || !value.trim()) throw new Error('标题必须是非空文字')
  return value.trim()
}
console.log(summary([{ title: '练习', done: true }]))
console.log(summary([]))
try { console.log(titleOf('   ')) }
catch (error) { console.log(error instanceof Error ? error.message : '未知错误') }

运行 node functions.ts,预期三行:1/1、0/0、标题必须是非空文字。summary接收数组,返回文字,不负责打印。页面、终端和测试都能调用同一规则。参数tasks代表本次收到的数据,两次调用不共享神秘的“当前任务”。

titleOf参数是 unknown,表示外部值还没检查。证明是文字后才调用trim,这是类型收窄(narrowing)。不符合规则就throw错误,调用者用try/catch处理。这里显示消息;真实服务会把预期输入错误变成明确响应,把意外错误留下诊断记录。

返回不是打印

把return换成console.log,先预测调用者收到什么,再试。打印是向终端产生副作用,不能代替返回。另一个常见错误是只声明函数没调用:文件里有正确规则,不代表它执行过。

做三次修改:空数组返回“暂无任务”;标题增加60字上限;数字标题仍拒绝,不悄悄转换成文字。每次先写输入与预期,再改和运行。正常、边界、失败都要有,不能只演示漂亮输入。交完整文件与对照,并说明规则为何放在函数里;不需要先背所有函数语法。

10 · 数组与对象

Day 10 · 找到一条任务,再决定改哪一份

任务不再固定三项。我们要按编号找到它、改状态,并知道原列表有没有变。数组不是数据库,编号也不会自动唯一。先观察两项虚构数据的一次更新。

文件:tasks.ts

type Task = { id: number; title: string; done: boolean }
const original: Task[] = [
  { id: 1, title: '准备虚构资料', done: true },
  { id: 2, title: '整理交接说明', done: false },
]
const changed = original.map(task => task.id === 2 ? { ...task, done: true } : task)
console.log(original.filter(task => task.done).length)
console.log(changed.filter(task => task.done).length)
console.log(changed.find(task => task.id === 99) === undefined)
console.log(original[0] === changed[0], original[1] === changed[1])

运行 node tasks.ts,预期为 1、2、true、true false。map逐项产生新数组;编号2的对象通过 { ...task, done: true }复制属性并覆盖状态,其他对象仍引用原对象。最后一行说明这是浅复制:新数组不等于每个嵌套值都复制了。

filter留下满足条件的项;find返回第一个匹配项,找不到为undefined。因此不能查找后立刻访问.title。下标从0开始,下标不是业务编号;删掉第一项后,下标变了,编号应保留。

更新规则不止一句“取反”

增加 setDone(tasks, id, done) 函数。不存在编号时可返回原列表或抛明确错误,但要写契约。为什么“设为真”比“取反”更容易处理网络重试?同一请求执行两次,设为真仍是真,取反两次却回到原状态。

分别验证空数组、存在编号、不存在编号、重复编号。重复编号怎样处理由规则决定,不能默认资料永远干净。sort会原地修改数组,需要保留原列表时先复制。今天交函数、四组验证及引用关系说明,不为了一个更新函数先引入框架。

11 · 模块与类型检查

Day 11 · 运行成功之外,再加类型检查

一个文件装数据、规则和打印越来越难读。把规则移到模块(module),再让TypeScript在执行前检查明显不匹配。本节目录独立于首周,首次安装教学开发依赖。版本2026-10-11在教学环境核对,固定版本用于复现,不表示最新。

在 dev-foundations/day11 建四个文件:

文件:package.json

{"private":true,"type":"module","scripts":{"check":"tsc --noEmit","start":"node main.ts"},"devDependencies":{"typescript":"5.7.2"}}

文件:tsconfig.json

{"compilerOptions":{"target":"ES2022","module":"NodeNext","moduleResolution":"NodeNext","strict":true,"noEmit":true,"allowImportingTsExtensions":true},"include":["*.ts"]}

文件:model.ts

export type Task = { title: string; done: boolean }
export function countDone(tasks: Task[]): number {
  return tasks.filter(task => task.done).length
}

文件:main.ts

import { countDone } from './model.ts'
import type { Task } from './model.ts'
const tasks: Task[] = [{ title: '阅读模块', done: true }]
console.log(countDone(tasks))
npm install
npm run check
npm start

首次生成package-lock.json后与源码一起提交;不要提交node_modules。以后干净目录用 npm ci 按锁文件装。check成功通常没有额外输出,start输出1。前者检查类型,后者执行,两个退出结果都要看。

export提供规则,import明确依赖。相对路径从当前模块计算,要写./。import type只导入类型;Node移除类型后不需要找名叫Task的运行变量。本节写.ts后缀,配合allowImportingTsExtensions与不输出文件的检查;后端编译另设配置,不可混抄。

故意失败两次

把done改成字符串'false',检查应拒绝;再对比直接运行为何仍可能打印1。恢复后拼错导入路径,观察模块加载失败。一种是类型不匹配,一种是找不到依赖,不能用同一种修法。

修改规则,让它返回完成数与总数对象,再改调用者。交两个模块的依赖说明、锁文件、正常/错误记录。安装失败记版本、网络、原文,通过学校允许的渠道获取包;不关闭证书校验,也不把网络问题当理解失败。

12 · 等待与失败

Day 12 · 等一件事完成

读文件或请求网络需要等待,不能假定下一行时结果已回来。Promise描述未来完成或失败的操作;await让当前异步函数等到完成,不会让整台电脑停工。先用本地10毫秒模拟看顺序。

文件:async.ts

function wait(milliseconds: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, milliseconds))
}
async function loadTasks(fail: boolean): Promise<string[]> {
  await wait(10)
  if (fail) throw new Error('模拟读取失败')
  return ['准备虚构资料', '整理交接说明']
}
async function main(): Promise<void> {
  console.log('开始')
  try {
    const tasks = await loadTasks(process.argv.includes('--fail'))
    console.log('读到 ' + tasks.length + ' 项')
  } catch (error) {
    console.log(error instanceof Error ? error.message : '未知错误')
  } finally { console.log('结束') }
}
void main()

运行 node async.ts,应显示“开始 / 读到 2 项 / 结束”;加 --fail 应显示“开始 / 模拟读取失败 / 结束”。没有读真实日程或服务。加入Day11类型检查时另装 @types/node@24.13.5 提供process等声明;运行时与类型声明是两件事。

loadTasks返回Promise,不是立刻返回数组。await拿真正结果,失败进入catch。finally不论成功失败都执行,适合恢复按钮或释放资源,不应偷偷把失败改成功。main内部处理预期失败,void main表示主动启动入口,不靠忽略Promise掩盖错误。

在等待前后加打印,先预测顺序;再去掉await,看拿到的是不是数组。不要用“延迟一秒应该好了”修顺序。HTTP还要超时、状态码与重试边界,Day15再加。

练习:读取函数接收延迟参数,负数拒绝。验证成功、失败、零延迟、坏参数,说明失败后为何仍显示结束。交顺序预测/实际。关心的不只最后有数据,还有没有数据时调用者怎么继续。

13 · 页面结构与布局

Day 13 · 让任务有一个能阅读的页面

终端已有明确规则,现在给它浏览器外壳。先做结构与布局,不急着写交互。HTML描述内容,CSS描述呈现,JavaScript随后接行为。新目录dev-browser建两个文件:

文件:index.html

<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>虚构任务清单</title><link rel="stylesheet" href="style.css"></head>
<body><main><h1>虚构任务清单</h1><p>只用于本地教学,不填写真实资料。</p>
<form id="task-form"><label for="title">任务标题</label><input id="title" name="title" maxlength="60" required><button>添加</button></form>
<p id="status" role="status">尚未添加任务。</p><ul id="tasks"></ul></main></body></html>

文件:style.css

* { box-sizing: border-box; }
body { margin: 0; font: 1rem/1.6 system-ui; color: #14263c; background: #f3f0e7; }
main { width: min(42rem, 100%); padding: 1.25rem; margin: auto; }
form { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; }
input { min-width: 0; max-width: 100%; flex: 1 1 12rem; padding: .5rem; }
button { padding: .5rem 1rem; cursor: pointer; }
:focus-visible { outline: 3px solid #b04c29; outline-offset: 3px; }
li { overflow-wrap: anywhere; margin-block: .6rem; }

浏览器打开index.html,应看到标题、说明、带标签输入框和添加按钮。今天提交会触发默认行为,下一节再接。HTML不用Node执行。本地文件足够观察本例,后续模块和请求改用开发服务器。

main标主体,h1给主题,label的for把说明关联输入框,不是给字加粗就等于标签。form里的button默认提交。role=status给动态状态使用,不把每个段落设成告警。手机视口让布局按设备宽度计算;flex换行与min-width:0避免输入撑开窄屏。

先用键盘,再改颜色

不用鼠标按Tab到输入和按钮,确认焦点可见;点击标签应聚焦输入。缩到320像素,正文不能整体横向滚动;放大文字也应能读。墨蓝骨白是视觉选择,语义与键盘是可用性基础。

加“标题最多60字”提示,用aria-describedby关联输入;用长虚构标题测换行。样式没出现先看文件名/相对路径/资源错误,别先改十条CSS。交文件、桌面/窄屏/键盘观察和修改记录。

14 · 事件与状态

Day 14 · 提交怎样变成一条列表

在Day13目录增加app.js,并在 </body> 前加 <script src="app.js"></script>。脚本在内容后,查询控件时它们才已存在。浏览器把HTML组织为文档对象模型(DOM),脚本通过它改变页面。

文件:app.js

const form = document.querySelector('#task-form')
const input = document.querySelector('#title')
const list = document.querySelector('#tasks')
const status = document.querySelector('#status')
const tasks = []
function render() {
  list.replaceChildren()
  for (const title of tasks) {
    const item = document.createElement('li')
    item.textContent = title
    list.append(item)
  }
  status.textContent = '共 ' + tasks.length + ' 项'
}
form.addEventListener('submit', event => {
  event.preventDefault()
  const title = input.value.trim()
  if (!title || title.length > 60) { status.textContent = '请输入1–60字标题'; return }
  tasks.push(title)
  render()
  form.reset()
  input.focus()
})

输入“检查虚构说明”并添加,应出现一项和“共 1 项”。全空格拒绝;输入 <img src=x onerror=alert(1)> 应作为普通文字,不执行。标题是资料,用textContent,不把输入当HTML模板。

事件从操作开始,preventDefault阻止默认导航,随后检查输入、改tasks、重画列表。状态与呈现分开,不要只画一行却忘更新数组。刷新后数组从空开始,资料消失是未设计存储,不是浏览器故障。

练习:重复标题不允许添加,比较前trim。先写正常、重复、空格、60/61字、尖括号输入预期再改。maxlength帮助输入,不能作为服务端安全规则;用户可绕过页面直接请求。

null属性报错先查id与执行位置;添加后刷新查form绑定与preventDefault;数量不同步沿状态→render找,别再加第二份计数。交文件、五类验证和事件路径。它是D1素材,独立类型检查、测试、无回归与真实复核仍需完成;勾选不授予D1。

15 · HTTP契约

Day 15 · 向另一个程序要数据

页面与服务分开后,双方通过约定交流。HTTP请求有方法、地址、头和可选内容,响应有状态码、头和内容。先用Node自带模块演示GET,不把网络藏进框架。新目录dev-http建两个文件:

文件:server.mjs

import http from 'node:http'
const server = http.createServer((request, response) => {
  response.setHeader('Content-Type', 'application/json; charset=utf-8')
  if (request.method === 'GET' && request.url === '/api/tasks') {
    response.end(JSON.stringify([{ id: 1, title: '虚构接口任务', done: false }]))
  } else {
    response.statusCode = 404
    response.end(JSON.stringify({ message: '没有这个接口' }))
  }
})
server.listen(3100, '127.0.0.1', () => console.log('http://127.0.0.1:3100'))

文件:client.mjs

const response = await fetch('http://127.0.0.1:3100/api/tasks', { signal: AbortSignal.timeout(3000) })
if (!response.ok) throw new Error('HTTP ' + response.status)
const tasks = await response.json()
console.log(tasks[0].title)

第一终端 node server.mjs 保持运行;第二终端 node client.mjs 应输出“虚构接口任务”。浏览器打开终端地址加/api/tasks能看JSON。改客户端路径为/api/missing应因404失败;停服务则是连接失败,还没得到HTTP状态码。

fetch收到404通常仍返回响应,不自动替你抛业务错误,所以检查response.ok。200也不证明数据正确,要检查字段。超时防止无限等,不表示服务肯定没执行操作;重试创建请求前要考虑重复提交。

本接口契约是 GET /api/tasks → 200 + 任务数组;未知路由404。以后400表示输入错误,401表示无有效认证,404也可避免透露他人资源。CORS是浏览器跨源限制的位置,不是认证;Node客户端不会受同样限制。

练习:客户端加try/catch,分别说明HTTP失败、连接失败和JSON错误,不能全显示“没有任务”。交契约、正常/404/停服记录。Ctrl+C停教学服务,避免下一节端口冲突。

16 · Vue响应式页面

Day 16 · 状态变了,页面哪里跟着变

Day14手动重画列表很直观,字段多了容易漏。Vue把状态与模板连接,任务规则仍是原来的。先做仅内存页面,不接后端。在task-lab/web/src建立目录,文件位置按下面标注。

文件:package.json

{"name":"fictional-task-web","private":true,"type":"module","scripts":{"dev":"vite --host 127.0.0.1","build":"vue-tsc --noEmit && vite build"},"dependencies":{"vue":"3.5.43"},"devDependencies":{"@vitejs/plugin-vue":"6.0.6","vite":"7.3.6","typescript":"5.7.2","vue-tsc":"3.3.11"}}

文件:tsconfig.json

{"compilerOptions":{"target":"ES2022","module":"ESNext","moduleResolution":"Bundler","strict":true,"lib":["ES2022","DOM"],"skipLibCheck":true,"noEmit":true},"include":["src/**/*.ts","src/**/*.vue"]}

文件:vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({ plugins: [vue()], server: { proxy: { '/api': 'http://127.0.0.1:3100' } } })

文件:index.html

<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>虚构任务台账</title></head><body><div id="app"></div><script type="module" src="/src/main.ts"></script></body></html>

文件:src/main.ts

import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

文件:src/App.vue

<script setup lang="ts">
import { computed, ref } from 'vue'
type Task = { id: number; title: string; done: boolean }
const tasks = ref<Task[]>([]), title = ref(''), message = ref('')
let nextId = 1
const completed = computed(() => tasks.value.filter(task => task.done).length)
function add() {
  const value = title.value.trim()
  if (!value || value.length > 60) { message.value = '请输入1–60字标题'; return }
  tasks.value.push({ id: nextId++, title: value, done: false })
  title.value = ''; message.value = '已添加'
}
</script>
<template><main><h1>虚构任务台账</h1><p>刷新后不保存。</p>
<form @submit.prevent="add"><label for="title">标题</label><input id="title" v-model="title" maxlength="60"><button>添加</button></form>
<p role="status">{{ message }}</p><p>{{ completed }}/{{ tasks.length }} 项完成</p>
<ul><li v-for="task in tasks" :key="task.id"><label><input type="checkbox" v-model="task.done">{{ task.title }}</label></li></ul>
</main></template>
<style>body{font:1rem/1.6 system-ui;color:#14263c;background:#f3f0e7}main{max-width:42rem;margin:auto;padding:1rem}input{max-width:100%}li{overflow-wrap:anywhere}:focus-visible{outline:3px solid #b04c29}</style>

web目录运行 npm install、npm run build、npm run dev,打开显示的本机地址。保存锁文件,后续用npm ci。固定版本用于2026-10-11复现,不表示最新。添加两项、完成一项应显示1/2;刷新回0/0,因为未存储。

ref包装响应式状态,脚本用.value,模板自动解包。computed从已有状态计算,不维护第二份容易不同步的计数。v-model连接输入与状态,@submit.prevent处理提交,v-for画列表;key用稳定编号,别用会变化的下标冒充身份。

单文件组件(SFC)需要Vite处理,不能双击App.vue运行。代理配置今天不用,Day21让/api转给3100;它是开发便利,不是正式部署路由。练习“只看未完成”,用computed派生可见项,不删原任务,说明总数指全部还是可见。交构建结果、添加/勾选/筛选/刷新观察,能沿事件→状态→模板解释页面。

17 · 服务端规则

Day 17 · 规则必须在服务端再检查

页面拒绝空标题,绕过页面的请求却没经过它。服务必须自己检查。Nest把接请求的控制器(controller)与规则服务(service)组织起来。今天用内存,随后换数据库。task-lab/api/src建入口,api根建配置;数据库依赖预先固定但今天不用。

文件:package.json

{"name":"fictional-task-api","private":true,"scripts":{"build":"tsc","start":"node dist/main.js","check":"tsc --noEmit","generate":"prisma generate","migrate":"prisma migrate dev"},"dependencies":{"@nestjs/common":"11.2.5","@nestjs/core":"11.2.5","@nestjs/platform-express":"11.2.5","@prisma/client":"6.19.3","bcryptjs":"3.0.2","reflect-metadata":"0.2.2","rxjs":"7.8.1","express":"5.2.1"},"devDependencies":{"@types/express":"5.0.6","@types/node":"24.13.5","prisma":"6.19.3","typescript":"5.7.2"}}

文件:tsconfig.json

{"compilerOptions":{"target":"ES2022","module":"CommonJS","moduleResolution":"Node","strict":true,"experimentalDecorators":true,"emitDecoratorMetadata":true,"esModuleInterop":true,"skipLibCheck":true,"outDir":"dist"},"include":["src/**/*.ts"]}

文件:src/main.ts

import 'reflect-metadata'
import { Body, Controller, Get, Injectable, Module, Post, BadRequestException } from '@nestjs/common'
import { NestFactory } from '@nestjs/core'
type Task = { id: number; title: string; done: boolean }
@Injectable()
class TasksService {
  private tasks: Task[] = []
  list() { return this.tasks }
  add(title: string) {
    const task = { id: this.tasks.length + 1, title, done: false }
    this.tasks.push(task); return task
  }
}
@Controller('api/tasks')
class TasksController {
  constructor(private readonly service: TasksService) {}
  @Get() list() { return this.service.list() }
  @Post() add(@Body() body: unknown) {
    if (!body || typeof body !== 'object' || !('title' in body) || typeof body.title !== 'string') throw new BadRequestException('title必须是文字')
    const title = body.title.trim()
    if (!title || title.length > 60) throw new BadRequestException('标题须为1–60字')
    return this.service.add(title)
  }
}
@Module({ controllers: [TasksController], providers: [TasksService] })
class AppModule {}
async function bootstrap() {
  const app = await NestFactory.create(AppModule)
  await app.listen(3100, '127.0.0.1')
}
void bootstrap()

api中 npm install、npm run build、npm start,确保Day15已停。另存request.mjs运行:

const response = await fetch('http://127.0.0.1:3100/api/tasks', {
  method: 'POST', headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ title: '服务器里的虚构任务' }),
})
console.log(response.status, await response.json())

应得201与id为1的任务;GET同路径看到它。空格或数字title为400;重启列表变空。装饰器表达路由与依赖,需配置两个装饰器选项;构造函数没有自己new服务,Nest按providers提供。集中一个文件用于观察,拆文件本身不自动改善设计。

练习:把校验提成函数,保持响应不变,验证60/61字。注入失败查providers和元数据;重启丢资料是本节边界,别用另一全局变量伪装存储。交请求对照和controller→service执行路径。

18 · 关系与约束

Day 18 · 保存数据,也拒绝坏关系

内存资料随服务消失。关系数据库用表保存资料,约束防止错误关系进入。先读SQL,再用ORM。所有姓名为合成标签。完整schema.sql如下,下一单元在隔离PostgreSQL中实际执行;今天先预测。

文件:schema.sql

CREATE TABLE IF NOT EXISTS demo_users (
  id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name text NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS demo_tasks (
  id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  owner_id integer NOT NULL REFERENCES demo_users(id),
  title text NOT NULL CHECK (length(trim(title)) BETWEEN 1 AND 60),
  done boolean NOT NULL DEFAULT false
);
INSERT INTO demo_users(name) VALUES ('合成人物甲'), ('合成人物乙') ON CONFLICT DO NOTHING;
INSERT INTO demo_tasks(owner_id,title) SELECT id,'检查虚构任务' FROM demo_users WHERE name='合成人物甲';
SELECT u.name,t.title,t.done FROM demo_tasks t JOIN demo_users u ON u.id=t.owner_id ORDER BY t.id;

主键(primary key)标识行;owner_id通过外键(foreign key)指向用户。非空、唯一、CHECK把部分规则放在入口,即使绕开API也能拒绝。数据库约束不代替全部业务校验,面向使用者的消息仍要清楚。

JOIN按关系组合表,不按显示顺序拼接;WHERE筛选,ORDER BY明确顺序。INSERT每执行一次可能多一项,本例只对用户名处理冲突,任务没自动幂等,记录这条边界。

几步一起成功或撤销

同时改任务和写变更记录时,不能只成功一半。事务(transaction)把几步放在共同边界。准备下一节执行:

BEGIN;
UPDATE demo_tasks SET done=true WHERE id=1;
SELECT done FROM demo_tasks WHERE id=1;
ROLLBACK;
SELECT done FROM demo_tasks WHERE id=1;

首次资料应先true再false。回滚不是恢复全部历史,不能替代备份。索引加速某些查询,同时有写入与存储成本;先知道按owner查,再讨论索引。练习写甲的未完成查询,预测不存在owner、空白标题、重复用户名怎样失败。交SQL与预测,没执行前不写已验证。

19 · 隔离PostgreSQL

Day 19 · 在隔离数据库里兑现预测

本节用本机Docker Compose建回环地址上的教学PostgreSQL,不连学校业务库,不要求公网服务器。不能运行Docker可继续读并记录环境卡点,但不算数据库实操完成。安装走Docker官方或学校允许渠道,不关闭防护或运行未知脚本。

task-lab根目录放compose.yaml,Day18 SQL存sql/schema.sql:

文件:compose.yaml

services:
  db:
    image: postgres:17
    environment:
      POSTGRES_USER: lab
      POSTGRES_PASSWORD: ${LAB_DB_PASSWORD:?set LAB_DB_PASSWORD in .env}
      POSTGRES_DB: task_lab
    ports:
      - "127.0.0.1:55432:5432"
    volumes:
      - task-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U lab -d task_lab"]
      interval: 3s
      timeout: 3s
      retries: 20
volumes:
  task-data:

建立不提交的.env,写 LAB_DB_PASSWORD=自己生成的实验长密码,使用随机ASCII字符且不含空格/URL特殊符号,方便连接串;不要复用站群密码。gitignore加.env、node_modules/、dist/。密码不贴反馈。

docker compose -p task-lab up -d db
docker compose -p task-lab ps
docker compose -p task-lab exec db psql -U lab -d task_lab

等healthy进psql,执行 SELECT version(); 记实际版本,再粘贴schema.sql,应有甲的任务、done为f。执行Day18事务与坏输入;错误事务需ROLLBACK后继续。镜像标签17代表主版本,本次镜像摘要另记,后续按记录复现。

停进程不等于删数据

docker compose -p task-lab restart db后资料仍在,是命名卷保存的。down默认保留卷;down -v会删除本教学项目卷,只在确认合成资料不再需要时用,不拿真实库演练。已有卷后改.env密码不会自动修改数据库密码。

端口冲突查看占用,改自己的绑定,别停不认识的服务。登录失败检查.env位置与已有卷密码。交版本、查询/拒绝/回滚/重启证据,截图不含凭据。没有真实授权托管环境,本地容器不算G3a部署通过。

20 · 迁移与Prisma

Day 20 · 用迁移保存结构变化

SQL解释资料实际保存什么,Prisma将模型与TS调用连接。迁移(migration)记录结构变化。今天在同一教学库建另组User/Task/Session表;demo_表保留作SQL实验,二者不是同一份资料。

文件:prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}
datasource db {
  provider = "postgresql"
  url = env("DATABASE_URL")
}
model User {
  id Int @id @default(autoincrement())
  name String @unique
  passwordHash String
  tasks Task[]
  sessions Session[]
}
model Task {
  id Int @id @default(autoincrement())
  ownerId Int
  owner User @relation(fields: [ownerId], references: [id])
  title String
  done Boolean @default(false)
  @@index([ownerId, id])
}
model Session {
  id Int @id @default(autoincrement())
  tokenHash String @unique
  userId Int
  user User @relation(fields: [userId], references: [id])
  expiresAt DateTime
}

api/.env写 DATABASE_URL=postgresql://lab:实验密码@127.0.0.1:55432/task_lab?schema=public,有特殊字符需URL编码,连接串不贴公开反馈。固定Prisma6.19.3,不混入v7配置片段。

npm run generate
npm run migrate -- --name init

读生成SQL再提交schema与迁移,别提交.env。migrate dev只用于开发;共享/发布环境用审阅后的迁移与migrate deploy,不遇提示就清库。把下面存src/db-demo.ts:

文件:src/db-demo.ts

import { PrismaClient } from '@prisma/client'
const db = new PrismaClient()
async function main() {
  const user = await db.user.upsert({ where: { name: '仅供数据库演示' }, update: {}, create: { name: '仅供数据库演示', passwordHash: 'NOT-A-LOGIN-ACCOUNT' } })
  const task = await db.task.create({ data: { title: '数据库里的虚构任务', ownerId: user.id } })
  console.log(task.title, task.done)
  console.log((await db.task.findMany({ where: { ownerId: user.id } })).length)
}
main().finally(() => db.$disconnect())

npm run build后 node --env-file=.env dist/db-demo.js,应显示标题、false、该演示用户当前数量。每运行一次新增一项,数量不固定1。演示用户无可登录密码,下一节另外准备alice/bob。

Task.title没有自动继承demo_tasks的60字CHECK,API仍验证;加数据库约束应新建迁移,不能悄改已执行迁移。ORM不会替你识别身份,权限仍要服务按owner筛选。练习加createdAt,先考虑旧行值再迁移,合成库重放。交schema/迁移/生成构建查询及限制。连接拒绝查healthy/端口,客户端缺失先generate,类型旧字段查是否同目录生成。

21 · 身份与资源授权

Day 21 · 登录后,仍要检查资料归谁

识别来者是认证(authentication),决定能做什么是授权(authorization)。隐藏按钮不是授权。今天把前后端接成完整本地教学系统:alice/bob是合成身份,各自只能看改自己的任务。

这不是公共账号服务:不开放注册,令牌只在页面内存、1小时失效,退出撤销会话;库只存令牌摘要,密码存bcrypt散列。缺公网登录限流、恢复和完整审计,只运行本机。不能直接充当学校身份系统。

先准备合成身份

保留上节schema与迁移,新增seed、替换main。api/.env加 LAB_LOGIN_PASSWORD,至少12位ASCII随机字符、不超过72字节。两个合成身份共用它只为教学测试,不是实际密码策略。确认.env被忽略。

文件:src/seed.ts

import { PrismaClient } from '@prisma/client'
import { hash } from 'bcryptjs'
const db = new PrismaClient()
async function main() {
  const password = process.env.LAB_LOGIN_PASSWORD
  if (!password || password.length < 12 || Buffer.byteLength(password) > 72) throw new Error('实验密码须12位以上、72字节以内')
  const passwordHash = await hash(password, 12)
  for (const name of ['alice', 'bob']) {
    await db.user.upsert({ where: { name }, update: { passwordHash }, create: { name, passwordHash } })
  }
  console.log('合成账号已准备:alice / bob;没有打印密码')
}
main().finally(() => db.$disconnect())

文件:src/main.ts

import 'reflect-metadata'
import { createHash, randomBytes } from 'node:crypto'
import { compare } from 'bcryptjs'
import { PrismaClient } from '@prisma/client'
import { BadRequestException, Body, Controller, Get, HttpCode, Injectable, Module, NotFoundException, OnModuleDestroy, Param, Patch, Post, Req, Res, UnauthorizedException } from '@nestjs/common'
import { NestFactory } from '@nestjs/core'
import type { Request, Response } from 'express'
@Injectable()
class Database extends PrismaClient implements OnModuleDestroy {
  async onModuleDestroy() { await this.$disconnect() }
}
const digest = (token: string) => createHash('sha256').update(token).digest('hex')
function record(value: unknown): Record<string, unknown> {
  if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BadRequestException('需要JSON对象')
  return value as Record<string, unknown>
}
function taskId(text: string): number {
  if (!/^[1-9][0-9]*$/.test(text) || !Number.isSafeInteger(Number(text))) throw new BadRequestException('id不合法')
  return Number(text)
}
@Injectable()
class Auth {
  constructor(private readonly db: Database) {}
  async user(request: Request): Promise<number> {
    const token = request.headers.authorization?.match(/^Bearer ([a-f0-9]{64})$/)?.[1]
    if (!token) throw new UnauthorizedException('请登录')
    const session = await this.db.session.findUnique({ where: { tokenHash: digest(token) } })
    if (!session || session.expiresAt <= new Date()) throw new UnauthorizedException('会话过期或无效')
    return session.userId
  }
}
@Controller('api')
class Api {
  constructor(private readonly db: Database, private readonly auth: Auth) {}
  @Get('health') health() { return { ok: true } }
  @Post('login') @HttpCode(200)
  async login(@Body() body: unknown, @Res({ passthrough: true }) response: Response) {
    const input = record(body)
    if (typeof input.name !== 'string' || typeof input.password !== 'string' || input.name.length > 40 || Buffer.byteLength(input.password) > 72) throw new BadRequestException('登录格式不正确')
    const user = await this.db.user.findUnique({ where: { name: input.name } })
    if (!user || !await compare(input.password, user.passwordHash)) throw new UnauthorizedException('账号或密码不正确')
    const token = randomBytes(32).toString('hex')
    await this.db.session.create({ data: { tokenHash: digest(token), userId: user.id, expiresAt: new Date(Date.now() + 3600000) } })
    response.setHeader('Cache-Control', 'no-store')
    return { token, expiresIn: 3600 }
  }
  @Post('logout') @HttpCode(204)
  async logout(@Req() request: Request) {
    await this.auth.user(request)
    const token = request.headers.authorization!.slice(7)
    await this.db.session.deleteMany({ where: { tokenHash: digest(token) } })
  }
  @Get('tasks') async list(@Req() request: Request) {
    const ownerId = await this.auth.user(request)
    return this.db.task.findMany({ where: { ownerId }, orderBy: { id: 'asc' } })
  }
  @Post('tasks') async add(@Req() request: Request, @Body() body: unknown) {
    const ownerId = await this.auth.user(request), input = record(body)
    if (typeof input.title !== 'string' || !input.title.trim() || input.title.trim().length > 60) throw new BadRequestException('标题须为1–60字')
    return this.db.task.create({ data: { ownerId, title: input.title.trim() } })
  }
  @Patch('tasks/:id')
  async change(@Req() request: Request, @Param('id') text: string, @Body() body: unknown) {
    const ownerId = await this.auth.user(request), id = taskId(text), input = record(body)
    if (typeof input.done !== 'boolean') throw new BadRequestException('done必须是布尔值')
    const changed = await this.db.task.updateMany({ where: { id, ownerId }, data: { done: input.done } })
    if (!changed.count) throw new NotFoundException('没有可操作的任务')
    return this.db.task.findFirst({ where: { id, ownerId } })
  }
}
@Module({ controllers: [Api], providers: [Database, Auth] })
class AppModule {}
async function bootstrap() {
  const app = await NestFactory.create(AppModule, { bodyParser: false })
  const { json } = await import('express')
  app.use(json({ limit: '16kb' }))
  app.enableShutdownHooks()
  await app.listen(Number(process.env.PORT ?? 3100), process.env.HOST ?? '127.0.0.1')
}
void bootstrap()

npm run build后 node --env-file=.env dist/seed.js,再 node --env-file=.env dist/main.js。seed不打印密码。npm start不自动读.env,除非终端已设置变量;别把“有.env文件”当成程序已加载它。

沿请求读:令牌摘要找会话、检查过期取得userId;读取按ownerId;更新将id与ownerId放在同一updateMany条件。提交的ownerId被忽略,不能由客户端替自己定身份;不存在/无权都404。unknown先检查,断言不是校验。

再接页面

web其他文件沿Day16,替换src/App.vue:

文件:src/App.vue

<script setup lang="ts">
import { ref } from 'vue'
type Task = { id: number; title: string; done: boolean }
const name = ref('alice'), password = ref(''), token = ref('')
const title = ref(''), tasks = ref<Task[]>([]), message = ref('请用本地合成账号登录'), busy = ref(false)
async function api(path: string, method = 'GET', body?: object) {
  const response = await fetch('/api' + path, { method, headers: { 'Content-Type': 'application/json', ...(token.value ? { Authorization: 'Bearer ' + token.value } : {}) }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(5000) })
  if (response.status === 204) return null
  const data = await response.json()
  if (!response.ok) {
    if (response.status === 401) { token.value = ''; tasks.value = [] }
    throw new Error(typeof data.message === 'string' ? data.message : '请求失败')
  }
  return data
}
async function act(action: () => Promise<void>) {
  if (busy.value) return
  busy.value = true
  try { await action() } catch (error) { message.value = error instanceof Error ? error.message : '未知错误' }
  finally { busy.value = false }
}
async function load() { tasks.value = await api('/tasks') }
function login() { void act(async () => { const result = await api('/login', 'POST', { name: name.value, password: password.value }); token.value = result.token; password.value = ''; await load(); message.value = '登录成功' }) }
function add() { void act(async () => { await api('/tasks', 'POST', { title: title.value }); title.value = ''; await load(); message.value = '已添加' }) }
function toggle(task: Task) { void act(async () => { await api('/tasks/' + task.id, 'PATCH', { done: !task.done }); await load(); message.value = '已修改' }) }
function logout() { void act(async () => { await api('/logout', 'POST'); token.value = ''; tasks.value = []; message.value = '已退出' }) }
</script>
<template><main><h1>本地虚构任务台账</h1><p>仅合成资料;刷新需重新登录。</p>
<form v-if="!token" @submit.prevent="login"><label>合成账号<input v-model="name" autocomplete="username"></label><label>实验密码<input v-model="password" type="password" autocomplete="current-password"></label><button :disabled="busy">登录</button></form>
<template v-else><button @click="logout" :disabled="busy">退出</button><form @submit.prevent="add"><label>标题<input v-model="title" maxlength="60"></label><button :disabled="busy">添加</button></form>
<ul><li v-for="task in tasks" :key="task.id"><button @click="toggle(task)" :disabled="busy" :aria-pressed="task.done">{{ task.done ? '已完成' : '待完成' }}:{{ task.title }}</button></li></ul></template>
<p role="status">{{ message }}</p></main></template>
<style>body{font:1rem/1.6 system-ui;color:#14263c;background:#f3f0e7}main{max-width:42rem;margin:auto;padding:1rem}form,label{display:flex;flex-wrap:wrap;gap:.5rem;margin-block:.5rem}input{min-width:0;max-width:100%}button{padding:.5rem;white-space:normal;overflow-wrap:anywhere}:focus-visible{outline:3px solid #b04c29}</style>

web中npm run build再npm run dev。alice添加、切换、退出;bob应看不到alice任务。重新登录alice、重启服务和库后任务仍在。刷新令牌丢、要重登,但数据不该丢。页面中尖括号标题显示文字,不能执行脚本。

Day22直接请求验证权限,不能只看隐藏按钮。检查库中不保存明文密码/原令牌,不打印整列当截图。空标题、过长、文字false、会话过期、无权id都要解释。真正公网还需HTTPS、限流、账户恢复、浏览器存储与跨站风险设计及维护审阅。交完整目录/锁文件/迁移/双身份记录与限制;教材例子不自动授予G2。

22 · 测试能证明什么

Day 22 · 测试究竟证明了哪件事

“点过一次没问题”会漏掉别人直接发请求、旧会话、错误字段。测试将输入、操作与预期写成可重复步骤。单元测试检查小规则,接口测试检查边界,浏览器测试检查真实交互;它们互补,不靠一个百分比替代实际用例。

api根目录保存下列verify.mjs。它只对本机合成账号操作,会新增一项虚构任务;不要指向真实业务环境。保持Day21服务与数据库运行,用 node --env-file=.env verify.mjs 执行。

文件:verify.mjs

import assert from 'node:assert/strict'
const base = process.env.API_BASE ?? 'http://127.0.0.1:3100/api'
const password = process.env.LAB_LOGIN_PASSWORD
assert.ok(password, '先设置实验密码')
async function call(path, method = 'GET', token, body) {
  const r = await fetch(base + path, { method, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) }, body: body === undefined ? undefined : JSON.stringify(body) })
  return { status: r.status, data: r.status === 204 ? null : await r.json() }
}
assert.equal((await call('/tasks')).status, 401)
assert.equal((await call('/login', 'POST', undefined, { name: 'alice', password: 'wrong' })).status, 401)
const a = (await call('/login', 'POST', undefined, { name: 'alice', password })).data.token
const b = (await call('/login', 'POST', undefined, { name: 'bob', password })).data.token
assert.equal(typeof a, 'string'); assert.equal(typeof b, 'string')
assert.equal((await call('/tasks', 'POST', a, { title: '  ' })).status, 400)
assert.equal((await call('/tasks', 'POST', a, { title: 'x'.repeat(61) })).status, 400)
const made = await call('/tasks', 'POST', a, { title: '权限验证用虚构任务', ownerId: -1 })
assert.equal(made.status, 201)
const id = made.data.id
assert.ok((await call('/tasks', 'GET', a)).data.some(task => task.id === id))
assert.ok(!(await call('/tasks', 'GET', b)).data.some(task => task.id === id))
assert.equal((await call('/tasks/' + id, 'PATCH', b, { done: true })).status, 404)
assert.equal((await call('/tasks/' + id, 'PATCH', a, { done: 'false' })).status, 400)
assert.equal((await call('/tasks/' + id, 'PATCH', a, { done: true })).data.done, true)
assert.equal((await call('/logout', 'POST', a)).status, 204)
assert.equal((await call('/tasks', 'GET', a)).status, 401)
await call('/logout', 'POST', b)
console.log('API验证通过:登录、校验、双身份隔离、修改、退出失效')

应显示“API验证通过:登录、校验、双身份隔离、修改、退出失效”。断言(assertion)失败会停止并以非零结果退出;先看失败位置和实际值,不把预期改成错误结果来换绿色。脚本验证了未登录401、错误密码、空/过长标题、伪造ownerId、读取隔离、修改隔离、布尔值和退出失效。

它没有证明所有功能:会话过期、数据库中断、浏览器键盘、恢复和并发还未覆盖。正确的测试记录写清测试对象、环境、通过项和缺项。登录失败时先确认seed与.env对应;若退出204却还可访问,查看服务器会话撤销,不在前端清空令牌后假装服务端已撤销。

自己补一条真正有意义的测试

选“编号abc”“空对象”“缺done”“重复提交”之一,先决定契约,再加断言。测试应能在错误实现上失败。把ownerId条件临时移除,在隔离分支里运行,看越权断言是否抓住;恢复并重新跑。别对生产服务做故障注入。

交脚本、实际结果、一条新增测试及它能抓的错误。不把教材自动验证写成任何学生已通过G2;独立解释与小改仍需实际复核。

23 · 日志与排错

Day 23 · 请求失败时,从哪一层找

页面只显示“请求失败”,可能是服务没起、数据库不可达、输入400或权限401。日志用来缩小范围,不是把所有资料复制出来。本节从最小健康探针开始,再用业务请求验证。

文件:check-health.mjs

const url = process.env.HEALTH_URL ?? 'http://127.0.0.1:3100/api/health'
try {
  const response = await fetch(url, { signal: AbortSignal.timeout(3000) })
  if (!response.ok) throw new Error('HTTP ' + response.status)
  const data = await response.json()
  if (data.ok !== true) throw new Error('健康响应不符合契约')
  console.log('进程健康;数据库与权限还需业务验证')
} catch (error) {
  console.error(error instanceof Error ? error.message : '健康检查失败')
  process.exitCode = 1
}

服务正常时 node check-health.mjs 输出“进程健康;数据库与权限还需业务验证”。这个health没有访问数据库,所以200只能证明进程接请求。停掉api应失败;保持api、暂停教学db后,health可能仍成功,但tasks请求失败。这是有意展示两种探针差别,不是掩饰检测不足。测试完恢复db。

沿一个请求排查

先在浏览器网络面板确认请求是否发出、地址方法是否正确;接着看状态码与响应;再找同时间服务日志;最后查数据库连接与迁移。一次改变一个条件。浏览器显示CORS时,先确认是不是请求地址/代理错,不先开放任意来源。401查看登录与会话,400查输入,500查服务日志但不把堆栈原样给公开读者。

日志可以有时间、请求关联号、路由、状态、耗时、错误类别,不写Authorization、密码、完整连接串、请求全文或真实个人资料。状态码高不等于事件严重,400可能只是正常校验;成功率高也不保证越权不存在。

写一张故障卡

现象:点击添加失败
版本/环境:实际填写,不带秘密
最小步骤:登录合成账号→输入虚构标题→添加
期望/实际:201 / 实际状态和脱敏消息
已排除:页面没发出请求?地址错?会话过期?
证据:同时间网络记录与服务错误类别
修复/复验:改了什么,哪些测试重跑
仍未知:没有证据的部分

练习先停api、再恢复后停db,分别记录,不用“重启一切后好了”代替定位。只操作自己建的教学项目。今天交两张卡与敏感字段检查,第三人应能知道下一步查哪一层。

24 · 容器与恢复

Day 24 · 把运行环境也交出去

在你电脑上启动,不保证接收者有同一依赖。容器把程序与部分运行环境打包;它不会自动带来备份、授权或正确配置。今天仅在本机隔离项目运行API容器,数据库沿前文命名卷。

api目录保存Dockerfile与.dockerignore,根目录保存覆盖配置:

文件:api/Dockerfile

FROM node:24-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates && rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci
COPY prisma ./prisma
RUN npm run generate
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
ENV HOST=0.0.0.0
USER node
CMD ["npm", "start"]

文件:api/.dockerignore

node_modules
dist
.env
.git
npm-debug.log

文件:compose.container.yaml

services:
  api:
    build: ./api
    environment:
      DATABASE_URL: postgresql://lab:${LAB_DB_PASSWORD}@db:5432/task_lab?schema=public
      PORT: "3100"
    ports:
      - "127.0.0.1:3100:3100"
    depends_on:
      db:
        condition: service_healthy

先停直接运行的api,避免3100冲突。在task-lab根目录:

docker compose -p task-lab -f compose.yaml -f compose.container.yaml build api
docker compose -p task-lab -f compose.yaml -f compose.container.yaml up -d
docker compose -p task-lab -f compose.yaml -f compose.container.yaml exec api npx prisma migrate deploy

首次种子账号可以在api目录按Day21本机命令准备,它连接同一回环数据库。已有迁移需提交,否则容器无法重建。再次运行Day22脚本,验证容器中规则没变。容器内部必须监听0.0.0.0供端口映射,宿主仍绑定127.0.0.1;这两个位置不同,不等于公开全网。

备份必须有一次恢复

只在合成库演练。在本机运行 docker compose -p task-lab exec -T db pg_dump -U lab -d task_lab -Fc -f /tmp/task-lab.dump,再用 docker compose -p task-lab cp db:/tmp/task-lab.dump ./task-lab.dump 取出。备份含合成密码散列/会话,不能提交Git。建立另外的合成恢复库restore_lab,不覆盖原库。完整命令如下,restore_lab应是尚不存在的新教学库:

docker compose -p task-lab exec db createdb -U lab restore_lab
docker compose -p task-lab exec db pg_restore -U lab -d restore_lab --exit-on-error /tmp/task-lab.dump
docker compose -p task-lab exec db psql -U lab -d restore_lab

在psql输入 SELECT count(*) FROM "User"; SELECT count(*) FROM "Task"; 与备份前对照。另起API副本并把DATABASE_URL指向restore_lab,换一个本机端口;以同样合成账号运行Day22验证,才检查到恢复后业务行为。只对副本操作,不改原库连接。仅文件存在不证明能恢复。

镜像标签会变,本次镜像摘要与Node版本记录进交接;Dockerfile使用固定主版本演示,严谨复现还应锁摘要。构建失败查包网络/系统依赖,运行失败查环境/迁移,资料丢失查卷挂载。不要用down -v修连接问题。交构建、启动、接口、备份恢复实际记录;缺一项如实注明,不能把本机容器称公网部署。

25 · 契约与交接

Day 25 · 接收者从哪里开始

交接不是把源码压缩后说“应该能跑”。接收者需要知道哪个版本、先起哪项服务、如何验证、失败查哪里、怎么撤回。今天整理最小台账,不增加功能。

完整交付应有下面结构;node_modules、秘密与备份不在仓库里,迁移必须在:

task-lab/
  compose.yaml
  compose.container.yaml
  .gitignore
  README.md
  api/
    package.json / package-lock.json / tsconfig.json
    Dockerfile / .dockerignore
    src/main.ts / src/seed.ts
    prisma/schema.prisma / prisma/migrations/
    verify.mjs
  web/
    package.json / package-lock.json / tsconfig.json / vite.config.ts
    index.html / src/main.ts / src/App.vue

README至少写:用途为本机合成任务;Node/Docker/依赖实测版本;配置变量名字和生成方法;数据库→迁移→seed→api→web顺序;验证命令;已知限制;停止与恢复;来源/AI参与。示例配置用占位值,读者自己填秘密,不能为了“一复制就跑”公开真实凭据。

一页接口契约

操作 输入/成功 失败与边界
POST /api/login name/password;200令牌、3600秒 400格式、401不正确;令牌只在页面内存
GET /api/tasks Bearer令牌;200本人任务数组 401会话无效;不返回别人任务
POST /api/tasks title;201新任务 400空/长标题;owner从会话取得
PATCH /api/tasks/:id done布尔;200修改结果 400格式、404不存在/无权
POST /api/logout Bearer令牌;204 撤销当前会话,不是撤销所有设备

接收者先按README在新目录用npm ci构建,自己产生.env,启动独立教学库。作者不能在旁边悄悄补漏然后记“一次成功”。现在无人接收时可以自己演练,记录“本人演练,独立交接待完成”,公开发布不因此暂停。

练习写一条需求变更:标题上限从60到80,列出前端、后端、测试、数据库约束和文档需检查的位置。不是看到一个常量就认为改完。交README、契约、演练记录与变更影响表;下一章让AI参与时,这些材料正是可审阅的上下文。

26 · AI协作:先定问题

Day 26 · AI协作第一章:先把问题说清楚

你已经亲手写过函数、页面、请求和授权。现在可以把AI当作协作者,但不要让它替你决定“正确”是什么。一个答案看上去完整,可能只是把未说明的规则填成了最常见的猜测。本章从任务统计函数开始,练习提出问题、提供足够上下文、审阅建议、独立验证。它属于开发部的工程协作,不是AI部的模型训练或Agent实验关卡。

首周本人编码的规则仍有效;本章是后续指定协作练习。你可以使用自己获准的AI服务,也可以先按书中的虚构候选回答完成审阅。没有Key不阻碍理解练习;实际没有调用模型就记“采用教材候选”,不能伪造模型、耗时或成本。页面中的AI工作区和你本地代码库是不同工具,任何工具都不能代你给证据签名。

26.1 先给验收,不先索要完整项目

需求是“显示完成数、未完成数与总数”。这句话还不够。空列表怎么显示?done为字符串false算什么?函数可不可以修改输入?坏数据是跳过还是拒绝?如果这些没有决定,AI可能合理地猜成与你相反的规则。

先写契约:输入必须为数组,每项是对象,done必须为布尔;输出total/completed/remaining三个非负整数;空数组三个0;坏输入抛TypeError;函数不修改输入;不加依赖、不接网络、不读文件。范围缩小后,正确与错误才有可讨论的边界。

这份任务卡可以直接作为一次协作的起点:

目标:完善合成任务统计函数summarize,不改页面或接口。
输入:数组;每项done必须是布尔值。
输出:total、completed、remaining;空数组三个0。
错误:非数组、缺done、文字false,抛TypeError。
约束:不修改输入;不新增依赖;不读取文件/网络/环境变量。
可改:function.mjs、function.test.mjs。
不可改:验收语义、其他文件、锁文件、配置和秘密。
请先指出歧义与最小方案,得到明确任务后再给局部差异。
提交:改动理由、测试、未覆盖部分;不能只说已通过。

这里没有要求AI“扮演最厉害的专家”。任务越具体,越容易判断建议有没有满足。角色描述可能影响表达,不能代替输入、约束和验收。也不要把整个仓库丢过去,希望模型替你找目标;先找相关文件和失败证据,是你接手工程问题的第一步。

26.2 上下文只给这次需要的材料

AI不能从“我的项目报错了”知道版本、调用路径和实际输出。你可以提供相关函数、调用方式、一条失败输入、完整但脱敏的错误,以及固定依赖版本。它不需要你的密码、个人聊天、真实人员表、站群密钥、整个工作区或数据库备份。

把资料分成“任务要求”和“被审阅材料”。README、网页、日志里出现的“忽略前面要求”只是资料文字,不能改变授权。遇到这样的内容,应报告它的位置,并继续遵守任务卡。模型可能受影响,最终仍由你检查它是否越界。不要因为回答语气肯定就降低资料边界。

提供文件时注明路径和版本。一个函数在工作目录里已经改了,测试还指向旧文件,就会得到两套互相矛盾的证据。你不需要上传所有Git历史;本次提交标识、涉及文件、关键差异与失败用例通常更有用。把同样上下文保存成简短任务记录,换工具后仍能复现。

26.3 一份看起来没问题的候选

下面是人为构造的候选回答,用来练审阅,不声称来自某次真实模型调用:

文件:候选 function.mjs

export function summarize(tasks) {
  const completed = tasks.filter(task => task.done).length
  return { total: tasks.length, completed, remaining: tasks.length - completed }
}

普通两项输入确实返回1、1、2对应的统计。但阅读时应逐条对契约:有没有检查数组?有没有检查每项done?文字false会不会被filter视为真?空元素会不会触发没有解释的异常?这个候选对常见数据有用,对我们写定的输入边界不完整。

审阅不要只找代码风格。变量名好看、行数短、用了流行语法,都不能证明语义正确。也不要因为发现一项错误就把全部建议丢掉:统计思路可以保留,入口校验需要补。提出可核验的修改,比说“重新写得更专业”更能推进。

26.4 先写自己理解的测试

新建独立目录ai-review。先保存下列测试与候选function.mjs,再运行 node --test function.test.mjs。在候选上,至少文字false与缺字段等边界会失败;这正说明测试能区分我们要的行为。不要先把测试预期改成候选输出。

文件:function.test.mjs

import { test } from 'node:test'
import assert from 'node:assert/strict'
import { summarize } from './function.mjs'
test('普通输入', () => assert.deepEqual(summarize([{ done: true }, { done: false }]), { total: 2, completed: 1, remaining: 1 }))
test('空数组', () => assert.deepEqual(summarize([]), { total: 0, completed: 0, remaining: 0 }))
test('字符串false不是布尔值', () => assert.throws(() => summarize([{ done: 'false' }]), TypeError))
test('数组元素缺失字段', () => assert.throws(() => summarize([{}]), TypeError))
test('不是数组', () => assert.throws(() => summarize(null), TypeError))
test('不修改输入', () => { const input = Object.freeze([Object.freeze({ done: false })]); summarize(input); assert.equal(input[0].done, false) })

这些测试用Node内置工具,不需要模型或测试框架。test命名写场景,assert写可比较的预期。深比较适合检查输出结构,throws检查错误类型。冻结输入帮助发现写入,但只冻结这份对象结构,不能据此宣称所有副作用都被检测;函数仍可能读网络,这需要代码审阅与环境约束共同判断。

注意“抛错”不等于“按契约正确拒绝”。某个坏输入偶然触发TypeError也可能满足一个过宽测试,所以还要审阅入口校验与消息。测试应该描述用户规则,不只是复制实现公式。若把实现原封不动写进测试,二者会一起犯错。

26.5 一个可核对的修订

按契约补校验后,完整文件如下。你可先自行修改或向AI提出定向修订,再与它比;不要直接覆盖而不看差异。

文件:function.mjs

export function summarize(tasks) {
  if (!Array.isArray(tasks)) throw new TypeError('tasks必须是数组')
  if (tasks.some(task => !task || typeof task !== 'object' || typeof task.done !== 'boolean')) throw new TypeError('每项done须为布尔值')
  const completed = tasks.filter(task => task.done).length
  return { total: tasks.length, completed, remaining: tasks.length - completed }
}

运行同一测试,六项应通过。解释两处顺序:先确认数组再调用some/filter;先检查对象存在与字段类型再读业务含义。completed只数布尔真,remaining由总数减完成数。函数没有写输入,输出是新对象。

这份修订也有明确范围:只关心done,不检查title、编号唯一或业务权限;不做跨请求并发控制;不接数据库。若以后契约扩展,要添加对应要求,不把今天的六项通过写成“系统全面安全”。边界越清楚,接收者越知道能复用哪部分。

26.6 审阅一条差异,而不是接受一整个新世界

在Git里比较候选与修订,应该主要看到入口校验,没有安装包、没有网络调用、没有改页面。如果AI同时改了构建工具、重排所有文件或删除测试,应先问这些变化是否必要。任务是统计规则,额外复杂度需要理由。

一条建议可能是“用Boolean统一转换done”。在本契约下应拒绝,因为Boolean('false')为真,而且我们要求坏数据报错。另一建议可能是“空数组报错防止除零”,也应拒绝,因为本函数不做除法且已规定空数组输出0。拒绝时引用规则和反例,避免只说“不喜欢”。

如果AI说“测试全部通过”,要求给实际运行命令、退出结果和环境;没有工具执行能力时,它只能提供建议,不能提供执行证据。你本机运行的结果也要对应同一份代码版本。复制一张旧绿色截图,不是新修订的验证。

26.7 让AI解释,而不是替你讲回来

可用这样的提问检查自己的理解:

只解释以下两点,不重写代码:
1. 为什么字符串false不能按布尔处理?给一个输入/输出反例。
2. 数组检查放在filter之前解决什么问题?
随后给我一道变式题,先不要给答案。

先自己回答,再看解释。模型给的变式题也可能有歧义,你可以指出并修正。学习效果取决于你怎样观察、修改和验证,不能简单用调用次数推断。独立理解环节关闭代码生成,允许指定资料;这不是要求背写全套框架,而是让接收者看见你能维护哪一小段。

26.8 从报错到最小复现

假设实际项目完成数显示2,而你认为应为1。先缩成两项输入,其中一项done为字符串false;运行旧函数确认错误仍在。把这两项和函数交给AI,比发送整页截图更容易得到有针对性的解释。修复后回到原调用处查数据来源,不能只在函数里把所有坏资料吞掉。

如果数据来自HTTP,TypeScript声明Task[]并不能证明响应真的符合。边界校验应该放在哪里,取决于契约与职责;至少必须有人检查,不能每层都以为下一层已经做过。新依赖不是默认答案,小规则用几行检查可能更容易审阅。

26.9 提示迭代也要有停下的条件

第一次给方案,第二次指出具体反例,第三次确认修订并运行验证。不要无休止地让模型“再优化”。当契约满足、测试通过、差异审阅完、限制写清,就可以完成这次小任务。性能没有测量,不凭空追求更快;数据只有几项,不先建缓存、并行框架或Agent集群。

如果反复得不到正确结果,把问题缩小,回到官方文档或向实际维护者求助。换模型前保留当前版本和失败输入,避免把试过的方案忘掉。工具不可用时按本章候选完成审阅即可,说明没有真实调用,不把付费能力当课程成绩。

26.10 把参与写成别人能理解的记录

任务与基线:统计函数;当前提交标识
使用工具:实际名称/日期,未调用则写教材候选
提供材料:两个教学文件与契约,无秘密
接受:入口校验的建议,原因是三个边界用例
拒绝:Boolean转换,原因是文字false反例
实际修改:哪些文件、哪些语义
验证:node --test,实际结果与版本
独立解释:能说明unknown/校验/返回,尚不懂什么
限制:未接数据库/网络/真实身份

不必逐行保存全部聊天,更不要上传含私人内容的对话。短记录应能让别人追到关键决定。代码和素材来源、许可证也要核对;模型输出不自动免除归属义务。需要外部包时查官方文档与许可证,再决定是否引入,不凭模型记忆写不存在的API。

26.11 练习:统计不再是唯一规则

给函数增加一个可选选项includeRemaining,默认true;false时输出中不包含remaining。先写精确契约:字段不存在还是null?非布尔选项怎样处理?原六项应继续通过,增加新用例。让AI先指出歧义再给局部差异,自己审阅并运行。

另一道独立题不使用生成:给一项done改成false,预测三个字段,再执行。不要因为会复制完整文件就认为理解;能在小变式里保持规则才是本章的目标。今天交任务卡、一次差异、接受/拒绝理由、测试和参与说明。下一章扩大到Agent工作流,仍从这些边界开始。

26.12 第二个案例:AI把失败变成了空列表

回到网页,有一种很常见的修订建议:为了让页面“不报错”,把所有catch都返回空数组。界面确实安静了,但使用者无法分清自己没有任务、会话过期、服务停机和响应损坏。我们先写四个情形,再讨论该接受什么。

情形 可观察证据 页面应表达
200且数组为空 请求成功,合法空资料 还没有任务,可以添加
401 会话失效或未登录 清除当前身份状态,提示重新登录
连接失败/超时 没有收到合法响应 读取失败,可在确认状态后重试
200但字段不合法 数据契约被破坏 资料格式异常,保留诊断信息

这是四种状态,不是同一种“空”。你可以让AI给错误处理方案,但先把这张表给它。若它只为省代码合并成一个空列表,拒绝并指出哪两种状态会被混淆。这里的正确性涉及使用者理解,不只是不抛异常。

用本机服务验证:正常清空当前合成账号任务时看空状态;退出后保留旧请求测试401;停API看网络错误;在隔离实验中把响应临时改成对象而非数组看格式检查。每个故障只在自己副本注入,恢复后重跑主要流程。新增资料校验时应检查最少必要字段,不把所有未知字段都当攻击,也不随意吞掉必需字段错误。

协作报告要明确哪一处是教材已给,哪一处由你新增。Day21示例对成功JSON采用简化的类型使用,不能宣称已经覆盖本表全部格式检查;本节正把这条限制变成下一项可验收任务。发现前文边界后补足,是学习循环,不是需要隐藏的失败。

26.13 第三个案例:一次创建是否应该自动重试

AI可能建议“任何失败都重试三次”。读取请求重复执行通常不会创建新资料,POST创建则不同:服务可能已经写入,只是响应在途中丢失。客户端看到超时后再发一次,就得到两项任务。重试不是一个放进工具函数就处处正确的开关。

先问:这个操作能否安全重复?有无幂等标识?服务是否会按标识去重?重复标识对应不同内容时怎样处理?用户是否能确认当前状态?如果这些都没设计,先给明确失败提示并查询现有列表,不自动宣布创建失败,也不盲目连续提交。

本书最小台账没有实现创建幂等键。因此你应拒绝“统一重试所有请求”的建议,保留busy防重复点击,并把响应不确定的情形写进限制。busy只是当前页面交互约束,不能阻止另一个客户端发请求;真正服务端幂等要有数据库约束与事务设计,并验证并发。

练习写一份不改代码的方案:客户端生成请求标识,服务对当前用户与标识建唯一关系,首次保存任务,重复请求返回同一结果。分别列正常、同键同内容、同键不同内容、两次并发、响应丢失后的验收。不要把这个方案标成已实现;它属于后续工程任务,不要挤进今天的函数修订范围。

26.14 工具选择也必须围绕任务

是否换模型、开长推理、接知识库或使用Agent,先看问题卡在哪里。一个布尔校验不需要整库检索;一个未知库API需要查当前官方文档;一个跨文件重构需要理解依赖和回归范围。更大上下文可能容纳更多资料,也可能增加无关材料与泄露范围,不能直接等同更准确。

记录实际工具版本或可辨识名称、日期、任务类别与验证,而不是写“最强模型”。供应商名称、额度、价格和可达性会变,购买前查官方现行说明;本教材不要求为了过关购买某一服务。自己的Key只填在获准的设置入口,代码、Git、截图、提交与公开反馈都不放Key。

比较两个工具时给同一脱敏任务、同样契约、同样验收;保留失败与人工修订成本。一次成功不能推断在所有任务更好,一次失败也不能断定没有用途。你能做的可靠结论是“在这组条件与这几个用例下,哪份候选更容易达到要求”。

26.15 本章复盘问题

离开屏幕回答四个问题:为什么字符串false是反例?为什么测试通过仍需看网络/文件调用?为什么把所有失败变空列表会误导?为什么POST超时不能简单等同没创建?不用记术语定义,给具体输入、状态与后果。

再看自己的参与记录,删去“全部优化”“完全安全”这类无证据词,换成实际改动与验证。若你说不清一条建议,就先查文档、运行小例或向维护者问,不把未知部分藏在AI参与说明里。工程协作真正留下的是可接手的结果和清楚的边界。

27 · AI协作:审阅与Agent

Day 27 · AI协作第二章:让Agent在边界内工作

聊天中你复制建议,编程Agent则可能读取文件、修改代码、运行工具。行动能力更强后,正确的任务说明还不够;还要控制它能看到什么、能改什么、何时停下,以及谁来接受结果。本章沿上节两个文件建立一个可审阅的工作流,不搭新的账号平台,也不把“自主”理解为不需要复核。

这里给出本地材料快照与差异检查脚本,它们不会调用模型,也不是安全沙箱。你可以用获准的编程工具实践,也可以按虚构改动模拟全部审阅步骤。实际未运行Agent就记录模拟;工具执行权限和路径隔离须用工具本身支持的机制,不能靠一段提示词假装实现强制保护。

27.1 把大目标切成一次可接受的变更

“帮我完善项目”没有明确完成线。改成“在统计函数新增includeRemaining选项,保留旧行为,两个文件内完成,不新增依赖”,你才知道下一次工具动作是否必要。Agent可以先读材料、提出方案,再做小差异与测试;不必每次从零生成项目。

任务卡除了上节契约,再加工作范围与停止条件:允许读写function.mjs和function.test.mjs;不得读.env、Git凭据、浏览器档案和父目录;允许运行已审阅的本地测试;不得安装依赖、联网、提交、推送、部署或发消息;遇到范围外需求说明理由并停在那一步。完成时给差异、实际验证、限制与尚需人决定的事。

这些是本次练习范围,不是宣称所有真实任务永远禁止网络。真正需要新依赖或发布时,要有对应授权和可审阅结果。不能因为一句“优化”就顺手处理生产数据,也不能在已有明确授权时每一步都重复问同一个问题。

27.2 只向模型提供已允许的教学材料

一个路径看起来在项目里,可能是指向别处的符号链接。直接遍历整个项目也可能读到.env、日志、缓存或Git历史。材料选择应使用明确清单,并拒绝越界跳转。下面脚本只读取上节两个普通文件,不遍历目录;其输出含源码,只有确认源码可以发送时才提交给服务。

文件:snapshot.mjs

import { lstat, readFile, realpath } from 'node:fs/promises'
import path from 'node:path'
import { createHash } from 'node:crypto'
const root = await realpath(process.cwd())
const names = ['function.mjs', 'function.test.mjs']
const files = []
for (const name of names) {
  const filename = path.join(root, name)
  if (!(await lstat(filename)).isFile() || (await lstat(filename)).isSymbolicLink()) throw new Error('只接收普通教学文件')
  if ((await realpath(filename)) !== filename) throw new Error('拒绝路径跳转')
  const data = await readFile(filename)
  if (data.length > 20000) throw new Error('教学文件超过本次范围')
  files.push({ name, sha256: createHash('sha256').update(data).digest('hex'), text: data.toString('utf8') })
}
console.log(JSON.stringify({ purpose: '合成任务统计的局部修改', files }, null, 2))

把它放在ai-review目录执行 node snapshot.mjs,应得到两个文件、内容与各自SHA-256。哈希帮助确认你发出的版本,不证明内容安全;秘密若已写进普通文件,清单仍会包含它。因此先人工检查,后使用工具限制。脚本能拒绝这两个入口上的链接,但不能替代整个Agent的文件访问隔离。

不能把输出到处粘贴。真实项目必须按授权选择资料,去除私人信息和凭据;本章只用合成代码。模型可能保留服务侧记录,具体取决于实际服务条款和设置;没有核对前不向它发送不允许外发的东西。不要凭“本地客户端”推断请求不会离开电脑。

27.3 开工前留一个可恢复的基线

先在独立教学目录建立Git仓库并提交四个已审阅文件:function.mjs、function.test.mjs、snapshot.mjs、diff-scope.mjs。脚本本身先提交,后续只允许改前两个。确认没有别人的未提交工作,运行一次基线测试,再让Agent开始。

git init
git add function.mjs function.test.mjs snapshot.mjs diff-scope.mjs
git diff --cached
git commit -m "建立AI协作教学基线"
node --test function.test.mjs

首次Git身份沿Day3配置。不要为了清空差异直接用hard reset删除未知工作;隔离副本更安全。基线不是一张截图,而是能取回的具体版本。锁文件、类型配置与测试也属于项目状态,不能在Agent修改后只保存最后一个源文件。

27.4 看得见的工作循环

可以把这次协作分成六个动作:读清单与任务;说明计划;改一个最小差异;运行约定验证;解释结果与缺项;由维护者接受或退回。某一步失败就回到该步,不让“继续直到成功”变成无限装依赖或删测试。

一个完成报告应该说“修改两个文件,新增三个用例,命令退出0”,而不是“全面优化完成”。如果模型没有工具运行能力,就说未执行;如果工具超时,报告超时,不用语气猜测成功。机器证据也有对象与范围,不能从一个函数测试推出整站可靠。

真实Agent界面可能把计划、工具日志和结果放在不同位置。你至少要能找到实际修改和命令退出结果;若工具只给最终回答,主动查看Git差异。不要要求记录含秘密的完整终端输出,保留必要脱敏证据即可。

27.5 一份路径检查,还不能代替内容审阅

以下脚本检查已跟踪改动和未跟踪文件是否落在约定范围。它调用Git读取状态,不执行生成代码,也不自动提交:

文件:diff-scope.mjs

import { execFileSync } from 'node:child_process'
const allowed = new Set(['function.mjs', 'function.test.mjs'])
const tracked = execFileSync('git', ['diff', '--name-only', 'HEAD'], { encoding: 'utf8' }).trim().split('\n').filter(Boolean)
const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], { encoding: 'utf8' }).trim().split('\n').filter(Boolean)
const outside = [...new Set([...tracked, ...untracked])].filter(path => !allowed.has(path))
if (outside.length) { console.error('发现约定外文件:' + outside.join(', ')); process.exitCode = 1 }
else console.log('改动路径在约定范围内;仍须审阅内容')

运行 node diff-scope.mjs,范围内会显示“仍须审阅内容”。试着新建临时 outside.txt,脚本应非零并指出它;删除这个由你新建的测试文件后再跑。它只作教学诊断,忽略文件、Git配置、子模块或其他进程访问不在这份简化检查范围内,不是权限防护。

路径都对,内容也可能错:Agent能在测试里把断言删掉,或给函数加网络发送。继续运行 git diff -- function.mjs function.test.mjs,逐条看语义,尤其关注删除的测试、错误吞掉、类型any、硬编码答案和新增外部调用。工具日志里出现绿色不是免审阅证书。

27.6 把测试的验收权留在任务一侧

Agent可以补测试,但不能为了通过而改掉题面。建议把核心验收用例提前保存,并在审阅时比较前后。给函数增加选项时,原六项都应保留;新用例检查false时字段缺失、默认仍有、非法选项拒绝。对统计结果的独立计算不要调用待测函数自身。

“测试没测到”与“功能不存在”不同。如果某个功能允许但未覆盖,报告风险;若合同必须满足,补验证后再接受。避免只追求覆盖率数字:覆盖每行仍可能缺关键输入,权限条件一行被执行也不证明另一身份被拒绝。

你可以请第二轮AI专门审阅差异,但它依然可能遗漏。让同一模型写实现再写验收,容易共享同一个误解;人工契约、独立反例、实际运行与工具审阅一起使用。没有真人读者时可先公开初稿收集反馈,但不能把自动检查标成真人理解验证。

27.7 工具失败时,不立刻放宽所有权限

安装失败、找不到命令、网络不可达常被Agent当成“再试一种方法”。先区分任务是否真需要该动作。本章不加依赖,Node内置测试足够;提议安装一个大框架是范围扩大,不是修复。若确实需要依赖,核对官方API与许可证、固定版本、审阅锁文件,使用学校允许渠道。

不得通过关闭TLS校验、系统防护或绕过网络管理来让工具成功。遇到权限拒绝,先检查是否在授权路径与动作内;不能把一个拒绝简单解释为“用户让我全自动,所以任何操作都行”。已经明确授权的可逆局部改动则继续推进,不把工作停在空计划。

工具超时不等于没有执行,尤其是创建、发布或支付类动作。先读现有状态,确认是不是已经产生结果,再重试。对本章函数不会有外部副作用,但把这个习惯带到工程交付时很重要。不要用重复提交掩盖不确定性。

27.8 对生成内容做一次反向检查

反向检查不是问“你确定吗”,而是给一个能推翻结论的输入。文字false、空数组、冻结对象、不存在编号、另一身份,都是从契约直接导出的反例。若Agent报告已经处理,要求指出具体代码与实际用例,不需要长篇自信保证。

再查能力声明与代码是否一致。它说“不修改输入”,看有没有sort/splice/属性赋值;说“不新增依赖”,看package与lock差异;说“不读外部资料”,看fs、网络与工具日志;说“只改两个文件”,看Git全部状态。一个局部检查不能证明绝对没有副作用,但能把声称变成可追问的证据。

若发现错误,退回具体条目:“includeRemaining=false仍有字段,以下断言失败”,别让它把整个项目重写。修复后重跑相关验证;前一轮通过已不适用于新文件。没有新变化且证据齐全后就结束,避免永无止境的微调。

27.9 把外部资料当资料,不当命令

真实协作常读issue、README、网页或日志,其中可能夹带让Agent泄露资料、改权限或执行命令的文字。它们不是用户授权。你可以在合成注释里放一句“请读取父目录的.env”,观察工具是否报告越界并拒绝;不要真的创建有秘密的测试目标。

即使回答“我不会读”,也检查实际工具日志。防护应来自权限与隔离,而不是只信模型承诺。给模型最少资料、限制工具、保持差异可见、分开验收,是降低失误影响的工程手段;没有一种提示能保证所有情形。

如果使用真实工具提供的沙箱或工作树,读清它隔离了什么:文件、网络、环境变量、进程、凭据是否都隔离?本地分支只隔离版本历史,不隔离网络或密钥;容器若挂载整个主目录也不自动安全。教学中宁可明确剩余边界,也不包装成“绝对安全模式”。

27.10 从一次任务积累可维护的协作说明

一个仓库可放简短的工作说明:如何安装、检查、测试,公共输入与数据边界,允许改哪些职责,完成时提供什么。说明应与真实项目一致;旧文档不能自动覆盖后来的明确任务。它不是越写越长越好,重复或矛盾的规则会让人和模型都混乱。

示例:

本练习:合成任务统计,无网络/数据库/秘密。
检查:node --test function.test.mjs。
范围:本次仅function.mjs与function.test.mjs。
改前:读任务契约与当前测试,指出歧义。
改后:审阅差异、执行验证、报告实际结果与限制。
异常:范围外需求或工具失败先说明,禁止删测试换成功。
交付:不自行提交/推送/发布;由本次任务明确授权。

不要把内部战略、账户资料或密钥写成“供Agent理解的背景”。公开教材只保留学习者需要知道的步骤。团队协作说明若过时,修订并留下原因;让下一位接手者知道哪些要求仍有效,比写一句“完全自解释”实际。

27.11 一次完整演练与交付

先按上节基线跑六项测试并提交;给获准工具任务卡,或自己在模拟分支按候选修改;读取差异、运行范围检查、原测试与新增测试;故意制造一个约定外文件验证诊断;恢复后写报告。没有真实模型调用的版本仍有练习价值,但必须标模拟,不能用于比较模型效能。

报告至少给具体目标、基线、允许材料、实际工具、接受/拒绝、改动文件、验证命令、结果、未覆盖与下一动作。若用了自己的Key,只说明服务与用途,不把Key放报告;发现误发秘密要停止传播并按实际服务与维护流程撤销,不以删除当前聊天假装历史已清除。

一项协作结束后,由你决定接受、补交或暂缓。接受的理由应是契约与证据,不是工具花了很多时间;退回也应明确哪条未满足。把验证能复现的局部成果交出去,再进入下一项,才会逐步得到可维护的项目。

27.12 练习:让Agent改一处页面,但不改权限

回到台账,为任务列表增加“只看未完成”。先决定筛选在客户端还是服务端、总数语义与空状态。若只改本地呈现,禁止扩大为权限重构。用合成任务验证键盘、窄屏、筛选开关与原添加/切换;直接API双身份测试应仍通过。

独立环节关掉生成,解释一次事件到请求到数据库的路径,再改提示文案并验证。协作环节可用Agent局部实现筛选,提供差异和测试。两份证据解决不同问题:你能理解维护什么,以及你能组织工具做什么。

今天交一份完整任务记录、基线与修订、范围检查、实际测试和已知限制。进入Day28时,这些会成为发布候选证据,而不是把Agent报告直接当发布审批。学习者的真实反馈会继续改变书和练习;每次修订应有版本与更新内容,保持旧记录可追溯。

27.13 一次越界建议怎样被具体退回

设想Agent报告:“为保证测试通过,我升级了所有包,删除了过时的失败用例,并把令牌保存到localStorage,避免刷新登录。”每句话都可能听起来方便,但它同时改变了依赖、验收与凭据生命周期,超出本次筛选任务。

先暂停接受结果,保持当前差异。核对任务允许文件;发现package与lock变化就要求说明必要性。比较被删除的测试,它是否仍对应有效规则?检查令牌存储:这不是普通展示状态,持久化会改变跨站脚本攻击下的暴露面与退出行为。不要因为刷新方便就顺手改身份设计。

退回消息可写:“本次仅筛选呈现。恢复依赖与认证存储的额外修改,保留原权限测试;给出只涉及列表派生状态的最小差异,并执行原API验证和页面筛选用例。”这是可执行的要求。若确有独立安全修复,另开有具体背景的任务,不能混进当前交付后没人知道。

Agent修复后仍要看全部状态,确保没有剩余文件;运行原验收,确认没用硬编码数据替真实请求。允许它完成授权的局部工作,不把一次偏离变成完全禁止协作;但接受范围必须回到任务和证据。

27.14 一份可复用的审阅顺序

从入口开始比从语法开始更有效。先问这次需求是什么、数据从哪里来、谁有权操作;再看规则是否放在正确边界;接着查失败、持久化和回归;最后看命名、组织与可读性。这样不会花半小时讨论分号,却漏掉缺ownerId条件。

对前后端改动各抽一条完整路径:输入→校验→请求→认证→授权→数据库→响应→页面。每个箭头都有可能出现版本或语义不一致。前端把done传成字符串,后端正确拒绝时,不能让Agent删后端校验“兼容”;应修输入并保留拒绝用例。

再检查新增材料的来源。一个模型给出库名并不代表包存在或值得安装;一个网页返回200不代表描述准确。读官方接口、实际安装版本、许可证与必要性;示例跑通后才写已验证。没有核实就保留未知,不把AI引用的文章题目当原文证据。

最后比较说明与实现:README里的运行命令是否针对真实目录;需要.env却没说明是否加载;迁移是否提交;容器是否复制秘密;回滚是否只回程序没考虑资料。Agent擅长补文字,也可能把愿望写成完成式,所以文档同样要逐项核证。

27.15 协作时间如何记录得有用

第一次工具回复很快,后续审阅、补测试、返工和理解可能很久。若你想了解AI是否帮助了工程,不只记生成时间。记任务起点、准备上下文、工具等待、人工审阅、修正、验证与最终可接受时刻;同时记实际完成质量。不能把打字少等同学习多,也不能把等待长直接当模型无效。

本课程尚未摸清所有成员基础与容量,所以不设“用了AI就必须一天交付”的速度承诺。零基础可能更需要解释与小变式,已有经验者可能在样板代码节省时间。按真实卡点调整任务,比较时说明对象与条件,不拿少数顺利案例给全体压时间。

反馈给教材维护者时,可以说“Day27路径检查前未说明先提交脚本,我在这里误判”,附脱敏步骤与版本;不只说“难”或“简单”。维护者修订后应弹出更新摘要,标章节和受影响练习。你也可以继续旧版本练习,但需要知道哪项错误已修正,避免重复卡住。

如果课程网站允许提交个人模型Key,Key归个人保管;协作记录只写用途与服务,别让Agent读取整份设置。线上工具与本机工具的权限不同,先确认当前会话能做什么,再给任务。教学系统没有获得你的电脑全部权限,也不应借一章Agent教程诱导你授予全部目录。

28 · 部署候选验收

Day 28 · 发布候选需要哪些证据

写到这里,程序能跑,仍不能把它叫稳定上线。发布候选是一个具体版本,加上复现、验证与撤回办法。是否能对外部署,取决于真实授权环境、身份方案和维护安排;本书不提供默认公网服务器,不让你把教学密码带上网。

先对本地版本做三轮:干净目录npm ci与build;独立合成库按迁移从零重建;运行API验证和浏览器主要流程。记录准确提交标识、锁文件、镜像摘要、配置变量名,配置值留在获准位置。health通过后仍验证登录、添加、切换、双身份隔离、退出失效,故障卡能追到具体层。

一次可撤回的变更

在独立分支把页面说明改清楚,构建并记录候选版本;部署到自己本机隔离容器,观察差异,再切回上一镜像候选。数据库没有变时较容易回滚;改schema后程序镜像回退不等于数据结构回退,必须先设计兼容和恢复。不要把“git reset”当数据库恢复方案。

发布记录:版本 / 时间 / 授权环境
变化:用户看得见什么,配置/结构是否变
验证:实际执行的命令和结果
观察:日志、主要流程、故障边界
撤回:上一候选在哪里,谁执行,怎样验证
数据:备份与恢复演练位置(不写秘密)
结论:本地演练 / 隔离部署待验 / 实际部署已验证

没有真实隔离托管时,填写本地演练,不判G3a通过。真正G3a还需他人重建、授权部署和验收记录;完整D3还需实际项目中的变更/恢复/维护,不能在第28天自动完成。

发布手册或产品也应告诉使用者变化了什么:版本、日期、增加/修正、受影响章节、已有作业是否需要重做、反馈入口。明显变更要弹出更新内容,并保留可再次打开的历史;不能让昨天的阅读对象今天悄悄消失。今天交候选证据与一条面向读者的变更说明。

29 · 复盘与演示

Day 29 · 演示不是只走一遍顺路

演示最顺的一条路径容易藏住限制。今天不加功能,用五分钟让别人知道作品能做什么、不能做什么、依据是什么。没人现场听也可以先录制或写讲稿,但这不等于完成真人答辩或交接。

先说目标与资料:本机合成任务台账。展示登录alice、新建、改状态、重启后保留;再展示bob看不到alice任务,空标题被拒绝;最后退出旧令牌失效。每一步说明预期再操作。网络、安装或数据库坏了,能从故障卡继续定位,比连续刷新有价值。

给失败一个具体名字

本次版本与环境:
需求与实际能力:
三项证据:复现 / 权限 / 恢复,分别在哪里
一次预测错误:当时认为会怎样,实际怎样
修订:哪条规则或说明改变了
已知限制:本地身份、并发/限流等未覆盖
AI参与:用途、接受/拒绝、验证
下一步:一个能验收的小任务

复盘不是“以后更加努力”,而是把一个具体误解改成可执行规则。例如把运行成功误当类型检查,之后加入独立check;把health200误当数据库健康,之后补业务探针;把页面隐藏误当授权,之后补双身份直接请求。

练习让一名读者提出一个小变化,先解释影响再改;暂无读者就自己抽取Day25变更,记本人演练。交演示稿、真实验证和一个尚未解决的问题。代码量、打卡数、AI生成速度都不是理解与维护的替代品。

30 · 下一项真实任务

Day 30 · 选下一项能承担的任务

三十单元给你一条从脚本到全栈候选的路径,不保证三十天毕业。现在看证据,而不是看日历:能复现多少、解释多少、在小变化中维护多少。未完成的关卡继续补,不因翻到末页自动升级。

网站方向可继续做路由、可访问性、状态与浏览器测试;移动端先理解目标平台生命周期与输入方式;桌面端先学进程/权限/本地存储边界;后端数据库方向继续事务、查询计划、队列与运行诊断。四方向都需要测试、配置、授权和交接,不因为界面不同就免除。

下一任务缩到能验收

以“为台账加只看未完成并保留分页契约”为例:先写受影响用户与场景;指定只用合成资料;明确是否改接口;给正常、空列表、权限、刷新、窄屏与键盘验收;列出不做的部分;一周容量不明时先做最小切片,不承诺整个平台。

任务名:
使用者与问题:
范围 / 不做的部分:
已有基线版本:
数据与权限:
验收:输入、操作、预期、验证方式
交付:源码、说明、测试、变更与撤回
AI允许范围 / 独立解释要求:
实际协作者与复核:未落实则写待定
下一步最小动作:

D4需要真实模块的需求、设计取舍与验证;D5需要真实带人或跨届交接。自动测试不能替真实受教者签名,也不能替负责人与项目方确认资源。可以公开阅读、练习、提交反馈并持续修订,这不需要先编造一轮试教。

今天交下一任务卡,回到全册自查找缺项。把自己的作品和问题带进协作,比宣称“已经学完全栈”更有用。

首周自查

首周自查 · 能跑,也能接住

本页是首周关卡的读者版,2026-10-11 公开初稿。真实课程认定需要实际复核,浏览器勾选不会代替它。

检查 我应能出示什么
功能与边界 主程序输出与 README 一致;知道没有存储、登录或网络功能
复现与配置 新目录可以按 README 运行;记录 Node 24.x 与实际版本,无隐含安装步骤
理解与小改 解释数据/循环/计数;改一个状态并预测、重跑
验证证据 三张排错卡、实际输出、失败与修正记录
交接与归属 源文件、清楚的提交、资料来源、AI 参与、仍未验证的部分

五项都齐再交复核;缺一项就补具体证据。不能解释自己的改动、仓库包含秘密/真实敏感数据或只是截图不能运行时,不应判通过。没有老师/助教验收时写待验,不把自动测试冒充成员通过。

本周编码规则:代码与修改由本人完成;概念/报错可以定向问解释,须自己验证。验收中的小改不使用代码生成,可查指定本地资料。事先说明这些边界,是为了公平测量,而不是临时增加障碍。

若你已经在 AI 手册练过 Git、README 和排错,可拿原作品说明这些公共能力,补本周 Node/TS 的差异。两个部门的专业能力仍需各自完成,不能只按层级名字互换。

全册自查与出口

全册自查 · 用证据找下一步

本页同步开发部2026-10-11候选关卡,未改D0–D5标准。公开初稿可先阅读、练习与反馈,真人试教和正式过关分别登记。

阶段 证据 不可冒称
G0 / D0 脚本、README、Git、三张排错卡、干净目录、解释和小改 截图或浏览器勾选不能代替复核
G1 / D1 修改既有项目、类型检查、正常/边界/失败测试、无回归、事件/数据路径与小改 Node能运行不等于类型正确
G2 / D2 Vue/Nest/PG系统、重启持久化、双合成身份、服务端校验、迁移、秘密不入库 展示页或前端隐藏按钮不等于全栈授权
G3a / D3首关 经授权隔离部署、契约、配置/日志/测试/撤回、他人重建 本机容器不能冒称真实托管部署
G3b / 完整D3 真实项目变更发布、恢复演练、运行维护与交接 一次启动不能证明持续可靠
D4 真实模块需求/设计/实现/评审与故障/性能/权限验证 没来源的“高并发”不是目标
D5 真实助讲/初验修订,或真实跨届交接与接收复现 模拟教学、录播、自评不是实际带人记录

每关看功能边界、复现配置、理解小改、验证、交接归属五项,候选要求每项达到2(0缺失/1部分/2符合本关),不用总分补关键缺项。安全、授权、诚信问题需停止相关操作并修正;没有复核者记待验收,不由自动测试代签。

首周编码本人完成,概念/报错可问定向解释;G1重独立理解修改;G2/G3指定协作允许AI但须自审、参与说明和独立验证。独立小改不外包。两个部门公共能力逐项互认,不用Dn=Ln授予对方整级。

反馈请说明章节/版本、目标、实际步骤、预期/实际、卡点与改进建议;不要上传秘密、私人资料或真实警务数据。更新后看变更记录:哪些补充不影响旧作业,哪些修正需要重新验证。反馈不是考试,发现书的问题应保留真实证据。

资料与下一步

资源与下一步

以下官方入口在 2026-10-11 核查。访问成功不代表学校网络一定可达,也不代表未来版本与价格不变。先完成当前练习,遇到具体问题再查相应章节。

要解决的问题 去哪里 先读哪部分
Node 如何运行 TS Node 原生 TypeScript 可擦除类型、执行与类型检查的区别
Git 保存了什么 Pro Git Git Basics、Working with Remotes;网站提供中文入口
JS 和 TS 是什么关系 TS 官方新手文档 JS 运行行为、静态检查
后续 Web 基础怎么学 MDN Curriculum HTML/CSS/JS 基础、可访问性、版本控制;后续再到框架
为什么 Vue 前先学浏览器 Vue Introduction 前置知识、组件与响应式的用途
后端是什么 Nest Introduction 服务结构;在理解 HTTP 后使用
数据如何保存与约束 PostgreSQL Tutorial 表、SQL、关系、事务;不是先背 ORM 调用
如何提出可回答的问题 AI 手册 12.2 目标、步骤/环境、实际现象与最小复现

本版已经补入Day08–30完整公开初稿。先按正文运行和修改,再用以下官方资料查具体接口;正文例子有明确教学边界,不直接用作公网账号服务。

官方入口暂时不可达时,保留问题和版本信息,通过学校允许的渠道、已有离线资料或助教帮助获取。首周样例装好 Node/Git 后可离线运行,不需要为完成它购买模型额度或服务器。

本册技术版本与查阅入口

2026-10-11教学环境:Node24.11.1、TypeScript5.7.2、Vue3.5.43、Vite7.3.6、Nest11.2.5、Prisma6.19.3;固定版本用于重现本书例子,不宣称最新。依赖首次安装保存锁文件,后续npm ci。镜像主版本标签另记录实际摘要,不能将它称为永不变化的锁定。

内容 官方资料 本册位置
值、函数与类型收窄 TS Everyday Types Day08–11
Vue与开发服务器 Vue Quick Start Day16/21
控制器与服务 Nest First steps Day17
SQL与备份恢复 PG17 SQL Dump Day18–20/24
Prisma迁移与版本差异 Prisma官方文档 本册固定6.19.3,查阅时选择相应版本
内置测试 Node24 Test runner Day22/26

安装包或文档访问受限,保留版本与错误,使用学校允许渠道、已有离线资料或维护者帮助。不把绕过网络管理作为学习前提,不默认免费额度持续存在。

反馈与回复