quality-code-review
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseQuality Code Review
高质量代码审查
A reviewer's checklist for Frappe applications. Protect correctness, security,
and the future maintainer, in that order of consequence. The sections below run
in that order — spend most attention on §1 and §2. Prefer a root-cause fix over a
workaround, and say why a finding matters (what breaks, for whom).
针对Frappe应用的审查者清单。优先保障正确性、安全性,以及未来维护者的工作体验,三者的重要性依次递减。以下章节也按此顺序排列——请重点关注第1节和第2节。优先选择从根源解决问题而非临时workaround,并说明问题的影响(会导致什么故障、影响哪些用户)。
1. Correctness & stability (highest consequence)
1. 正确性与稳定性(最高优先级)
The worst bug is silent stateful corruption — wrong ledgers/stock posted
with no error. Treat stateful and legal/accounting/compliance code as "failure
is not an option" code.
- Fail early and loudly. Use assertions for internal invariants:
. (Assertions are for invariants the code guarantees — not user-facing validation.)
assert total_credit == total_debit - Picture how it breaks. For every change ask: Where can this break? How will someone misuse this? Write fool-proof code. Extensions and overrides should especially consider handling all sorts of failure modes.
- No partial commits. A stray /
frappe.db.commit()mid-transaction ends the transaction and exposes partial state — flag every one. Submitting/saving with validation bypassed (docs posted with no SL/GL entry when validation fails) is a critical bug.db.rollback() - Don't sacrifice atomicity for convenience (e.g. adding to fix bootstrapping makes transactions non-atomic). Autocommit belongs only on schema creation.
autocommit - Preserve invariants over UX. "Compromise UX, but guarantee correctness." Don't degrade working code to accommodate broken code.
- Validate the issue before fixing it. Sometimes the correct fix is "don't fix this". Identify the root causes first.
- Watch for destructive DB APIs with empty/filters.
None/set_value("Site", None, ...)with no filter updates/deletes every row. These must error, not silently operate on the whole table. Flag anydb.delete/set_value/deletewhere the name/filter could beget_valueor empty- or attacker-controlled.None - Check the types in a condition actually match. A comparison between
mismatched types (string vs , string vs int) silently never matches or is always true — cast explicitly (
datetime/cint) at the boundary.flt - Question the return shape. Before indexing a result, ask whether it can be
/
None; watch[None, None](list of dicts) vs scalar confusion.as_dict=1 - Don't silently change long-standing semantics. Behavior callers have relied on for a long time is a contract — altering it is a breaking change in disguise, even when no signature changed.
最严重的bug是无提示的状态损坏——错误的台账/库存记录已提交却未触发任何错误。将涉及状态、法务/会计/合规的代码视为**“不容出错”**的代码。
- 尽早且明确地抛出错误。使用断言确保内部不变量:。(断言用于保障代码自身承诺的不变量,而非面向用户的验证。)
assert total_credit == total_debit - 预判故障场景。针对每一处变更思考:*这里可能在哪里出问题?用户会如何误用?*编写防错代码。扩展和重写逻辑时尤其要考虑所有可能的故障模式。
- 禁止部分提交。事务中途出现的/
frappe.db.commit()会终止事务并暴露部分状态——需标记每一处此类代码。绕过验证提交/保存(验证失败时仍提交文档却未生成SL/GL条目)属于严重bug。db.rollback() - 不要为了便利牺牲原子性(例如添加来解决启动问题会导致事务失去原子性)。Autocommit仅应用于创建数据库表结构的场景。
autocommit - 优先保障不变量而非用户体验。“可以牺牲用户体验,但必须保证正确性。”不要为了适配有问题的代码而破坏原本正常的代码。
- 修复前先验证问题。有时正确的处理方式是“不修复此问题”。先找到问题的根源。
- 警惕使用空/过滤器的破坏性数据库API。
None/set_value("Site", None, ...)在无过滤器时会更新/删除所有行。此类操作必须触发错误,而非静默操作整个表。标记所有db.delete/set_value/delete中名称/过滤器可能为get_value、空值或受攻击者控制的情况。None - 检查条件中的类型是否匹配。不同类型间的比较(字符串 vs 、字符串 vs 整数)会静默地永远不匹配或始终为真——需在边界处显式转换(使用
datetime/cint)。flt - 质疑返回值的结构。在索引结果前,确认它是否可能为/
None;注意区分[None, None](字典列表)与标量值的混淆。as_dict=1 - 不要静默更改长期存在的语义。调用者长期依赖的行为属于一种契约——即使函数签名未变,修改该行为也属于隐性的破坏性变更。
2. Security
2. 安全性
Avoiding a vulnerability is far easier than fixing one safely. Audit
security-critical code (auth, authorization, permissions, user management)
especially hard.
Injection
- NEVER build SQL by string concatenation/f-strings. Use the ORM or query
builder. If raw SQL is unavoidable, use parameter substitution
() — never interpolate yourself. Better still, avoid introducing new raw SQL at all: beyond injection risk it ties code to one database, and the framework aims to stay DB-agnostic (Postgres support). Prefer
frappe.db.sql("... where name = %s", (user,)).frappe.qb - Type confusion is an injection vector even with the ORM. Frappe accepts
complex types, so a parameter expected to be a string can arrive as a filter
list: passed to
{"key": ["!=", ""]}bypasses a secret-key check. Validate input types at trust boundaries — explicitdb.get_value. Audit everyisinstance(key, str)method for this.@frappe.whitelist - Never /
evalanything yourself.exec/safe_evalonly, in limited volume, and "safe_exec is not magic." Never accept a client-supplied method path to execute.safe_exec
Sandboxing & trust boundaries
- Sandboxed execution (RestrictedPython/) is not reliably safe — assume escapes exist. Security toggles must live at the right trust boundary: server-script enablement is a bench-level config, never site-level (a tenant could enable it and take over the whole server).
safe_exec - Prefer allowlists over blocklists — blocklists are bypassable. Don't expose everything by default.
Access control
- "Think 10 times before " — it is not a shortcut around real authn/authz. Web pages must apply permissions before reading/sharing data. Prefer
allow_guest=True/get_listover hand-rolled queries.get_all - Scope relaxations precisely. Verify a rate-limit/permission exception targets exactly the intended principal — not, say, all non-guest users.
Path traversal / filesystem
- Prefer the File doctype API. If user input enters a path, ensure it can't
traverse () outside the site folder.
/../../
Crypto / secrets
- Never roll your own crypto; reuse existing implementations. Verify authenticity of guest/webhook requests (HMAC).
- Signed/one-time URLs: use a truly secret signing value; expire by both time and first use; validate using the URL alone, not merged form data (Frappe merges URL + form data → replay attacks with one valid signature).
- Store secrets in password fields; never plain text; never leak secrets in logs or error messages.
XSS & the rest of OWASP
- Don't inject user input into the DOM. Treat XSS as critical even when it looks trivial — HTML/JS injection usually leads to account hijack.
- Don't fix XSS by sanitizing and throwing away special characters. Prefer escaping right before injecting values in DOM.
避免漏洞比安全修复漏洞容易得多。需重点审查安全关键代码(认证、授权、权限、用户管理)。
注入攻击
- 绝对不要通过字符串拼接/f-strings构建SQL语句。使用ORM或查询构建器。如果必须使用原生SQL,请使用参数替换()——绝对不要自行插值。更好的做法是:完全避免引入新的原生SQL:除了注入风险,还会将代码绑定到特定数据库,而Frappe框架旨在保持数据库无关性(支持Postgres)。优先使用
frappe.db.sql("... where name = %s", (user,))。frappe.qb - 即使使用ORM,类型混淆也是注入向量。Frappe接受复杂类型,因此预期为字符串的参数可能会以过滤器列表的形式传入:传入
{"key": ["!=", ""]}会绕过密钥检查。在信任边界处验证输入类型——显式使用db.get_value。审查每一个isinstance(key, str)方法是否存在此类问题。@frappe.whitelist - 绝对不要自行使用/
eval。仅在有限场景下使用exec/safe_eval,且“safe_exec并非万能”。绝对不要接受客户端提供的方法路径来执行。safe_exec
沙箱与信任边界
- 沙箱执行(RestrictedPython/)并非绝对安全——假设存在逃逸漏洞。安全开关必须设置在正确的信任边界:服务器脚本的启用应是bench级别的配置,而非站点级(租户可能启用它并接管整个服务器)。
safe_exec - 优先使用白名单而非黑名单——黑名单容易被绕过。不要默认暴露所有内容。
访问控制
- “使用前请三思”——这不是绕过真正认证/授权的捷径。网页必须在读取/共享数据前应用权限检查。优先使用
allow_guest=True/get_list而非手动编写查询。get_all - 精确放宽权限范围。验证速率限制/权限例外是否精准针对预期主体——而非例如所有非访客用户。
路径遍历 / 文件系统
- 优先使用File文档类型API。如果用户输入涉及路径,确保无法通过遍历到站点文件夹之外。
/../../
加密 / 密钥
- 绝对不要自行实现加密逻辑;复用现有实现。验证访客/ webhook请求的真实性(使用HMAC)。
- 签名/一次性URL:使用真正保密的签名值;同时通过时间和首次使用来过期;仅使用URL本身进行验证,不要合并表单数据(Frappe会合并URL + 表单数据 → 存在有效签名的重放攻击风险)。
- 将密钥存储在密码字段中;绝对不要明文存储;绝对不要在日志或错误信息中泄露密钥。
XSS及其他OWASP风险
- 不要将用户输入注入DOM。即使看似轻微的XSS也需视为严重问题——HTML/JS注入通常会导致账户劫持。
- 不要通过清理并丢弃特殊字符来修复XSS。优先在将值注入DOM前进行转义。
3. Performance is correctness
3. 性能即正确性
The cheapest time to fix performance is at review; slow code merged sits
undetected for years. Performance is a feature (Doherty threshold; humans
perceive ~100ms).
- Budgets: common reads < 100ms; reads < 1s; most writes < 5s; never exceed ~10s. P99 of a frequent read-only request should be ~1s. A slow synchronous request blocks a worker (head-of-line blocking).
- Complexity rule: a frequently-called endpoint must do O(1) or O(log N)
work — a large constant factor at worst, never O(N). Counting rows is O(N), not
O(1); over a large/filtered table is expensive. Bound unbounded scans (e.g. last 3 months, a "1000+" sentinel) rather than scanning everything.
COUNT(*) - Indexes are code. Flag any /join/filter on an unindexed column. Indexes (and custom indexes) must be committed in code, not applied ad-hoc — they get lost on migration otherwise. Form loads that pull comments/versions/assignments need all those queries indexed; one unindexed query makes everything sluggish.
WHERE - No DB calls in loops. "Don't write validations that call db in LOOPS." Flag N+1 patterns. Cache stable values (UOM, docstatus, status) instead of re-querying. This is acceptable in background jobs, but never in requests.
- The Remove → Reduce → Reuse ladder for slow code you can't fix: remove it,
invoke it less, or memoize. Pick the right cache scope (
,
@redis_cache,@request_cache— the last balloons memory if overused). DO NOT hand-roll caches in@site_cacheorfrappe.local: "you'll just be creating brand-new cache-invalidation bugs." Don't cache trivially cheap work.frappe.flags - Memory: don't stuff junk into shared module-level files / / class-level state — it stays resident forever. Remove unused module-level imports (move into the function that uses them). Watch for leaks.
__init__.py - Reorder conditionals so the DB call is last. In a boolean expression, put cheap in-memory checks first so short-circuiting can skip the query entirely.
- Aggregate in SQL, not Python. Use /
SUM()in the query instead of fetching all rows to reduce them in memory; push filters into the subquery so they apply before the join.COUNT() - Don't fetch a whole doc for one value. Use /
get_value/get_single_valuefor a single column instead ofset_value; useget_doc().save()instead offrappe.delete_doc(which fetches the doc only to delete it).get_doc().delete() - No MyISAM tables in hot paths. Reading a MyISAM table takes an implicit table-level lock — never touch one in a request path.
- Move long work to a background queue. Long-running work belongs in
, not a synchronous request that blocks a worker.
enqueue(..., queue="long")
修复性能的最佳时机是代码审查阶段;合并后的慢代码可能多年未被发现。性能是一项功能(Doherty阈值;人类对~100ms的延迟敏感)。
- 性能预算:常规读取操作 < 100ms;读取操作 < 1s;大多数写入操作 < 5s;绝对不要超过~10s。频繁的只读请求的P99延迟应约为1s。缓慢的同步请求会阻塞工作进程(队头阻塞)。
- 复杂度规则:频繁调用的端点必须执行O(1)或O(log N)的操作——最坏情况下是较大的常数因子,绝对不能是O(N)。统计行数是O(N),而非O(1);对大型/过滤后的表执行代价高昂。限制无界扫描(例如仅扫描最近3个月的数据,或设置“1000+”的阈值)而非扫描全部数据。
COUNT(*) - 索引即代码。标记所有在未索引列上的/连接/过滤操作。索引(及自定义索引)必须在代码中提交,而非临时添加——否则在迁移时会丢失。加载表单时获取评论/版本/任务分配的所有查询都需要索引;一个未索引的查询会导致整个操作变慢。
WHERE - 禁止在循环中调用数据库。“不要编写在循环中调用数据库的验证逻辑。”标记N+1查询模式。缓存稳定值(UOM、docstatus、status)而非重复查询。此规则在后台任务中可例外,但绝对不能在请求处理中出现。
- 慢代码优化阶梯:移除→减少调用→复用:如果无法修复慢代码,先尝试移除它,减少调用次数,或进行记忆化缓存。选择正确的缓存范围(,
@redis_cache,@request_cache——过度使用@site_cache会导致内存膨胀)。不要在@site_cache或frappe.local中手动实现缓存:“这只会带来全新的缓存失效bug”。不要缓存代价极低的操作。frappe.flags - 内存管理:不要将无用数据存入共享模块级文件 / / 类级状态——这些数据会永久驻留内存。移除未使用的模块级导入(移至使用它的函数内部)。警惕内存泄漏。
__init__.py - 调整条件顺序,将数据库调用放在最后。在布尔表达式中,先执行廉价的内存内检查,这样短路求值可以跳过查询。
- 在SQL中进行聚合,而非Python。在查询中使用/
SUM()而非获取所有行后在内存中聚合;将过滤条件推入子查询,使其在连接前生效。COUNT() - 不要为了获取一个值而加载整个文档。使用/
get_value/get_single_value获取单个列的值,而非set_value;使用get_doc().save()而非frappe.delete_doc(后者会先加载文档再删除)。get_doc().delete() - 热路径中禁止使用MyISAM表。读取MyISAM表会隐式获取表级锁——绝对不要在请求处理路径中使用。
- 将耗时操作移至后台队列。长时间运行的任务应放入,而非阻塞工作进程的同步请求。
enqueue(..., queue="long")
4. Concurrency
4. 并发
- Check-then-act is a race. — two workers both see "not exists" and both insert. Prefer a DB-level unique constraint; "outsource integrity to the database."
if not frappe.db.exists(...): insert() - Locking footguns: on an unindexed query locks every scanned row (and gaps) — always ensure the filter uses an index, or you lock the whole table. Locking a parent but not its children yields a "mutant" doc.
SELECT ... FOR UPDATE - Global mutable state / class attributes are global in Python — a shared
engine/class attribute leaking query state across concurrent requests produces
garbage. Make query-building stateless. Don't do "weird shit with
" —
frappe.localis for variables, not static state.local
- 检查后执行存在竞态条件。——两个工作进程可能都看到“不存在”并都执行插入。优先使用数据库级唯一约束;“将完整性保障交给数据库”。
if not frappe.db.exists(...): insert() - 锁定陷阱:对未索引查询执行会锁定所有扫描到的行(及间隙)——始终确保过滤条件使用索引,否则会锁定整个表。锁定父文档但未锁定其子文档会产生“变异”文档。
SELECT ... FOR UPDATE - 全局可变状态 / 类属性在Python中是全局的——共享引擎/类属性在并发请求间泄露查询状态会产生无效数据。查询构建必须无状态。不要对进行“奇怪的操作”——
frappe.local用于存储变量,而非静态状态。local
5. Readability & maintainability
5. 可读性与可维护性
~50% of dev time is spent reading code; rotten code eventually forces a rewrite.
- Keep functions pure when they can be pure — easy to read and test.
- Don't pass mutable objects around to be filled in ("assembly" code) — return new values. Passing a mutable to be mutated forces a reader to open two files to understand one thing.
- A function that mutates its input must be named appropriately.
- Prefer the boring construct. While that functional map-reduce one-liner looks beautiful, please just write a 4-line for-loop. Favor debuggable code over clever code.
- Consistency over personal style. A codebase shouldn't be a hodge-podge of 10 styles. Match the surrounding formatting/naming/import conventions; flag a change that breaks them.
- Good taste: restructure so the edge case becomes the common case, removing special-case branches. Ask: can this be simpler? Less code? Is it over-indented if-else soup?
- Prefer extending shared components over copy-paste divergence. 3–4 forked implementations of one thing → slow long-term velocity. Avoid tight coupling across modules; integrate through clear, documented public APIs.
- Document public modules/classes/functions with docstrings; prefer type annotations over describing types in prose ("type hints are 10x better"); type checkers find non-obvious bugs.
- Docstrings should only mention important things. Keep them short and to the point. Don't explain what's trivially understood from function name. Focus on "why".
- Split unrelated changes into separate commits/PRs — keeps review focused
and /reverts clean.
git blame
约50%的开发时间用于阅读代码;糟糕的代码最终会迫使重构。
- 尽可能保持函数纯函数化——易于阅读和测试。
- 不要传递可变对象来填充数据(“组装式”代码)——返回新值。传递可变对象并修改它会迫使读者打开两个文件才能理解逻辑。
- 修改输入的函数必须有合适的命名。
- 优先使用常规结构。虽然函数式的map-reduce单行代码看起来很优雅,但请写一个4行的for循环。优先选择可调试的代码而非巧妙的代码。
- 一致性优先于个人风格。代码库不应是10种风格的大杂烩。匹配周围代码的格式/命名/导入约定;标记破坏约定的变更。
- 优化代码结构:重构代码使边缘情况变为常规情况,移除特殊分支。思考:能否更简单?代码能否更少?是否是过度缩进的if-else嵌套?
- 优先扩展共享组件而非复制粘贴。同一功能有3-4个分叉实现会降低长期开发速度。避免模块间的紧耦合;通过清晰、有文档的公共API进行集成。
- 为公共模块/类/函数编写文档字符串;优先使用类型注解而非用文字描述类型(“类型提示比文字描述好10倍”);类型检查器能发现非显而易见的bug。
- 文档字符串仅需提及重要内容。保持简短切题。不要解释从函数名就能轻易理解的内容。重点说明“为什么”。
- 将无关变更拆分到不同的提交/PR中——使审查更聚焦,且/回滚更清晰。
git blame
6. API design & backward compatibility
6. API设计与向后兼容性
- Principle of least astonishment: an API's name + signature should convey ~90% of intent; users shouldn't be surprised by behavior.
- Reject loose/overloaded parameters that accept many disjoint types (string/dict/list/None). Prefer separate single-purpose functions. Beware implicit fallbacks; use explicit variants. "APIs whose correct use depends on tribal knowledge are a liability."
- Build for extension, not override. Provide hooks; never monkey-patch core at runtime ("inexcusably horrible" — breaks future fixes) and never copy a whole core file to change a few lines (fixes won't propagate).
- Backward compatibility is an obligation for mature/public APIs. Follow semver; minor versions = zero breaking changes. Breaking changes include: removing public functions/fields, reordering args, new mandatory args, changed business logic, moved/renamed files (broken imports), bumped shared deps. Renaming without keeping the old name as an alias is an unnecessary break. Every breaking change ships a deprecation warning + docs.
- Watch for schema breaking-change footguns: adding mandatory fields to existing sites, making long-lived fields unique (needs a data patch), changing field types without patches, removing fields.
- Schema changes that silently skip existing sites need a data patch. Single doctypes don't sync new-field defaults to existing sites, and a field-type change (e.g. text→int) doesn't convert existing values — both need an explicit patch, tested against a populated site.
- New parameters go last as keyword args with safe defaults (, not
None) so existing positional callers don't break. When renaming, keep the old name as a shim:"".def old_name(...): return new_name(...) - Patch hygiene. Data patches must be idempotent (safe to re-run), correctly ordered (run after the field/doctype they read exists), and live in the right app (a framework change is patched in the framework, not the downstream app).
- A modified existing test is a red flag. If making a change pass required editing an existing test's assertions, you've likely broken a real workflow — justify it explicitly rather than bending the test.
- 最小惊讶原则:API的名称+签名应传达约90%的意图;用户不应对行为感到意外。
- 拒绝松散/重载参数——即接受多种不相关类型(字符串/字典/列表/None)的参数。优先使用单独的单一用途函数。警惕隐式回退;使用显式变体。“正确使用依赖于内部知识的API是一种负担。”
- 为扩展而构建,而非重写。提供钩子;绝对不要在运行时猴子补丁(monkey-patch)核心代码(“极其糟糕”——会破坏未来的修复),也不要复制整个核心文件来修改几行代码(修复不会同步)。
- 向后兼容是成熟/公共API的义务。遵循语义化版本(semver);小版本=零破坏性变更。破坏性变更包括:移除公共函数/字段、调整参数顺序、新增必填参数、修改业务逻辑、移动/重命名文件(导致导入失败)、升级共享依赖。不保留旧名称作为别名的重命名属于不必要的破坏性变更。每一项破坏性变更都需附带弃用警告+文档说明。
- 警惕架构破坏性变更陷阱:向现有站点添加必填字段、将长期存在的字段设为唯一(需要数据补丁)、无补丁修改字段类型、移除字段。
- 静默跳过现有站点的架构变更需要数据补丁。单个文档类型不会将新字段的默认值同步到现有站点,字段类型变更(例如文本→整数)不会转换现有值——这两种情况都需要显式补丁,并针对有数据的站点进行测试。
- 新增参数应放在最后作为带安全默认值的关键字参数(而非
None),这样现有的位置参数调用不会失效。重命名时,保留旧名称作为垫片:""。def old_name(...): return new_name(...) - 补丁规范。数据补丁必须幂等(可安全重复运行)、顺序正确(在其读取的字段/文档类型存在后运行),且位于正确的应用中(框架变更应在框架中打补丁,而非下游应用)。
- 修改现有测试是危险信号。如果为了让变更通过而修改现有测试的断言,很可能破坏了实际工作流程——需明确说明理由,而非修改测试来适配变更。
7. Testing
7. 测试
- Each PR needs decent test coverage — patch coverage on the diff, not just project coverage. (Frappe target: 85% covered lines in the diff.) Tests should capture the most-used business scenarios.
- Regression test every fix. A bug fix without a test that would have caught it invites the regression back. For extreme-consequence (stateful/compliance) code, go beyond examples — property-based testing (Hypothesis).
- Flag missing migration/data-patch coverage. Schema changes and data patches are the highest-risk, least-tested area; a change that alters fields or migrates data needs a patch tested against a realistic, populated site (empty tables always "migrate" successfully even when the change is invalid).
- Tests must be deterministic and independent. No (flaky); no reliance on state left by other tests (order-dependence); use
randomfor time-dependent logic.freeze_time
- 每个PR都需要足够的测试覆盖率——针对差异的补丁覆盖率,而非仅项目整体覆盖率。(Frappe目标:差异中85%的代码行被覆盖。)测试应覆盖最常用的业务场景。
- 每一个修复都需要回归测试。没有测试的bug修复会导致问题再次出现。对于高风险(状态/合规)代码,除示例测试外,还应使用属性测试(Hypothesis)。
- 标记缺失的迁移/数据补丁测试覆盖。架构变更和数据补丁是风险最高、测试最少的领域;修改字段或迁移数据的变更需要针对真实的、有数据的站点测试补丁(空表总是能“成功迁移”,即使变更无效)。
- 测试必须是确定性且独立的。不要使用(会导致测试不稳定);不要依赖其他测试留下的状态(顺序依赖);对时间相关逻辑使用
random。freeze_time
8. Error messages, logging & observability
8. 错误信息、日志与可观测性
- Error message quality is a legitimate review item. Titles must be specific
and Google-able (never "Message"/"Error"). Reference field names as fields.
State what changed: the row, the field, and before→after values
(). The user must know "qty changed from what to what?"
1 → 2 - Surface failures to the affected party — "a broken email setup is the user's problem only if they know it's broken."
- Log things. Preserve tracebacks/exception context (orders of magnitude easier debugging). Log destructive/admin actions with attributable identity (who, when, from where), persisted outside ephemeral containers.
- 错误信息的质量是合理的审查项。标题必须具体且可搜索(绝对不要用“Message”/“Error”)。引用字段名称。说明变更内容:行、字段、前后值()。用户必须知道“数量从什么值变成了什么值?”
1 → 2 - 向受影响的用户告知故障——“邮件配置故障只有在用户知道的情况下才是他们的问题。”
- 记录日志。保留回溯/异常上下文(大幅简化调试)。记录破坏性/管理操作的可追溯身份(谁、何时、从何处操作),并存储在临时容器之外。",