① 先认核心术语
先认词:这 12 个词会贯穿全课。先看一句话直觉,细节在后面主线中展开。
Software Engineering · 软件工程把需求、设计、实现、验证、发布和演进组织成可检查的过程。
- 直觉
- 不是“会写代码”,而是让软件持续、可靠地满足目标。
- 边界
- 代码只是交付链的一部分。
Client / Frontend · 客户端 / 前端用户直接操作的一端:浏览器、小程序和手机 App 都可以是客户端。
- 当前案例
- 教练排课页、运动员训练页都属于客户端。
Server / Backend · 服务端 / 后端接收请求、执行可信业务规则、访问数据库并连接外部服务的一端。
- 边界
- 关键权限和资金规则不能只放在前端。
Application Programming Interface (API) · 应用程序接口两个程序约定怎样调用、传什么、返回什么。
- 当前案例
- “发布训练计划”“回填成绩”都应对应明确接口合同。
State · 状态描述某个对象此刻情况的信息。
- 边界
- 界面状态、请求状态、业务状态不是同一件事。
Invariant · 不变量无论经历什么合法操作都必须保持成立的条件。
- 当前案例
- 一次成功支付只能入账一次;其他机构不能读取本机构数据。
Persistence / Database · 持久化 / 数据库把需要长期存在的事实可靠保存下来。
- 边界
- 手机本地保存成功不等于服务器已经接受。
Authentication · 身份认证回答“你是谁”。
- 边界
- 登录成功不等于有权访问所有资源。
Authorization · 授权 / 鉴权回答“这个身份能否对这个对象执行当前操作”。
- 当前案例
- 教练只能修改自己有管理关系的队伍与训练计划。
Transaction · 事务一组关键数据库操作要么整体成立,要么整体不成立。
- 当前案例
- 记账与更新余额不能只完成一半。
Idempotency · 幂等性同一个业务动作被重复送达时,不应重复产生副作用。
- 当前案例
- 支付平台重复发 Webhook,余额仍只能增加一次。
Verification / Acceptance · 验证 / 验收验证证明技术条件;验收由有权主体决定是否接受结果。
- 边界
- 测试全绿、构建成功、推到 main 都不自动等于线上验收完成。
② 十分钟全景:软件系统不是页面集合
两小时目标:不追求记住所有框架名,而是看到一个功能时,能回答五个问题:谁发起?谁有权?权威数据在哪里?失败怎么恢复?什么证据能证明完成?
| 时间 | 主线 | 结束后能回答 |
|---|---|---|
| 0–10 | 宏观地图 | 页面、后端、数据库、外部平台是什么关系? |
| 10–30 | 界面与请求 | 点按钮后,请求和状态怎样流动? |
| 30–50 | 数据与状态机 | 怎样避免半写入、覆盖和状态越迁? |
| 50–65 | 身份、权限与多租户 | 怎样证明“这个人能改这份数据”? |
| 65–85 | 异步、支付与计费 | 超时、重复通知时怎样不多扣钱? |
| 85–100 | 小程序与手机 App | 断网、后台、权限、旧版本为什么更难? |
| 100–110 | 开发、测试与发布 | 代码写完以后还缺哪些证据? |
| 110–120 | 综合排错 | 把“看似成功”拆成可验证结论。 |
宏观模型:客户端负责收集意图与展示反馈;后端负责可信规则;数据库保存权威事实;队列与外部平台完成可重试的后续动作;测试、日志和线上读回负责提供证据。
③ 先把知识放进坐标系
分类关系与学习依赖要分开。 分类上,本课属于软件工程中的应用工程;学习依赖上,先理解请求和状态,再理解数据、权限、支付与交付。
| 观察轴 | 核心问题 | 常见术语 |
|---|---|---|
| 界面 | 用户现在看到什么、能做什么? | HTML、CSS、JavaScript、Component、State |
| 通信 | 请求如何从客户端到服务端? | HTTP、HTTPS、JSON、API、Webhook |
| 数据 | 事实如何保存且不互相覆盖? | Schema、CRUD、Transaction、Lock、Migration |
| 权限 | 谁能对哪个对象做什么? | Authentication、Authorization、RBAC、Tenant |
| 可靠性 | 重复、失败、超时如何恢复? | Retry、Idempotency、Queue、Audit Log |
| 交付 | 如何证明改动真的在线且正确? | Git、Build、QA、E2E、CI/CD、Observability、Rollback |
先学会读本课里的“变量”和伪代码
后面会出现很多像 user_id、tenant_id、version、artifact_digest 这样的名字。它们不是要求你提前会编程,而只是给某个值起的名字。先把这些最基本的写法认清,后面的推理会轻松很多。
| 写法 | 先把它理解成什么 | 例子 |
|---|---|---|
variable / 变量 | 一个“有名字的值”。名字帮助我们在推理时指代某个东西。 | version = 7 表示当前版本号是 7。 |
xxx_id | 某个对象的唯一标识,不是对象本身。 | user_id 是“这个用户是谁”的编号;plan_id 是某份计划的编号。 |
= | 在伪代码里通常读作“等于 / 由……得到”;在赋值代码里也常表示“把右边的值放进左边变量”。 | View = render(props, state) 读成“界面结果由 render 根据 props 与 state 生成”。 |
AND | 逻辑“并且”。所有条件都必须成立。 | A AND B:A 和 B 都为真才算通过。 |
OR | 逻辑“或者”。至少一个条件成立即可。 | owner OR admin:是 owner 或 admin 任一即可。 |
→ | 流程顺序,“先发生左边,再进入右边”。 | 点击 → 请求 → 后端 → 数据库。 |
v3 / v7 / v8 | version 的缩写,表示对象的第几个版本。 | v7 就是“版本 7”。 |
?(SQL 中) | 参数占位符。真正执行时再把具体值安全地放进去。 | content = ? 表示“这里稍后传入真实 content 值”。 |
200 / 403 / 409 / 500 | HTTP 状态码,表示一次网络请求的结果类别。 | 200 成功;403 无权限;409 冲突;500 服务端错误。 |
UUID | Universally Unique Identifier,通用唯一标识符。可以理解为几乎不会重复的随机身份证。 | 手机离线先生成 local_log_uuid,联网后用它识别“这还是同一条训练记录”。 |
深挖一:一个按钮点击以后,前端到底发生了什么?
0. 本节先认词 / 先认变量
| 词或变量 | 中文直觉 | 本节为什么需要它 |
|---|---|---|
Component · 组件 | 页面里可以独立复用的一块界面与逻辑。 | “发布按钮”“动作编辑卡”“训练日面板”都可以是组件。 |
props | 父组件传进来的外部输入,可以理解为“别人给我的参数”。 | 例如父页面把 plan_id、运动员名字传给子组件。 |
state | 组件当前自己记住的状态,可以理解为“我现在是什么情况”。 | 例如 Drawer 是否打开、输入框内容、请求是否 loading。 |
render · 渲染 | 根据当前数据计算“现在应该显示什么界面”的过程。 | state 变化后,页面需要重新算一遍应该显示什么。 |
View | 最终呈现给用户看的界面结果。 | 后面的公式左边只是给“当前界面”起一个名字。 |
Request · 请求 | 客户端发给服务器的一次消息。 | 点击“发布”后,前端要把“我要发布这份计划”的意图发给后端。 |
Response · 响应 | 服务器处理请求后回给客户端的结果。 | 前端必须根据 Response 判断成功、冲突还是失败。 |
Route / Routing · 路由 | 把一个 URL 或导航目标映射到某个页面。 | 决定“用户现在在哪个页面”,不是决定页面内部怎么排版。 |
HTTP | HyperText Transfer Protocol,超文本传输协议;Web 客户端和服务器常用的通信协议。 | 后面的 Method、URL、Status Code 都属于 HTTP 请求/响应的一部分。 |
Endpoint · 接口地址 | 后端提供某项能力的具体网络入口。 | 例如 POST /api/plans/123/publish。 |
1. 先把页面理解成“状态 → 界面”的函数
现在再看下面这句:
View = render(props, state)
逐个翻译:
View:用户现在看到的界面。render:根据数据生成界面的过程。props:外部传给组件的数据。state:组件当前自己保存的状态。
所以整句不是让你算数学题,而是在说:“页面现在长什么样,由外部输入和当前状态共同决定。”
例如教练正在编辑一周计划,至少同时存在三类状态:
- 界面状态:周三是否选中、动作 Drawer 是否打开、筛选器是什么。
- 请求状态:保存请求当前是 idle(还没发)、loading(正在等)、success(服务器确认成功)、error(失败)。
- 业务状态:这份计划究竟是 draft、published、archived,或者服务器里已经变成了新版本。
最关键的边界是:业务状态不能只听前端自己的。 前端可以临时显示“正在发布”,但最终“是否真的发布成功”要以服务端权威状态为准。
计划要求 · props(props):外层页面提供的计划数据,不由这次填写动作改写。来源:父组件传入;例子:要求 4 组。
已填写 · state(state):训练卡本地暂存的填写进度。来源:当前组件维护;例子:已填写 2 组,随后 3 组。
计算界面 · render(render):根据外部输入与本地状态计算界面。来源:前端渲染过程;例子:输入 4 和 3,显示 3/4。
显示结果 · View(View):用户当前看到的结果。来源:渲染结果;例子:2/4 → 3/4。
下面按具体值读图。图是教学示例,不是生产系统运行记录。
左右对照同一行:先看计划要求,再看本地填写进度,最后看显示结果。用户在这两刻之间又填写了一组。
- ① 再填一组之前
计划要求 · props:要求 4 组
已填写 · state:已填写 2 组
计算界面 · render:根据 4 和 2 计算
显示结果 · View:已填写 2/4 组
- ② 再填一组之后
计划要求 · props:要求 4 组
已填写 · state:已填写 3 组(变化)
计算界面 · render:根据 4 和 3 重新计算
显示结果 · View:已填写 3/4 组
查看完整图(可横向滚动)
结论:外部计划保持不变,本地填写进度变化,重新渲染后显示结果随之变化。
边界:图只描述组件中的本地界面变化,不表示已经发送请求,也不证明服务器保存成功。
2. 路由、布局、滚动是三个不同问题
Routing 回答“在哪个页面”;Layout 回答“页面内部怎么排”;Scroll Ownership 回答“内容超出以后到底哪一层滚动”。
| 场景 | 更合理的布局 | 原因 |
|---|---|---|
| 设置页、长表单 | content | 内容多就自然向下长,让浏览器页面滚 |
| 聊天、地图、IDE | fill | 工作区需要占满视口,让内部区域滚 |
| 统计卡片矩阵 | Grid | 它是二维行列关系 |
| Header + Sidebar + Main | Flex / Grid | 固定区域与剩余空间关系清楚 |
fill 和 content 是项目语义,不是 CSS 官方关键字。你真正要问的是:这个页面是在“读一份会自然增长的内容”,还是在“操作一个需要占满视口的工作台”?
3. 点击“发布”之后发生了什么
先认下面链路里的三个新词:Reverse Proxy(反向代理)是服务端前门;Authentication(身份认证)回答“你是谁”;Authorization(授权)回答“你能不能做这件事”。后面第三章会细讲,这里只看它们在流程中的位置。
用户点击
→ 前端读取 state
→ 构造 HTTP Request
→ 浏览器发送请求
→ Reverse Proxy / API Gateway
→ 后端 Route
→ Authentication
→ Authorization
→ Business Rule
→ Database Transaction
→ HTTP Response
→ 前端更新 request state
→ 显示权威业务状态
4. HTTP Request 里每个变量是什么意思
| 字段 | 意思 | 示例 |
|---|---|---|
| Method | 这次请求想做哪类动作 | GET 读取;POST 创建/触发动作 |
| URL / Endpoint | 请求要去哪 | /api/plans/123/publish |
| Headers | 附加的元信息 | 身份 Token、Content-Type |
| Body | 请求真正携带的数据 | 备注、目标版本等 |
| Status Code | 服务器告诉客户端的结果类别 | 200、403、409、500 |
常见状态码也先翻译成人话:200 大体成功;400 输入本身有问题;401 没有有效身份;403 有身份但没权限;409 当前状态/版本冲突;500 服务端内部出错。
5. 错误提示真正解决的是“用户下一步做什么”
| 错误 | 真正含义 | 下一步 |
|---|---|---|
| 输入错误 | 用户可以修 | 保留草稿并定位字段 |
| 403 | 当前身份没有权限 | 停止提交,不给“重试”假希望 |
| 409 | 你手里的版本旧了 | 拉取新版本并比较 |
| Timeout | 没及时收到结果,不等于没执行 | 查询结果或安全重试 |
| 500 | 后端失败 | 保留输入,给错误追踪 ID |
答案:因为客户端只知道“没收到 Response”,不知道服务端是否已经执行。如果第一次其实已经写库并发通知,第二次可能重复产生副作用。后面支付章节会进一步解释“幂等”如何解决这类问题。
深挖二:数据库不是仓库,而是“守住事实”的机器
0. 本节先认词 / 先认变量
| 词或变量 | 先把它理解成什么 | 例子 |
|---|---|---|
| Database · 数据库 | 长期保存结构化事实,并负责约束、并发和事务的系统。 | 保存用户、训练计划、订单、训练记录。 |
| Table / Row / Column | 表 / 行 / 列。表像一类对象,行像一条记录,列像对象的某个属性。 | plans 表里一行可以是一份训练计划。 |
| Schema | 数据库结构定义:有哪些表、字段、类型和约束。 | 规定 plan_id、status、version 等字段。 |
id / Primary Key | 一条记录自己的唯一身份证。 | plan_id=123 表示第 123 号计划。 |
| Foreign Key | 一条记录引用另一条记录的关系。 | 训练记录里的 plan_version_id 指向实际执行的计划版本。 |
| Constraint · 约束 | 数据库自己强制执行的规则。 | 某字段不能为空;某业务键必须唯一。 |
| Transaction · 事务 | 把多步数据库变化当成一个不可分割动作。 | 记账和扣额度要么一起成功,要么一起失败。 |
status | 对象当前业务状态。 | DRAFT、PUBLISHED。 |
version | 对象版本号,用来区分“我看到的是第几版”。 | version=7 表示当前第 7 版。 |
| State Machine · 状态机 | 规定有哪些状态,以及哪些状态之间允许迁移。 | DRAFT → PUBLISHED 可以;某些反向迁移不允许。 |
| Race Condition · 竞态条件 | 两个并发操作因为执行先后不同,产生了不同或错误结果。 | 两名教练同时改同一份计划。 |
| Migration · 数据库迁移 | 让数据库结构从旧版本安全升级到新版本。 | 给 plans 新增一个字段并处理旧数据。 |
1. CRUD 只是最表层
CRUD 是 Create(创建)、Read(读取)、Update(更新)、Delete(删除)。它告诉你数据库能做哪四类基础动作,但没有回答“哪些数据可以更新、什么时候能删、两个人一起更新怎么办”。
以训练 SaaS 为例,可以把三个对象分开:
plans:计划这个业务对象的“当前入口”。plan_versions:每次发布/修订后形成的历史版本。training_logs:运动员真实执行后产生的记录。
这里 plans、plan_versions、training_logs 只是示例表名。重点是:“计划本身”“计划的版本”“实际执行记录”不是同一个对象。
2. 不变量:真正要守的是规则,而不只是字段
Invariant(不变量)可以理解为:无论系统经历多少合法操作,都必须一直成立的条件。
- 已经发布的历史版本不能被偷偷改写。
- 训练记录必须知道自己执行的是哪个计划版本。
- 同一个支付事件不能生成两条有效账本。
- 一个机构的数据不能被另一个机构的普通账号读到。
这些规则如果只写在前端 if 里,很脆弱;越关键的不变量,越应该由数据库约束、唯一索引、事务和服务端规则一起兜底。
3. Transaction 为什么解决“一半成功”
先认代码里的两个关键词:BEGIN 表示“事务开始”,COMMIT 表示“确认提交”。
BEGIN
INSERT usage_event ...
INSERT ledger_entry ...
UPDATE quota ...
UPDATE invoice ...
COMMIT
逐行翻译:
usage_event:记录这次到底用了多少服务。ledger_entry:账本里新增一条金额/额度变化记录。quota:用户剩余额度。invoice:账单。
如果这四件事在业务上是一体的,就不希望只做完前两件服务器就挂掉。Transaction 的作用就是尽量让它们表现为“整体成功”或“整体失败”。
4. ACID 四个字母先翻译成人话
| 字母 | 全称 | 人话 |
|---|---|---|
| A | Atomicity · 原子性 | 不留半成品 |
| C | Consistency · 一致性 | 操作前后都不能违反约束 |
| I | Isolation · 隔离性 | 并发事务尽量不要互相踩坏 |
| D | Durability · 持久性 | 一旦提交,结果应可靠保存 |
5. 状态机不是“有个 status 字段”
DRAFT
↓ publish
PUBLISHED
↓ execute
IN_EXECUTION
↓ complete
COMPLETED
↓ review
REVIEWED
这里 publish、execute、complete、review 是“触发状态变化的动作”。状态机真正规定的是:哪些动作在当前状态下合法。
所以“已发布以后为了方便继续改,直接把 status 改回 DRAFT”通常很危险。更合理的是保留已发布的 v7,再创建新的 draft v8。
6. 并发更新为什么会把别人改动覆盖掉
先定义:v7 就是版本 7,v8 是版本 8。A、B 两位教练同时看到 v7。A 先保存,服务器变成 v8。B 还拿着旧 v7 保存,如果服务器不检查版本,就可能把 A 的修改覆盖掉。这叫 Lost Update(丢失更新)。
当前版本(version):服务器目前持久保存的计划版本号。来源:由成功提交原子递增;例子:7 → 8。
期望版本(expected_version):客户端提交修改时声明自己此前读到的版本。来源:客户端读取后随保存请求发送;例子:7。
响应状态码(status_code):409 在此示例表示版本冲突,200 表示请求成功。来源:服务器返回;例子:409:不覆盖当前版本。
下面按具体值读图。图是教学示例,不是生产系统运行记录。
从上往下沿三列读。本例画 A 先成功;两次读取的先后并非业务要求,换成 B 先提交,结果对称。
- 服务器 → 教练 A
读取计划
当前版本:7
- 服务器 → 教练 B
读取计划
当前版本:7
- 教练 A → 服务器
提交修改
期望版本:7
- 服务器 · 本地步骤
原子比较与写入
期望:7;当前:7
匹配,原子写入 → 8
- 服务器 → 教练 A
返回保存结果
200 · 已提交
- 教练 B → 服务器
仍提交旧版修改
期望版本:7
- 服务器 · 本地步骤
再次原子比较版本
期望:7;当前:8
不匹配,拒绝写入 当前版本保持 8
- 服务器 → 教练 B
返回结果,不覆盖 A
409 · 版本冲突
查看完整图(可横向滚动)
结论:服务器原子地比较期望版本与当前版本;版本已经改变,就拒绝旧修改,不静默覆盖。
边界:冲突检测不等于自动合并。B 需要先读取新版、比较改动,再决定如何重新提交;示例省略身份认证和权限检查。
Optimistic Locking(乐观锁)的一种写法:
UPDATE plans
SET content = ?, version = 8
WHERE id = 123 AND version = 7;
逐个解释:content 是计划内容;? 是稍后传入真实内容的参数占位符;id=123 指具体计划;version=7 表示“只有服务器仍然是我看到的旧版本 7 时,才允许这次更新”。如果服务器已经是 v8,这次 UPDATE 会匹配不到记录,应返回冲突。
7. Migration:数据库结构自己也有版本
例如现在要给 plans 增加 coach_note。不能只改代码,因为旧数据库没有这个字段。常见安全思路是:
expand → backfill → switch → contract
翻译:先新增兼容结构(expand)→ 给旧数据补值(backfill)→ 新代码切过去(switch)→ 确认旧逻辑不用后再删旧结构(contract)。
答案:因为这会让历史事实倒退。已经收到或执行 v7 的运动员仍需要一个稳定的 v7。正确思路通常是保留 v7,再创建 v8 draft。
深挖三:鉴权真正难的不是登录,而是“谁能对哪条数据做什么”
0. 本节先认词 / 先认变量
| 词或变量 | 中文直觉 | 例子 |
|---|---|---|
user_id | 当前用户的唯一编号。 | 区分“是谁在发请求”。 |
tenant_id | 当前机构/租户的唯一编号。 | 俱乐部 A 和俱乐部 B 是两个 tenant。 |
resource_id | 当前被操作资源的编号。 | 某份计划、某个训练组。 |
role | 用户在组织里的角色。 | coach、athlete、admin。 |
action | 用户准备做的动作。 | read、edit、publish、delete。 |
relation | 用户和资源之间的关系。 | “这个教练是否真的管理这个训练组”。 |
| Authentication · 身份认证 | 确认“你是谁”。 | 登录、Session、Token。 |
| Authorization · 授权 | 确认“你能不能对当前资源做当前动作”。 | 能否发布这份计划。 |
| RBAC | Role-Based Access Control,基于角色的权限控制。 | coach 可以 publish,athlete 不可以。 |
| ABAC | Attribute-Based Access Control,基于属性的权限控制。 | 还要看机构、资源归属、状态等属性。 |
1. Session、Cookie、Token、JWT 都只是“维持身份”的手段
Session 可以理解为服务端保存的登录会话;Cookie 是浏览器保存并自动携带的小段数据;Token 是客户端主动携带的身份凭证;JWT 是 JSON Web Token,一种带签名的 Token 格式。
它们解决的是“服务端如何知道这个请求来自谁”。但即使 Token 里写着 role=coach,也不能因此允许他编辑所有机构的所有计划。
2. 真实授权往往是多个条件的 AND
先解释下面变量:authenticated 表示身份是否有效;same_tenant 表示用户和资源是否属于同一租户;role_allows(action) 表示角色是否允许这个动作;has_relation 表示用户和资源是否有业务关系;state_allows 表示资源当前状态是否允许这个动作。
allow = authenticated
AND same_tenant
AND role_allows(action)
AND has_relation(user, resource)
AND state_allows(action)
AND 的意思是“并且”:所有条件都成立,allow 才是真。
例如教练发布训练计划,需要同时满足:账号有效;属于这个机构;角色允许 publish;确实管理这个训练组;计划当前仍处于可发布状态。
3. 为什么 RBAC 一个人通常不够
RBAC 擅长回答“这个角色大体能干什么”,但不能单独回答“能不能操作这一条具体数据”。例如两个用户都是 coach,一个管理短跑一组,一个管理跳远组。只判断角色会让二者都拥有同样的资源范围。
所以真实 SaaS 常常是:Role + Tenant + Resource Relation + Resource State 一起判断。
4. 多租户最怕“改个 ID 就看到别人数据”
假设页面从来不展示机构 B 的计划,但用户手工调用:
GET /api/plans/98765
这里 98765 只是某份计划的 resource_id。如果后端只按这个 ID 查数据库然后返回,就可能出现对象级越权。
正确思路通常是:查询和授权都带上当前 tenant_id,再校验用户和资源的 relation。前端隐藏按钮只是体验优化,不是安全边界。
5. Audit Log 和普通 Debug Log 不一样
Audit Log(审计日志)记录高价值业务动作,用于回答“谁、什么时候、对哪个对象、做了什么”。典型字段包括 user_id、tenant_id、resource_id、action、version、request_id。
request_id 是一次请求的追踪编号,方便把前端报错、后端日志和审计事件串起来。
答案:浏览器完全可以被用户控制。用户可以自己构造 HTTP 请求。真正的安全条件必须由服务端针对当前 user、tenant、resource、action 再做一次授权判断。
深挖四:异步、Webhook、幂等与支付,为什么会成为生产级难点
0. 本节先认词 / 先认变量
| 词或变量 | 中文直觉 | 例子 |
|---|---|---|
| Synchronous · 同步 | 当前步骤没结束,调用方就继续等。 | 用户点保存,等数据库写完再返回。 |
| Asynchronous · 异步 | 核心动作完成后,后续工作可以慢慢做,不让用户一直等。 | 发布计划成功后,通知 300 个运动员可以后台发送。 |
| Queue · 队列 | 等待后台处理的任务列表。 | “给运动员发推送”先进入队列。 |
| Worker | 后台真正拿任务来执行的进程。 | 从 Queue 取一条通知任务并发送。 |
| Retry · 重试 | 失败或超时后重新执行。 | 推送接口暂时失败,过一会再试。 |
| Webhook | 外部平台主动调用你的 HTTP 接口来通知事件。 | 支付平台通知“这笔付款成功了”。 |
event_id | 某个外部事件的唯一编号。 | 用它判断“这个 Webhook 我是不是已经处理过”。 |
| Idempotency · 幂等 | 同一个业务意图重复到达,最终副作用只发生一次。 | 支付成功通知来 5 次,余额也只加一次。 |
idempotency_key | 一次业务意图的唯一身份。 | 网络重试时复用同一个 key。 |
balance | 账户余额。 | balance += 100 表示余额增加 100。 |
quota | 剩余可用额度。 | 还有多少 API 次数或服务金额。 |
| Ledger · 账本 | 每次余额/额度变化的可追溯记录。 | 不是只保存最终 balance,而是保存每一笔为什么变。 |
1. 为什么要异步
发布计划时,真正必须同步确认的通常只有核心事实:“这份计划版本是否成功发布”。给 300 个运动员发通知、调用 AI 生成摘要、生成报表,可以放到后台。
HTTP Request
→ 核心事务
→ 写入 Queue
→ Response 返回给用户
↓
Worker
↓
Push / AI / Email
这里 HTTP Request 是用户发来的请求;核心事务 是必须当场确认的数据库变化;Queue 保存待处理任务;Worker 在后台执行。
2. 为什么 Retry 会逼出幂等问题
系统很难保证每个消息“恰好只送一次”。现实里更常见的是:宁愿重复,也别无声丢失。这类语义常被称为 at-least-once delivery,中文可以理解为“至少送达一次,因此可能送达多次”。
于是业务必须能安全处理重复,这就是幂等。
3. 先定义 business key,再谈去重
假设手机离线上传训练记录。先定义两个变量:
athlete_id:哪位运动员。local_log_uuid:手机在本地生成的这条训练记录的唯一编号。
business_key = athlete_id + local_log_uuid
这句话读成:“一条训练记录的业务身份,由运动员身份 + 本地唯一记录号共同决定。” 手机重试 5 次都复用同一个 local_log_uuid,数据库对这个业务键做唯一约束,就能避免生成 5 条重复记录。
4. Webhook 为什么不能假设只来一次
先验签并核对支付交易、金额、币种,再处理业务。验签确认通知来自可信平台;下面的数据库事务把唯一业务占位、账本分录、余额变化一起提交。不能先单独写“已处理”,随后才尝试入账。
接收并验证通知
→ 在同一事务内判定是否已提交该业务效果
→ 首次:唯一占位 + 写账本 + 改余额一起提交
→ 重复且已提交:不再入账
→ 提交成功后返回结果
提交前故障不能吞掉重试;提交后响应丢失不能重复记账。真实异步系统还需区分“已持久接收”和“业务已完成”;这里仅展示一个受控的同步事务例子。
通知编号(event_id):支付平台此次通知事件的标识,不等于支付交易编号。来源:支付平台提供;例子:evt_42。
业务键(business_key):渠道、交易、业务效果共同标识的一次入账意图。来源:按渠道与业务规则派生;例子:pay_42_credit。
余额(balance):示例账户当前可用金额,单位元。来源:数据库事务维护;例子:0 → 100 元。
账本(Ledger):逐笔保存金额变化和原因的记录。来源:与余额同事务提交;例子:增加一笔 100 元分录。
下面按具体值读图。图是教学示例,不是生产系统运行记录。
从上往下比较首次通知与提交后的重复通知。每次数据库处理都有独立行,返回箭头与处理结果分开。
- 支付平台 → 接收服务
首次成功通知
通知编号:evt_42
- 接收服务 → 数据库
验签、字段校验后请求入账
业务键:pay_42_credit
- 数据库 · 本地步骤
同一数据库事务
业务键:pay_42_credit
唯一占位、记账、余额一起提交
余额:100 元;账本:1 笔
- 数据库 → 接收服务
确认事务结果
200 · 已提交
- 接收服务 → 支付平台
返回处理结果
200 · 已提交
- 支付平台 → 接收服务
相同通知再次到达
通知编号:evt_42
- 接收服务 → 数据库
仍请求同一业务效果
业务键:pay_42_credit
- 数据库 · 本地步骤
核对已提交的业务键
业务键:pay_42_credit
已提交:本次不再记账
余额:100 元;账本:1 笔
- 数据库 → 接收服务
确认已经处理
200 · 已处理,不重复
- 接收服务 → 支付平台
返回成功,不再入账
200 · 已处理,不重复
查看完整图(可横向滚动)
结论:“已收到通知”不等于“业务已提交”。唯一业务占位、账本和余额同事务提交;重复到达不重复记账。
边界:前提是已经验签并核对交易、金额和币种。提交前失败须可恢复重试;提交后响应丢失则按已提交业务键去重。不同通知编号也可能描述同一交易,业务键不能简单使用“用户+金额”。
5. “先 SELECT 再 if”为什么仍然会多加钱
请求 A: SELECT processed = false
请求 B: SELECT processed = false
A: balance += 100
B: balance += 100
先解释:processed=false 表示“数据库暂时认为这个事件还没处理”;balance += 100 表示“在原余额基础上再加 100”。
两个并发请求都可能在任何一个写入之前读到 false,于是两个 if 都通过。真正防重不能只靠应用层查询,而要用数据库 UNIQUE、事务、锁等原子机制。
6. Order、Payment、Ledger、Refund 是不同对象
| 对象 | 它回答什么 |
|---|---|
| Order · 订单 | 用户想买什么、应付多少钱 |
| Payment Transaction · 支付交易 | 支付渠道实际发生了哪次资金尝试 |
| Ledger Entry · 账本分录 | 系统内部余额为什么变化 |
| Refund · 退款 | 原支付的全部或部分资金如何退回 |
| Settlement · 结算 | 支付机构何时把钱结给商户 |
| Reconciliation · 对账 | 你的记录与支付平台记录是否一致 |
7. Timeout 不是 Failure
Timeout 只表示“规定时间内没收到 Response”。第一次充值请求可能已经成功,只是响应没回来。所以创建支付/订单这类有副作用的动作,要让重试复用同一个 idempotency_key。
答案:一次明确业务意图生成一个 idempotency key;网络重试复用这个 key。用户明确重新发起一次新购买,则生成新的 key。服务端按 key 唯一,而不是简单按“用户+金额”去重。
深挖五:小程序和手机 App,真正多出来的是“平台与现实世界”
0. 本节先认词 / 先认变量
| 词或变量 | 中文直觉 | 例子 |
|---|---|---|
| Runtime · 运行时 | 程序真正执行代码、维护状态、调用平台能力的环境。 | 浏览器、微信小程序运行环境、iOS/Android App。 |
| App Lifecycle · 应用生命周期 | App 从启动、前台、后台、挂起到被系统回收的状态变化。 | 锁屏后 App 可能进入后台甚至被杀死。 |
| Local Storage · 本地存储 | 数据保存在手机本机,而不是服务器。 | 草稿、离线记录。 |
| Sync · 同步 | 把本地和服务器两边的数据重新对齐。 | 恢复网络后上传离线训练记录。 |
| Conflict · 冲突 | 本地和服务器都改了同一个事实,不能简单自动合并。 | 离线期间两端都编辑了同一条备注。 |
local_uuid | 手机本地生成的唯一记录 ID。 | 用来识别“重试上传的还是同一条记录”。 |
plan_version | 运动员执行时所依据的计划版本。 | 执行 v3,不等于当前服务器计划仍是 v3。 |
| Push Notification · 推送通知 | 服务器通过操作系统/厂商通道给手机发消息。 | 训练提醒。 |
| Permission · 系统权限 | 操作系统是否允许 App 使用某项设备能力。 | 相机、定位、蓝牙、麦克风。 |
1. 小程序登录为什么比普通 Web 多一层
先定义:code 是微信临时给客户端的一次性登录凭证;openid 可以理解为某个用户在某个小程序里的平台身份标识;user_id 是你自己业务数据库里的用户 ID。
wx.login()
→ 得到临时 code
→ code 发给你的后端
→ 后端向微信验证
→ 得到平台身份 openid
→ 映射到你自己的 user_id
→ 建立业务 Session / Token
这里最重要的边界是:openid 不是你的完整业务用户模型。 同一个微信用户可能在机构 A 是教练,在机构 B 是运动员。
2. 平台能力意味着平台规则
小程序除了你的代码,还受宿主平台约束:合法请求域名、支付 API、隐私接口、审核、WebView 业务域名等。于是“代码逻辑正确”不等于“平台允许这样运行”。
3. App Lifecycle:程序可能突然不再运行
先解释几个状态:Foreground 是 App 正在屏幕前台;Background 是离开当前屏幕但进程可能还在;Suspended 是系统暂时停止它继续执行;Cold Start 是完全没运行时重新启动。
Cold Start
→ Foreground
→ Background
→ Suspended
→ OS 回收进程
→ 用户重新打开
→ 从本地恢复
→ 再和服务器同步
所以关键输入不能只存在内存变量里。否则用户锁屏或系统回收进程后,刚填的训练记录可能直接消失。
4. Offline-first:本地事实和服务器事实怎么合并
假设运动员离线时执行计划 v3,并记录 100m=10.92s。这里 v3 是 plan_version=3。期间教练在服务器发布 v5。
恢复网络后,正确理解不是“手机有 v3,所以把服务器改回 v3”,而是:
- 服务器当前计划仍然是 v5。
- 本次训练记录保存
plan_version=3,说明当时执行的是 v3。 - 这条本地训练记录有稳定
local_uuid,重复上传不会生成多条。 - 如果同一条记录两端都编辑过,再进入 Conflict Resolution(冲突解决)。
5. Last Write Wins 为什么不是万能答案
Last Write Wins(LWW,最后写入者获胜)就是“谁最后保存,谁覆盖前面”。它对昵称、普通草稿可能够用;对计划版本、审批、支付、训练历史等关键事实通常不够,因为你可能需要保留两边来源和历史。
6. Push accepted 不等于用户已接受业务
先区分三个事实:
- 推送平台 accepted:平台接收了你要发消息的请求。
- 设备 displayed / received:设备可能已经展示或收到。
- 业务 accept:用户本人针对某个具体计划版本明确点击“接受”。
这三件事不能混成一个布尔值。
7. Permission 被拒绝本身就是一种正常状态
定位权限可能是“未请求、允许一次、使用时允许、始终允许、拒绝”。产品逻辑要明确:这个能力是核心必要,还是增强功能。
答案:如果核心任务还能通过手动输入完成,就不应把增强能力缺失变成全局阻断。应该说明哪些自动分析会受影响,同时保留手动路径。
深挖六:AI Coding 时代,真正的交付链不是“代码写完 → push”
0. 本节先认词 / 先认变量
| 词或变量 | 中文直觉 | 例子 |
|---|---|---|
| Source Code · 源码 | 开发者真正编写和维护的代码。 | 仓库里的前端、后端、配置。 |
| Commit | Git 中一版固定历史快照。 | 某次修改对应一个 commit SHA。 |
| Build · 构建 | 把源码变成可运行或可发布产物。 | 生成前端静态文件、Docker image。 |
| Artifact · 构建产物 | 真正准备部署的输出。 | 镜像、二进制、静态站点包。 |
artifact_digest | 构建产物的内容指纹。 | 证明“部署的是不是刚才验收的同一份字节”。 |
| Release Candidate · 发布候选 | 已经冻结、准备进入最终验收/发布的一版明确对象。 | 绑定源码 commit、artifact digest、测试结果。 |
| CI | Continuous Integration,持续集成。 | 代码变化后自动 build、test、lint。 |
| CD | Continuous Delivery / Deployment,持续交付 / 持续部署。 | 把合格候选推进到可发布或生产环境。 |
| Deployment · 部署 | 把某个明确产物放进目标运行环境。 | 把 image digest 部署到生产。 |
| Readback · 读回 | 部署以后从真实环境重新读取版本/页面/接口,确认真的生效。 | 线上 API 返回当前 build SHA。 |
| Observability · 可观测性 | 通过日志、指标、追踪推断系统内部状态。 | 错误率、P95 延迟、request trace。 |
| Rollback · 回滚 | 把运行版本恢复到之前的已知安全状态。 | 切回上一个 image digest。 |
1. Git 证明“源码历史”,不证明“线上版本”
Commit 只能证明某一版源码存在。main 指向最新 commit,也不能推出生产服务器已经运行它。
至少要区分三个身份:
source_commit:源码是哪一版。artifact_digest:构建出的产物是哪一份。running_version:线上当前到底运行哪一版。
这三个变量最好能互相追溯。
2. 测试分层:每种测试证明的东西不同
| 测试 | 先翻译 | 擅长证明 |
|---|---|---|
| Unit Test | 单元测试 | 函数、小算法、小状态转换 |
| Integration Test | 集成测试 | 数据库、服务、组件之间能否正确协作 |
| Smoke Test | 冒烟测试 | 系统最关键入口是不是基本活着 |
| E2E | End-to-End,端到端测试 | 从用户入口穿过多个组件的完整链路 |
| UAT | User Acceptance Testing,用户验收测试 | 真实业务场景是否满足使用目标 |
所以“Mock API 下 E2E 全绿”只证明:在假后端返回可控数据时,前端链路能工作。它不能单独证明真实鉴权、真实数据库、支付平台和生产配置正确。
3. Release Candidate 为什么必须冻结
先定义下面几个变量:
source_commit:候选对应的 Git commit。artifact_digest:候选构建产物的指纹。migration_version:数据库结构迁移到哪一版。configuration_version:运行配置是哪一版。verification_report:这版已经跑过哪些检查及结果。known_risks:已知但仍接受的风险。
release_candidate = {
source_commit,
artifact_digest,
migration_version,
configuration_version,
verification_report,
known_risks
}
这段伪代码只是在表达:验收对象必须是一个有明确身份的组合,而不是“最新代码”这种不断变化的概念。
4. CI/CD 是把重复动作变成机器合同
CI 负责在代码变化后统一执行 build、test、lint、type check 等。Continuous Delivery 的目标是“合格候选随时可发布”;Continuous Deployment 再进一步,把生产发布也自动执行。
高风险系统可以保留人工 Acceptance(验收接受)门,但这不妨碍构建、测试、部署准备都自动化。
5. Deployment Readback:真正去线上看一眼
部署命令返回成功只说明“部署工具认为操作完成”。你还应从真实目标环境读回:
- 页面上是否出现新版本标识。
- API 是否返回预期 build SHA。
- 关键接口是否真的能工作。
- 数据库 migration 是否处于目标版本。
Readback 是把“我执行过发布命令”升级成“我有证据证明目标环境真的变成了这版”。
6. Observability:Logs、Metrics、Traces 分别是什么
- Logs · 日志:某一时刻发生了什么事件。
- Metrics · 指标:错误率、请求量、P95/P99 延迟等连续数值。
- Traces · 链路追踪:把一次请求经过的多个服务串起来。
P95 的意思是“95% 的请求延迟不超过这个值”;P99 类似,只是看更尾部的慢请求。
7. Rollback 为什么也不是万能药
代码版本可以切回去,但已经发生的支付、通知、邮件、数据库不可逆迁移不一定能自动撤销。这类外部副作用可能需要 Compensation · 补偿动作,例如单独发退款,而不是假装旧代码重新上线就把钱退回来了。
答案:还缺明确候选身份、真实后端/目标 Runtime 证据、数据库迁移状态、部署 artifact 身份、线上 readback、关键负向测试,以及有权主体的 Acceptance。
⑪ 回到原来的主线速览
主线一|10–30 分钟:界面与请求
HTML(HyperText Markup Language,超文本标记语言)描述内容结构;CSS(Cascading Style Sheets,层叠样式表)描述布局与样式;JavaScript处理交互与请求。现代框架把页面拆成组件(Component),并根据状态(State)重新渲染。
最容易混淆的是三种状态:①输入框、抽屉等界面状态;② loading / success / error 等请求状态;③ draft / published / accepted 等业务状态。前两者可以由客户端维护,第三种通常必须以服务端权威记录为准。
路由(Routing)把 URL 或导航目标映射到页面;Flexbox擅长一维排列,Grid擅长二维网格。fill / content 是项目层的布局语义:前者倾向占满可用视口并让内部区域承担滚动,后者让页面随内容自然增长。真正重要的是滚动所有权(Scroll Ownership)——到底浏览器页面滚,还是内部 Pane 滚。
点击“发布计划”后,浏览器通常通过超文本传输协议(HyperText Transfer Protocol,HTTP)发送请求;HTTPS 是加密后的 HTTP。常见请求包含 method、URL、headers 和 body,服务器返回 status code、headers 和 response body。JSON 是常见的数据交换格式。
主线二|30–50 分钟:数据、事务与状态机
Schema · 数据结构定义决定表和字段;CRUD是 Create / Read / Update / Delete,增删改查;Primary Key · 主键唯一标识一条记录;Constraint · 约束让数据库拒绝不合法状态。
Transaction · 事务让一组相关操作要么全部提交,要么全部回滚。ACID 中的 Atomicity、Consistency、Isolation、Durability 可直观理解为:不半写、守规则、并发尽量互不破坏、提交后不轻易丢失。
State Machine · 状态机把允许的业务迁移写清楚。例如 DRAFT → PUBLISHED → EXECUTED → REVIEWED;若规则没有允许 REVIEWED → DRAFT,接口就不应偷偷这么改。
并发修改时会出现Race Condition · 竞态条件。最常用的入门做法之一是Optimistic Locking · 乐观锁:客户端写入时同时带上自己看到的版本号;服务器只在版本仍相同的情况下更新,否则返回冲突,让用户比较新旧内容。数据库结构升级则通过Migration · 迁移可追踪地完成。
主线三|50–65 分钟:身份、权限与多租户
Authentication · 身份认证回答“你是谁”;Authorization · 授权回答“这个身份现在能否对这个资源做这个动作”。Session、Cookie、Token、JWT 都只是帮助维持身份会话的机制,不能替代真正的资源级判权。
RBAC(Role-Based Access Control,基于角色的访问控制)用 coach / athlete / admin 等角色给出基础权限;ABAC(Attribute-Based Access Control,基于属性的访问控制)进一步考虑组织、资源归属、时间、状态等属性。实际 SaaS 常常是“角色 + 资源关系 + 业务状态”共同决定。
Tenant · 租户通常对应一家机构或客户。多租户系统必须保证机构 A 的账号即使手工改 URL、请求参数,也不能读取机构 B 的数据。隐藏按钮不是安全边界;服务端每次受保护操作都必须重新判权。
主线四|65–85 分钟:异步、Webhook、支付与计费
Asynchronous · 异步表示当前请求不必等所有后续工作完成。发送通知、生成报告、调用模型等常放进Message Queue · 消息队列,由后台 Worker 消费。失败后可以Retry · 重试,但重试会带来“同一个任务可能执行多次”的现实。
Webhook是外部系统主动调用你的 HTTP 地址通知事件。例如支付平台告诉你“订单已支付”。Webhook 可能重复、延迟、乱序,因此业务不能建立在“它只会来一次”的假设上。
Idempotency · 幂等性的目标是:同一个业务事件重复送达,副作用仍只发生一次。典型做法是给业务事件一个唯一键,并在数据库建立唯一约束;去重记录、账本分录和余额变化放在同一个事务里。
支付应区分:Order · 订单、支付平台的Transaction · 资金交易、Refund · 退款、Settlement · 结算与Reconciliation · 对账。SaaS 进一步还有Billing · 计费、Metering · 计量、Quota · 额度、Subscription 与 Usage-based Billing。
答案:“先查再加”不是原子的。应用层 if 不足以挡并发;应以支付业务唯一键建立数据库级唯一约束,并让“确认未入账 → 写账本 → 更新余额”处于同一事务。
主线五|85–100 分钟:小程序与手机 App
小程序比普通 Web 多一层平台约束:平台登录、合法域名、权限接口、审核、支付接口都由宿主平台规定。微信身份通常需要通过登录 code 映射到你自己的用户主键;OpenID 之类的平台标识不应直接等同于完整业务用户模型。
手机 App 额外面对App Lifecycle · 应用生命周期:前台、后台、挂起、系统回收、重新启动。还要处理Local Storage · 本地存储、断网、弱网和Offline-first · 离线优先。
离线同步的核心不是“联网后把本地全部覆盖服务器”,而是明确对象边界与冲突策略。运动员离线执行的是计划版本 3,教练此时已经发布版本 5:正确模型通常是“训练记录引用执行时的 v3”,而不是把服务器计划改回 v3。
Push Notification · 推送通知只说明通知链路的一部分。APNs、FCM 或厂商 Push 接受消息,不等于用户已经看见,更不等于用户接受业务条款。定位、相机、蓝牙、健康数据等还涉及系统权限;权限被拒绝本身也是正常状态。
主线六|100–110 分钟:从代码到可靠交付
Git负责版本历史与协作;Build · 构建把源码变成可运行产物;测试分层验证不同范围。Unit Test 验证小单元,Integration Test 验证组件协作,End-to-End Testing(E2E,端到端测试)从真实入口验证完整用户链路。
Quality Assurance(QA,质量保证)是更广的质量活动;Acceptance · 验收是有权主体对候选作接受/拒绝决定。Continuous Integration(CI,持续集成)把自动检查接入代码变化;Continuous Delivery / Deployment 负责把合格候选推进到可发布或自动上线。
Observability · 可观测性通过日志、指标、追踪等证据帮助回答“线上发生了什么”;Rollback · 回滚用于恢复软件版本,但它不会自动撤销已经发生的资金和数据副作用。
⑫ 把所有概念映射回一个完整功能
把六条主线放回同一个真实功能
| 动作 | 客户端 | 后端 / 数据 | 失败边界与证据 |
|---|---|---|---|
| 教练发布计划 | 编辑表单、展示提交状态 | 验证身份、租户、角色、计划版本;事务写入发布版本 | 版本冲突不能静默覆盖;读回发布版本 |
| 运动员执行 | 手机可离线记录成绩 | 成绩引用具体计划版本 | 同步重试不能产生重复训练记录 |
| 生成分析 | 展示 pending / ready / failed | 后台任务调用模型并保存结果 | Worker 可重试;任务有稳定业务键 |
| 按量计费 | 只展示服务端账本结果 | 计量、账本、额度更新保持原子与幂等 | 重复事件不重复扣费;可对账 |
| 教练复盘 | 展示当前版本和历史 | Review / Receipt 等业务对象有明确状态机 | ack 与 accept 不能混为一个布尔值 |
| 上线新版本 | 目标浏览器 / 小程序 / App 验证 | API、迁移、权限与监控一起验证 | 构建成功之外还要部署读回和验收 |
遇到陌生系统时的通用排错顺序
- 先描述用户动作和期望结果,不先猜框架。
- 定位对象:用户、组织、资源、订单、计划、训练记录分别是谁。
- 定位状态:现在处于什么状态,允许迁移到哪里。
- 定位权威数据:客户端缓存、数据库、支付平台谁能证明什么。
- 定位异常:重复、并发、超时、断网、旧版本、越权。
- 最后选择证据:日志、数据库查询、负向权限测试、故障注入、真实浏览器或目标 Runtime。
⑬ 综合训练
下面六项都“看起来做完了”。请指出违反了哪条工程规则、你会怎样修、以及至少一条可重复验收条件。
| 编号 | 观察 |
|---|---|
| A | 机构甲页面看不到机构乙按钮,但手工请求乙的资源 URL 返回 200 和完整数据。 |
| B | 两位教练同时基于版本 7 编辑,两个请求都 200,后写覆盖先写。 |
| C | 同一笔 100 元支付两个 Webhook 并发到达,余额最终 +200。 |
| D | 运动员离线执行计划 v3;服务器已有 v5;同步后服务器计划被手机覆盖回 v3。 |
| E | 推送平台返回“accepted”,系统立即把运动员业务回执写为“已接受”。 |
| F | Agent 报告:Mock API 下 E2E 全绿、build 成功、代码已推 main,所以线上验收完成。 |
建议先自己写四列:错误结论 / 应守住的不变量 / 修复位置 / 验收证据。
⑭ 分级提示与实际答案
Hint 1 · 方向
分别寻找:跨租户授权、并发丢失更新、资金幂等、对象边界、通知语义、证据范围。先问“哪个事实被错误地等同于另一个事实”。
Hint 2 · 结构
A 看服务器资源级授权;B 看写入是否携带期望版本;C 看唯一约束和事务;D 区分计划与训练记录;E 区分推送送达与本人接受;F 区分 Mock、构建、部署与验收。
Hint 3 · 接近答案
高风险先止损:跨租户泄露和重复资金入账优先。不要通过删除日志、直接改余额来掩盖问题;先保存证据,再修约束,再处理历史影响。
答案
参考答案
| 项 | 问题 | 修复与验收 |
|---|---|---|
| A | 把“前端隐藏”当成授权。 | 后端按当前用户、租户和资源关系统一判权。甲账号请求乙资源必须被拒绝或返回不泄露存在性的结果,且无副作用。 |
| B | 丢失更新。 | 使用版本号或等价并发控制;两个基于 v7 的并发写入只允许一个成功,另一个得到可处理冲突。 |
| C | 幂等和原子性不足。 | 支付事件唯一键建立数据库唯一约束;记账、余额变更与去重状态在同一事务。并发重放多次,余额只能 +100。 |
| D | 把“实际执行记录”和“当前计划”当成同一对象。 | 训练结果引用 v3;当前计划保持 v5。重复同步不重复生成训练记录,也不回写计划。 |
| E | 把平台接受推送等同于用户业务接受。 | 推送只更新通知链路状态;只有本人针对具体对象/版本的受保护动作才能形成 accept 回执。 |
| F | 把局部技术证据扩张成线上验收。 | 还需真实后端、目标 Runtime、候选版本、部署读回、关键负向测试和授权验收记录。Mock E2E 必须单独标注。 |
最后记住一句:生产级软件难的地方,往往不是“写出一次成功路径”,而是让权限、并发、失败、重复、离线和发布变化发生时,不变量仍然成立,而且你能拿出证据证明它成立。