mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
Compare commits
584 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f79fb9969 | |||
| bbd7f72c2d | |||
| 81739f9cb4 | |||
| 2b69f4d8d7 | |||
| b56f27632a | |||
| fc733d0295 | |||
| 453f7e5d90 | |||
| 63e3b85294 | |||
| e66dea9090 | |||
| 451ce52592 | |||
| bbf79199aa | |||
| 40232d86ba | |||
| 55db1c01ec | |||
| 3528323b50 | |||
| 2cb339258c | |||
| 63007fc8c7 | |||
| 4f8e7e66e3 | |||
| ed1efd3d54 | |||
| efd8268a5d | |||
| 0dd2cf9e80 | |||
| be5d067af9 | |||
| 3e5f3cc562 | |||
| d73aa5f9f0 | |||
| c9fc9c0eea | |||
| cdc7474d7e | |||
| 983cce3e80 | |||
| 76e9d5b0e7 | |||
| 6b75706b8e | |||
| 59fd8cda7b | |||
| cb94081ebd | |||
| 3de0a54d47 | |||
| e7b8fb2f99 | |||
| 454542c1d0 | |||
| 580c51a73a | |||
| 6b7df5a6b5 | |||
| 497edfd564 | |||
| d7d510ec97 | |||
| f4ec58c0e6 | |||
| 2ba28417b3 | |||
| 3f97193280 | |||
| 2aa0a70762 | |||
| 55c1fcd5f9 | |||
| e5d2e4b6d5 | |||
| 4f360cc7a9 | |||
| da43de56ac | |||
| f538670e1f | |||
| 2f60329886 | |||
| c76c5a697b | |||
| 9609bec8b0 | |||
| 9b89d3c630 | |||
| e87f445218 | |||
| 40eee778ac | |||
| 6c128e0be5 | |||
| 7d03154a8a | |||
| 7f8e257d33 | |||
| 4d78bc1c38 | |||
| 1813bbdba3 | |||
| aa4faddade | |||
| ab70633e9f | |||
| e1b439d6a5 | |||
| 4962bf90d1 | |||
| c455be3002 | |||
| ab0e5fecf2 | |||
| f5c9da03f4 | |||
| a16be014d4 | |||
| c85373ff47 | |||
| d7b8f44f90 | |||
| 85321888e0 | |||
| 65c02ef7a5 | |||
| 63a24da9ee | |||
| e5f6b0ad90 | |||
| 111d2900d7 | |||
| 73d8173018 | |||
| 4ecec2cf1b | |||
| 86fad02c41 | |||
| 600a7acdfb | |||
| 288b74d104 | |||
| ce28f63659 | |||
| d0414b402a | |||
| 699e95f12c | |||
| 4e3d79c001 | |||
| 5aaaf8f197 | |||
| b76f707c8b | |||
| f1f6bb858a | |||
| 305d609d0d | |||
| 5a8722ff07 | |||
| 64fbaa7ef1 | |||
| fa689aedbc | |||
| f960511cc0 | |||
| 465440fa5b | |||
| a4dd5ca9e1 | |||
| a9e4237bbf | |||
| 75d1fcf345 | |||
| f1577bf092 | |||
| 01ed2c5e36 | |||
| 80696c12fa | |||
| 3d4d99081e | |||
| 0639855653 | |||
| 0524ae1da4 | |||
| adee4f7b27 | |||
| 1d0f2d6342 | |||
| e3f603f72a | |||
| 3b010bb15e | |||
| f530cd4025 | |||
| 08d28c2c8e | |||
| 34a0896ff8 | |||
| 0c22e76f4b | |||
| bd2183c8bb | |||
| 9df2437e47 | |||
| e8c414aa12 | |||
| 7e8aa5fa0f | |||
| 7e518987de | |||
| 61484090f9 | |||
| 94b74d72f6 | |||
| 6487ce666d | |||
| c4be34b214 | |||
| fda727cf53 | |||
| f083da20f2 | |||
| 9797fcdb2f | |||
| 93ec3096f3 | |||
| 0e34301c92 | |||
| 12b5271f92 | |||
| 8ee966434d | |||
| 6882481a56 | |||
| b675038bba | |||
| d17ec9d17d | |||
| 8758f9a061 | |||
| 7d93d3d2a1 | |||
| 074edf17a1 | |||
| 6faf525af0 | |||
| a6fc2b7737 | |||
| ca21ff3a5b | |||
| 7d71f1e4e1 | |||
| 734fe45baa | |||
| ef22ecc5dc | |||
| 6738abdec1 | |||
| d17d8457f3 | |||
| 16f34928c9 | |||
| 3328d3d121 | |||
| fb08002e99 | |||
| f650214bbb | |||
| ba1c9222c2 | |||
| 076bf8b95c | |||
| 835c50dbaa | |||
| 42f7f47716 | |||
| 7d47db1f34 | |||
| 68d8f786cc | |||
| 9d93dc0b9f | |||
| 1a4a03a20d | |||
| 07e835c543 | |||
| 1f5bebd18a | |||
| fd62570431 | |||
| 484b49d79d | |||
| cffa009b8c | |||
| 4eced2b721 | |||
| ea7658815a | |||
| 3edcdb9e9f | |||
| 99f0f63b99 | |||
| 21fb303ef2 | |||
| 3aa4d98cd6 | |||
| 1f71c9f25b | |||
| 943818f7d4 | |||
| 23a5488203 | |||
| d99c5b7c43 | |||
| 33a1c32cf8 | |||
| 68d730a388 | |||
| f94767fbc7 | |||
| 5b1e27d0a3 | |||
| f28aa6520e | |||
| d58b4b6b0e | |||
| 866f1df5e3 | |||
| 351e8ce78c | |||
| 67a30eb5ed | |||
| 80c47f6ff3 | |||
| a261c01a9c | |||
| ae5345c03e | |||
| fda8d7fcb1 | |||
| b91c848256 | |||
| 36cff502f7 | |||
| fa588797bf | |||
| a963b8bf54 | |||
| d9663f91d6 | |||
| 4481677ef3 | |||
| 20249d917c | |||
| abe8fb8268 | |||
| f0b51a99b3 | |||
| c92f986978 | |||
| ccea08fe47 | |||
| 72962beb0f | |||
| b56290d79d | |||
| 67b051c2bc | |||
| 999428cf9a | |||
| 86d2d6b0ad | |||
| 848884d8cd | |||
| f783a1e6fa | |||
| c39a3edcc3 | |||
| 4c17f5277a | |||
| 0e097a66c4 | |||
| 39cba821d5 | |||
| c5f8105db8 | |||
| 2bc2d82ad0 | |||
| a3125c8276 | |||
| 4d7b63f217 | |||
| fada04c373 | |||
| 38b0516937 | |||
| 4e8ec23264 | |||
| f386674464 | |||
| e0398397a9 | |||
| fafee0055a | |||
| 6619f5b650 | |||
| a65d0f291b | |||
| c00ead9aa0 | |||
| 7366832e12 | |||
| 1a7e5e6c41 | |||
| 46ce7de513 | |||
| 39473cb370 | |||
| 64e40a7c18 | |||
| 53ddb45614 | |||
| ad6621fce9 | |||
| 60d6e3e846 | |||
| fd9348b7bd | |||
| 32113eb790 | |||
| 1ba05ec0bd | |||
| b75f985815 | |||
| db89f68547 | |||
| 74106474ca | |||
| 1d97ea69d0 | |||
| 53d9572508 | |||
| 8f3ff59567 | |||
| d47ceb9971 | |||
| 7476c86976 | |||
| 28eef0bbcd | |||
| 047ed6554d | |||
| b5e27fabde | |||
| 4166cc9861 | |||
| 24862dcbed | |||
| 920a530aa7 | |||
| bfd9de69af | |||
| 204f6d9a8b | |||
| 04f029c705 | |||
| 7401f5d0b4 | |||
| 0bb6830047 | |||
| 6f221b042e | |||
| fb5a4e5b59 | |||
| ee9d651c8a | |||
| 9aec984bee | |||
| bf71bc540b | |||
| e49078ac3b | |||
| 177578ef4e | |||
| 4c0c389122 | |||
| a0ccafc6ee | |||
| 26057514a1 | |||
| 55f8c9a527 | |||
| 802d516f5b | |||
| 71ce028f91 | |||
| f0e234df1f | |||
| 9a0974cce8 | |||
| 3b9f4daa4e | |||
| 285f127d48 | |||
| 79820b33eb | |||
| ce736e2de4 | |||
| a0fcf9f627 | |||
| 368df3f76b | |||
| 15e614b304 | |||
| 46941f65d5 | |||
| 0c2961ae6d | |||
| a61d55bb1b | |||
| 6b6c786cfe | |||
| 26be762c3a | |||
| 0548a8a5d4 | |||
| 60bc03f519 | |||
| 9dc3983e0f | |||
| 1eff7878a1 | |||
| 08e8eea932 | |||
| 85d5c8568c | |||
| 74ddf97b36 | |||
| a1a997bcda | |||
| d36409fbf9 | |||
| 4000366856 | |||
| af20e2e838 | |||
| 2fff30e188 | |||
| 74c2f57453 | |||
| 30e09f5985 | |||
| 43e293e062 | |||
| 439ac41da8 | |||
| d97581fb1e | |||
| 83f126795d | |||
| 69467914fc | |||
| c624512da6 | |||
| 50717d1baf | |||
| fc569d1758 | |||
| ec4f1d4d23 | |||
| 9268acb84c | |||
| 41cd23a64d | |||
| f02fc9676a | |||
| 31b4886a14 | |||
| efcf61e32d | |||
| d615d85a26 | |||
| 8afd103751 | |||
| 7ef84cce52 | |||
| 96b8ddc077 | |||
| 03b81e5f74 | |||
| 5b52acdd6c | |||
| fb3dd5afe6 | |||
| 1160d5846a | |||
| 350b433cc1 | |||
| d4d9bad74d | |||
| d0536fcdd5 | |||
| e51f1e583d | |||
| b835144cd0 | |||
| 53c868e99b | |||
| 50678756d4 | |||
| 3eb670c674 | |||
| d10132fb02 | |||
| 61cf581621 | |||
| e2ac531abb | |||
| c8b1289043 | |||
| 13c5073bf8 | |||
| da1dd92404 | |||
| 4b11279662 | |||
| bbadcca294 | |||
| 44ce6497a1 | |||
| 4b83f91b31 | |||
| 9d2fac5d4c | |||
| b4b93ff4ed | |||
| 160e63558f | |||
| 9b3555c569 | |||
| b928928958 | |||
| b312460ddf | |||
| 50f7257d93 | |||
| 336185f01c | |||
| 44bba0f19a | |||
| f0eca028f9 | |||
| 58624db397 | |||
| 38946d1af5 | |||
| caf2ffcff4 | |||
| 0e86fe3547 | |||
| 6525bef15d | |||
| 3e910f1961 | |||
| 28c14eb054 | |||
| ae618905a3 | |||
| 5ad151469c | |||
| 6467b32d8e | |||
| cf72420815 | |||
| 34225cb88a | |||
| 389f02b6b0 | |||
| 2fcbb945fb | |||
| 23501259b2 | |||
| c561e65cd3 | |||
| 97095e8f12 | |||
| 23be2f9296 | |||
| 113ea25aa4 | |||
| c230d5a744 | |||
| c02b649b46 | |||
| fb54d6da61 | |||
| 9c7896df50 | |||
| 01ebec6dfb | |||
| 2816152536 | |||
| b029714c7a | |||
| e0f452eaae | |||
| d721a8fd74 | |||
| 1e349e5cde | |||
| f03c88ffad | |||
| 3038304382 | |||
| 196bdabc80 | |||
| 77931c3c1e | |||
| a97d87acf5 | |||
| 8d8814b416 | |||
| 02ebb81929 | |||
| 60222acf7e | |||
| 7b1fea8194 | |||
| ac7b776378 | |||
| 13d6966cb5 | |||
| b48414e1fe | |||
| 894f8f1ea2 | |||
| b89dc9ec7e | |||
| 49eae80c78 | |||
| 8ed91dbf97 | |||
| 92ceecc6ce | |||
| d9b8dc81ee | |||
| deb232d840 | |||
| 346979344f | |||
| 1e5f35b9a3 | |||
| 346024f346 | |||
| ffe98f6307 | |||
| 6719c02d05 | |||
| 0a3cd250d1 | |||
| 53e6efa33b | |||
| 3d15c65afd | |||
| 16b02fd3f1 | |||
| e8778a8641 | |||
| 7b14e15afb | |||
| 3a2878d070 | |||
| df3bcd3d19 | |||
| 895dec208f | |||
| 9d56f02e64 | |||
| 40291136b7 | |||
| d3777eac2d | |||
| ee047cb351 | |||
| 6ed3c0c81f | |||
| 13a375e042 | |||
| 36f11c6ecb | |||
| 0f904b4b6d | |||
| e479ae75e6 | |||
| 6f267bbf21 | |||
| ce2b931a78 | |||
| 6e86901a58 | |||
| 42896a8473 | |||
| 665dd09e11 | |||
| b12a9b0185 | |||
| a343c7a605 | |||
| 117d473c27 | |||
| 99f6f3231a | |||
| b04a358e5e | |||
| 6fc39d9e80 | |||
| 889e79c8b8 | |||
| 9bf7e3cd1b | |||
| 8751c0dee3 | |||
| 498a9ed3ff | |||
| ec53629971 | |||
| 6c46f5d24f | |||
| e077b12328 | |||
| 570b639e07 | |||
| 3a368119e5 | |||
| 6975a6c290 | |||
| cdac1f8a45 | |||
| 8c872f9b32 | |||
| 2a0ebd16fa | |||
| b3a55d4ab5 | |||
| 5bed2bae9f | |||
| b6c4181c70 | |||
| 3187934f72 | |||
| 9491b2a744 | |||
| ca6c20ebd9 | |||
| 578110f615 | |||
| 32861c5db9 | |||
| 0b34792709 | |||
| db9a9f98fd | |||
| cc5e53c51e | |||
| 9eeeb09d2f | |||
| 84303d25b2 | |||
| 63cd906cfc | |||
| 19d476ed7f | |||
| 8506a03f1c | |||
| 8a00f53b16 | |||
| fff26aa343 | |||
| 425ca89765 | |||
| 7eb943f02f | |||
| d78449cbc9 | |||
| aa11c0f52a | |||
| 88360350a0 | |||
| e3353cd09d | |||
| 46123d62ae | |||
| 68ddad98fb | |||
| 33b4123444 | |||
| 16e97d0dc3 | |||
| 7aa4cc908d | |||
| 8fb988ba0a | |||
| 53bd451450 | |||
| 42ca0ec642 | |||
| 49d32d0b25 | |||
| 915c00eb34 | |||
| e3309df336 | |||
| c69378f0de | |||
| 71ebfa555f | |||
| 6bd7dc91fc | |||
| 399c1bc88d | |||
| aa348a1b48 | |||
| a2f838605c | |||
| fb7d7ff3a4 | |||
| 89b3f5d843 | |||
| 056c75a853 | |||
| 676899cab0 | |||
| cf5c2b7258 | |||
| 391823a915 | |||
| b56b4c93b6 | |||
| 29176da35f | |||
| 9ac5ff6925 | |||
| 1283aa5175 | |||
| 1208f7ee94 | |||
| 0c5136e59e | |||
| 8b3d220d73 | |||
| 134d279f0c | |||
| 2eb84fc7f8 | |||
| dde0117a37 | |||
| ce72de2d63 | |||
| bb5c626cd9 | |||
| 3342d3c39d | |||
| 49c40b20b7 | |||
| 194560c887 | |||
| 7b05e2dd3c | |||
| b59a2f3be3 | |||
| f1badac257 | |||
| 2cc0f3e673 | |||
| 769045af15 | |||
| 0d5fac1621 | |||
| fbc668c7bf | |||
| caa6649297 | |||
| e875ffd865 | |||
| e51ada4d60 | |||
| 22fbfc92c6 | |||
| 32d300ce58 | |||
| 1857fe4c98 | |||
| 0b646238e9 | |||
| 48421c12de | |||
| 75140fc3bb | |||
| 15fa943d41 | |||
| e7b8993181 | |||
| 31b114b0cc | |||
| a9fd0bd128 | |||
| d7fe4ef8be | |||
| 001f5c968e | |||
| 01c28bb88b | |||
| dfc480c73a | |||
| e3bfd9ca6d | |||
| 61cfacba55 | |||
| 5943372ce4 | |||
| e889247387 | |||
| 4ddda8e733 | |||
| 2480d74410 | |||
| 772962c2e9 | |||
| 3366edb3a1 | |||
| 99738bbc17 | |||
| d6a7011885 | |||
| 6ea2c90f75 | |||
| 959b134d67 | |||
| 314229b7ee | |||
| 7ed75e79ce | |||
| 52efc629b7 | |||
| 123140b762 | |||
| 6bf5023af1 | |||
| 4be7c19798 | |||
| 32e1a07157 | |||
| 2662c65f57 | |||
| 3cfefb4367 | |||
| ee1110b752 | |||
| 9959397934 | |||
| 5f82f72a6f | |||
| 43e8ef154f | |||
| 4dc4c745c8 | |||
| a5257be319 | |||
| 30f36efe5a | |||
| 5a6c5cf72c | |||
| 189916d1db | |||
| 546856594e | |||
| 457b3397fd | |||
| 47ff33c653 | |||
| 1d41b108fc | |||
| b7a101fc60 | |||
| c85d9104ec | |||
| f3cdbdd7d5 | |||
| e93131ab46 | |||
| 161e6c4e86 | |||
| 3dbc7b3045 | |||
| 4a4189705a | |||
| 6aa71a4da8 | |||
| bdc96f6d8e | |||
| 1c1063f448 | |||
| 29fdc378a1 | |||
| bd659d493d | |||
| 6a2a6028c3 | |||
| 6e85f1158b | |||
| e117e314d9 | |||
| fbb0909638 | |||
| 3eecd31868 | |||
| 68d70bbbe8 | |||
| 08ec945e59 | |||
| 4401cb0d66 | |||
| 36ae6247f9 | |||
| 1088086399 | |||
| 2c74d042ed | |||
| 3ec607106d | |||
| f671a96d8c | |||
| fe5cf9021f | |||
| 1be2461716 | |||
| a0e9484e37 | |||
| 4ca6f2957b | |||
| dfd040a9de | |||
| f29292dd81 | |||
| 4566fc1f53 | |||
| 4e58bdd85b | |||
| c009b9e283 | |||
| 4e33e0e521 | |||
| 7252fb6285 |
@@ -0,0 +1,217 @@
|
||||
---
|
||||
name: "cache-framework"
|
||||
description: "Wavelet 项目专用:当新增或修改业务缓存(RAM/Redis/DB 三层读路径)、缓存失效、多节点 pub/sub 同步、或评估高频读是否应接入缓存时必须使用。本技能说明系统标准缓存框架、参考实现、禁止写法与分布式一致性要求。"
|
||||
---
|
||||
|
||||
# 系统三层缓存框架
|
||||
|
||||
开始前阅读根目录 `AGENTS.md`(含 **Skill 关联索引**)。Wavelet 标准读路径为 **本地 RAM → Redis → PostgreSQL**(由快到慢),不是 DB 优先。
|
||||
|
||||
详细性能背景见 `docs/PERFORMANCE.md`。
|
||||
|
||||
## 关联 Skill
|
||||
|
||||
| 关联 | 何时一并阅读 |
|
||||
| :--- | :--- |
|
||||
| [database-migration](../database-migration/SKILL.md) | 缓存对象对应新表/列/索引,或 seed 变更 |
|
||||
| [new-setting](../new-setting/SKILL.md) | 系统配置类缓存(`GetSystemConfigByKey`、`ListSystemConfigsByKeys`) |
|
||||
| [file-upload](../file-upload/SKILL.md) | 上传元数据 `upload:meta:{id}`、ingest/remove/cleanup 失效钩子 |
|
||||
| [clickhouse-batchwriter](../clickhouse-batchwriter/SKILL.md) | 分析写入走 batchwriter,**不要**用本技能模式缓存 CH flush 队列 |
|
||||
| [new-api](../new-api/SKILL.md) | 在 Handler 层接入 `GetXxxCached` 或评估高频读 |
|
||||
| [new-async-task](../new-async-task/SKILL.md) | Worker/定时任务变更数据后必须 `Invalidate*`(如 `system:cleanup`) |
|
||||
|
||||
## 标准模式(金标准)
|
||||
|
||||
参考:`internal/repository/system_config_cache.go` + `GetSystemConfigByKey` / `ListSystemConfigsByKeys`。
|
||||
|
||||
| 层级 | 技术 | 职责 |
|
||||
| :--- | :--- | :--- |
|
||||
| L1 本地 | `pkg/cache/ram`(Otter v2) | 进程内热数据,最低延迟 |
|
||||
| L2 共享 | Redis `db.GetJSON` / `SetJSON` / `HSetJSON` + `db.PrefixedKey` | 跨节点共享,带 TTL 或写穿 |
|
||||
| L3 权威 | PostgreSQL via `db.DB(ctx)` | 唯一数据源 |
|
||||
|
||||
### 读路径模板
|
||||
|
||||
```go
|
||||
func GetThingCached(ctx context.Context, key string) (Thing, error) {
|
||||
ensureThingCacheListener() // 订阅 pub/sub,仅 sync.Once
|
||||
|
||||
if v, ok := thingRAM.GetIfPresent(key); ok {
|
||||
return cloneThing(v), nil
|
||||
}
|
||||
if db.Redis != nil {
|
||||
var v Thing
|
||||
if err := db.GetJSON(ctx, redisKey(key), &v); err == nil {
|
||||
thingRAM.Set(key, cloneThing(v))
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
v, err := loadThingFromDB(ctx, key)
|
||||
if err != nil {
|
||||
return Thing{}, err
|
||||
}
|
||||
populateThingCache(ctx, v) // 回写 RAM + Redis
|
||||
return v, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 写穿(populate)
|
||||
|
||||
DB miss 或业务创建成功后,**必须**回写上层:
|
||||
|
||||
```go
|
||||
func populateThingCache(ctx context.Context, v Thing) {
|
||||
thingRAM.Set(v.Key, cloneThing(v))
|
||||
if db.Redis != nil {
|
||||
_ = db.SetJSON(ctx, redisKey(v.Key), v, cacheTTL)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 失效(Invalidate)— 分布式必做三步
|
||||
|
||||
数据变更(Admin 更新、软删除、状态迁移)时:
|
||||
|
||||
1. **本机 RAM** — `thingRAM.Invalidate(key)` 或 `InvalidateAll()`
|
||||
2. **Redis** — `Del` / `HDel` 对应 key
|
||||
3. **pub/sub 广播** — 通知**其他节点**清除 RAM(Redis 已由写节点清掉)
|
||||
|
||||
```go
|
||||
func InvalidateThingCache(ctx context.Context, key string) error {
|
||||
ensureThingCacheListener()
|
||||
thingRAM.Invalidate(key)
|
||||
if db.Redis != nil {
|
||||
if err := db.Redis.Del(ctx, db.PrefixedKey(redisKey(key))).Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
publishThingRAMInvalidation(ctx, key) // 只广播 RAM 失效
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### pub/sub 监听模板
|
||||
|
||||
```go
|
||||
const thingInvalidationChannel = "domain:thing_invalidation"
|
||||
|
||||
func startThingCacheInvalidationListener() {
|
||||
if db.Redis == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
pubsub := db.Redis.Subscribe(context.Background(), thingInvalidationChannel)
|
||||
defer func() { _ = pubsub.Close() }()
|
||||
for msg := range pubsub.Channel() {
|
||||
// 解析 payload,Invalidate RAM;勿重复 Del Redis
|
||||
thingRAM.Invalidate(parsedKey)
|
||||
}
|
||||
}()
|
||||
}
|
||||
```
|
||||
|
||||
- 使用 `sync.Once` 启动监听;**`ensureListener` 必须在 `db.Redis == nil` 时直接 return,不可消费 Once**(否则测试或 Redis 晚初始化时监听器永不启动)。
|
||||
- 测试可提供 `StopThingCacheListener` + 重置 `Once`(参考 `StopUploadMetaCacheListener`、`StopAuthSourceCacheListener`)。
|
||||
- 其他节点收到消息后**只清 RAM**,不再删 Redis。
|
||||
|
||||
## 现有实现速查
|
||||
|
||||
| 域 | 文件 | L1 | L2 | pub/sub |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| 系统配置 | `repository/system_config_cache.go` | `pkg/cache/store` | ❌ 无 Redis 缓存 | `system:config_broadcast` (别名 `system:config_invalidation`) ✅ |
|
||||
| CAPTCHA 运行时 | `apps/cap/runtime_settings.go` | atomic.Pointer | (借配置 Redis) | 订阅 `system:config_invalidation` ✅ |
|
||||
| 上传元数据 | `apps/upload/cache/meta_cache.go` | Otter | Redis JSON | `upload:meta_invalidation` ✅ |
|
||||
| 上传访问白名单 | `apps/upload/cache/access_cache.go` | 进程内 TTL | (借配置读路径) | `upload:file_access_invalidation` ✅ |
|
||||
| Auth Source | `repository/auth_source_cache.go` | Otter | Redis JSON | `oauth:auth_source_invalidation` ✅ |
|
||||
| OAuth 用户/Token | `apps/oauth/cache.go` | 自研 map | Redis JSON | ❌ 无 pub/sub(历史债) |
|
||||
| 推送渠道 | `repository/push_channel.go` | 无 | Redis JSON | ❌ 仅 Redis Del |
|
||||
| Storage 驱动 | `internal/infra/objectstore/storage.go` | RWMutex 快照 | — | `storage:config_invalidation` ✅ |
|
||||
|
||||
## 新增缓存工作流
|
||||
|
||||
1. **判定是否需要缓存**:高频读、低变更、可容忍短暂 TTL;写路径必须能统一失效。
|
||||
2. **选型 L1**:优先 `pkg/cache/ram.MustNew`;**禁止**自研 `map+mutex+TTL`,除非有充分理由并文档说明。
|
||||
3. **选型 L2**:小对象 `SetJSON`;配置类多条目用 Redis Hash(`HSetJSON`)。
|
||||
4. **定义 Redis key**:小写蛇形,带业务前缀(`upload:meta:{id}`);统一 `db.PrefixedKey`。
|
||||
5. **实现 Invalidate + pub/sub**:凡多实例部署可读的 RAM 缓存**必须**有失效广播。
|
||||
6. **挂载变更钩子**:在所有 DB 变更入口调用 Invalidate(含 Worker/定时任务,不只 HTTP Handler)。
|
||||
7. **测试**:
|
||||
- RAM hit / Redis hit / DB fallback
|
||||
- Invalidate 清 L1+L2
|
||||
- pub/sub 触发他机 RAM 失效(可用 miniredis Publish 模拟)
|
||||
- `Reset*RAMCacheForTest` 仅清本机 RAM
|
||||
8. 运行 `go test` 相关包 + `make code-check`。
|
||||
|
||||
## 变更钩子清单(上传元数据示例)
|
||||
|
||||
| 入口 | 动作 |
|
||||
| :--- | :--- |
|
||||
| `ingest.persistUploadRecord` 创建成功 | `SetUploadMetaCache` |
|
||||
| `ingest.Remove` / `RemoveOwned` | `InvalidateUploadMetaCache` |
|
||||
| `task/cleanup.go` 软删除 pending 文件 | `InvalidateUploadMetaCache` |
|
||||
| 直接 `repository.SoftDeleteUpload` | **禁止** — 必须走 `upload.Remove` |
|
||||
|
||||
## 禁止写法
|
||||
|
||||
```go
|
||||
// ❌ 自研 L1,与 pkg/cache/ram 重复
|
||||
var mu sync.RWMutex
|
||||
var items = map[uint64]entry{}
|
||||
|
||||
// ❌ 只清本机 RAM + Redis,无 pub/sub(多节点 RAM 脏读)
|
||||
func Invalidate(ctx context.Context, id uint64) {
|
||||
localDelete(id)
|
||||
redis.Del(...)
|
||||
}
|
||||
|
||||
// ❌ DB 变更后忘记 Worker 路径
|
||||
// cleanup 任务删了 upload 行,但未 InvalidateUploadMetaCache
|
||||
|
||||
// ❌ 在 Handler 里直接查 DB,绕过已有 GetXxxCached
|
||||
|
||||
// ❌ Redis key 不用 PrefixedKey(多环境共 Redis 时冲突)
|
||||
|
||||
// ❌ 在 init() 里启动 pub/sub 监听 — 与 bootstrap 规范冲突;用 sync.Once 懒启动
|
||||
```
|
||||
|
||||
## 特殊场景
|
||||
|
||||
### 敏感字段(ClientSecret)
|
||||
|
||||
模型 `json:"-"` 时,Redis DTO 用独立 `*RedisRecord` struct 显式序列化字段(见 `auth_source_cache.go`)。
|
||||
|
||||
### 批量读配置
|
||||
|
||||
批量接口必须与单 key 一致走 Redis(`ListSystemConfigsByKeys` 在 RAM miss 后逐 key `HGetJSON`,再 DB `IN`)。
|
||||
|
||||
### 仅进程内、短 TTL、配置衍生
|
||||
|
||||
可用进程内快照 + 订阅上游 pub/sub(`access_cache.go`、`cap/runtime_settings.go`),不必强行 Redis L2。
|
||||
|
||||
### OAuth 用户/Token
|
||||
|
||||
沿用 `oauth/cache.go`;新增逻辑调用 `SetCachedUser` / `SetCachedToken` 预热,变更调用 `InvalidateCachedUser` / `InvalidateCachedToken`。
|
||||
|
||||
## 验证清单
|
||||
|
||||
```bash
|
||||
go test ./internal/repository/... ./internal/apps/upload/cache/...
|
||||
make code-check
|
||||
```
|
||||
|
||||
- [ ] L1 使用 `pkg/cache/ram`(或已文档化的例外)
|
||||
- [ ] 读路径:RAM → Redis → DB
|
||||
- [ ] 写穿 populate 在 DB load / 创建成功后
|
||||
- [ ] Invalidate:RAM + Redis + Publish
|
||||
- [ ] `ensureListener` + pub/sub 清他机 RAM
|
||||
- [ ] 所有变更入口(含 Worker)已挂钩
|
||||
- [ ] 测试含 Invalidate 与 pub/sub
|
||||
|
||||
## 相关文件
|
||||
|
||||
- L1 引擎:`pkg/cache/ram/cache.go`
|
||||
- DB/Redis 助手:`internal/infra/persistence/redis.go`(`GetJSON`, `SetJSON`, `HGetJSON`, `PrefixedKey`)
|
||||
- 金标准:`internal/repository/system_config_cache.go`
|
||||
- 上传元数据:`internal/apps/upload/cache/meta_cache.go`
|
||||
- Auth Source:`internal/repository/auth_source_cache.go`
|
||||
- 性能文档:`docs/PERFORMANCE.md`
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
name: "clickhouse-batchwriter"
|
||||
description: "Wavelet 项目专用:当新增或修改 ClickHouse 批量写入、接入 internal/infra/persistence/batchwriter、将业务域异步 flush 到分析表、迁移 risk_control/节点访问日志/可观测时序写入、或评估 async_insert 与背压策略时必须使用。本技能指导分层职责、各域独立 Writer 实例、repository 批量 API 与禁止写法。"
|
||||
---
|
||||
|
||||
# ClickHouse 批量写入开发
|
||||
|
||||
开始前阅读根目录 `AGENTS.md`。ClickHouse 是辅助 OLAP 存储,**厌恶高频单条写入**(过多小 part);写入路径必须优先批量或异步聚合。
|
||||
|
||||
DDL 与表结构变更见 `database-migration` 技能。日志/分析用途表的判定、三库回落与切换见 `logstore` 技能。本技能只覆盖**运行时写入架构**。
|
||||
|
||||
## 分层职责
|
||||
|
||||
| 层级 | 路径 | 职责 |
|
||||
| :--- | :--- | :--- |
|
||||
| 连接 | `internal/infra/persistence/clickhouse.go` | `ChConn`(原生批量写)、`ChDB`(GORM 查询);禁止在业务包直接 `clickhouse.Open` |
|
||||
| 批量框架 | `internal/infra/persistence/batchwriter/` | 泛型队列 + 按条数/时间 flush + 非阻塞入队 + 优雅停机;**各业务域独立实例** |
|
||||
| Model | `internal/model/analytics/` | 列定义、`TableName()`、`BatchInsertSQL()`(及可选 `InsertColumns()`) |
|
||||
| Repository | `internal/repository/analytics/` | `BatchInsert*` / `BatchInsertNodeAccessLogs` 等;`PrepareBatch` + 多行 `Append` + 一次 `Send` |
|
||||
| Apps | `internal/apps/<domain>/` | 采集、入队、背压;`FlushFunc` 只调 logstore / repository,不写 SQL、不 `PrepareBatch` |
|
||||
| 装配 | `internal/platform/bootstrap/bootstrap.go` | 进程启动时调用 `Writer.Start`;初始化时需调用 `lifecycle.OnShutdown` 挂载停机钩子 |
|
||||
| 生命周期 | `internal/platform/lifecycle/lifecycle.go` | 统一协调全局并发优雅停机,业务包无需在 `bootstrap.go` 中硬编码 `Stop` 逻辑 |
|
||||
|
||||
**禁止**在 Handler / middleware 内直接 `db.ChConn.PrepareBatch`;**禁止**在 repository 内启动 goroutine 或维护全局 channel(队列生命周期由 apps + bootstrap 或专用 writer 包负责)。
|
||||
|
||||
## batchwriter 框架契约
|
||||
|
||||
```go
|
||||
writer, err := batchwriter.New[YourType](cfg, flushFunc, opts...)
|
||||
writer.Start(ctx)
|
||||
writer.TryEnqueue(item) // 非阻塞;满则 false
|
||||
writer.IsFull() // 背压探测
|
||||
writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
|
||||
```
|
||||
|
||||
### Config 默认值(`batchwriter.DefaultConfig()`)
|
||||
|
||||
- `QueueSize`: 10_000
|
||||
- `MaxBatchSize`: 1_000
|
||||
- `MinBatchSize`: 50(未达阈值则跳过按时间 flush,除非设了 `MaxFlushWait`)
|
||||
- `FlushInterval`: 1s
|
||||
|
||||
各域可独立覆盖;可观测低频指标可用更小 `MaxBatchSize`(如 100)与更长 `FlushInterval`(如 2–5s),但**不要**退化为逐条 `Send`。
|
||||
|
||||
### 可选回调
|
||||
|
||||
- `WithFlushErrorHandler[T]`:flush 失败时记录日志;批次丢弃后 worker 继续
|
||||
- `WithDropHandler[T]`:队列满或未 `Start` 时丢弃项
|
||||
|
||||
### FlushFunc 规范
|
||||
|
||||
- 签名:`func(ctx context.Context, items []T) error`
|
||||
- **日志/分析用途表**:`logstore.Active(ctx)` 再调对应 `BatchInsert*`。禁止 apps 直连 `analyticsrepo` 或 `db.ChConn`。
|
||||
- 仅 CH、无需主库回落的分析表:才直接调 `repository/analytics` 的 `BatchInsert*`。
|
||||
- 在 flush 边界记录一次错误日志,不要把 DB 驱动错误直接暴露给 HTTP 客户端
|
||||
- `Start` 使用 `context.WithoutCancel(parent)`,避免请求 ctx 取消中断后台 flush
|
||||
|
||||
## 各域独立实例(不共享队列)
|
||||
|
||||
每个业务域拥有自己的 `Writer`、配置与 `FlushFunc`:
|
||||
|
||||
| 域 | 表 | 写入路径 |
|
||||
| :--- | :--- | :--- |
|
||||
| 管理端审计 | `w_user_access_logs` | `risk_control` → `batchwriter` → `logstore.Active` |
|
||||
| 边缘访问日志 | `of_node_access_logs` | `openflare/chwriter` → `logstore.Active` |
|
||||
| 可观测时序 | `of_node_metric_snapshots` 等 | `openflare/chwriter` 分表 writer + 进程内短 TTL 去重 → `logstore.Active` |
|
||||
|
||||
**不要**把 audit、access log、observability 并入同一 channel。
|
||||
|
||||
## 新增 ClickHouse 写入工作流
|
||||
|
||||
1. **Model**:在 `internal/model/analytics/` 定义 struct 与 `BatchInsertSQL()`(列顺序与 goose DDL 一致)。
|
||||
2. **Goose DDL**:在 `internal/infra/persistence/migrator/goose/clickhouse/` 新增迁移(见 `database-migration`)。
|
||||
3. **Repository**:实现 `BatchInsertX(ctx, []analyticsmodel.X) error`:
|
||||
- `len(items)==0` 直接返回
|
||||
- `db.ChConn == nil` 返回明确错误
|
||||
- 一次 `PrepareBatch` → 循环 `Append` → 一次 `Send`
|
||||
4. **Writer 胶水**(`internal/apps/<domain>/`):
|
||||
- `New` + `Start`,并在初始化逻辑内通过 `lifecycle.OnShutdown("your_writer_name", Stop)` 注册停机回调
|
||||
- 日志表的 `FlushFunc` 调 `logstore.Active`(见 `logstore` skill)
|
||||
- 业务路径 `TryEnqueue`;HTTP 背压用 `IsFull()`
|
||||
5. **测试**:
|
||||
- repository:mock `ChConn` 验证 `BatchInsertSQL` 与 append 列数
|
||||
- batchwriter:`go test ./internal/infra/persistence/batchwriter`
|
||||
6. 运行 `make code-check`;有 API 变更时 `make swagger`。
|
||||
|
||||
## 背压与丢弃策略
|
||||
|
||||
| 场景 | 推荐策略 |
|
||||
| :--- | :--- |
|
||||
| 管理端 API 审计 | 队列满 → `IsFull()` 触发 429(见 `risk_control` middleware) |
|
||||
| Agent 心跳指标 | 队列满 → `WithDropHandler` 记 warn;不阻塞心跳响应 |
|
||||
| 边缘 access log | 优先扩大队列与 batch;必要时丢弃最旧或采样 |
|
||||
|
||||
## 禁止写法
|
||||
|
||||
```go
|
||||
// ❌ 单条伪批量:每条都 PrepareBatch + Send
|
||||
batch.Append(oneRow)
|
||||
batch.Send()
|
||||
|
||||
// ❌ 写前 OLTP 式去重(高 RTT + 仍产生小 part)
|
||||
SELECT count() FROM ... WHERE node_id = ? AND captured_at = ?
|
||||
|
||||
// ❌ Handler 内直接写 ClickHouse
|
||||
db.ChConn.PrepareBatch(...)
|
||||
|
||||
// ❌ 全局单队列承载所有分析表
|
||||
var globalChan chan any
|
||||
```
|
||||
|
||||
去重应使用:`ReplacingMergeTree`、查询侧 `argMax`、或进程内短 TTL 去重缓存——**不要**在每次 insert 前 `SELECT count()`。
|
||||
|
||||
## async_insert(补充,非主方案)
|
||||
|
||||
可在 `internal/infra/persistence/clickhouse.go` 的 `Settings` 增加服务端异步写入作为第二层防护:
|
||||
|
||||
```go
|
||||
"async_insert": 1,
|
||||
"wait_for_async_insert": 1,
|
||||
```
|
||||
|
||||
**不能替代**应用层批量;接入前需评估丢失可观测性与服务端负载。优先完成 `batchwriter` 接入后再考虑。
|
||||
|
||||
## Bootstrap 装配示例
|
||||
|
||||
```go
|
||||
// internal/platform/bootstrap/bootstrap.go(示意)
|
||||
func RegisterAPI(ctx context.Context) {
|
||||
// 日志 writer 不依赖 clickhouse.enabled:flush 时由 logstore 选库
|
||||
risk_control.InitLogWriter(ctx)
|
||||
}
|
||||
```
|
||||
|
||||
- `RegisterAPI` / `RegisterAll`:`Start`
|
||||
- 进程优雅停机:业务模块在初始化时调用 `lifecycle.OnShutdown` 注册,由 `bootstrap.Stop()` 代理 `lifecycle.Stop()` 并发停机。
|
||||
- 使用 `sync.Once` 保证幂等
|
||||
|
||||
## 验证清单
|
||||
|
||||
```bash
|
||||
go test ./internal/infra/persistence/batchwriter
|
||||
go test ./internal/repository/analytics
|
||||
make code-check
|
||||
```
|
||||
|
||||
- flush 按 `MaxBatchSize` 与 `FlushInterval` 触发
|
||||
- `Stop` 能 drain 队列内剩余项
|
||||
- repository 层无 goroutine、无 channel
|
||||
- 日志表:`clickhouse.enabled: false` 时 writer 仍 `Start`,flush 走主库 logstore
|
||||
- 仅 CH 的分析表:未启用 CH 时不要 `Start`、不要入队
|
||||
|
||||
## 相关文件速查
|
||||
|
||||
- 框架:`internal/infra/persistence/batchwriter/{config,writer,errs}.go`
|
||||
- 连接:`internal/infra/persistence/clickhouse.go`
|
||||
- 审计写入:`internal/apps/risk_control/logics.go`
|
||||
- OpenFlare 写入胶水:`internal/apps/openflare/chwriter/writer.go`
|
||||
- 日志抽象:`internal/repository/logstore`
|
||||
- 节点访问日志 CH 实现:`internal/repository/analytics/node_access_log_writer.go`
|
||||
- 可观测 CH 实现:`internal/repository/analytics/node_observability_writer.go`
|
||||
- 生命周期管理器:`internal/platform/lifecycle/lifecycle.go`
|
||||
- Bootstrap:`internal/platform/bootstrap/bootstrap.go`
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor files
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Local config
|
||||
.env
|
||||
.env.local
|
||||
Regular → Executable
+348
@@ -0,0 +1,348 @@
|
||||
# Contributing to AI Code Review Guide
|
||||
|
||||
Thank you for your interest in contributing! This document provides guidelines for contributing to this Claude Code Skill project.
|
||||
|
||||
## Claude Code Skill 开发规范
|
||||
|
||||
本项目是一个 Claude Code Skill,贡献者需要遵循以下规范。
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
code-review-skill/
|
||||
├── SKILL.md # Required: main file (always loaded)
|
||||
├── README.md
|
||||
├── CONTRIBUTING.md
|
||||
├── LICENSE
|
||||
├── reference/ # On-demand language/framework guides
|
||||
│ ├── react.md # React 19 / Next.js / TanStack Query v5
|
||||
│ ├── vue.md # Vue 3.5 Composition API
|
||||
│ ├── angular.md # Angular 17+, Signals, Standalone, RxJS
|
||||
│ ├── svelte.md # Svelte 5 / SvelteKit, runes, SSR boundary
|
||||
│ ├── rust.md # Ownership, async, unsafe, cancellation
|
||||
│ ├── typescript.md # Type safety, generics, strict mode
|
||||
│ ├── nestjs.md # NestJS DI, modules, Guards/Pipes, DTOs
|
||||
│ ├── python.md # Type hints, async, testing
|
||||
│ ├── django.md # Django / DRF, N+1, serializers, async views
|
||||
│ ├── fastapi.md # FastAPI, Depends, Pydantic v2, async
|
||||
│ ├── java.md # Java 17/21, Spring Boot 3, virtual threads
|
||||
│ ├── kotlin.md # Kotlin / Android, coroutines, Flow, Compose
|
||||
│ ├── go.md # Error handling, goroutines, context
|
||||
│ ├── csharp.md # C# / .NET 8, async, EF Core, ASP.NET Core
|
||||
│ ├── php.md # PHP 8.x, types, PDO, security, Composer
|
||||
│ ├── c.md # Memory safety, UB, error handling
|
||||
│ ├── cpp.md # RAII, move semantics, exception safety
|
||||
│ ├── qt.md # Object model, signals/slots, GUI perf
|
||||
│ ├── css-less-sass.md # Variables, responsive, performance
|
||||
│ ├── architecture-review-guide.md # SOLID, anti-patterns, coupling
|
||||
│ ├── performance-review-guide.md # Web Vitals, N+1, complexity
|
||||
│ ├── security-review-guide.md # OWASP Top 10, JWT, validation
|
||||
│ ├── common-bugs-checklist.md # Quick-reference bug patterns
|
||||
│ ├── code-quality-universal.md # Language-agnostic quality anti-patterns
|
||||
│ └── code-review-best-practices.md # Communication & process
|
||||
├── assets/ # Templates and quick reference
|
||||
│ ├── review-checklist.md
|
||||
│ └── pr-review-template.md
|
||||
└── scripts/
|
||||
└── pr-analyzer.py # PR complexity analyzer
|
||||
```
|
||||
|
||||
### Frontmatter 规范
|
||||
|
||||
SKILL.md 必须包含 YAML frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: skill-name
|
||||
description: |
|
||||
功能描述。触发条件说明。
|
||||
Use when [具体使用场景]。
|
||||
allowed-tools: ["Read", "Grep", "Glob"] # 可选:限制工具访问
|
||||
---
|
||||
```
|
||||
|
||||
#### 必需字段
|
||||
|
||||
| 字段 | 说明 | 约束 |
|
||||
|------|------|------|
|
||||
| `name` | Skill 标识符 | 小写字母、数字、连字符;最多 64 字符 |
|
||||
| `description` | 功能和激活条件 | 最多 1024 字符;必须包含 "Use when" |
|
||||
|
||||
#### 可选字段
|
||||
|
||||
| 字段 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `allowed-tools` | 限制工具访问 | `["Read", "Grep", "Glob"]` |
|
||||
|
||||
### 命名约定
|
||||
|
||||
**Skill 名称规则**:
|
||||
- 仅使用小写字母、数字和连字符(kebab-case)
|
||||
- 最多 64 个字符
|
||||
- 避免下划线或大写字母
|
||||
|
||||
```
|
||||
✅ 正确:code-review-skill, typescript-advanced-types
|
||||
❌ 错误:CodeReview, code_review, TYPESCRIPT
|
||||
```
|
||||
|
||||
**文件命名规则**:
|
||||
- reference 文件使用小写:`react.md`, `vue.md`
|
||||
- 多词文件使用连字符:`common-bugs-checklist.md`
|
||||
|
||||
### Description 写法规范
|
||||
|
||||
Description 必须包含两部分:
|
||||
|
||||
1. **功能陈述**:具体说明 Skill 能做什么
|
||||
2. **触发条件**:以 "Use when" 开头,说明何时激活
|
||||
|
||||
```yaml
|
||||
# ✅ 正确示例
|
||||
description: |
|
||||
Provides comprehensive code review guidance for React 19, Vue 3, Rust,
|
||||
TypeScript, Java, Python, and C/C++.
|
||||
Helps catch bugs, improve code quality, and give constructive feedback.
|
||||
Use when reviewing pull requests, conducting PR reviews, establishing
|
||||
review standards, or mentoring developers through code reviews.
|
||||
|
||||
# ❌ 错误示例(太模糊,缺少触发条件)
|
||||
description: |
|
||||
Helps with code review.
|
||||
```
|
||||
|
||||
### Progressive Disclosure(渐进式披露)
|
||||
|
||||
Claude 只在需要时加载支持文件,不会一次性加载所有内容。
|
||||
|
||||
#### 文件职责划分
|
||||
|
||||
| 文件 | 加载时机 | 内容 |
|
||||
|------|----------|------|
|
||||
| `SKILL.md` | 始终加载 | 核心原则、快速索引、何时使用 |
|
||||
| `reference/*.md` | 按需加载 | 语言/框架的详细指南 |
|
||||
| `assets/*.md` | 明确需要时 | 模板、清单 |
|
||||
| `scripts/*.py` | 明确指引时 | 工具脚本 |
|
||||
|
||||
#### 内容组织原则
|
||||
|
||||
**SKILL.md**(~200 行以内):
|
||||
- 简述:2-3 句话说明用途
|
||||
- 核心原则和方法论
|
||||
- 语言/框架索引表(链接到 reference/)
|
||||
- 何时使用此 Skill
|
||||
|
||||
**reference/*.md**(详细内容):
|
||||
- 完整的代码示例
|
||||
- 所有最佳实践
|
||||
- Review Checklist
|
||||
- 边界情况和陷阱
|
||||
|
||||
### 文件引用规范
|
||||
|
||||
在 SKILL.md 中引用其他文件时:
|
||||
|
||||
```markdown
|
||||
# ✅ 正确:使用 Markdown 链接格式
|
||||
| **React** | [React Guide](reference/react.md) | Hooks, React 19, RSC |
|
||||
| **Vue 3** | [Vue Guide](reference/vue.md) | Composition API |
|
||||
|
||||
详见 [React Guide](reference/react.md) 获取完整指南。
|
||||
|
||||
# ❌ 错误:使用代码块格式
|
||||
参考 `reference/react.md` 文件。
|
||||
```
|
||||
|
||||
**路径规则**:
|
||||
- 使用相对路径(相对于 Skill 目录)
|
||||
- 使用正斜杠 `/`,不使用反斜杠
|
||||
- 不需要 `./` 前缀
|
||||
|
||||
### 约定(Conventions)
|
||||
|
||||
**严重级别(severity)**:审查意见统一使用 SKILL.md「Technique 4」的标记方案,三档由红到绿表示优先级:
|
||||
|
||||
- 🔴 `[blocking]` - 合并前必须修复
|
||||
- 🟡 `[important]` - 应当修复,有异议可讨论
|
||||
- 🟢 `[nit]` - 可选优化,不阻塞合并
|
||||
|
||||
新增 reference 指南时请沿用这套标记,不要自创等价的名称(如 critical/warning/suggestion)。
|
||||
|
||||
**语言策略**:现有指南是中英混合的——部分通篇中文,部分(如 fastapi.md、php.md)以英文为主。新增内容时**跟随同一领域既有指南的语言**:改某个指南就用它的语言;新建指南可自行选择中文或英文,但单个文件内部保持一致。
|
||||
|
||||
---
|
||||
|
||||
## 贡献类型
|
||||
|
||||
### 添加新语言支持
|
||||
|
||||
1. 在 `reference/` 目录创建新文件(如 `go.md`)
|
||||
2. 遵循以下结构:
|
||||
|
||||
```markdown
|
||||
# [Language] Code Review Guide
|
||||
|
||||
> 简短描述,一句话说明覆盖内容。
|
||||
|
||||
## 目录
|
||||
- [主题1](#主题1)
|
||||
- [主题2](#主题2)
|
||||
- [Review Checklist](#review-checklist)
|
||||
|
||||
---
|
||||
|
||||
## 主题1
|
||||
|
||||
### 子主题
|
||||
|
||||
```[language]
|
||||
// ❌ Bad pattern - 说明为什么不好
|
||||
bad_code_example()
|
||||
|
||||
// ✅ Good pattern - 说明为什么好
|
||||
good_code_example()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Review Checklist
|
||||
|
||||
### 类别1
|
||||
- [ ] 检查项 1
|
||||
- [ ] 检查项 2
|
||||
```
|
||||
|
||||
3. 在 `SKILL.md` 的索引表中添加链接
|
||||
4. 更新 `README.md` 的统计信息
|
||||
|
||||
### 添加框架模式
|
||||
|
||||
1. 确保引用官方文档
|
||||
2. 包含版本号(如 "React 19", "Vue 3.5+")
|
||||
3. 提供可运行的代码示例
|
||||
4. 添加对应的 checklist 项
|
||||
|
||||
### 改进现有内容
|
||||
|
||||
- 修复拼写或语法错误
|
||||
- 更新过时的模式(注明版本变化)
|
||||
- 添加边界情况示例
|
||||
- 改进代码示例的清晰度
|
||||
|
||||
---
|
||||
|
||||
## 代码示例规范
|
||||
|
||||
### 格式要求
|
||||
|
||||
```markdown
|
||||
// ❌ 问题描述 - 解释为什么这样做不好
|
||||
problematic_code()
|
||||
|
||||
// ✅ 推荐做法 - 解释为什么这样做更好
|
||||
recommended_code()
|
||||
```
|
||||
|
||||
### 质量标准
|
||||
|
||||
- 示例应基于真实场景,避免人为构造
|
||||
- 同时展示问题和解决方案
|
||||
- 保持示例简洁聚焦
|
||||
- 包含必要的上下文(import 语句等)
|
||||
|
||||
---
|
||||
|
||||
## 提交流程
|
||||
|
||||
### Issue 报告
|
||||
|
||||
- 使用 GitHub Issues 报告问题或建议
|
||||
- 提供清晰的描述和示例
|
||||
- 标注相关的语言/框架
|
||||
|
||||
### Pull Request 流程
|
||||
|
||||
1. Fork 仓库
|
||||
2. 创建功能分支:`git checkout -b feature/add-go-support`
|
||||
3. 进行修改
|
||||
4. 提交(见下文 commit 格式)
|
||||
5. 推送到 fork:`git push origin feature/add-go-support`
|
||||
6. 创建 Pull Request
|
||||
|
||||
### Commit 消息格式
|
||||
|
||||
```
|
||||
类型: 简短描述
|
||||
|
||||
详细说明(如需要)
|
||||
|
||||
- 具体变更 1
|
||||
- 具体变更 2
|
||||
```
|
||||
|
||||
**类型**:
|
||||
- `feat`: 新功能或新内容
|
||||
- `fix`: 修复错误
|
||||
- `docs`: 仅文档变更
|
||||
- `refactor`: 重构(不改变功能)
|
||||
- `chore`: 维护性工作
|
||||
|
||||
**示例**:
|
||||
```
|
||||
feat: 添加 Go 语言代码审查指南
|
||||
|
||||
- 新增 reference/go.md
|
||||
- 覆盖错误处理、并发、接口设计
|
||||
- 更新 SKILL.md 索引表
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Skill 设计原则
|
||||
|
||||
### 单一职责
|
||||
|
||||
每个 Skill 专注一个核心能力。本 Skill 专注于**代码审查**,不应扩展到:
|
||||
- 代码生成
|
||||
- 项目初始化
|
||||
- 部署配置
|
||||
|
||||
### 版本管理
|
||||
|
||||
- 在 reference 文件中标注框架/语言版本
|
||||
- 更新时在 commit 中说明版本变化
|
||||
- 过时内容应更新而非删除(除非完全废弃)
|
||||
|
||||
### 内容质量
|
||||
|
||||
- 所有建议应有依据(官方文档、最佳实践)
|
||||
- 避免主观偏好(如代码风格),专注于客观问题
|
||||
- 优先覆盖常见陷阱和安全问题
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 如何测试我的更改?
|
||||
|
||||
将修改后的 Skill 复制到 `~/.claude/skills/` 目录,然后在 Claude Code 中测试:
|
||||
```bash
|
||||
cp -r code-review-skill ~/.claude/skills/code-review-skill
|
||||
```
|
||||
|
||||
### Q: 我应该更新 SKILL.md 还是 reference 文件?
|
||||
|
||||
- **SKILL.md**:只修改索引表或核心原则
|
||||
- **reference/*.md**:添加/更新具体的语言或框架内容
|
||||
|
||||
### Q: 如何处理过时的内容?
|
||||
|
||||
1. 标注版本变化(如 "React 18 → React 19")
|
||||
2. 保留旧版本内容(如果仍有用户使用)
|
||||
3. 在 checklist 中更新相关项
|
||||
|
||||
---
|
||||
|
||||
## 问题咨询
|
||||
|
||||
如有任何问题,欢迎在 GitHub Issues 中提问。
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 tt-a1i
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Executable
+658
@@ -0,0 +1,658 @@
|
||||
<div align="center">
|
||||
|
||||
<h1>🔍 Code Review Skill</h1>
|
||||
|
||||
<p>
|
||||
<strong>A comprehensive, modular code review skill for Claude Code</strong><br/>
|
||||
<strong>面向 Claude Code 的全面模块化代码审查技能</strong>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/awesome-skills/code-review-skill/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/badge/License-MIT-22c55e?style=flat-square" alt="License: MIT"/>
|
||||
</a>
|
||||
<img src="https://img.shields.io/badge/Claude_Code-Skill-7c3aed?style=flat-square&logo=anthropic&logoColor=white" alt="Claude Code Skill"/>
|
||||
<img src="https://img.shields.io/badge/Total_Lines-16%2C000%2B-3b82f6?style=flat-square" alt="16000+ lines"/>
|
||||
<img src="https://img.shields.io/badge/Languages-20%2B-f59e0b?style=flat-square" alt="20+ languages"/>
|
||||
<img src="https://img.shields.io/badge/PRs-Welcome-ec4899?style=flat-square" alt="PRs Welcome"/>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="#english">English</a>
|
||||
·
|
||||
<a href="#chinese">中文</a>
|
||||
·
|
||||
<a href="./CONTRIBUTING.md">Contributing</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<a name="english"></a>
|
||||
|
||||
## English
|
||||
|
||||
### What is this?
|
||||
|
||||
**Code Review Skill** is a production-ready skill for [Claude Code](https://claude.ai/code) that transforms AI-assisted code review from vague suggestions into a **structured, consistent, and expert-level** process.
|
||||
|
||||
It covers **20+ languages and frameworks** with over **16,000 lines** of carefully curated review guidelines — loaded progressively to minimize context window usage.
|
||||
|
||||
---
|
||||
|
||||
### ✨ Key Features
|
||||
|
||||
- **Progressive Disclosure** — Core skill is ~190 lines; language guides (~200–1,000 lines each) load only when needed.
|
||||
- **Four-Phase Review Process** — Structured workflow from understanding scope to delivering clear feedback.
|
||||
- **Severity Labeling** — Every finding is categorized: `blocking` · `important` · `nit` · `suggestion` · `learning` · `praise`
|
||||
- **Security-First** — Dedicated security checklists per language ecosystem.
|
||||
- **Collaborative Tone** — Questions over commands, suggestions over mandates.
|
||||
- **Automation Awareness** — Clearly separates what human review should catch vs. what linters handle.
|
||||
|
||||
---
|
||||
|
||||
### 🌐 Supported Languages & Frameworks
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th>Technology</th>
|
||||
<th>Guide</th>
|
||||
<th>Lines</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="6"><strong>Frontend</strong></td>
|
||||
<td>⚛️ React 19 / Next.js / TanStack Query v5</td>
|
||||
<td><code>reference/react.md</code></td>
|
||||
<td>~870</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>💚 Vue 3.5 + Composition API</td>
|
||||
<td><code>reference/vue.md</code></td>
|
||||
<td>~920</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🔮 Angular 17+ / Signals / Zoneless</td>
|
||||
<td><code>reference/angular.md</code></td>
|
||||
<td>~420</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🔥 Svelte 5 / SvelteKit</td>
|
||||
<td><code>reference/svelte.md</code></td>
|
||||
<td>~1,060</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🎨 CSS / Less / Sass</td>
|
||||
<td><code>reference/css-less-sass.md</code></td>
|
||||
<td>~660</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🔷 TypeScript</td>
|
||||
<td><code>reference/typescript.md</code></td>
|
||||
<td>~540</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="9"><strong>Backend</strong></td>
|
||||
<td>☕ Java 17/21 + Spring Boot 3</td>
|
||||
<td><code>reference/java.md</code></td>
|
||||
<td>~410</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>⚡ FastAPI</td>
|
||||
<td><code>reference/fastapi.md</code></td>
|
||||
<td>~590</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>PHP 8.x</td>
|
||||
<td><code>reference/php.md</code></td>
|
||||
<td>~700</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>📦 NestJS</td>
|
||||
<td><code>reference/nestjs.md</code></td>
|
||||
<td>~590</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🐍 Django / DRF</td>
|
||||
<td><code>reference/django.md</code></td>
|
||||
<td>~1,030</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🐹 Go</td>
|
||||
<td><code>reference/go.md</code></td>
|
||||
<td>~990</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🦀 Rust</td>
|
||||
<td><code>reference/rust.md</code></td>
|
||||
<td>~840</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>💻 C# / .NET 8</td>
|
||||
<td><code>reference/csharp.md</code></td>
|
||||
<td>~520</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🐍 Python</td>
|
||||
<td><code>reference/python.md</code></td>
|
||||
<td>~1,070</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="5"><strong>Mobile / Systems</strong></td>
|
||||
<td>📱 Kotlin / Android</td>
|
||||
<td><code>reference/kotlin.md</code></td>
|
||||
<td>~1,020</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🍎 Swift / SwiftUI</td>
|
||||
<td><code>reference/swift.md</code></td>
|
||||
<td>~930</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>⚙️ C</td>
|
||||
<td><code>reference/c.md</code></td>
|
||||
<td>~290</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🔩 C++</td>
|
||||
<td><code>reference/cpp.md</code></td>
|
||||
<td>~390</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🖥️ Qt Framework</td>
|
||||
<td><code>reference/qt.md</code></td>
|
||||
<td>~190</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="3"><strong>Cross-Cutting</strong></td>
|
||||
<td>🏛️ Architecture Design Review</td>
|
||||
<td><code>reference/architecture-review-guide.md</code></td>
|
||||
<td>~470</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>⚡ Performance Review</td>
|
||||
<td><code>reference/performance-review-guide.md</code></td>
|
||||
<td>~820</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🔍 Universal Quality Anti-Patterns</td>
|
||||
<td><code>reference/code-quality-universal.md</code></td>
|
||||
<td>~490</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
### 🔄 The Four-Phase Review Process
|
||||
|
||||
```
|
||||
Phase 1 - Context Gathering
|
||||
Understand PR scope, linked issues, and intent
|
||||
|
|
||||
v
|
||||
Phase 2 - High-Level Review
|
||||
Architecture - Performance impact - Test strategy
|
||||
|
|
||||
v
|
||||
Phase 3 - Line-by-Line Analysis
|
||||
Logic - Security - Maintainability - Edge cases
|
||||
|
|
||||
v
|
||||
Phase 4 - Summary & Decision
|
||||
Structured feedback - Approval status - Action items
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🏷️ Severity Labels
|
||||
|
||||
| Label | Meaning |
|
||||
|-------|---------|
|
||||
| 🔴 `blocking` | Must be fixed before merge |
|
||||
| 🟠 `important` | Should be fixed; may block depending on context |
|
||||
| 🟡 `nit` | Minor style or preference issue |
|
||||
| 🔵 `suggestion` | Optional improvement worth considering |
|
||||
| 📚 `learning` | Educational note for the author |
|
||||
| 🌟 `praise` | Explicitly highlight great work |
|
||||
|
||||
---
|
||||
|
||||
### 📁 Repository Structure
|
||||
|
||||
```
|
||||
code-review-skill/
|
||||
|
|
||||
+-- SKILL.md # Core skill - loaded on activation (~190 lines)
|
||||
+-- README.md
|
||||
+-- LICENSE
|
||||
+-- CONTRIBUTING.md
|
||||
|
|
||||
+-- reference/ # On-demand language guides
|
||||
| +-- react.md # React 19 / Next.js / TanStack Query v5
|
||||
| +-- vue.md # Vue 3.5 Composition API
|
||||
| +-- angular.md # Angular 17+ / Signals / Zoneless
|
||||
| +-- svelte.md # Svelte 5 / SvelteKit
|
||||
| +-- rust.md # Rust ownership, async/await, unsafe
|
||||
| +-- typescript.md # TypeScript strict mode, generics, ESLint
|
||||
| +-- nestjs.md # NestJS DI, Guards, Interceptors, DTOs
|
||||
| +-- java.md # Java 17/21 & Spring Boot 3
|
||||
| +-- php.md # PHP 8.x types, PDO, security, Composer
|
||||
| +-- python.md # Python async, typing, pytest
|
||||
| +-- django.md # Django / DRF security, serializers, async
|
||||
| +-- fastapi.md # FastAPI Depends, Pydantic v2, async, test-driven verification
|
||||
| +-- go.md # Go goroutines, channels, context, interfaces
|
||||
| +-- kotlin.md # Kotlin / Android coroutines, Compose, Flow
|
||||
| +-- swift.md # Swift 5.9+/6, SwiftUI, concurrency, optionals
|
||||
| +-- csharp.md # C# 12 / .NET 8, EF Core, ASP.NET Core
|
||||
| +-- c.md # C memory safety, UB, error handling
|
||||
| +-- cpp.md # C++ RAII, move semantics, exception safety
|
||||
| +-- qt.md # Qt object model, signals/slots, GUI perf
|
||||
| +-- css-less-sass.md # CSS/Less/Sass variables, responsive design
|
||||
| +-- architecture-review-guide.md # SOLID, anti-patterns, coupling/cohesion
|
||||
| +-- code-quality-universal.md # Reuse audit, parameter sprawl, TOCTOU, no-op updates
|
||||
| +-- performance-review-guide.md # Core Web Vitals, N+1, memory leaks
|
||||
| +-- security-review-guide.md # Security checklist (all languages)
|
||||
| +-- common-bugs-checklist.md # Language-specific bug patterns
|
||||
| +-- code-review-best-practices.md # Communication & process guidelines
|
||||
|
|
||||
+-- assets/
|
||||
| +-- review-checklist.md # Quick reference checklist
|
||||
| +-- pr-review-template.md # PR review comment template
|
||||
|
|
||||
+-- scripts/
|
||||
+-- pr-analyzer.py # PR complexity analyzer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🚀 Installation
|
||||
|
||||
**Clone to your Claude Code skills directory:**
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
git clone https://github.com/awesome-skills/code-review-skill.git \
|
||||
~/.claude/skills/code-review-skill
|
||||
|
||||
# Windows (PowerShell)
|
||||
git clone https://github.com/awesome-skills/code-review-skill.git `
|
||||
"$env:USERPROFILE\.claude\skills\code-review-skill"
|
||||
```
|
||||
|
||||
**Or add to an existing plugin:**
|
||||
|
||||
```bash
|
||||
cp -r code-review-skill ~/.claude/plugins/your-plugin/skills/code-review/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 💡 Usage
|
||||
|
||||
Once installed, activate the skill in your Claude Code session:
|
||||
|
||||
```
|
||||
Use code-review-skill to review this PR
|
||||
```
|
||||
|
||||
Or create a custom slash command in `.claude/commands/`:
|
||||
|
||||
```markdown
|
||||
<!-- .claude/commands/review.md -->
|
||||
Use code-review-skill to perform a thorough review of the changes in this PR.
|
||||
Focus on: security, performance, and maintainability.
|
||||
```
|
||||
|
||||
**Example prompts:**
|
||||
|
||||
| Prompt | What happens |
|
||||
|--------|-------------|
|
||||
| `Review this React component` | Loads `react.md` - checks hooks, Server Components, Suspense patterns |
|
||||
| `Review this Java PR` | Loads `java.md` - checks virtual threads, JPA, Spring Boot 3 patterns |
|
||||
| `Security review of this Go service` | Loads `go.md` + `security-review-guide.md` |
|
||||
| `Architecture review` | Loads `architecture-review-guide.md` - SOLID, anti-patterns, coupling |
|
||||
| `Performance review` | Loads `performance-review-guide.md` - Web Vitals, N+1, complexity |
|
||||
|
||||
---
|
||||
|
||||
### 🔬 Highlights by Language
|
||||
|
||||
<details>
|
||||
<summary><strong>⚛️ React 19</strong></summary>
|
||||
|
||||
- `useActionState` - Unified form state management
|
||||
- `useFormStatus` - Access parent form status without prop drilling
|
||||
- `useOptimistic` - Optimistic UI updates with automatic rollback
|
||||
- Server Components & Server Actions patterns (Next.js 15+)
|
||||
- Suspense boundary design, Error Boundary integration, streaming SSR
|
||||
- `use()` Hook for consuming Promises
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>☕ Java & Spring Boot 3</strong></summary>
|
||||
|
||||
- **Java 17/21**: Records, Pattern Matching for Switch, Text Blocks, Sealed Classes
|
||||
- **Virtual Threads** (Project Loom): High-throughput I/O patterns
|
||||
- **Spring Boot 3**: Constructor injection, `@ConfigurationProperties`, `ProblemDetail`
|
||||
- **JPA Performance**: Solving N+1, correct `equals`/`hashCode` on Entities
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>🦀 Rust</strong></summary>
|
||||
|
||||
- Ownership patterns and common pitfalls
|
||||
- `unsafe` code review requirements (mandatory `SAFETY` comments)
|
||||
- Async/await - avoiding blocking in async context, cancellation safety
|
||||
- Error handling: `thiserror` for libraries, `anyhow` for applications
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>🐹 Go</strong></summary>
|
||||
|
||||
- Goroutine lifecycle management and leak prevention
|
||||
- Channel patterns, select usage
|
||||
- `context.Context` propagation
|
||||
- Interface design (accept interfaces, return structs)
|
||||
- Error wrapping with `%w`
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>⚙️ C / C++</strong></summary>
|
||||
|
||||
- **C**: Pointer/buffer safety, undefined behavior, resource cleanup, integer overflow
|
||||
- **C++**: RAII ownership, Rule of 0/3/5, move semantics, exception safety, `noexcept`
|
||||
- **Qt**: Object parent/child memory model, thread-safe signal/slot connections, GUI performance
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### 🤝 Contributing
|
||||
|
||||
Contributions are welcome! See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
|
||||
|
||||
**Ideas:**
|
||||
- New language guides (Ruby, Elixir, Scala...)
|
||||
- Framework-specific guides (Laravel, Spring WebFlux...)
|
||||
- Additional checklists and templates
|
||||
- Translations of core documentation
|
||||
|
||||
---
|
||||
|
||||
### 📄 License
|
||||
|
||||
MIT © [awesome-skills](https://github.com/awesome-skills)
|
||||
|
||||
---
|
||||
|
||||
<a name="chinese"></a>
|
||||
|
||||
## 中文
|
||||
|
||||
### 这是什么?
|
||||
|
||||
**Code Review Skill** 是专为 [Claude Code](https://claude.ai/code) 打造的生产级代码审查技能,将 AI 辅助的代码审查从模糊建议转变为**结构化、一致且专业级**的流程。
|
||||
|
||||
覆盖 **20+ 种语言和框架**,拥有超过 **16,000 行**精心整理的代码审查指南——按需加载,最大程度减少上下文占用。
|
||||
|
||||
---
|
||||
|
||||
### ✨ 核心特性
|
||||
|
||||
- **渐进式加载** — 核心技能仅 ~190 行,各语言指南(每份 200–1,000 行)仅在需要时才加载。
|
||||
- **四阶段审查流程** — 从理解 PR 范围到输出清晰反馈,每一步都有规可循。
|
||||
- **严重性标记** — 每条发现均分级:`blocking` · `important` · `nit` · `suggestion` · `learning` · `praise`
|
||||
- **安全优先** — 每个语言生态均配备专属安全检查清单。
|
||||
- **协作式语气** — 以提问替代命令,以建议替代指令。
|
||||
- **自动化感知** — 明确区分人工审查应关注的内容与 linter 自动处理的内容。
|
||||
|
||||
---
|
||||
|
||||
### 🌐 支持的语言与框架
|
||||
|
||||
| 分类 | 技术栈 | 指南文件 | 行数 |
|
||||
|------|--------|----------|------|
|
||||
| **前端** | ⚛️ React 19 / Next.js / TanStack Query v5 | `reference/react.md` | ~870 |
|
||||
| | 💚 Vue 3.5 Composition API | `reference/vue.md` | ~920 |
|
||||
| | 🔮 Angular 17+ / Signals / Zoneless | `reference/angular.md` | ~420 |
|
||||
| | 🔥 Svelte 5 / SvelteKit | `reference/svelte.md` | ~1,060 |
|
||||
| | 🎨 CSS / Less / Sass | `reference/css-less-sass.md` | ~660 |
|
||||
| | 🔷 TypeScript | `reference/typescript.md` | ~540 |
|
||||
| **后端** | ☕ Java 17/21 + Spring Boot 3 | `reference/java.md` | ~410 |
|
||||
| | ⚡ FastAPI | `reference/fastapi.md` | ~590 |
|
||||
| | PHP 8.x | `reference/php.md` | ~700 |
|
||||
| | 📦 NestJS | `reference/nestjs.md` | ~590 |
|
||||
| | 🐍 Django / DRF | `reference/django.md` | ~1,030 |
|
||||
| | 🐍 Python | `reference/python.md` | ~1,070 |
|
||||
| | 🐹 Go | `reference/go.md` | ~990 |
|
||||
| | 🦀 Rust | `reference/rust.md` | ~840 |
|
||||
| | 💻 C# / .NET 8 | `reference/csharp.md` | ~520 |
|
||||
| **移动 / 系统** | 📱 Kotlin / Android | `reference/kotlin.md` | ~1,020 |
|
||||
| | 🍎 Swift / SwiftUI | `reference/swift.md` | ~930 |
|
||||
| | ⚙️ C | `reference/c.md` | ~290 |
|
||||
| | 🔩 C++ | `reference/cpp.md` | ~390 |
|
||||
| | 🖥️ Qt 框架 | `reference/qt.md` | ~190 |
|
||||
| **架构** | 🏛️ 架构设计审查 | `reference/architecture-review-guide.md` | ~470 |
|
||||
| | ⚡ 性能审查 | `reference/performance-review-guide.md` | ~820 |
|
||||
| | 🔍 通用质量反模式 | `reference/code-quality-universal.md` | ~490 |
|
||||
|
||||
---
|
||||
|
||||
### 🔄 四阶段审查流程
|
||||
|
||||
```
|
||||
阶段一 - 上下文收集
|
||||
理解 PR 范围、关联 Issue 和实现意图
|
||||
|
|
||||
v
|
||||
阶段二 - 高层级审查
|
||||
架构设计 - 性能影响 - 测试策略
|
||||
|
|
||||
v
|
||||
阶段三 - 逐行深度分析
|
||||
逻辑正确性 - 安全漏洞 - 可维护性 - 边界情况
|
||||
|
|
||||
v
|
||||
阶段四 - 总结与决策
|
||||
结构化反馈 - 审批状态 - 后续行动项
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🏷️ 严重性标记说明
|
||||
|
||||
| 标记 | 含义 |
|
||||
|------|------|
|
||||
| 🔴 `blocking` | 合并前必须修复 |
|
||||
| 🟠 `important` | 应当修复,视情况可能阻塞合并 |
|
||||
| 🟡 `nit` | 风格或偏好上的小问题 |
|
||||
| 🔵 `suggestion` | 值得考虑的可选优化 |
|
||||
| 📚 `learning` | 给作者的教育性说明 |
|
||||
| 🌟 `praise` | 明确表扬优秀代码 |
|
||||
|
||||
---
|
||||
|
||||
### 📁 仓库结构
|
||||
|
||||
```
|
||||
code-review-skill/
|
||||
|
|
||||
+-- SKILL.md # 核心技能,激活时加载(~190 行)
|
||||
+-- README.md
|
||||
+-- LICENSE
|
||||
+-- CONTRIBUTING.md
|
||||
|
|
||||
+-- reference/ # 按需加载的语言指南
|
||||
| +-- react.md # React 19 / Next.js / TanStack Query v5
|
||||
| +-- vue.md # Vue 3.5 组合式 API
|
||||
| +-- angular.md # Angular 17+ / Signals / Zoneless
|
||||
| +-- svelte.md # Svelte 5 / SvelteKit
|
||||
| +-- rust.md # Rust 所有权、async/await、unsafe
|
||||
| +-- typescript.md # TypeScript strict 模式、泛型、ESLint
|
||||
| +-- nestjs.md # NestJS 依赖注入、Guard、Interceptor、DTO
|
||||
| +-- java.md # Java 17/21 & Spring Boot 3
|
||||
| +-- php.md # PHP 8.x 类型、PDO、安全、Composer
|
||||
| +-- python.md # Python async、类型注解、pytest
|
||||
| +-- django.md # Django / DRF 安全、Serializer、异步视图
|
||||
| +-- fastapi.md # FastAPI Depends、Pydantic v2、异步、测试驱动验证
|
||||
| +-- go.md # Go goroutine、channel、context、接口
|
||||
| +-- kotlin.md # Kotlin / Android 协程、Compose、Flow
|
||||
| +-- swift.md # Swift 5.9+/6、SwiftUI、并发、可选值
|
||||
| +-- csharp.md # C# 12 / .NET 8、EF Core、ASP.NET Core
|
||||
| +-- c.md # C 内存安全、UB、错误处理
|
||||
| +-- cpp.md # C++ RAII、移动语义、异常安全
|
||||
| +-- qt.md # Qt 对象模型、信号/槽、GUI 性能
|
||||
| +-- css-less-sass.md # CSS/Less/Sass 变量、响应式设计
|
||||
| +-- architecture-review-guide.md # SOLID、反模式、耦合度分析
|
||||
| +-- code-quality-universal.md # 复用审查、参数膨胀、抽象泄漏、TOCTOU
|
||||
| +-- performance-review-guide.md # Core Web Vitals、N+1、内存泄漏
|
||||
| +-- security-review-guide.md # 安全审查清单(全语言通用)
|
||||
| +-- common-bugs-checklist.md # 各语言常见 Bug 模式
|
||||
| +-- code-review-best-practices.md # 沟通与流程最佳实践
|
||||
|
|
||||
+-- assets/
|
||||
| +-- review-checklist.md # 快速参考清单
|
||||
| +-- pr-review-template.md # PR 审查评论模板
|
||||
|
|
||||
+-- scripts/
|
||||
+-- pr-analyzer.py # PR 复杂度分析工具
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🚀 安装方法
|
||||
|
||||
**克隆到 Claude Code skills 目录:**
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
git clone https://github.com/awesome-skills/code-review-skill.git \
|
||||
~/.claude/skills/code-review-skill
|
||||
|
||||
# Windows(PowerShell)
|
||||
git clone https://github.com/awesome-skills/code-review-skill.git `
|
||||
"$env:USERPROFILE\.claude\skills\code-review-skill"
|
||||
```
|
||||
|
||||
**或添加到现有插件:**
|
||||
|
||||
```bash
|
||||
cp -r code-review-skill ~/.claude/plugins/your-plugin/skills/code-review/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 💡 使用方式
|
||||
|
||||
安装后,在 Claude Code 会话中激活技能:
|
||||
|
||||
```
|
||||
Use code-review-skill to review this PR
|
||||
```
|
||||
|
||||
或在 `.claude/commands/` 中创建自定义斜杠命令:
|
||||
|
||||
```markdown
|
||||
<!-- .claude/commands/review.md -->
|
||||
使用 code-review-skill 对这次 PR 的变更进行全面审查。
|
||||
重点关注:安全性、性能和可维护性。
|
||||
```
|
||||
|
||||
**示例提示词:**
|
||||
|
||||
| 提示词 | 效果 |
|
||||
|--------|------|
|
||||
| `审查这个 React 组件` | 加载 `react.md`,检查 Hooks、Server Components、Suspense |
|
||||
| `审查这个 Java PR` | 加载 `java.md`,检查虚拟线程、JPA、Spring Boot 3 |
|
||||
| `对这个 Go 服务进行安全审查` | 加载 `go.md` + `security-review-guide.md` |
|
||||
| `架构审查` | 加载 `architecture-review-guide.md`,检查 SOLID 与反模式 |
|
||||
| `性能审查` | 加载 `performance-review-guide.md`,分析 Web Vitals、N+1 等 |
|
||||
|
||||
---
|
||||
|
||||
### 🔬 各语言核心内容
|
||||
|
||||
<details>
|
||||
<summary><strong>⚛️ React 19</strong></summary>
|
||||
|
||||
- `useActionState` — 统一的表单状态管理
|
||||
- `useFormStatus` — 无需 props 透传即可访问父表单状态
|
||||
- `useOptimistic` — 带自动回滚的乐观 UI 更新
|
||||
- Server Components & Server Actions(Next.js 15+)
|
||||
- Suspense 边界设计、Error Boundary 集成、流式 SSR
|
||||
- `use()` Hook 消费 Promise
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>☕ Java & Spring Boot 3</strong></summary>
|
||||
|
||||
- **Java 17/21**:Records、Switch 模式匹配、文本块、Sealed Classes
|
||||
- **虚拟线程**(Project Loom):高吞吐量 I/O 模式
|
||||
- **Spring Boot 3**:构造器注入、`@ConfigurationProperties`、`ProblemDetail`
|
||||
- **JPA 性能**:解决 N+1、Entity 正确的 `equals`/`hashCode` 实现
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>🦀 Rust</strong></summary>
|
||||
|
||||
- 所有权模式与常见陷阱
|
||||
- `unsafe` 代码审查要求(必须有 `SAFETY` 注释)
|
||||
- Async/await — 避免在异步上下文中阻塞,取消安全性
|
||||
- 错误处理:库用 `thiserror`,应用用 `anyhow`
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>🐹 Go</strong></summary>
|
||||
|
||||
- Goroutine 生命周期管理与泄漏预防
|
||||
- Channel 模式、select 用法
|
||||
- `context.Context` 传播规范
|
||||
- 接口设计原则(接受接口,返回结构体)
|
||||
- 错误包装:使用 `%w`
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>⚙️ C / C++</strong></summary>
|
||||
|
||||
- **C**:指针/缓冲区安全、未定义行为、资源清理、整数溢出
|
||||
- **C++**:RAII 所有权、Rule of 0/3/5、移动语义、异常安全、`noexcept`
|
||||
- **Qt**:父子内存模型、线程安全的信号/槽连接、GUI 性能优化
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### 🤝 参与贡献
|
||||
|
||||
欢迎贡献!请查阅 [CONTRIBUTING.md](./CONTRIBUTING.md) 了解规范。
|
||||
|
||||
**可贡献方向:**
|
||||
- 新增语言指南(Ruby、Elixir、Scala...)
|
||||
- 框架专属指南(Laravel、Spring WebFlux...)
|
||||
- 补充检查清单和审查模板
|
||||
- 核心文档的多语言翻译
|
||||
|
||||
---
|
||||
|
||||
### 📄 开源协议
|
||||
|
||||
MIT © [awesome-skills](https://github.com/awesome-skills)
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
Made with ❤️ for developers who care about code quality
|
||||
</div>
|
||||
Executable
+220
@@ -0,0 +1,220 @@
|
||||
---
|
||||
name: code-review-skill
|
||||
description: |
|
||||
Provides comprehensive code review guidance for React 19, Vue 3, Angular 17+, Svelte 5, Rust, TypeScript, Java, PHP, Python, Django, Go, C#/.NET, Kotlin, Swift, NestJS, C/C++, and more.
|
||||
Helps catch bugs, improve code quality, and give constructive feedback.
|
||||
Use when: reviewing pull requests, conducting PR reviews, code review, reviewing code changes,
|
||||
establishing review standards, mentoring developers, architecture reviews, security audits,
|
||||
checking code quality, finding bugs, giving feedback on code.
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Bash # 运行 lint/test/build 命令验证代码质量
|
||||
- WebFetch # 查阅最新文档和最佳实践
|
||||
---
|
||||
|
||||
# Code Review Skill
|
||||
|
||||
Transform code reviews from gatekeeping to knowledge sharing through constructive feedback, systematic analysis, and collaborative improvement.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Reviewing pull requests and code changes
|
||||
- Establishing code review standards for teams
|
||||
- Mentoring junior developers through reviews
|
||||
- Conducting architecture reviews
|
||||
- Creating review checklists and guidelines
|
||||
- Improving team collaboration
|
||||
- Reducing code review cycle time
|
||||
- Maintaining code quality standards
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. The Review Mindset
|
||||
|
||||
**Goals of Code Review:**
|
||||
- Catch bugs and edge cases
|
||||
- Ensure code maintainability
|
||||
- Share knowledge across team
|
||||
- Enforce coding standards
|
||||
- Improve design and architecture
|
||||
- Build team culture
|
||||
|
||||
**Not the Goals:**
|
||||
- Show off knowledge
|
||||
- Nitpick formatting (use linters)
|
||||
- Block progress unnecessarily
|
||||
- Rewrite to your preference
|
||||
|
||||
### 2. Effective Feedback
|
||||
|
||||
**Good Feedback is:**
|
||||
- Specific and actionable
|
||||
- Educational, not judgmental
|
||||
- Focused on the code, not the person
|
||||
- Balanced (praise good work too)
|
||||
- Prioritized (critical vs nice-to-have)
|
||||
|
||||
```markdown
|
||||
❌ Bad: "This is wrong."
|
||||
✅ Good: "This could cause a race condition when multiple users
|
||||
access simultaneously. Consider using a mutex here."
|
||||
|
||||
❌ Bad: "Why didn't you use X pattern?"
|
||||
✅ Good: "Have you considered the Repository pattern? It would
|
||||
make this easier to test. Here's an example: [link]"
|
||||
|
||||
❌ Bad: "Rename this variable."
|
||||
✅ Good: "[nit] Consider `userCount` instead of `uc` for
|
||||
clarity. Not blocking if you prefer to keep it."
|
||||
```
|
||||
|
||||
### 3. Review Scope
|
||||
|
||||
**What to Review:**
|
||||
- Logic correctness and edge cases
|
||||
- Security vulnerabilities
|
||||
- Performance implications
|
||||
- Test coverage and quality
|
||||
- Error handling
|
||||
- Documentation and comments
|
||||
- API design and naming
|
||||
- Architectural fit
|
||||
|
||||
**What Not to Review Manually:**
|
||||
- Code formatting (use Prettier, Black, etc.)
|
||||
- Import organization
|
||||
- Linting violations
|
||||
- Simple typos
|
||||
|
||||
## Review Process
|
||||
|
||||
### Phase 1: Context Gathering (2-3 minutes)
|
||||
|
||||
Before diving into code, understand:
|
||||
1. Read PR description and linked issue
|
||||
2. Check PR size (>400 lines? Ask to split)
|
||||
3. Review CI/CD status (tests passing?)
|
||||
4. Understand the business requirement
|
||||
5. Note any relevant architectural decisions
|
||||
|
||||
> For large diffs, pipe the diff through [`scripts/pr-analyzer.py`](scripts/pr-analyzer.py) (`git diff main...HEAD | python scripts/pr-analyzer.py`) to triage complexity and get a suggested review approach before reading.
|
||||
|
||||
### Phase 2: High-Level Review (5-10 minutes)
|
||||
|
||||
1. **Architecture & Design** - Does the solution fit the problem?
|
||||
- For significant changes, consult [Architecture Review Guide](reference/architecture-review-guide.md)
|
||||
- Check: SOLID principles, coupling/cohesion, anti-patterns
|
||||
2. **Performance Assessment** - Are there performance concerns?
|
||||
- For performance-critical code, consult [Performance Review Guide](reference/performance-review-guide.md)
|
||||
- Check: Algorithm complexity, N+1 queries, memory usage
|
||||
3. **File Organization** - Are new files in the right places?
|
||||
4. **Testing Strategy** - Are there tests covering edge cases?
|
||||
|
||||
### Phase 3: Line-by-Line Review (10-20 minutes)
|
||||
|
||||
For each file, check:
|
||||
- **Logic & Correctness** - Edge cases, off-by-one, null checks, race conditions
|
||||
- **Security** - Input validation, injection risks, XSS, sensitive data
|
||||
- **Performance** - N+1 queries, unnecessary loops, memory leaks
|
||||
- **Maintainability** - Clear names, single responsibility, comments
|
||||
- **Reuse** - Before accepting new code, search for existing utilities/helpers that could replace it. Check adjacent files and shared modules for similar patterns. See [Universal Quality Guide](reference/code-quality-universal.md) for anti-patterns like parameter sprawl, leaky abstractions, nested conditionals, stringly-typed code, TOCTOU, and no-op updates.
|
||||
|
||||
### Phase 4: Summary & Decision (2-3 minutes)
|
||||
|
||||
1. Summarize key concerns
|
||||
2. Highlight what you liked
|
||||
3. Make clear decision:
|
||||
- ✅ Approve
|
||||
- 💬 Comment (minor suggestions)
|
||||
- 🔄 Request Changes (must address)
|
||||
4. Offer to pair if complex
|
||||
|
||||
## Review Techniques
|
||||
|
||||
### Technique 1: The Checklist Method
|
||||
|
||||
Use checklists for consistent reviews. See [Security Review Guide](reference/security-review-guide.md) for comprehensive security checklist.
|
||||
|
||||
### Technique 2: The Question Approach
|
||||
|
||||
Instead of stating problems, ask questions:
|
||||
|
||||
```markdown
|
||||
❌ "This will fail if the list is empty."
|
||||
✅ "What happens if `items` is an empty array?"
|
||||
|
||||
❌ "You need error handling here."
|
||||
✅ "How should this behave if the API call fails?"
|
||||
```
|
||||
|
||||
### Technique 3: Suggest, Don't Command
|
||||
|
||||
Use collaborative language:
|
||||
|
||||
```markdown
|
||||
❌ "You must change this to use async/await"
|
||||
✅ "Suggestion: async/await might make this more readable. What do you think?"
|
||||
|
||||
❌ "Extract this into a function"
|
||||
✅ "This logic appears in 3 places. Would it make sense to extract it?"
|
||||
```
|
||||
|
||||
### Technique 4: Differentiate Severity
|
||||
|
||||
Use labels to indicate priority:
|
||||
|
||||
- 🔴 `[blocking]` - Must fix before merge
|
||||
- 🟡 `[important]` - Should fix, discuss if disagree
|
||||
- 🟢 `[nit]` - Nice to have, not blocking
|
||||
- 💡 `[suggestion]` - Alternative approach to consider
|
||||
- 📚 `[learning]` - Educational comment, no action needed
|
||||
- 🎉 `[praise]` - Good work, keep it up!
|
||||
|
||||
**Severity levels:** 🔴 / 🟡 / 🟢 are the three severity tiers used as the standard across all guides in this skill — 🔴 blocks the merge, 🟡 should be addressed, 🟢 is optional. The remaining markers (💡 / 📚 / 🎉) are non-blocking annotations.
|
||||
|
||||
## Language-Specific Guides
|
||||
|
||||
根据审查的代码语言,查阅对应的详细指南:
|
||||
|
||||
| Language/Framework | Reference File | Key Topics |
|
||||
|-------------------|----------------|------------|
|
||||
| **React** | [React Guide](reference/react.md) | Hooks, useEffect, React 19 Actions, RSC, Suspense, TanStack Query v5 |
|
||||
| **Vue 3** | [Vue Guide](reference/vue.md) | Composition API, 响应性系统, Props/Emits, Watchers, Composables |
|
||||
| **Angular 17+** | [Angular Guide](reference/angular.md) | Signals, Standalone 组件, RxJS, Zoneless 变更检测, 模板优化 |
|
||||
| **Rust** | [Rust Guide](reference/rust.md) | 所有权/借用, Unsafe 审查, 异步代码, 取消安全性, 错误处理 |
|
||||
| **TypeScript** | [TypeScript Guide](reference/typescript.md) | 类型安全, async/await, 不可变性 |
|
||||
| **Python** | [Python Guide](reference/python.md) | 可变默认参数, 异常处理, 类属性 |
|
||||
| **Django / DRF** | [Django Guide](reference/django.md) | 安全审查, N+1 查询, Serializer 反模式, ViewSet, 异步视图 |
|
||||
| **FastAPI** | [FastAPI Guide](reference/fastapi.md) | Depends, Pydantic v2 validation, async correctness, sessions/N+1, auth vs authorization, test-driven verification |
|
||||
| **Java** | [Java Guide](reference/java.md) | Java 17/21 新特性, Spring Boot 3, 虚拟线程, Stream/Optional |
|
||||
| **PHP** | [PHP Guide](reference/php.md) | PHP 8.x type system, PDO, security review, Composer, PHPUnit/PHPStan |
|
||||
| **C# / .NET** | [C# Guide](reference/csharp.md) | C# 12 特性, 异步编程, EF Core 性能, ASP.NET Core, LINQ |
|
||||
| **Go** | [Go Guide](reference/go.md) | 错误处理, goroutine/channel, context, 接口设计 |
|
||||
| **Kotlin / Android** | [Kotlin Guide](reference/kotlin.md) | 协程, Flow, Jetpack Compose, 空安全, 内存泄漏, 架构模式 |
|
||||
| **Swift / SwiftUI** | [Swift Guide](reference/swift.md) | Optionals, Swift Concurrency, Sendable/actors, SwiftUI property wrappers, value vs reference types, API design |
|
||||
| **NestJS** | [NestJS Guide](reference/nestjs.md) | 依赖注入, 分层架构, DTO 验证, Guard/Interceptor, 循环依赖 |
|
||||
| **Svelte / SvelteKit** | [Svelte Guide](reference/svelte.md) | Runes, Load 函数, Form Actions, Store 迁移, SSR/CSR 边界 |
|
||||
| **C** | [C Guide](reference/c.md) | 指针/缓冲区, 内存安全, UB, 错误处理 |
|
||||
| **C++** | [C++ Guide](reference/cpp.md) | RAII, 生命周期, Rule of 0/3/5, 异常安全 |
|
||||
| **CSS/Less/Sass** | [CSS Guide](reference/css-less-sass.md) | 变量规范, !important, 性能优化, 响应式, 兼容性 |
|
||||
| **Qt** | [Qt Guide](reference/qt.md) | 对象模型, 信号/槽, 内存管理, 线程安全, 性能 |
|
||||
|
||||
## Cross-Cutting Guides
|
||||
|
||||
Language-agnostic patterns applicable to all code reviews:
|
||||
|
||||
| Topic | Reference File | Key Topics |
|
||||
|-------|----------------|------------|
|
||||
| **Universal Quality** | [Universal Quality Guide](reference/code-quality-universal.md) | Reuse audit, parameter sprawl, leaky abstractions, nested conditionals, stringly-typed code, TOCTOU, no-op updates, redundant state |
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Architecture Review Guide](reference/architecture-review-guide.md) - 架构设计审查指南(SOLID、反模式、耦合度)
|
||||
- [Performance Review Guide](reference/performance-review-guide.md) - 性能审查指南(Web Vitals、N+1、复杂度)
|
||||
- [Common Bugs Checklist](reference/common-bugs-checklist.md) - 按语言分类的常见错误清单
|
||||
- [Security Review Guide](reference/security-review-guide.md) - 安全审查指南
|
||||
- [Code Review Best Practices](reference/code-review-best-practices.md) - 代码审查最佳实践
|
||||
- [PR Review Template](assets/pr-review-template.md) - PR 审查评论模板
|
||||
- [Review Checklist](assets/review-checklist.md) - 快速参考清单
|
||||
@@ -0,0 +1,114 @@
|
||||
# PR Review Template
|
||||
|
||||
Copy and use this template for your code reviews.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
[Brief overview of what was reviewed - 1-2 sentences]
|
||||
|
||||
**PR Size:** [Small/Medium/Large] (~X lines)
|
||||
**Review Time:** [X minutes]
|
||||
|
||||
## Strengths
|
||||
|
||||
- [What was done well]
|
||||
- [Good patterns or approaches used]
|
||||
- [Improvements from previous code]
|
||||
|
||||
## Required Changes
|
||||
|
||||
🔴 **[blocking]** [Issue description]
|
||||
> [Code location or example]
|
||||
> [Suggested fix or explanation]
|
||||
|
||||
🔴 **[blocking]** [Issue description]
|
||||
> [Details]
|
||||
|
||||
## Important Suggestions
|
||||
|
||||
🟡 **[important]** [Issue description]
|
||||
> [Why this matters]
|
||||
> [Suggested approach]
|
||||
|
||||
## Minor Suggestions
|
||||
|
||||
🟢 **[nit]** [Minor improvement suggestion]
|
||||
|
||||
💡 **[suggestion]** [Alternative approach to consider]
|
||||
|
||||
## Learning Notes
|
||||
|
||||
📚 [Educational context worth sharing about X]
|
||||
|
||||
📚 [Background behind design decision Y]
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- [ ] No hardcoded secrets
|
||||
- [ ] Input validation present
|
||||
- [ ] Authorization checks in place
|
||||
- [ ] No SQL/XSS injection risks
|
||||
|
||||
## Test Coverage
|
||||
|
||||
- [ ] Unit tests added/updated
|
||||
- [ ] Edge cases covered
|
||||
- [ ] Error cases tested
|
||||
|
||||
## Verdict
|
||||
|
||||
**[ ] ✅ Approve** - Ready to merge
|
||||
**[ ] 💬 Comment** - Minor suggestions, can merge
|
||||
**[ ] 🔄 Request Changes** - Must address blocking issues
|
||||
|
||||
---
|
||||
|
||||
## Quick Copy Templates
|
||||
|
||||
### Blocking Issue
|
||||
```
|
||||
🔴 **[blocking]** [Title]
|
||||
|
||||
[Description of the issue]
|
||||
|
||||
**Location:** `file.ts:123`
|
||||
|
||||
**Suggested fix:**
|
||||
\`\`\`typescript
|
||||
// Your suggested code
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
### Important Suggestion
|
||||
```
|
||||
🟡 **[important]** [Title]
|
||||
|
||||
[Why this is important]
|
||||
|
||||
**Consider:**
|
||||
- Option A: [description]
|
||||
- Option B: [description]
|
||||
```
|
||||
|
||||
### Minor Suggestion
|
||||
```
|
||||
🟢 **[nit]** [Suggestion]
|
||||
|
||||
Not blocking, but consider [improvement].
|
||||
```
|
||||
|
||||
### Praise
|
||||
```
|
||||
🎉 **[praise]** Great work on [specific thing]!
|
||||
|
||||
[Why this is good]
|
||||
```
|
||||
|
||||
### Learning
|
||||
```
|
||||
📚 **[learning]** [Educational note]
|
||||
|
||||
For context, [X] works this way because [Y]. No action needed — just sharing.
|
||||
```
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
# Code Review Quick Checklist
|
||||
|
||||
Quick reference checklist for code reviews.
|
||||
|
||||
## Pre-Review (2 min)
|
||||
|
||||
- [ ] Read PR description and linked issue
|
||||
- [ ] Check PR size (<400 lines ideal)
|
||||
- [ ] Verify CI/CD status (tests passing?)
|
||||
- [ ] Understand the business requirement
|
||||
|
||||
## Architecture & Design (5 min)
|
||||
|
||||
- [ ] Solution fits the problem
|
||||
- [ ] Consistent with existing patterns
|
||||
- [ ] No simpler approach exists
|
||||
- [ ] Will it scale?
|
||||
- [ ] Changes in right location
|
||||
|
||||
## Logic & Correctness (10 min)
|
||||
|
||||
- [ ] Edge cases handled
|
||||
- [ ] Null/undefined checks present
|
||||
- [ ] Off-by-one errors checked
|
||||
- [ ] Race conditions considered
|
||||
- [ ] Error handling complete
|
||||
- [ ] Correct data types used
|
||||
|
||||
## Security (5 min)
|
||||
|
||||
- [ ] No hardcoded secrets
|
||||
- [ ] Input validated/sanitized
|
||||
- [ ] SQL injection prevented
|
||||
- [ ] XSS prevented
|
||||
- [ ] Authorization checks present
|
||||
- [ ] Sensitive data protected
|
||||
|
||||
## Performance (3 min)
|
||||
|
||||
- [ ] No N+1 queries
|
||||
- [ ] Expensive operations optimized
|
||||
- [ ] Large lists paginated
|
||||
- [ ] No memory leaks
|
||||
- [ ] Caching considered where appropriate
|
||||
|
||||
## Testing (5 min)
|
||||
|
||||
- [ ] Tests exist for new code
|
||||
- [ ] Edge cases tested
|
||||
- [ ] Error cases tested
|
||||
- [ ] Tests are readable
|
||||
- [ ] Tests are deterministic
|
||||
|
||||
## Code Quality (3 min)
|
||||
|
||||
- [ ] Clear variable/function names
|
||||
- [ ] No code duplication
|
||||
- [ ] Functions do one thing
|
||||
- [ ] Complex code commented
|
||||
- [ ] No magic numbers
|
||||
|
||||
## Documentation (2 min)
|
||||
|
||||
- [ ] Public APIs documented
|
||||
- [ ] README updated if needed
|
||||
- [ ] Breaking changes noted
|
||||
- [ ] Complex logic explained
|
||||
|
||||
---
|
||||
|
||||
## Severity Labels
|
||||
|
||||
| Label | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| 🔴 `[blocking]` | Must fix | Block merge |
|
||||
| 🟡 `[important]` | Should fix | Discuss if disagree |
|
||||
| 🟢 `[nit]` | Nice to have | Non-blocking |
|
||||
| 💡 `[suggestion]` | Alternative | Consider |
|
||||
| 📚 `[learning]` | Educational comment | No action needed |
|
||||
| 🎉 `[praise]` | Good work | Celebrate! |
|
||||
|
||||
---
|
||||
|
||||
## Decision Matrix
|
||||
|
||||
| Situation | Decision |
|
||||
|-----------|----------|
|
||||
| Critical security issue | 🔴 Block, fix immediately |
|
||||
| Breaking change without migration | 🔴 Block |
|
||||
| Missing error handling | 🟡 Should fix |
|
||||
| No tests for new code | 🟡 Should fix |
|
||||
| Style preference | 🟢 Non-blocking |
|
||||
| Minor naming improvement | 🟢 Non-blocking |
|
||||
| Clever but working code | 💡 Suggest simpler |
|
||||
|
||||
---
|
||||
|
||||
## Time Budget
|
||||
|
||||
| PR Size | Target Time |
|
||||
|---------|-------------|
|
||||
| < 100 lines | 10-15 min |
|
||||
| 100-400 lines | 20-40 min |
|
||||
| > 400 lines | Ask to split |
|
||||
|
||||
---
|
||||
|
||||
## Red Flags
|
||||
|
||||
Watch for these patterns:
|
||||
|
||||
- `// TODO` in production code
|
||||
- `console.log` left in code
|
||||
- Commented out code
|
||||
- `any` type in TypeScript
|
||||
- Empty catch blocks
|
||||
- `unwrap()` in Rust production code
|
||||
- Magic numbers/strings
|
||||
- Copy-pasted code blocks
|
||||
- Missing null checks
|
||||
- Hardcoded URLs/credentials
|
||||
+702
@@ -0,0 +1,702 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>code-review-skill(1) — User Commands (en_US)</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #14110d;
|
||||
--bg-alt: #1a1611;
|
||||
--fg: #c4b596;
|
||||
--fg-bright:#e8d5a8;
|
||||
--fg-dim: #7a6f56;
|
||||
--fg-faint: #4a4334;
|
||||
--amber: #d8964a;
|
||||
--amber-2: #e8a455;
|
||||
--red: #d56350;
|
||||
--green: #8fae5a;
|
||||
--blue: #6b94c4;
|
||||
--rule: #2a2520;
|
||||
}
|
||||
|
||||
html { background: var(--bg); }
|
||||
|
||||
body {
|
||||
font-family: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
color: var(--fg);
|
||||
background: var(--bg);
|
||||
min-height: 100vh;
|
||||
padding: 0 0 4rem;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* faint scanline-free phosphor texture — very subtle */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 0%, rgba(216,150,74,0.04) 0%, transparent 60%);
|
||||
}
|
||||
|
||||
/* ─── HEADER / FOOTER BAND ─── */
|
||||
.band {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--rule);
|
||||
z-index: 10;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.band-inner {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
padding: 0.625rem 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
.band-l, .band-r {
|
||||
color: var(--fg-bright);
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.band-c { color: var(--fg-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.band a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted var(--fg-faint);
|
||||
}
|
||||
.band a:hover { color: var(--amber); border-bottom-color: var(--amber); }
|
||||
|
||||
/* ─── PAGE ─── */
|
||||
main {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 2rem 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
pre, .pre {
|
||||
font-family: inherit;
|
||||
white-space: pre;
|
||||
color: inherit;
|
||||
background: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ─── SECTIONS ─── */
|
||||
h2.sec {
|
||||
color: var(--fg-bright);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 2.75rem 0 0.875rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h2.sec::before { content: ''; }
|
||||
|
||||
section.body {
|
||||
padding-left: 7ch;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
section.body p {
|
||||
margin-bottom: 0.875rem;
|
||||
max-width: 70ch;
|
||||
}
|
||||
section.body p:last-child { margin-bottom: 0; }
|
||||
|
||||
.em { color: var(--fg-bright); }
|
||||
.dim { color: var(--fg-dim); }
|
||||
.faint { color: var(--fg-faint); }
|
||||
.amber { color: var(--amber); }
|
||||
.red { color: var(--red); }
|
||||
.green { color: var(--green); }
|
||||
.blue { color: var(--blue); }
|
||||
|
||||
a.link {
|
||||
color: var(--amber);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted var(--amber);
|
||||
}
|
||||
a.link:hover {
|
||||
color: var(--bg);
|
||||
background: var(--amber);
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
/* ─── TITLE BLOCK ─── */
|
||||
.title-block {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.ascii-title {
|
||||
color: var(--amber);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
margin: 1.5rem 0 2.25rem;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
text-shadow: 0 0 12px rgba(216,150,74,0.25);
|
||||
}
|
||||
|
||||
.one-liner {
|
||||
color: var(--fg-bright);
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lang-toggle {
|
||||
font-size: 12px;
|
||||
color: var(--fg-dim);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.lang-toggle a {
|
||||
color: var(--fg-dim);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted var(--fg-faint);
|
||||
padding-bottom: 1px;
|
||||
margin: 0 0.25em;
|
||||
}
|
||||
.lang-toggle a.on {
|
||||
color: var(--amber);
|
||||
border-bottom-color: var(--amber);
|
||||
}
|
||||
.lang-toggle a:hover { color: var(--amber); border-bottom-color: var(--amber); }
|
||||
.lang-toggle .sep { color: var(--fg-faint); }
|
||||
|
||||
.one-liner-sub {
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
/* ─── TABLES ─── */
|
||||
.lang-row {
|
||||
display: grid;
|
||||
grid-template-columns: 26ch 1fr 7ch;
|
||||
gap: 1ch;
|
||||
padding: 0.125rem 0;
|
||||
align-items: baseline;
|
||||
transition: background 0.1s;
|
||||
border-bottom: 1px dotted var(--rule);
|
||||
}
|
||||
|
||||
.lang-row:hover { background: var(--bg-alt); }
|
||||
|
||||
.lang-row .file { color: var(--amber); }
|
||||
.lang-row .desc { color: var(--fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.lang-row .desc .topics { color: var(--fg-dim); }
|
||||
.lang-row .lines { text-align: right; color: var(--fg-dim); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.dotleader {
|
||||
color: var(--fg-faint);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cat-head {
|
||||
color: var(--fg-bright);
|
||||
margin: 1.25rem 0 0.5rem;
|
||||
padding-bottom: 0.25rem;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.cat-head:first-child { margin-top: 0; }
|
||||
|
||||
/* ─── PHASE DIAGRAM ─── */
|
||||
.phase-flow {
|
||||
margin: 1rem 0 1.5rem;
|
||||
color: var(--fg-dim);
|
||||
line-height: 1.4;
|
||||
font-size: 13px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.phase-flow .box { color: var(--amber); }
|
||||
.phase-flow .arrow { color: var(--fg-bright); }
|
||||
|
||||
.phase-list dt {
|
||||
color: var(--fg-bright);
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
.phase-list dt:first-child { margin-top: 0; }
|
||||
.phase-list dd {
|
||||
color: var(--fg);
|
||||
max-width: 70ch;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
.phase-list dd.t {
|
||||
color: var(--fg-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ─── SEVERITY LIST ─── */
|
||||
.sev-list {
|
||||
list-style: none;
|
||||
}
|
||||
.sev-list li {
|
||||
display: grid;
|
||||
grid-template-columns: 16ch 1fr;
|
||||
gap: 1ch;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px dotted var(--rule);
|
||||
align-items: baseline;
|
||||
}
|
||||
.sev-list li:last-child { border-bottom: none; }
|
||||
.sev-list li .label { color: var(--fg-bright); }
|
||||
.sev-list li .desc { color: var(--fg); }
|
||||
.sev-list li .desc .aside { color: var(--fg-dim); }
|
||||
|
||||
/* ─── CODE BLOCKS ─── */
|
||||
.codeblock {
|
||||
background: var(--bg-alt);
|
||||
border-left: 2px solid var(--amber);
|
||||
padding: 0.875rem 1.25rem;
|
||||
margin: 0.875rem 0;
|
||||
color: var(--fg);
|
||||
overflow-x: auto;
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
.codeblock .prompt { color: var(--green); }
|
||||
.codeblock .cmt { color: var(--fg-dim); }
|
||||
.codeblock .cmd { color: var(--amber); }
|
||||
.codeblock .arg { color: var(--fg-bright); }
|
||||
|
||||
.examples {
|
||||
list-style: none;
|
||||
max-width: 70ch;
|
||||
}
|
||||
.examples li {
|
||||
padding: 0.375rem 0;
|
||||
color: var(--fg);
|
||||
}
|
||||
.examples li::before {
|
||||
content: '$ ';
|
||||
color: var(--green);
|
||||
}
|
||||
.examples li .q { color: var(--fg-bright); }
|
||||
.examples li .note {
|
||||
display: block;
|
||||
margin-top: 0.125rem;
|
||||
padding-left: 2ch;
|
||||
color: var(--fg-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
.examples li .note::before { content: '↳ '; color: var(--fg-faint); }
|
||||
|
||||
/* ─── FILES TREE ─── */
|
||||
.tree {
|
||||
color: var(--fg);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.tree .dir { color: var(--amber); }
|
||||
.tree .file { color: var(--fg); }
|
||||
.tree .cmt { color: var(--fg-dim); }
|
||||
.tree .branch { color: var(--fg-faint); }
|
||||
|
||||
/* ─── STATUS BAR / VIM-LIKE ─── */
|
||||
.statusbar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--amber);
|
||||
color: var(--bg);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.02em;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.statusbar-inner {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
padding: 0.25rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.statusbar-l, .statusbar-r { display: flex; gap: 1.25rem; align-items: center; }
|
||||
.statusbar-l > span:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 22ch;
|
||||
}
|
||||
.statusbar kbd {
|
||||
background: var(--bg);
|
||||
color: var(--amber);
|
||||
padding: 1px 5px;
|
||||
border-radius: 2px;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── CURSOR ─── */
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 0.55em;
|
||||
height: 1em;
|
||||
background: var(--amber);
|
||||
vertical-align: -2px;
|
||||
animation: blink 1.1s steps(1) infinite;
|
||||
margin-left: 1px;
|
||||
}
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
/* ─── SEPARATOR ─── */
|
||||
.hr {
|
||||
color: var(--rule);
|
||||
margin: 2rem 0 0;
|
||||
max-width: 70ch;
|
||||
padding-left: 7ch;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ─── BIB ─── */
|
||||
.bib {
|
||||
max-width: 70ch;
|
||||
}
|
||||
.bib dt {
|
||||
color: var(--fg-bright);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.bib dt:first-child { margin-top: 0; }
|
||||
.bib dd { color: var(--fg-dim); }
|
||||
|
||||
/* ─── RESPONSIVE ─── */
|
||||
@media (max-width: 720px) {
|
||||
body { font-size: 13px; }
|
||||
main { padding: 2rem 1rem 0; }
|
||||
.band-inner, .statusbar-inner { padding: 0.5rem 1rem; font-size: 11px; }
|
||||
section.body { padding-left: 4ch; }
|
||||
.lang-row { grid-template-columns: 1fr 6ch; gap: 0.5ch; }
|
||||
.lang-row .desc { display: none; }
|
||||
.ascii-title { font-size: 9px; }
|
||||
.sev-list li { grid-template-columns: 14ch 1fr; }
|
||||
.phase-flow { font-size: 10px; }
|
||||
.band-c { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ═══ TOP BAND (man page header line) ═══ -->
|
||||
<div class="band">
|
||||
<div class="band-inner">
|
||||
<span class="band-l">CODE-REVIEW-SKILL(1)</span>
|
||||
<span class="band-c">User Commands · Edition 2026.01</span>
|
||||
<span class="band-r">CODE-REVIEW-SKILL(1)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
|
||||
<!-- ═══ TITLE BLOCK ═══ -->
|
||||
<div class="title-block">
|
||||
<pre class="ascii-title"> ___ ___ ___ ___ ___ _____ _____ _____ __ ___ _ _____ _ _
|
||||
/ __/ _ \| \| __| ___ | _ \ __\ \ / /_ _| __\ \ /\ / / __/ __| |/ /_ _| | | |
|
||||
| (_| (_) | |) | _| |___|| / _| \ V / | || _| \ V V /__\__ \ ' < | || |__| |__
|
||||
\___\___/|___/|___| |_|_\___| \_/ |___|___| \_/\_/ |___/_|\_\___|____|____|</pre>
|
||||
|
||||
<div class="one-liner">
|
||||
<span>
|
||||
<span class="dim">$ </span><span class="em">man code-review-skill</span><span class="cursor"></span>
|
||||
</span>
|
||||
<span class="lang-toggle">
|
||||
<span class="dim">LANG=</span><a href="index.html">zh_CN</a><span class="sep"> | </span><a href="index.en.html" class="on">en_US</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="one-liner-sub">
|
||||
v1.0 · awesome-skills · MIT · 20 languages · 16,000+ lines
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ NAME ═══ -->
|
||||
<h2 class="sec">NAME</h2>
|
||||
<section class="body">
|
||||
<p>
|
||||
<span class="em">code-review-skill</span> — A comprehensive, modular code review skill for Claude Code
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ═══ SYNOPSIS ═══ -->
|
||||
<h2 class="sec">SYNOPSIS</h2>
|
||||
<section class="body">
|
||||
<pre class="pre">
|
||||
<span class="amber">Use code-review-skill to</span> review this PR
|
||||
<span class="amber">Use code-review-skill to</span> review this <<span class="dim">component</span>>
|
||||
<span class="amber">Use code-review-skill for</span> <span class="dim">[</span>security <span class="dim">|</span> performance <span class="dim">|</span> architecture<span class="dim">]</span> review</pre>
|
||||
</section>
|
||||
|
||||
<!-- ═══ DESCRIPTION ═══ -->
|
||||
<h2 class="sec">DESCRIPTION</h2>
|
||||
<section class="body">
|
||||
<p>A production-grade code review skill. It transforms AI-assisted code review from vague suggestions into a structured, consistent, expert-level collaborative process.</p>
|
||||
<p>Core is only <span class="em">~190 lines</span>; the full <span class="em">16,000+ lines</span> of language guides load on demand. Covers <span class="em">20+</span> mainstream languages and frameworks — progressive loading, zero overhead.</p>
|
||||
<p>Every finding carries an explicit severity label. Every review proceeds through four phases: PR context · high-level assessment · line-by-line analysis · summary & decision.</p>
|
||||
</section>
|
||||
|
||||
<!-- ═══ LANGUAGES ═══ -->
|
||||
<h2 class="sec">LANGUAGES</h2>
|
||||
<section class="body">
|
||||
|
||||
<div class="cat-head">┌── frontend ──┘</div>
|
||||
<div class="lang-row"><span class="file">react.md</span><span class="desc">React 19, Hooks, Server Components, TanStack v5 <span class="dotleader">.................</span></span><span class="lines">870</span></div>
|
||||
<div class="lang-row"><span class="file">vue.md</span><span class="desc">Vue 3.5, Composition API, Composables, Watchers <span class="dotleader">.................</span></span><span class="lines">920</span></div>
|
||||
<div class="lang-row"><span class="file">angular.md</span><span class="desc">Angular 17+, Signals, Standalone, Zoneless <span class="dotleader">..........................</span></span><span class="lines">420</span></div>
|
||||
<div class="lang-row"><span class="file">svelte.md</span><span class="desc">Svelte 5, Runes, SvelteKit, SSR/CSR boundaries <span class="dotleader">..................</span></span><span class="lines">1,060</span></div>
|
||||
<div class="lang-row"><span class="file">typescript.md</span><span class="desc">TypeScript strict mode, generics, immutability <span class="dotleader">..................</span></span><span class="lines">540</span></div>
|
||||
<div class="lang-row"><span class="file">css-less-sass.md</span><span class="desc">CSS/Less/Sass variables, responsive, compatibility <span class="dotleader">..............</span></span><span class="lines">660</span></div>
|
||||
|
||||
<div class="cat-head">┌── backend ──┘</div>
|
||||
<div class="lang-row"><span class="file">python.md</span><span class="desc">Python async, typing, pytest, mutable defaults <span class="dotleader">.................</span></span><span class="lines">1,070</span></div>
|
||||
<div class="lang-row"><span class="file">django.md</span><span class="desc">Django/DRF security, N+1, serializers, async views <span class="dotleader">..............</span></span><span class="lines">1,030</span></div>
|
||||
<div class="lang-row"><span class="file">java.md</span><span class="desc">Java 17/21, Spring Boot 3, virtual threads, JPA <span class="dotleader">................</span></span><span class="lines">800</span></div>
|
||||
<div class="lang-row"><span class="file">php.md</span><span class="desc">PHP 8.x, types, PDO, security, Composer <span class="dotleader">...........................</span></span><span class="lines">700</span></div>
|
||||
<div class="lang-row"><span class="file">go.md</span><span class="desc">Goroutines, channels, context, interface design <span class="dotleader">.................</span></span><span class="lines">990</span></div>
|
||||
<div class="lang-row"><span class="file">rust.md</span><span class="desc">Ownership, async/await, unsafe, cancellation safety <span class="dotleader">.............</span></span><span class="lines">840</span></div>
|
||||
<div class="lang-row"><span class="file">csharp.md</span><span class="desc">C# 12 / .NET 8, EF Core, ASP.NET Core, LINQ <span class="dotleader">.....................</span></span><span class="lines">520</span></div>
|
||||
<div class="lang-row"><span class="file">nestjs.md</span><span class="desc">NestJS DI, guards, interceptors, DTO validation <span class="dotleader">.................</span></span><span class="lines">590</span></div>
|
||||
|
||||
<div class="cat-head">┌── mobile / systems ──┘</div>
|
||||
<div class="lang-row"><span class="file">kotlin.md</span><span class="desc">Kotlin/Android coroutines, Compose, Flow, null safety <span class="dotleader">...........</span></span><span class="lines">1,020</span></div>
|
||||
<div class="lang-row"><span class="file">swift.md</span><span class="desc">Swift 5.9+/6, SwiftUI, concurrency, Sendable, optionals <span class="dotleader">..........</span></span><span class="lines">930</span></div>
|
||||
<div class="lang-row"><span class="file">c.md</span><span class="desc">C pointer safety, undefined behavior, resources <span class="dotleader">.................</span></span><span class="lines">210</span></div>
|
||||
<div class="lang-row"><span class="file">cpp.md</span><span class="desc">C++ RAII, Rule of 0/3/5, move semantics, noexcept <span class="dotleader">...............</span></span><span class="lines">300</span></div>
|
||||
<div class="lang-row"><span class="file">qt.md</span><span class="desc">Qt object model, signals/slots, GUI performance <span class="dotleader">.................</span></span><span class="lines">190</span></div>
|
||||
|
||||
<div class="cat-head">┌── cross-cutting ──┘</div>
|
||||
<div class="lang-row"><span class="file">architecture-review-guide.md</span><span class="desc">SOLID, anti-patterns, coupling <span class="dotleader">...</span></span><span class="lines">470</span></div>
|
||||
<div class="lang-row"><span class="file">performance-review-guide.md</span><span class="desc">Web Vitals, N+1, complexity <span class="dotleader">.......</span></span><span class="lines">850</span></div>
|
||||
<div class="lang-row"><span class="file">code-quality-universal.md</span><span class="desc">TOCTOU, leaky abstractions, sprawl <span class="dotleader">....</span></span><span class="lines">320</span></div>
|
||||
<div class="lang-row"><span class="file">security-review-guide.md</span><span class="desc">Injection, XSS, secrets, all langs <span class="dotleader">.....</span></span><span class="lines">—</span></div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ PHASES ═══ -->
|
||||
<h2 class="sec">PHASES</h2>
|
||||
<section class="body">
|
||||
|
||||
<pre class="phase-flow"> <span class="box">┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐</span>
|
||||
<span class="box">│ context │</span> <span class="arrow">─▶</span> <span class="box">│ high level │</span> <span class="arrow">─▶</span> <span class="box">│ line by line│</span> <span class="arrow">─▶</span> <span class="box">│ decide │</span>
|
||||
<span class="box">│ 2-3m │</span> <span class="box">│ 5-10m │</span> <span class="box">│ 10-20m │</span> <span class="box">│ 2-3m │</span>
|
||||
<span class="box">└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘</span></pre>
|
||||
|
||||
<dl class="phase-list" style="margin-top:1.5rem;">
|
||||
<dt>1. context gathering <span class="dim">— 2-3 min</span></dt>
|
||||
<dd>Read the PR description and linked issues, assess scope, check CI status, understand the business intent.</dd>
|
||||
<dt>2. high-level review <span class="dim">— 5-10 min</span></dt>
|
||||
<dd>Evaluate architectural fit, performance impact, file organization, test strategy. See the whole first.</dd>
|
||||
<dt>3. line-by-line analysis <span class="dim">— 10-20 min</span></dt>
|
||||
<dd>Logic correctness · security · performance · maintainability · edge cases. One by one.</dd>
|
||||
<dt>4. summary & decision <span class="dim">— 2-3 min</span></dt>
|
||||
<dd>Summarize findings, name what was done well, deliver approve / comment / request-changes.</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<!-- ═══ SEVERITY ═══ -->
|
||||
<h2 class="sec">SEVERITY</h2>
|
||||
<section class="body">
|
||||
<ul class="sev-list">
|
||||
<li>
|
||||
<span class="label"><span class="red">●</span> [blocking]</span>
|
||||
<span class="desc">must fix <span class="aside">— resolve before merge; security / correctness / serious logic</span></span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="label"><span style="color:#d68a3d;">●</span> [important]</span>
|
||||
<span class="desc">should fix <span class="aside">— strongly recommended; discuss if you disagree</span></span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="label"><span style="color:#c7a648;">●</span> [nit]</span>
|
||||
<span class="desc">nice to have <span class="aside">— style or preference; non-blocking</span></span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="label"><span class="blue">●</span> [suggestion]</span>
|
||||
<span class="desc">alternative <span class="aside">— worth considering; author decides</span></span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="label"><span style="color:#9078b8;">●</span> [learning]</span>
|
||||
<span class="desc">educational <span class="aside">— no action needed; share knowledge</span></span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="label"><span class="green">●</span> [praise]</span>
|
||||
<span class="desc">good work <span class="aside">— say it out loud when you see it</span></span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- ═══ INSTALLATION ═══ -->
|
||||
<h2 class="sec">INSTALLATION</h2>
|
||||
<section class="body">
|
||||
|
||||
<p>Clone into the Claude Code skills directory. Two commands.</p>
|
||||
|
||||
<pre class="codeblock"><span class="cmt"># macOS / Linux</span>
|
||||
<span class="prompt">$</span> <span class="cmd">git clone</span> <span class="arg">https://github.com/awesome-skills/code-review-skill.git</span> \
|
||||
~/.claude/skills/code-review-skill
|
||||
|
||||
<span class="cmt"># Windows PowerShell</span>
|
||||
<span class="prompt">PS></span> <span class="cmd">git clone</span> <span class="arg">https://github.com/awesome-skills/code-review-skill.git</span> `
|
||||
"$env:USERPROFILE\.claude\skills\code-review-skill"</pre>
|
||||
</section>
|
||||
|
||||
<!-- ═══ EXAMPLES ═══ -->
|
||||
<h2 class="sec">EXAMPLES</h2>
|
||||
<section class="body">
|
||||
<ul class="examples">
|
||||
<li>
|
||||
<span class="q">Use code-review-skill to review this PR</span>
|
||||
<span class="note">runs the full four-phase review</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="q">Review this React component</span>
|
||||
<span class="note">loads react.md · checks Hooks · Server Components</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="q">Security review of this Go service</span>
|
||||
<span class="note">loads go.md + security-review-guide.md together</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="q">Architecture review</span>
|
||||
<span class="note">loads the architecture guide · SOLID · anti-patterns · coupling</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- ═══ FILES ═══ -->
|
||||
<h2 class="sec">FILES</h2>
|
||||
<section class="body">
|
||||
<pre class="tree">
|
||||
<span class="dir">~/.claude/skills/code-review-skill/</span>
|
||||
<span class="branch">├──</span> <span class="file">SKILL.md</span> <span class="cmt"># core, loaded on activation (~190 lines)</span>
|
||||
<span class="branch">├──</span> <span class="file">README.md</span>
|
||||
<span class="branch">├──</span> <span class="file">LICENSE</span> <span class="cmt"># MIT</span>
|
||||
<span class="branch">├──</span> <span class="dir">reference/</span> <span class="cmt"># on-demand language guides</span>
|
||||
<span class="branch">│ ├──</span> <span class="file">react.md</span> <span class="file">vue.md</span> <span class="file">angular.md</span> ...
|
||||
<span class="branch">│ └──</span> <span class="file">architecture-review-guide.md</span> ...
|
||||
<span class="branch">├──</span> <span class="dir">assets/</span>
|
||||
<span class="branch">│ ├──</span> <span class="file">review-checklist.md</span> <span class="cmt"># quick reference</span>
|
||||
<span class="branch">│ └──</span> <span class="file">pr-review-template.md</span> <span class="cmt"># PR comment template</span>
|
||||
<span class="branch">└──</span> <span class="dir">scripts/</span>
|
||||
<span class="branch">└──</span> <span class="file">pr-analyzer.py</span> <span class="cmt"># PR complexity analyzer</span></pre>
|
||||
</section>
|
||||
|
||||
<!-- ═══ SEE ALSO ═══ -->
|
||||
<h2 class="sec">SEE ALSO</h2>
|
||||
<section class="body">
|
||||
<p>
|
||||
<a class="link" href="https://claude.ai/code" target="_blank">claude-code(1)</a>,
|
||||
<a class="link" href="https://github.com/awesome-skills/code-review-skill" target="_blank">github / awesome-skills</a>,
|
||||
<a class="link" href="https://github.com/awesome-skills/code-review-skill/blob/main/CONTRIBUTING.md" target="_blank">CONTRIBUTING(7)</a>,
|
||||
<a class="link" href="https://github.com/awesome-skills/code-review-skill/blob/main/assets/review-checklist.md" target="_blank">review-checklist(7)</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ═══ AUTHORS ═══ -->
|
||||
<h2 class="sec">AUTHORS</h2>
|
||||
<section class="body">
|
||||
<dl class="bib">
|
||||
<dt>awesome-skills</dt>
|
||||
<dd>maintainer, primary author</dd>
|
||||
<dt>contributors</dt>
|
||||
<dd>see <a class="link" href="https://github.com/awesome-skills/code-review-skill/graphs/contributors" target="_blank">graphs/contributors</a></dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<!-- ═══ COPYRIGHT ═══ -->
|
||||
<h2 class="sec">COPYRIGHT</h2>
|
||||
<section class="body">
|
||||
<p>
|
||||
<span class="dim">Copyright (c) 2025 awesome-skills.</span><br>
|
||||
Released under the MIT License.<br>
|
||||
<span class="dim">This is free software: you are free to change and redistribute it.</span><br>
|
||||
<span class="dim">There is NO WARRANTY, to the extent permitted by law.</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div style="height: 4rem;"></div>
|
||||
|
||||
<!-- ═══ END-OF-PAGE BAND ═══ -->
|
||||
<div style="border-top:1px solid var(--rule); margin-top:2rem; padding:0.625rem 0;">
|
||||
<div style="display:flex; justify-content:space-between; color:var(--fg-dim); font-size:12px; white-space:nowrap; gap:1rem;">
|
||||
<span class="band-l" style="color:var(--fg-bright);">CODE-REVIEW-SKILL(1)</span>
|
||||
<span style="color:var(--fg-dim);">awesome-skills</span>
|
||||
<span class="band-r" style="color:var(--fg-bright);">CODE-REVIEW-SKILL(1)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- ═══ VIM-LIKE STATUS BAR ═══ -->
|
||||
<div class="statusbar">
|
||||
<div class="statusbar-inner">
|
||||
<div class="statusbar-l">
|
||||
<span>-- NORMAL --</span>
|
||||
<span>code-review-skill.1</span>
|
||||
</div>
|
||||
<div class="statusbar-r">
|
||||
<span><kbd>g</kbd> top</span>
|
||||
<span><kbd>G</kbd> end</span>
|
||||
<span><kbd>q</kbd> quit</span>
|
||||
<span id="pos">1,1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Vim-like keyboard nav for the man-page vibe
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||
|
||||
if (e.key === 'g') {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else if (e.key === 'G') {
|
||||
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||
} else if (e.key === 'j') {
|
||||
window.scrollBy({ top: 60, behavior: 'smooth' });
|
||||
} else if (e.key === 'k') {
|
||||
window.scrollBy({ top: -60, behavior: 'smooth' });
|
||||
} else if (e.key === 'q') {
|
||||
const ok = confirm('Quit man page?');
|
||||
if (ok) window.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Update line/col-like indicator from scroll position
|
||||
const posEl = document.getElementById('pos');
|
||||
function updatePos() {
|
||||
const pct = Math.round((window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100) || 0;
|
||||
const line = Math.max(1, Math.round((window.scrollY / 20)));
|
||||
posEl.textContent = line + ',1 ' + (pct >= 99 ? 'Bot' : pct <= 1 ? 'Top' : pct + '%');
|
||||
}
|
||||
updatePos();
|
||||
window.addEventListener('scroll', updatePos, { passive: true });
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Executable
+702
@@ -0,0 +1,702 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>code-review-skill(1) — User Commands</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #14110d;
|
||||
--bg-alt: #1a1611;
|
||||
--fg: #c4b596;
|
||||
--fg-bright:#e8d5a8;
|
||||
--fg-dim: #7a6f56;
|
||||
--fg-faint: #4a4334;
|
||||
--amber: #d8964a;
|
||||
--amber-2: #e8a455;
|
||||
--red: #d56350;
|
||||
--green: #8fae5a;
|
||||
--blue: #6b94c4;
|
||||
--rule: #2a2520;
|
||||
}
|
||||
|
||||
html { background: var(--bg); }
|
||||
|
||||
body {
|
||||
font-family: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
color: var(--fg);
|
||||
background: var(--bg);
|
||||
min-height: 100vh;
|
||||
padding: 0 0 4rem;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* faint scanline-free phosphor texture — very subtle */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 0%, rgba(216,150,74,0.04) 0%, transparent 60%);
|
||||
}
|
||||
|
||||
/* ─── HEADER / FOOTER BAND ─── */
|
||||
.band {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--rule);
|
||||
z-index: 10;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.band-inner {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
padding: 0.625rem 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
.band-l, .band-r {
|
||||
color: var(--fg-bright);
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.band-c { color: var(--fg-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.band a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted var(--fg-faint);
|
||||
}
|
||||
.band a:hover { color: var(--amber); border-bottom-color: var(--amber); }
|
||||
|
||||
/* ─── PAGE ─── */
|
||||
main {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 2rem 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
pre, .pre {
|
||||
font-family: inherit;
|
||||
white-space: pre;
|
||||
color: inherit;
|
||||
background: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ─── SECTIONS ─── */
|
||||
h2.sec {
|
||||
color: var(--fg-bright);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 2.75rem 0 0.875rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h2.sec::before { content: ''; }
|
||||
|
||||
section.body {
|
||||
padding-left: 7ch;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
section.body p {
|
||||
margin-bottom: 0.875rem;
|
||||
max-width: 70ch;
|
||||
}
|
||||
section.body p:last-child { margin-bottom: 0; }
|
||||
|
||||
.em { color: var(--fg-bright); }
|
||||
.dim { color: var(--fg-dim); }
|
||||
.faint { color: var(--fg-faint); }
|
||||
.amber { color: var(--amber); }
|
||||
.red { color: var(--red); }
|
||||
.green { color: var(--green); }
|
||||
.blue { color: var(--blue); }
|
||||
|
||||
a.link {
|
||||
color: var(--amber);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted var(--amber);
|
||||
}
|
||||
a.link:hover {
|
||||
color: var(--bg);
|
||||
background: var(--amber);
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
/* ─── TITLE BLOCK ─── */
|
||||
.title-block {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.ascii-title {
|
||||
color: var(--amber);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
margin: 1.5rem 0 2.25rem;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
text-shadow: 0 0 12px rgba(216,150,74,0.25);
|
||||
}
|
||||
|
||||
.one-liner {
|
||||
color: var(--fg-bright);
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lang-toggle {
|
||||
font-size: 12px;
|
||||
color: var(--fg-dim);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.lang-toggle a {
|
||||
color: var(--fg-dim);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted var(--fg-faint);
|
||||
padding-bottom: 1px;
|
||||
margin: 0 0.25em;
|
||||
}
|
||||
.lang-toggle a.on {
|
||||
color: var(--amber);
|
||||
border-bottom-color: var(--amber);
|
||||
}
|
||||
.lang-toggle a:hover { color: var(--amber); border-bottom-color: var(--amber); }
|
||||
.lang-toggle .sep { color: var(--fg-faint); }
|
||||
|
||||
.one-liner-sub {
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
/* ─── TABLES ─── */
|
||||
.lang-row {
|
||||
display: grid;
|
||||
grid-template-columns: 26ch 1fr 7ch;
|
||||
gap: 1ch;
|
||||
padding: 0.125rem 0;
|
||||
align-items: baseline;
|
||||
transition: background 0.1s;
|
||||
border-bottom: 1px dotted var(--rule);
|
||||
}
|
||||
|
||||
.lang-row:hover { background: var(--bg-alt); }
|
||||
|
||||
.lang-row .file { color: var(--amber); }
|
||||
.lang-row .desc { color: var(--fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.lang-row .desc .topics { color: var(--fg-dim); }
|
||||
.lang-row .lines { text-align: right; color: var(--fg-dim); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.dotleader {
|
||||
color: var(--fg-faint);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cat-head {
|
||||
color: var(--fg-bright);
|
||||
margin: 1.25rem 0 0.5rem;
|
||||
padding-bottom: 0.25rem;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.cat-head:first-child { margin-top: 0; }
|
||||
|
||||
/* ─── PHASE DIAGRAM ─── */
|
||||
.phase-flow {
|
||||
margin: 1rem 0 1.5rem;
|
||||
color: var(--fg-dim);
|
||||
line-height: 1.4;
|
||||
font-size: 13px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.phase-flow .box { color: var(--amber); }
|
||||
.phase-flow .arrow { color: var(--fg-bright); }
|
||||
|
||||
.phase-list dt {
|
||||
color: var(--fg-bright);
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
.phase-list dt:first-child { margin-top: 0; }
|
||||
.phase-list dd {
|
||||
color: var(--fg);
|
||||
max-width: 70ch;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
.phase-list dd.t {
|
||||
color: var(--fg-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ─── SEVERITY LIST ─── */
|
||||
.sev-list {
|
||||
list-style: none;
|
||||
}
|
||||
.sev-list li {
|
||||
display: grid;
|
||||
grid-template-columns: 16ch 1fr;
|
||||
gap: 1ch;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px dotted var(--rule);
|
||||
align-items: baseline;
|
||||
}
|
||||
.sev-list li:last-child { border-bottom: none; }
|
||||
.sev-list li .label { color: var(--fg-bright); }
|
||||
.sev-list li .desc { color: var(--fg); }
|
||||
.sev-list li .desc .aside { color: var(--fg-dim); }
|
||||
|
||||
/* ─── CODE BLOCKS ─── */
|
||||
.codeblock {
|
||||
background: var(--bg-alt);
|
||||
border-left: 2px solid var(--amber);
|
||||
padding: 0.875rem 1.25rem;
|
||||
margin: 0.875rem 0;
|
||||
color: var(--fg);
|
||||
overflow-x: auto;
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
.codeblock .prompt { color: var(--green); }
|
||||
.codeblock .cmt { color: var(--fg-dim); }
|
||||
.codeblock .cmd { color: var(--amber); }
|
||||
.codeblock .arg { color: var(--fg-bright); }
|
||||
|
||||
.examples {
|
||||
list-style: none;
|
||||
max-width: 70ch;
|
||||
}
|
||||
.examples li {
|
||||
padding: 0.375rem 0;
|
||||
color: var(--fg);
|
||||
}
|
||||
.examples li::before {
|
||||
content: '$ ';
|
||||
color: var(--green);
|
||||
}
|
||||
.examples li .q { color: var(--fg-bright); }
|
||||
.examples li .note {
|
||||
display: block;
|
||||
margin-top: 0.125rem;
|
||||
padding-left: 2ch;
|
||||
color: var(--fg-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
.examples li .note::before { content: '↳ '; color: var(--fg-faint); }
|
||||
|
||||
/* ─── FILES TREE ─── */
|
||||
.tree {
|
||||
color: var(--fg);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.tree .dir { color: var(--amber); }
|
||||
.tree .file { color: var(--fg); }
|
||||
.tree .cmt { color: var(--fg-dim); }
|
||||
.tree .branch { color: var(--fg-faint); }
|
||||
|
||||
/* ─── STATUS BAR / VIM-LIKE ─── */
|
||||
.statusbar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--amber);
|
||||
color: var(--bg);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.02em;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.statusbar-inner {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
padding: 0.25rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.statusbar-l, .statusbar-r { display: flex; gap: 1.25rem; align-items: center; }
|
||||
.statusbar-l > span:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 22ch;
|
||||
}
|
||||
.statusbar kbd {
|
||||
background: var(--bg);
|
||||
color: var(--amber);
|
||||
padding: 1px 5px;
|
||||
border-radius: 2px;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── CURSOR ─── */
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 0.55em;
|
||||
height: 1em;
|
||||
background: var(--amber);
|
||||
vertical-align: -2px;
|
||||
animation: blink 1.1s steps(1) infinite;
|
||||
margin-left: 1px;
|
||||
}
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
/* ─── SEPARATOR ─── */
|
||||
.hr {
|
||||
color: var(--rule);
|
||||
margin: 2rem 0 0;
|
||||
max-width: 70ch;
|
||||
padding-left: 7ch;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ─── BIB ─── */
|
||||
.bib {
|
||||
max-width: 70ch;
|
||||
}
|
||||
.bib dt {
|
||||
color: var(--fg-bright);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.bib dt:first-child { margin-top: 0; }
|
||||
.bib dd { color: var(--fg-dim); }
|
||||
|
||||
/* ─── RESPONSIVE ─── */
|
||||
@media (max-width: 720px) {
|
||||
body { font-size: 13px; }
|
||||
main { padding: 2rem 1rem 0; }
|
||||
.band-inner, .statusbar-inner { padding: 0.5rem 1rem; font-size: 11px; }
|
||||
section.body { padding-left: 4ch; }
|
||||
.lang-row { grid-template-columns: 1fr 6ch; gap: 0.5ch; }
|
||||
.lang-row .desc { display: none; }
|
||||
.ascii-title { font-size: 9px; }
|
||||
.sev-list li { grid-template-columns: 14ch 1fr; }
|
||||
.phase-flow { font-size: 10px; }
|
||||
.band-c { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ═══ TOP BAND (man page header line) ═══ -->
|
||||
<div class="band">
|
||||
<div class="band-inner">
|
||||
<span class="band-l">CODE-REVIEW-SKILL(1)</span>
|
||||
<span class="band-c">User Commands · Edition 2026.01</span>
|
||||
<span class="band-r">CODE-REVIEW-SKILL(1)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
|
||||
<!-- ═══ TITLE BLOCK ═══ -->
|
||||
<div class="title-block">
|
||||
<pre class="ascii-title"> ___ ___ ___ ___ ___ _____ _____ _____ __ ___ _ _____ _ _
|
||||
/ __/ _ \| \| __| ___ | _ \ __\ \ / /_ _| __\ \ /\ / / __/ __| |/ /_ _| | | |
|
||||
| (_| (_) | |) | _| |___|| / _| \ V / | || _| \ V V /__\__ \ ' < | || |__| |__
|
||||
\___\___/|___/|___| |_|_\___| \_/ |___|___| \_/\_/ |___/_|\_\___|____|____|</pre>
|
||||
|
||||
<div class="one-liner">
|
||||
<span>
|
||||
<span class="dim">$ </span><span class="em">man code-review-skill</span><span class="cursor"></span>
|
||||
</span>
|
||||
<span class="lang-toggle">
|
||||
<span class="dim">LANG=</span><a href="index.html" class="on">zh_CN</a><span class="sep"> | </span><a href="index.en.html">en_US</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="one-liner-sub">
|
||||
v1.0 · awesome-skills · MIT · 20 languages · 16,000+ lines
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ NAME ═══ -->
|
||||
<h2 class="sec">NAME</h2>
|
||||
<section class="body">
|
||||
<p>
|
||||
<span class="em">code-review-skill</span> — 面向 Claude Code 的全面、模块化代码审查技能
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ═══ SYNOPSIS ═══ -->
|
||||
<h2 class="sec">SYNOPSIS</h2>
|
||||
<section class="body">
|
||||
<pre class="pre">
|
||||
<span class="amber">Use code-review-skill to</span> review this PR
|
||||
<span class="amber">Use code-review-skill to</span> review this <<span class="dim">component</span>>
|
||||
<span class="amber">Use code-review-skill for</span> <span class="dim">[</span>security <span class="dim">|</span> performance <span class="dim">|</span> architecture<span class="dim">]</span> review</pre>
|
||||
</section>
|
||||
|
||||
<!-- ═══ DESCRIPTION ═══ -->
|
||||
<h2 class="sec">DESCRIPTION</h2>
|
||||
<section class="body">
|
||||
<p>一份生产级的代码审查技能。它把 AI 辅助的代码审查从模糊建议提升为结构化、一致、专业级的协作流程。</p>
|
||||
<p>核心仅约 <span class="em">190 行</span>,按需调阅共计 <span class="em">16,000+ 行</span> 的语言指南。覆盖 <span class="em">20+ 种</span> 主流语言与框架——按需加载,零冗余。</p>
|
||||
<p>每一条审查意见都带有明确的严重性标记。每一次审查都按四个阶段推进:从 PR 上下文 · 高层级评估 · 逐行分析 · 总结决策。</p>
|
||||
</section>
|
||||
|
||||
<!-- ═══ LANGUAGES ═══ -->
|
||||
<h2 class="sec">LANGUAGES</h2>
|
||||
<section class="body">
|
||||
|
||||
<div class="cat-head">┌── frontend ──┘</div>
|
||||
<div class="lang-row"><span class="file">react.md</span><span class="desc">React 19, Hooks, Server Components, TanStack v5 <span class="dotleader">.................</span></span><span class="lines">870</span></div>
|
||||
<div class="lang-row"><span class="file">vue.md</span><span class="desc">Vue 3.5, Composition API, Composables, Watchers <span class="dotleader">.................</span></span><span class="lines">920</span></div>
|
||||
<div class="lang-row"><span class="file">angular.md</span><span class="desc">Angular 17+, Signals, Standalone, Zoneless <span class="dotleader">..........................</span></span><span class="lines">420</span></div>
|
||||
<div class="lang-row"><span class="file">svelte.md</span><span class="desc">Svelte 5, Runes, SvelteKit, SSR/CSR boundaries <span class="dotleader">..................</span></span><span class="lines">1,060</span></div>
|
||||
<div class="lang-row"><span class="file">typescript.md</span><span class="desc">TypeScript strict mode, generics, immutability <span class="dotleader">..................</span></span><span class="lines">540</span></div>
|
||||
<div class="lang-row"><span class="file">css-less-sass.md</span><span class="desc">CSS/Less/Sass variables, responsive, compatibility <span class="dotleader">..............</span></span><span class="lines">660</span></div>
|
||||
|
||||
<div class="cat-head">┌── backend ──┘</div>
|
||||
<div class="lang-row"><span class="file">python.md</span><span class="desc">Python async, typing, pytest, mutable defaults <span class="dotleader">.................</span></span><span class="lines">1,070</span></div>
|
||||
<div class="lang-row"><span class="file">django.md</span><span class="desc">Django/DRF security, N+1, serializers, async views <span class="dotleader">..............</span></span><span class="lines">1,030</span></div>
|
||||
<div class="lang-row"><span class="file">java.md</span><span class="desc">Java 17/21, Spring Boot 3, virtual threads, JPA <span class="dotleader">................</span></span><span class="lines">800</span></div>
|
||||
<div class="lang-row"><span class="file">php.md</span><span class="desc">PHP 8.x, types, PDO, security, Composer <span class="dotleader">...........................</span></span><span class="lines">700</span></div>
|
||||
<div class="lang-row"><span class="file">go.md</span><span class="desc">Goroutines, channels, context, interface design <span class="dotleader">.................</span></span><span class="lines">990</span></div>
|
||||
<div class="lang-row"><span class="file">rust.md</span><span class="desc">Ownership, async/await, unsafe, cancellation safety <span class="dotleader">.............</span></span><span class="lines">840</span></div>
|
||||
<div class="lang-row"><span class="file">csharp.md</span><span class="desc">C# 12 / .NET 8, EF Core, ASP.NET Core, LINQ <span class="dotleader">.....................</span></span><span class="lines">520</span></div>
|
||||
<div class="lang-row"><span class="file">nestjs.md</span><span class="desc">NestJS DI, guards, interceptors, DTO validation <span class="dotleader">.................</span></span><span class="lines">590</span></div>
|
||||
|
||||
<div class="cat-head">┌── mobile / systems ──┘</div>
|
||||
<div class="lang-row"><span class="file">kotlin.md</span><span class="desc">Kotlin/Android coroutines, Compose, Flow, null safety <span class="dotleader">...........</span></span><span class="lines">1,020</span></div>
|
||||
<div class="lang-row"><span class="file">swift.md</span><span class="desc">Swift 5.9+/6, SwiftUI, concurrency, Sendable, optionals <span class="dotleader">..........</span></span><span class="lines">930</span></div>
|
||||
<div class="lang-row"><span class="file">c.md</span><span class="desc">C pointer safety, undefined behavior, resources <span class="dotleader">.................</span></span><span class="lines">210</span></div>
|
||||
<div class="lang-row"><span class="file">cpp.md</span><span class="desc">C++ RAII, Rule of 0/3/5, move semantics, noexcept <span class="dotleader">...............</span></span><span class="lines">300</span></div>
|
||||
<div class="lang-row"><span class="file">qt.md</span><span class="desc">Qt object model, signals/slots, GUI performance <span class="dotleader">.................</span></span><span class="lines">190</span></div>
|
||||
|
||||
<div class="cat-head">┌── cross-cutting ──┘</div>
|
||||
<div class="lang-row"><span class="file">architecture-review-guide.md</span><span class="desc">SOLID, anti-patterns, coupling <span class="dotleader">...</span></span><span class="lines">470</span></div>
|
||||
<div class="lang-row"><span class="file">performance-review-guide.md</span><span class="desc">Web Vitals, N+1, complexity <span class="dotleader">.......</span></span><span class="lines">850</span></div>
|
||||
<div class="lang-row"><span class="file">code-quality-universal.md</span><span class="desc">TOCTOU, leaky abstractions, sprawl <span class="dotleader">....</span></span><span class="lines">320</span></div>
|
||||
<div class="lang-row"><span class="file">security-review-guide.md</span><span class="desc">Injection, XSS, secrets, all langs <span class="dotleader">.....</span></span><span class="lines">—</span></div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ PHASES ═══ -->
|
||||
<h2 class="sec">PHASES</h2>
|
||||
<section class="body">
|
||||
|
||||
<pre class="phase-flow"> <span class="box">┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐</span>
|
||||
<span class="box">│ context │</span> <span class="arrow">─▶</span> <span class="box">│ high level │</span> <span class="arrow">─▶</span> <span class="box">│ line by line│</span> <span class="arrow">─▶</span> <span class="box">│ decide │</span>
|
||||
<span class="box">│ 2-3m │</span> <span class="box">│ 5-10m │</span> <span class="box">│ 10-20m │</span> <span class="box">│ 2-3m │</span>
|
||||
<span class="box">└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘</span></pre>
|
||||
|
||||
<dl class="phase-list" style="margin-top:1.5rem;">
|
||||
<dt>1. context gathering <span class="dim">— 2-3 min</span></dt>
|
||||
<dd>读 PR 描述与关联 issue,评估规模,检查 CI 状态,理解业务需求。</dd>
|
||||
<dt>2. high-level review <span class="dim">— 5-10 min</span></dt>
|
||||
<dd>评估架构合理性、性能影响面、文件组织、测试策略。先看全局。</dd>
|
||||
<dt>3. line-by-line analysis <span class="dim">— 10-20 min</span></dt>
|
||||
<dd>逻辑正确性 · 安全 · 性能 · 可维护性 · 边界情况。一一过目。</dd>
|
||||
<dt>4. summary & decision <span class="dim">— 2-3 min</span></dt>
|
||||
<dd>汇总问题,表扬亮点,给出 approve / comment / request-changes。</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<!-- ═══ SEVERITY ═══ -->
|
||||
<h2 class="sec">SEVERITY</h2>
|
||||
<section class="body">
|
||||
<ul class="sev-list">
|
||||
<li>
|
||||
<span class="label"><span class="red">●</span> [blocking]</span>
|
||||
<span class="desc">必须修复 <span class="aside">— 合并前解决;安全漏洞 / 数据正确性 / 严重逻辑</span></span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="label"><span style="color:#d68a3d;">●</span> [important]</span>
|
||||
<span class="desc">应当修复 <span class="aside">— 强烈建议;有分歧应讨论</span></span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="label"><span style="color:#c7a648;">●</span> [nit]</span>
|
||||
<span class="desc">细节建议 <span class="aside">— 风格或偏好,不阻塞合并</span></span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="label"><span class="blue">●</span> [suggestion]</span>
|
||||
<span class="desc">可选优化 <span class="aside">— 替代方案,由作者决定</span></span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="label"><span style="color:#9078b8;">●</span> [learning]</span>
|
||||
<span class="desc">知识分享 <span class="aside">— 教育性说明,无需采取行动</span></span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="label"><span class="green">●</span> [praise]</span>
|
||||
<span class="desc">表扬肯定 <span class="aside">— 看到好代码就说出来</span></span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- ═══ INSTALLATION ═══ -->
|
||||
<h2 class="sec">INSTALLATION</h2>
|
||||
<section class="body">
|
||||
|
||||
<p>克隆到 Claude Code skills 目录。两条命令即可。</p>
|
||||
|
||||
<pre class="codeblock"><span class="cmt"># macOS / Linux</span>
|
||||
<span class="prompt">$</span> <span class="cmd">git clone</span> <span class="arg">https://github.com/awesome-skills/code-review-skill.git</span> \
|
||||
~/.claude/skills/code-review-skill
|
||||
|
||||
<span class="cmt"># Windows PowerShell</span>
|
||||
<span class="prompt">PS></span> <span class="cmd">git clone</span> <span class="arg">https://github.com/awesome-skills/code-review-skill.git</span> `
|
||||
"$env:USERPROFILE\.claude\skills\code-review-skill"</pre>
|
||||
</section>
|
||||
|
||||
<!-- ═══ EXAMPLES ═══ -->
|
||||
<h2 class="sec">EXAMPLES</h2>
|
||||
<section class="body">
|
||||
<ul class="examples">
|
||||
<li>
|
||||
<span class="q">Use code-review-skill to review this PR</span>
|
||||
<span class="note">激活完整四阶段流程</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="q">Review this React component</span>
|
||||
<span class="note">加载 react.md · 检查 Hooks · Server Components</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="q">Security review of this Go service</span>
|
||||
<span class="note">同时加载 go.md + security-review-guide.md</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="q">Architecture review</span>
|
||||
<span class="note">加载架构指南 · SOLID · 反模式 · 耦合度</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- ═══ FILES ═══ -->
|
||||
<h2 class="sec">FILES</h2>
|
||||
<section class="body">
|
||||
<pre class="tree">
|
||||
<span class="dir">~/.claude/skills/code-review-skill/</span>
|
||||
<span class="branch">├──</span> <span class="file">SKILL.md</span> <span class="cmt"># 核心,激活时加载 (~190 行)</span>
|
||||
<span class="branch">├──</span> <span class="file">README.md</span>
|
||||
<span class="branch">├──</span> <span class="file">LICENSE</span> <span class="cmt"># MIT</span>
|
||||
<span class="branch">├──</span> <span class="dir">reference/</span> <span class="cmt"># 按需加载的语言指南</span>
|
||||
<span class="branch">│ ├──</span> <span class="file">react.md</span> <span class="file">vue.md</span> <span class="file">angular.md</span> ...
|
||||
<span class="branch">│ └──</span> <span class="file">architecture-review-guide.md</span> ...
|
||||
<span class="branch">├──</span> <span class="dir">assets/</span>
|
||||
<span class="branch">│ ├──</span> <span class="file">review-checklist.md</span> <span class="cmt"># 快速参考</span>
|
||||
<span class="branch">│ └──</span> <span class="file">pr-review-template.md</span> <span class="cmt"># 评论模板</span>
|
||||
<span class="branch">└──</span> <span class="dir">scripts/</span>
|
||||
<span class="branch">└──</span> <span class="file">pr-analyzer.py</span> <span class="cmt"># PR 复杂度分析</span></pre>
|
||||
</section>
|
||||
|
||||
<!-- ═══ SEE ALSO ═══ -->
|
||||
<h2 class="sec">SEE ALSO</h2>
|
||||
<section class="body">
|
||||
<p>
|
||||
<a class="link" href="https://claude.ai/code" target="_blank">claude-code(1)</a>,
|
||||
<a class="link" href="https://github.com/awesome-skills/code-review-skill" target="_blank">github / awesome-skills</a>,
|
||||
<a class="link" href="https://github.com/awesome-skills/code-review-skill/blob/main/CONTRIBUTING.md" target="_blank">CONTRIBUTING(7)</a>,
|
||||
<a class="link" href="https://github.com/awesome-skills/code-review-skill/blob/main/assets/review-checklist.md" target="_blank">review-checklist(7)</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ═══ AUTHORS ═══ -->
|
||||
<h2 class="sec">AUTHORS</h2>
|
||||
<section class="body">
|
||||
<dl class="bib">
|
||||
<dt>awesome-skills</dt>
|
||||
<dd>maintainer, primary author</dd>
|
||||
<dt>contributors</dt>
|
||||
<dd>see <a class="link" href="https://github.com/awesome-skills/code-review-skill/graphs/contributors" target="_blank">graphs/contributors</a></dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<!-- ═══ COPYRIGHT ═══ -->
|
||||
<h2 class="sec">COPYRIGHT</h2>
|
||||
<section class="body">
|
||||
<p>
|
||||
<span class="dim">Copyright (c) 2025 awesome-skills.</span><br>
|
||||
Released under the MIT License.<br>
|
||||
<span class="dim">This is free software: you are free to change and redistribute it.</span><br>
|
||||
<span class="dim">There is NO WARRANTY, to the extent permitted by law.</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div style="height: 4rem;"></div>
|
||||
|
||||
<!-- ═══ END-OF-PAGE BAND ═══ -->
|
||||
<div style="border-top:1px solid var(--rule); margin-top:2rem; padding:0.625rem 0;">
|
||||
<div style="display:flex; justify-content:space-between; color:var(--fg-dim); font-size:12px; white-space:nowrap; gap:1rem;">
|
||||
<span class="band-l" style="color:var(--fg-bright);">CODE-REVIEW-SKILL(1)</span>
|
||||
<span style="color:var(--fg-dim);">awesome-skills</span>
|
||||
<span class="band-r" style="color:var(--fg-bright);">CODE-REVIEW-SKILL(1)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- ═══ VIM-LIKE STATUS BAR ═══ -->
|
||||
<div class="statusbar">
|
||||
<div class="statusbar-inner">
|
||||
<div class="statusbar-l">
|
||||
<span>-- NORMAL --</span>
|
||||
<span>code-review-skill.1</span>
|
||||
</div>
|
||||
<div class="statusbar-r">
|
||||
<span><kbd>g</kbd> top</span>
|
||||
<span><kbd>G</kbd> end</span>
|
||||
<span><kbd>q</kbd> quit</span>
|
||||
<span id="pos">1,1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Vim-like keyboard nav for the man-page vibe
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||
|
||||
if (e.key === 'g') {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else if (e.key === 'G') {
|
||||
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||
} else if (e.key === 'j') {
|
||||
window.scrollBy({ top: 60, behavior: 'smooth' });
|
||||
} else if (e.key === 'k') {
|
||||
window.scrollBy({ top: -60, behavior: 'smooth' });
|
||||
} else if (e.key === 'q') {
|
||||
const ok = confirm('Quit man page?');
|
||||
if (ok) window.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Update line/col-like indicator from scroll position
|
||||
const posEl = document.getElementById('pos');
|
||||
function updatePos() {
|
||||
const pct = Math.round((window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100) || 0;
|
||||
const line = Math.max(1, Math.round((window.scrollY / 20)));
|
||||
posEl.textContent = line + ',1 ' + (pct >= 99 ? 'Bot' : pct <= 1 ? 'Top' : pct + '%');
|
||||
}
|
||||
updatePos();
|
||||
window.addEventListener('scroll', updatePos, { passive: true });
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+419
@@ -0,0 +1,419 @@
|
||||
# Angular Code Review Guide
|
||||
|
||||
> Angular 17+ 代码审查指南,覆盖 Signals、Standalone 组件、RxJS 反模式、Zoneless 变更检测、模板最佳实践及性能优化等核心主题。
|
||||
|
||||
## 目录
|
||||
|
||||
- [Signals 与变更检测](#signals-与变更检测)
|
||||
- [Standalone 组件迁移](#standalone-组件迁移)
|
||||
- [RxJS 反模式](#rxjs-反模式)
|
||||
- [Zoneless 变更检测](#zoneless-变更检测)
|
||||
- [模板最佳实践](#模板最佳实践)
|
||||
- [性能优化](#性能优化)
|
||||
- [Review Checklist](#review-checklist)
|
||||
|
||||
---
|
||||
|
||||
## Signals 与变更检测
|
||||
|
||||
### Signal + OnPush 自动触发变更检测
|
||||
|
||||
```typescript
|
||||
// ❌ 可变状态 + OnPush = 界面不更新
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `<p>{{ data.name }}</p>`,
|
||||
})
|
||||
export class UserProfile {
|
||||
data = { name: 'Alice' };
|
||||
changeName() { this.data.name = 'Bob'; } // UI 不会更新!
|
||||
}
|
||||
|
||||
// ✅ Signal + OnPush = 自动变更检测
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `<p>{{ name() }}</p>`,
|
||||
})
|
||||
export class UserProfile {
|
||||
name = signal('Alice');
|
||||
changeName() { this.name.set('Bob'); } // 自动触发 CD
|
||||
}
|
||||
```
|
||||
|
||||
### @Input() 对象变异不会被 OnPush 检测
|
||||
|
||||
```typescript
|
||||
// ❌ 变异 Input 对象——引用不变,OnPush 不检测
|
||||
@Input() config!: Config;
|
||||
updateConfig() { this.config.theme = 'dark'; }
|
||||
|
||||
// ✅ 创建新引用
|
||||
updateConfig() { this.config = { ...this.config, theme: 'dark' }; }
|
||||
```
|
||||
|
||||
### computed() 用于派生状态
|
||||
|
||||
```typescript
|
||||
// ❌ effect 用于同步状态——反模式,可能触发额外 CD 周期
|
||||
export class CartComponent {
|
||||
total = signal(0);
|
||||
discounted = signal(0);
|
||||
|
||||
constructor() {
|
||||
effect(() => this.discounted.set(this.total() * 0.9));
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ computed 用于派生状态——惰性计算,无副作用
|
||||
export class CartComponent {
|
||||
total = signal(0);
|
||||
discounted = computed(() => this.total() * 0.9);
|
||||
}
|
||||
```
|
||||
|
||||
### effect() 中 Signal 读取在 await 后不会被追踪
|
||||
|
||||
```typescript
|
||||
// ❌ await 之后读取 Signal——依赖未被追踪
|
||||
effect(async () => {
|
||||
const data = await fetchUserData();
|
||||
console.log(`Theme: ${theme()}`); // theme() 未被追踪!
|
||||
});
|
||||
|
||||
// ✅ 在 await 之前同步读取
|
||||
effect(async () => {
|
||||
const currentTheme = theme(); // 同步读取,被追踪
|
||||
const data = await fetchUserData();
|
||||
console.log(`Theme: ${currentTheme}`);
|
||||
});
|
||||
```
|
||||
|
||||
### effect 只在特定场景使用
|
||||
|
||||
```typescript
|
||||
// ❌ 用 effect 同步两个 Signal——永远用 computed
|
||||
effect(() => { this.filtered.set(this.items().filter(i => i.active)); });
|
||||
|
||||
// ✅ effect 的合理场景:DOM 操作、分析日志、订阅外部源
|
||||
effect(() => {
|
||||
const canvas = this.canvasRef.nativeElement;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = this.color();
|
||||
ctx.fillRect(0, 0, this.size(), this.size());
|
||||
});
|
||||
|
||||
// 💡 "There are no situations where effect is good,
|
||||
// only situations where it is appropriate."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Standalone 组件迁移
|
||||
|
||||
### Angular 19+ standalone 是默认值
|
||||
|
||||
```typescript
|
||||
// ❌ Legacy NgModule 组件
|
||||
@Component({
|
||||
selector: 'old-component',
|
||||
standalone: false,
|
||||
})
|
||||
export class OldComponent {}
|
||||
|
||||
// ✅ 现代 Standalone 组件(Angular 19+ standalone 是默认值)
|
||||
@Component({
|
||||
selector: 'user-profile',
|
||||
imports: [ProfilePhoto, RouterLink],
|
||||
template: `<profile-photo /><a routerLink="/edit">Edit</a>`,
|
||||
})
|
||||
export class UserProfile {}
|
||||
```
|
||||
|
||||
### 审查标记
|
||||
|
||||
```typescript
|
||||
// ⚠️ 需要迁移的信号:
|
||||
// 1. standalone: false
|
||||
// 2. @NgModule declarations
|
||||
// 3. 组件通过 NgModule 而非直接 import
|
||||
|
||||
// ✅ 迁移路径:
|
||||
// 1. 删除 standalone: false
|
||||
// 2. 将依赖添加到组件的 imports 数组
|
||||
// 3. 如果不再有 declarations,删除 NgModule
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RxJS 反模式
|
||||
|
||||
### subscribe() 必须配 takeUntilDestroyed
|
||||
|
||||
```typescript
|
||||
// ❌ 裸 subscribe——内存泄漏!组件销毁后仍继续接收数据
|
||||
@Component({ /* ... */ })
|
||||
export class UserProfile implements OnInit {
|
||||
ngOnInit() {
|
||||
this.data$.subscribe(data => this.processData(data));
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ takeUntilDestroyed——自动在组件销毁时取消(需在构造函数或注入上下文中调用)
|
||||
@Component({ /* ... */ })
|
||||
export class UserProfile {
|
||||
constructor() {
|
||||
this.data$.pipe(takeUntilDestroyed()).subscribe(data => {
|
||||
this.processData(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 在构造函数外使用——传入 DestroyRef
|
||||
@Component({ /* ... */ })
|
||||
export class UserProfile {
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
startListening() {
|
||||
this.data$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(/* ... */);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### toSignal 优于 AsyncPipe
|
||||
|
||||
```typescript
|
||||
// ❌ AsyncPipe——需要导入,模板中有 | async
|
||||
@Component({
|
||||
imports: [AsyncPipe],
|
||||
template: `{{ data$ | async }}`,
|
||||
})
|
||||
|
||||
// ✅ toSignal——自动取消订阅,可在任何地方使用
|
||||
export class UserProfile {
|
||||
data = toSignal(this.data$, { initialValue: null });
|
||||
// 模板直接用 data()
|
||||
}
|
||||
```
|
||||
|
||||
### 避免重复 toSignal 调用
|
||||
|
||||
```typescript
|
||||
// ❌ toSignal 每次调用都创建新订阅
|
||||
getData() {
|
||||
return toSignal(this.http.get('/api/data'));
|
||||
}
|
||||
|
||||
// ✅ 存储结果
|
||||
data = toSignal(this.http.get('/api/data'), { initialValue: null });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Zoneless 变更检测
|
||||
|
||||
### 普通属性变异不会被检测(Angular 21+)
|
||||
|
||||
```typescript
|
||||
// ❌ Zoneless 下普通属性赋值不触发 CD
|
||||
export class UserService {
|
||||
user: User | null = null;
|
||||
loadUser() { this.user = fetchResult; } // 不触发!
|
||||
}
|
||||
|
||||
// ✅ Signal 自动触发 CD
|
||||
export class UserService {
|
||||
private _user = signal<User | null>(null);
|
||||
readonly user = this._user.asReadonly();
|
||||
loadUser() { this._user.set(fetchResult); }
|
||||
}
|
||||
```
|
||||
|
||||
### NgZone API 在 Zoneless 中失效
|
||||
|
||||
```typescript
|
||||
// ❌ NgZone.onStable 在 zoneless 中永远不会触发
|
||||
ngZone.onStable.subscribe(() => { /* 永远不触发 */ });
|
||||
|
||||
// ✅ 使用 afterNextRender
|
||||
afterNextRender({ write: () => { /* CD 之后执行 */ } });
|
||||
```
|
||||
|
||||
### Reactive Forms 变异需要 markForCheck
|
||||
|
||||
```typescript
|
||||
// ❌ Reactive Forms 的 setValue/patchValue 在 zoneless 中不自动调度 CD
|
||||
this.form.patchValue({ name: 'Alice' }); // UI 可能不更新
|
||||
|
||||
// ✅ 手动标记或通过 Signal 反映
|
||||
this.form.patchValue({ name: 'Alice' });
|
||||
this.cdr.markForCheck();
|
||||
```
|
||||
|
||||
### Zoneless 下有效的 CD 触发器
|
||||
|
||||
| 触发器 | 说明 |
|
||||
|--------|------|
|
||||
| `signal.set()` / `.update()` | Signal 更新自动触发 |
|
||||
| `ChangeDetectorRef.markForCheck()` | 手动标记 |
|
||||
| `ComponentRef.setInput()` | 输入绑定 |
|
||||
| 模板事件监听器回调 | 用户交互 |
|
||||
|
||||
---
|
||||
|
||||
## 模板最佳实践
|
||||
|
||||
### 复杂逻辑提取为 computed Signal
|
||||
|
||||
```typescript
|
||||
// ❌ 模板中复杂表达式
|
||||
template: `<div *ngIf="items.filter(i => i.active).length > 0 && user.role === 'admin'">`
|
||||
|
||||
// ✅ 提取为 computed
|
||||
filteredItems = computed(() => this.items().filter(i => i.active));
|
||||
shouldShow = computed(() => this.filteredItems().length > 0 && this.user().role === 'admin');
|
||||
template: `@if (shouldShow()) { <div>...</div> }`
|
||||
```
|
||||
|
||||
### 原生绑定优于 NgClass / NgStyle
|
||||
|
||||
```typescript
|
||||
// ❌ NgClass/NgStyle——额外指令开销
|
||||
template: `<div [ngClass]="{active: isActive}" [ngStyle]="{'color': textColor}">`
|
||||
|
||||
// ✅ 原生 class/style 绑定——性能更好
|
||||
template: `<div [class.active]="isActive" [style.color]="textColor">`
|
||||
```
|
||||
|
||||
### 模板专用成员标记 protected
|
||||
|
||||
```typescript
|
||||
// ❂ 模板专用方法暴露为 public
|
||||
export class UserProfile {
|
||||
formatName(name: string) { return name.trim(); }
|
||||
}
|
||||
|
||||
// ✅ 模板专用成员用 protected
|
||||
export class UserProfile {
|
||||
protected formatName(name: string) { return name.trim(); }
|
||||
}
|
||||
```
|
||||
|
||||
### Angular 管理的属性标记 readonly
|
||||
|
||||
```typescript
|
||||
// ❌ input/output/model 可被意外覆盖
|
||||
userId = input<string>();
|
||||
userSaved = output<void>();
|
||||
|
||||
// ✅ readonly 防止意外赋值
|
||||
readonly userId = input<string>();
|
||||
readonly userSaved = output<void>();
|
||||
readonly userName = model<string>();
|
||||
```
|
||||
|
||||
### 命名规范:操作名而非事件名
|
||||
|
||||
```typescript
|
||||
// ❌ 以事件命名
|
||||
template: `<button (click)="handleClick()">Save</button>`
|
||||
|
||||
// ✅ 以操作命名
|
||||
template: `<button (click)="saveUserData()">Save</button>`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 性能优化
|
||||
|
||||
### effect 是最后手段——优先 computed
|
||||
|
||||
```typescript
|
||||
// ❌ effect 用于状态同步——触发额外 CD,可能无限循环
|
||||
effect(() => {
|
||||
this.filteredItems.set(this.items().filter(i => i.active));
|
||||
});
|
||||
|
||||
// ✅ computed——惰性计算,无副作用,无额外 CD
|
||||
filteredItems = computed(() => this.items().filter(i => i.active));
|
||||
```
|
||||
|
||||
### afterRenderEffect 分离读写阶段
|
||||
|
||||
```typescript
|
||||
// ❌ 无阶段指定 = mixedReadWrite = 额外 DOM 回流
|
||||
afterRenderEffect(() => {
|
||||
const height = el.offsetHeight; // 读
|
||||
el.style.height = height + 10 + 'px'; // 写
|
||||
});
|
||||
|
||||
// ✅ 分离阶段减少回流
|
||||
afterRenderEffect({
|
||||
earlyRead: () => el.offsetHeight,
|
||||
write: (height) => { el.style.height = height() + 10 + 'px'; },
|
||||
read: () => verifyLayout(),
|
||||
});
|
||||
```
|
||||
|
||||
### inject() 优于构造函数注入
|
||||
|
||||
```typescript
|
||||
// ❌ 构造函数注入——多依赖时难以阅读
|
||||
export class UserService {
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
private router: Router,
|
||||
private auth: AuthService,
|
||||
) {}
|
||||
}
|
||||
|
||||
// ✅ inject()——更好的类型推断和可读性
|
||||
export class UserService {
|
||||
private http = inject(HttpClient);
|
||||
private router = inject(Router);
|
||||
private auth = inject(AuthService);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Review Checklist
|
||||
|
||||
### Signals 与变更检测
|
||||
|
||||
- [ ] Signal + OnPush 用于模板状态(非可变对象)
|
||||
- [ ] `@Input()` 对象通过新引用更新(非变异)
|
||||
- [ ] 派生状态用 `computed()`,不用 `effect()`
|
||||
- [ ] `effect()` 中 Signal 读取在 `await` 之前
|
||||
- [ ] `effect()` 只用于 DOM 操作、日志、外部源订阅
|
||||
|
||||
### Standalone 组件
|
||||
|
||||
- [ ] 无 `standalone: false`(Angular 19+)
|
||||
- [ ] 组件通过 `imports` 数组导入依赖
|
||||
- [ ] 无不必要的 `@NgModule`
|
||||
|
||||
### RxJS
|
||||
|
||||
- [ ] `.subscribe()` 配 `takeUntilDestroyed` 或 `async` pipe
|
||||
- [ ] 优先 `toSignal` 而非 `AsyncPipe`
|
||||
- [ ] 无重复 `toSignal` 调用
|
||||
|
||||
### Zoneless
|
||||
|
||||
- [ ] 模板状态通过 Signal 管理(非普通属性)
|
||||
- [ ] 无 `NgZone.onStable` / `NgZone.onMicrotaskEmpty`
|
||||
- [ ] Reactive Forms 变异后有 `markForCheck()`
|
||||
|
||||
### 模板
|
||||
|
||||
- [ ] 复杂逻辑提取为 `computed` Signal
|
||||
- [ ] 使用原生 `[class]`/`[style]` 而非 `NgClass`/`NgStyle`
|
||||
- [ ] 模板专用成员标记 `protected`
|
||||
- [ ] `input`/`output`/`model` 属性标记 `readonly`
|
||||
- [ ] 事件处理器以操作命名(`saveData` 而非 `handleClick`)
|
||||
|
||||
### 性能
|
||||
|
||||
- [ ] `effect()` 不用于状态同步
|
||||
- [ ] `afterRenderEffect` 分离读写阶段
|
||||
- [ ] `inject()` 用于依赖注入
|
||||
@@ -0,0 +1,472 @@
|
||||
# Architecture Review Guide
|
||||
|
||||
架构设计审查指南,帮助评估代码的架构是否合理、设计是否恰当。
|
||||
|
||||
## SOLID 原则检查清单
|
||||
|
||||
### S - 单一职责原则 (SRP)
|
||||
|
||||
**检查要点:**
|
||||
- 这个类/模块是否只有一个改变的理由?
|
||||
- 类中的方法是否都服务于同一个目的?
|
||||
- 如果要向非技术人员描述这个类,能否用一句话说清楚?
|
||||
|
||||
**代码审查中的识别信号:**
|
||||
```
|
||||
⚠️ 类名包含 "And"、"Manager"、"Handler"、"Processor" 等泛化词汇
|
||||
⚠️ 一个类超过 200-300 行代码
|
||||
⚠️ 类有超过 5-7 个公共方法
|
||||
⚠️ 不同的方法操作完全不同的数据
|
||||
```
|
||||
|
||||
**审查问题:**
|
||||
- "这个类负责哪些事情?能否拆分?"
|
||||
- "如果 X 需求变化,哪些方法需要改?如果 Y 需求变化呢?"
|
||||
|
||||
### O - 开闭原则 (OCP)
|
||||
|
||||
**检查要点:**
|
||||
- 添加新功能时,是否需要修改现有代码?
|
||||
- 是否可以通过扩展(继承、组合)来添加新行为?
|
||||
- 是否存在大量的 if/else 或 switch 语句来处理不同类型?
|
||||
|
||||
**代码审查中的识别信号:**
|
||||
```
|
||||
⚠️ switch/if-else 链处理不同类型
|
||||
⚠️ 添加新功能需要修改核心类
|
||||
⚠️ 类型检查 (instanceof, typeof) 散布在代码中
|
||||
```
|
||||
|
||||
**审查问题:**
|
||||
- "如果要添加新的 X 类型,需要修改哪些文件?"
|
||||
- "这个 switch 语句会随着新类型增加而增长吗?"
|
||||
|
||||
### L - 里氏替换原则 (LSP)
|
||||
|
||||
**检查要点:**
|
||||
- 子类是否可以完全替代父类使用?
|
||||
- 子类是否改变了父类方法的预期行为?
|
||||
- 是否存在子类抛出父类未声明的异常?
|
||||
|
||||
**代码审查中的识别信号:**
|
||||
```
|
||||
⚠️ 显式类型转换 (casting)
|
||||
⚠️ 子类方法抛出 NotImplementedException
|
||||
⚠️ 子类方法为空实现或只有 return
|
||||
⚠️ 使用基类的地方需要检查具体类型
|
||||
```
|
||||
|
||||
**审查问题:**
|
||||
- "如果用子类替换父类,调用方代码是否需要修改?"
|
||||
- "这个方法在子类中的行为是否符合父类的契约?"
|
||||
|
||||
### I - 接口隔离原则 (ISP)
|
||||
|
||||
**检查要点:**
|
||||
- 接口是否足够小且专注?
|
||||
- 实现类是否被迫实现不需要的方法?
|
||||
- 客户端是否依赖了它不使用的方法?
|
||||
|
||||
**代码审查中的识别信号:**
|
||||
```
|
||||
⚠️ 接口超过 5-7 个方法
|
||||
⚠️ 实现类有空方法或抛出 NotImplementedException
|
||||
⚠️ 接口名称过于宽泛 (IManager, IService)
|
||||
⚠️ 不同的客户端只使用接口的部分方法
|
||||
```
|
||||
|
||||
**审查问题:**
|
||||
- "这个接口的所有方法是否都被每个实现类使用?"
|
||||
- "能否将这个大接口拆分为更小的专用接口?"
|
||||
|
||||
### D - 依赖倒置原则 (DIP)
|
||||
|
||||
**检查要点:**
|
||||
- 高层模块是否依赖于抽象而非具体实现?
|
||||
- 是否使用依赖注入而非直接 new 对象?
|
||||
- 抽象是否由高层模块定义而非低层模块?
|
||||
|
||||
**代码审查中的识别信号:**
|
||||
```
|
||||
⚠️ 高层模块直接 new 低层模块的具体类
|
||||
⚠️ 导入具体实现类而非接口/抽象类
|
||||
⚠️ 配置和连接字符串硬编码在业务逻辑中
|
||||
⚠️ 难以为某个类编写单元测试
|
||||
```
|
||||
|
||||
**审查问题:**
|
||||
- "这个类的依赖能否在测试时被 mock 替换?"
|
||||
- "如果要更换数据库/API 实现,需要修改多少地方?"
|
||||
|
||||
---
|
||||
|
||||
## 架构反模式识别
|
||||
|
||||
### 致命反模式
|
||||
|
||||
| 反模式 | 识别信号 | 影响 |
|
||||
|--------|----------|------|
|
||||
| **大泥球 (Big Ball of Mud)** | 没有清晰的模块边界,任何代码都可能调用任何其他代码 | 难以理解、修改和测试 |
|
||||
| **上帝类 (God Object)** | 单个类承担过多职责,知道太多、做太多 | 高耦合,难以重用和测试 |
|
||||
| **意大利面条代码** | 控制流程混乱,goto 或深层嵌套,难以追踪执行路径 | 难以理解和维护 |
|
||||
| **熔岩流 (Lava Flow)** | 没人敢动的古老代码,缺乏文档和测试 | 技术债务累积 |
|
||||
|
||||
### 设计反模式
|
||||
|
||||
| 反模式 | 识别信号 | 建议 |
|
||||
|--------|----------|------|
|
||||
| **金锤子 (Golden Hammer)** | 对所有问题使用同一种技术/模式 | 根据问题选择合适的解决方案 |
|
||||
| **过度工程 (Gas Factory)** | 简单问题用复杂方案解决,滥用设计模式 | YAGNI 原则,先简单后复杂 |
|
||||
| **船锚 (Boat Anchor)** | 为"将来可能需要"而写的未使用代码 | 删除未使用代码,需要时再写 |
|
||||
| **复制粘贴编程** | 相同逻辑出现在多处 | 提取公共方法或模块 |
|
||||
|
||||
### 审查问题
|
||||
|
||||
```markdown
|
||||
🔴 [blocking] "这个类有 2000 行代码,建议拆分为多个专注的类"
|
||||
🟡 [important] "这段逻辑在 3 个地方重复,考虑提取为公共方法?"
|
||||
💡 [suggestion] "这个 switch 语句可以用策略模式替代,更易扩展"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 耦合度与内聚性评估
|
||||
|
||||
### 耦合类型(从好到差)
|
||||
|
||||
| 类型 | 描述 | 示例 |
|
||||
|------|------|------|
|
||||
| **消息耦合** ✅ | 通过参数传递数据 | `calculate(price, quantity)` |
|
||||
| **数据耦合** ✅ | 共享简单数据结构 | `processOrder(orderDTO)` |
|
||||
| **印记耦合** ⚠️ | 共享复杂数据结构但只用部分 | 传入整个 User 对象但只用 name |
|
||||
| **控制耦合** ⚠️ | 传递控制标志影响行为 | `process(data, isAdmin=true)` |
|
||||
| **公共耦合** ❌ | 共享全局变量 | 多个模块读写同一个全局状态 |
|
||||
| **内容耦合** ❌ | 直接访问另一模块的内部 | 直接操作另一个类的私有属性 |
|
||||
|
||||
### 内聚类型(从好到差)
|
||||
|
||||
| 类型 | 描述 | 质量 |
|
||||
|------|------|------|
|
||||
| **功能内聚** | 所有元素完成单一任务 | ✅ 最佳 |
|
||||
| **顺序内聚** | 输出作为下一步输入 | ✅ 良好 |
|
||||
| **通信内聚** | 操作相同数据 | ⚠️ 可接受 |
|
||||
| **时间内聚** | 同时执行的任务 | ⚠️ 较差 |
|
||||
| **逻辑内聚** | 逻辑相关但功能不同 | ❌ 差 |
|
||||
| **偶然内聚** | 没有明显关系 | ❌ 最差 |
|
||||
|
||||
### 度量指标参考
|
||||
|
||||
```yaml
|
||||
耦合指标:
|
||||
CBO (类间耦合):
|
||||
好: < 5
|
||||
警告: 5-10
|
||||
危险: > 10
|
||||
|
||||
Ce (传出耦合):
|
||||
描述: 依赖多少外部类
|
||||
好: < 7
|
||||
|
||||
Ca (传入耦合):
|
||||
描述: 被多少类依赖
|
||||
高值意味着: 修改影响大,需要稳定
|
||||
|
||||
内聚指标:
|
||||
LCOM4 (方法缺乏内聚):
|
||||
1: 单一职责 ✅
|
||||
2-3: 可能需要拆分 ⚠️
|
||||
>3: 应该拆分 ❌
|
||||
```
|
||||
|
||||
### 审查问题
|
||||
|
||||
- "这个模块依赖了多少其他模块?能否减少?"
|
||||
- "修改这个类会影响多少其他地方?"
|
||||
- "这个类的方法是否都操作相同的数据?"
|
||||
|
||||
---
|
||||
|
||||
## 分层架构审查
|
||||
|
||||
### Clean Architecture 层次检查
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Frameworks & Drivers │ ← 最外层:Web、DB、UI
|
||||
├─────────────────────────────────────┤
|
||||
│ Interface Adapters │ ← Controllers、Gateways、Presenters
|
||||
├─────────────────────────────────────┤
|
||||
│ Application Layer │ ← Use Cases、Application Services
|
||||
├─────────────────────────────────────┤
|
||||
│ Domain Layer │ ← Entities、Domain Services
|
||||
└─────────────────────────────────────┘
|
||||
↑ 依赖方向只能向内 ↑
|
||||
```
|
||||
|
||||
### 依赖规则检查
|
||||
|
||||
**核心规则:源代码依赖只能指向内层**
|
||||
|
||||
```typescript
|
||||
// ❌ 违反依赖规则:Domain 层依赖 Infrastructure
|
||||
// domain/User.ts
|
||||
import { MySQLConnection } from '../infrastructure/database';
|
||||
|
||||
// ✅ 正确:Domain 层定义接口,Infrastructure 实现
|
||||
// domain/UserRepository.ts (接口)
|
||||
interface UserRepository {
|
||||
findById(id: string): Promise<User>;
|
||||
}
|
||||
|
||||
// infrastructure/MySQLUserRepository.ts (实现)
|
||||
class MySQLUserRepository implements UserRepository {
|
||||
findById(id: string): Promise<User> { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### 审查清单
|
||||
|
||||
**层次边界检查:**
|
||||
- [ ] Domain 层是否有外部依赖(数据库、HTTP、文件系统)?
|
||||
- [ ] Application 层是否直接操作数据库或调用外部 API?
|
||||
- [ ] Controller 是否包含业务逻辑?
|
||||
- [ ] 是否存在跨层调用(UI 直接调用 Repository)?
|
||||
|
||||
**关注点分离检查:**
|
||||
- [ ] 业务逻辑是否与展示逻辑分离?
|
||||
- [ ] 数据访问是否封装在专门的层?
|
||||
- [ ] 配置和环境相关代码是否集中管理?
|
||||
|
||||
### 审查问题
|
||||
|
||||
```markdown
|
||||
🔴 [blocking] "Domain 实体直接导入了数据库连接,违反依赖规则"
|
||||
🟡 [important] "Controller 包含业务计算逻辑,建议移到 Service 层"
|
||||
💡 [suggestion] "考虑使用依赖注入来解耦这些组件"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 设计模式使用评估
|
||||
|
||||
### 何时使用设计模式
|
||||
|
||||
| 模式 | 适用场景 | 不适用场景 |
|
||||
|------|----------|------------|
|
||||
| **Factory** | 需要创建不同类型对象,类型在运行时确定 | 只有一种类型,或类型固定不变 |
|
||||
| **Strategy** | 算法需要在运行时切换,有多种可互换的行为 | 只有一种算法,或算法不会变化 |
|
||||
| **Observer** | 一对多依赖,状态变化需要通知多个对象 | 简单的直接调用即可满足需求 |
|
||||
| **Singleton** | 确实需要全局唯一实例,如配置管理 | 可以通过依赖注入传递的对象 |
|
||||
| **Decorator** | 需要动态添加职责,避免继承爆炸 | 职责固定,不需要动态组合 |
|
||||
|
||||
### 过度设计警告信号
|
||||
|
||||
```
|
||||
⚠️ Patternitis(模式炎)识别信号:
|
||||
|
||||
1. 简单的 if/else 被替换为策略模式 + 工厂 + 注册表
|
||||
2. 只有一个实现的接口
|
||||
3. 为了"将来可能需要"而添加的抽象层
|
||||
4. 代码行数因模式应用而大幅增加
|
||||
5. 新人需要很长时间才能理解代码结构
|
||||
```
|
||||
|
||||
### 审查原则
|
||||
|
||||
```markdown
|
||||
✅ 正确使用模式:
|
||||
- 解决了实际的可扩展性问题
|
||||
- 代码更容易理解和测试
|
||||
- 添加新功能变得更简单
|
||||
|
||||
❌ 过度使用模式:
|
||||
- 为了使用模式而使用
|
||||
- 增加了不必要的复杂度
|
||||
- 违反了 YAGNI 原则
|
||||
```
|
||||
|
||||
### 审查问题
|
||||
|
||||
- "使用这个模式解决了什么具体问题?"
|
||||
- "如果不用这个模式,代码会有什么问题?"
|
||||
- "这个抽象层带来的价值是否大于它的复杂度?"
|
||||
|
||||
---
|
||||
|
||||
## 可扩展性评估
|
||||
|
||||
### 扩展性检查清单
|
||||
|
||||
**功能扩展性:**
|
||||
- [ ] 添加新功能是否需要修改核心代码?
|
||||
- [ ] 是否提供了扩展点(hooks、plugins、events)?
|
||||
- [ ] 配置是否外部化(配置文件、环境变量)?
|
||||
|
||||
**数据扩展性:**
|
||||
- [ ] 数据模型是否支持新增字段?
|
||||
- [ ] 是否考虑了数据量增长的场景?
|
||||
- [ ] 查询是否有合适的索引?
|
||||
|
||||
**负载扩展性:**
|
||||
- [ ] 是否可以水平扩展(添加更多实例)?
|
||||
- [ ] 是否有状态依赖(session、本地缓存)?
|
||||
- [ ] 数据库连接是否使用连接池?
|
||||
|
||||
### 扩展点设计检查
|
||||
|
||||
```typescript
|
||||
// ✅ 好的扩展设计:使用事件/钩子
|
||||
class OrderService {
|
||||
private hooks: OrderHooks;
|
||||
|
||||
async createOrder(order: Order) {
|
||||
await this.hooks.beforeCreate?.(order);
|
||||
const result = await this.save(order);
|
||||
await this.hooks.afterCreate?.(result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 差的扩展设计:硬编码所有行为
|
||||
class OrderService {
|
||||
async createOrder(order: Order) {
|
||||
await this.sendEmail(order); // 硬编码
|
||||
await this.updateInventory(order); // 硬编码
|
||||
await this.notifyWarehouse(order); // 硬编码
|
||||
return await this.save(order);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 审查问题
|
||||
|
||||
```markdown
|
||||
💡 [suggestion] "如果将来需要支持新的支付方式,这个设计是否容易扩展?"
|
||||
🟡 [important] "这里的逻辑是硬编码的,考虑使用配置或策略模式?"
|
||||
📚 [learning] "事件驱动架构可以让这个功能更容易扩展"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 代码结构最佳实践
|
||||
|
||||
### 目录组织
|
||||
|
||||
**按功能/领域组织(推荐):**
|
||||
```
|
||||
src/
|
||||
├── user/
|
||||
│ ├── User.ts (实体)
|
||||
│ ├── UserService.ts (服务)
|
||||
│ ├── UserRepository.ts (数据访问)
|
||||
│ └── UserController.ts (API)
|
||||
├── order/
|
||||
│ ├── Order.ts
|
||||
│ ├── OrderService.ts
|
||||
│ └── ...
|
||||
└── shared/
|
||||
├── utils/
|
||||
└── types/
|
||||
```
|
||||
|
||||
**按技术层组织(不推荐):**
|
||||
```
|
||||
src/
|
||||
├── controllers/ ← 不同领域混在一起
|
||||
│ ├── UserController.ts
|
||||
│ └── OrderController.ts
|
||||
├── services/
|
||||
├── repositories/
|
||||
└── models/
|
||||
```
|
||||
|
||||
### 命名约定检查
|
||||
|
||||
| 类型 | 约定 | 示例 |
|
||||
|------|------|------|
|
||||
| 类名 | PascalCase,名词 | `UserService`, `OrderRepository` |
|
||||
| 方法名 | camelCase,动词 | `createUser`, `findOrderById` |
|
||||
| 接口名 | I 前缀或无前缀 | `IUserService` 或 `UserService` |
|
||||
| 常量 | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT` |
|
||||
| 私有属性 | 下划线前缀或无 | `_cache` 或 `#cache` |
|
||||
|
||||
### 文件大小指南
|
||||
|
||||
```yaml
|
||||
建议限制:
|
||||
单个文件: < 300 行
|
||||
单个函数: < 50 行
|
||||
单个类: < 200 行
|
||||
函数参数: < 4 个
|
||||
嵌套深度: < 4 层
|
||||
|
||||
超出限制时:
|
||||
- 考虑拆分为更小的单元
|
||||
- 使用组合而非继承
|
||||
- 提取辅助函数或类
|
||||
```
|
||||
|
||||
### 审查问题
|
||||
|
||||
```markdown
|
||||
🟢 [nit] "这个 500 行的文件可以考虑按职责拆分"
|
||||
🟡 [important] "建议按功能领域而非技术层组织目录结构"
|
||||
💡 [suggestion] "函数名 `process` 不够明确,考虑改为 `calculateOrderTotal`?"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速参考清单
|
||||
|
||||
### 架构审查 5 分钟速查
|
||||
|
||||
```markdown
|
||||
□ 依赖方向是否正确?(外层依赖内层)
|
||||
□ 是否存在循环依赖?
|
||||
□ 核心业务逻辑是否与框架/UI/数据库解耦?
|
||||
□ 是否遵循 SOLID 原则?
|
||||
□ 是否存在明显的反模式?
|
||||
```
|
||||
|
||||
### 红旗信号(必须处理)
|
||||
|
||||
```markdown
|
||||
🔴 God Object - 单个类超过 1000 行
|
||||
🔴 循环依赖 - A → B → C → A
|
||||
🔴 Domain 层包含框架依赖
|
||||
🔴 硬编码的配置和密钥
|
||||
🔴 没有接口的外部服务调用
|
||||
```
|
||||
|
||||
### 黄旗信号(建议处理)
|
||||
|
||||
```markdown
|
||||
🟡 类间耦合度 (CBO) > 10
|
||||
🟡 方法参数超过 5 个
|
||||
🟡 嵌套深度超过 4 层
|
||||
🟡 重复代码块 > 10 行
|
||||
🟡 只有一个实现的接口
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工具推荐
|
||||
|
||||
| 工具 | 用途 | 语言支持 |
|
||||
|------|------|----------|
|
||||
| **SonarQube** | 代码质量、耦合度分析 | 多语言 |
|
||||
| **NDepend** | 依赖分析、架构规则 | .NET |
|
||||
| **JDepend** | 包依赖分析 | Java |
|
||||
| **Madge** | 模块依赖图 | JavaScript/TypeScript |
|
||||
| **ESLint** | 代码规范、复杂度检查 | JavaScript/TypeScript |
|
||||
| **CodeScene** | 技术债务、热点分析 | 多语言 |
|
||||
|
||||
---
|
||||
|
||||
## 参考资源
|
||||
|
||||
- [Clean Architecture - Uncle Bob](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
|
||||
- [SOLID Principles in Code Review - JetBrains](https://blog.jetbrains.com/upsource/2015/08/31/what-to-look-for-in-a-code-review-solid-principles-2/)
|
||||
- [Software Architecture Anti-Patterns](https://medium.com/@christophnissle/anti-patterns-in-software-architecture-3c8970c9c4f5)
|
||||
- [Coupling and Cohesion in System Design](https://www.geeksforgeeks.org/system-design/coupling-and-cohesion-in-system-design/)
|
||||
- [Design Patterns - Refactoring Guru](https://refactoring.guru/design-patterns)
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
# C Code Review Guide
|
||||
|
||||
> C code review guide focused on memory safety, undefined behavior, and portability. Examples assume C11.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Pointer and Buffer Safety](#pointer-and-buffer-safety)
|
||||
- [Ownership and Resource Management](#ownership-and-resource-management)
|
||||
- [Undefined Behavior Pitfalls](#undefined-behavior-pitfalls)
|
||||
- [Integer Types and Overflow](#integer-types-and-overflow)
|
||||
- [Error Handling](#error-handling)
|
||||
- [Concurrency](#concurrency)
|
||||
- [Macros and Preprocessor](#macros-and-preprocessor)
|
||||
- [API Design and Const](#api-design-and-const)
|
||||
- [Tooling and Build Checks](#tooling-and-build-checks)
|
||||
- [Review Checklist](#review-checklist)
|
||||
|
||||
---
|
||||
|
||||
## Pointer and Buffer Safety
|
||||
|
||||
### Always carry size with buffers
|
||||
|
||||
```c
|
||||
// ❌ Bad: ignores destination size
|
||||
bool copy_name(char *dst, size_t dst_size, const char *src) {
|
||||
strcpy(dst, src);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ✅ Good: validate size and terminate
|
||||
bool copy_name(char *dst, size_t dst_size, const char *src) {
|
||||
size_t len = strlen(src);
|
||||
if (len + 1 > dst_size) {
|
||||
return false;
|
||||
}
|
||||
memcpy(dst, src, len + 1);
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
### Avoid dangerous APIs
|
||||
|
||||
Prefer `snprintf`, `fgets`, and explicit bounds over `gets`, `strcpy`, or `sprintf`.
|
||||
|
||||
```c
|
||||
// ❌ Bad: unbounded write
|
||||
sprintf(buf, "%s", input);
|
||||
|
||||
// ✅ Good: bounded write
|
||||
snprintf(buf, buf_size, "%s", input);
|
||||
```
|
||||
|
||||
### Use the right copy primitive
|
||||
|
||||
```c
|
||||
// ❌ Bad: memcpy with overlapping regions
|
||||
memcpy(dst, src, len);
|
||||
|
||||
// ✅ Good: memmove handles overlap
|
||||
memmove(dst, src, len);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ownership and Resource Management
|
||||
|
||||
### One allocation, one free
|
||||
|
||||
Track ownership and clean up on every error path.
|
||||
|
||||
```c
|
||||
// ✅ Good: cleanup label avoids leaks
|
||||
int load_file(const char *path) {
|
||||
int rc = -1;
|
||||
FILE *f = NULL;
|
||||
char *buf = NULL;
|
||||
|
||||
f = fopen(path, "rb");
|
||||
if (!f) {
|
||||
goto cleanup;
|
||||
}
|
||||
buf = malloc(4096);
|
||||
if (!buf) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (fread(buf, 1, 4096, f) == 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
rc = 0;
|
||||
|
||||
cleanup:
|
||||
free(buf);
|
||||
if (f) {
|
||||
fclose(f);
|
||||
}
|
||||
return rc;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Undefined Behavior Pitfalls
|
||||
|
||||
### Common UB patterns
|
||||
|
||||
```c
|
||||
// ❌ Bad: use after free
|
||||
char *p = malloc(10);
|
||||
free(p);
|
||||
p[0] = 'a';
|
||||
|
||||
// ❌ Bad: uninitialized read
|
||||
int x;
|
||||
if (x > 0) { /* UB */ }
|
||||
|
||||
// ❌ Bad: signed overflow
|
||||
int sum = a + b;
|
||||
```
|
||||
|
||||
### Avoid pointer arithmetic past the object
|
||||
|
||||
```c
|
||||
// ❌ Bad: pointer past the end then dereference
|
||||
int arr[4];
|
||||
int *p = arr + 4;
|
||||
int v = *p; // UB
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integer Types and Overflow
|
||||
|
||||
### Avoid signed/unsigned surprises
|
||||
|
||||
```c
|
||||
// ❌ Bad: negative converted to large size_t
|
||||
int len = -1;
|
||||
size_t n = len;
|
||||
|
||||
// ✅ Good: validate before converting
|
||||
if (len < 0) {
|
||||
return -1;
|
||||
}
|
||||
size_t n = (size_t)len;
|
||||
```
|
||||
|
||||
### Check for overflow in size calculations
|
||||
|
||||
```c
|
||||
// ❌ Bad: potential overflow in multiplication
|
||||
size_t bytes = count * sizeof(Item);
|
||||
|
||||
// ✅ Good: check before multiplying
|
||||
if (count > SIZE_MAX / sizeof(Item)) {
|
||||
return NULL;
|
||||
}
|
||||
size_t bytes = count * sizeof(Item);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Always check return values
|
||||
|
||||
```c
|
||||
// ❌ Bad: ignore errors
|
||||
fread(buf, 1, size, f);
|
||||
|
||||
// ✅ Good: handle errors
|
||||
size_t read = fread(buf, 1, size, f);
|
||||
if (read != size && ferror(f)) {
|
||||
return -1;
|
||||
}
|
||||
```
|
||||
|
||||
### Consistent error contracts
|
||||
|
||||
- Use a clear convention: 0 for success, negative for failure.
|
||||
- Document ownership rules on success and failure.
|
||||
- If using `errno`, set it only for actual failures.
|
||||
|
||||
---
|
||||
|
||||
## Concurrency
|
||||
|
||||
### volatile is not synchronization
|
||||
|
||||
```c
|
||||
// ❌ Bad: data race
|
||||
volatile int stop = 0;
|
||||
void worker(void) {
|
||||
while (!stop) { /* ... */ }
|
||||
}
|
||||
|
||||
// ✅ Good: C11 atomics
|
||||
_Atomic int stop = 0;
|
||||
void worker(void) {
|
||||
while (!atomic_load(&stop)) { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### Use mutexes for shared state
|
||||
|
||||
Protect shared data with `pthread_mutex_t` or equivalent. Avoid holding locks while doing I/O.
|
||||
|
||||
---
|
||||
|
||||
## Macros and Preprocessor
|
||||
|
||||
### Parenthesize arguments
|
||||
|
||||
```c
|
||||
// ❌ Bad: macro with side effects
|
||||
#define MIN(a, b) ((a) < (b) ? (a) : (b))
|
||||
int x = MIN(i++, j++);
|
||||
|
||||
// ✅ Good: static inline function
|
||||
static inline int min_int(int a, int b) {
|
||||
return a < b ? a : b;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Design and Const
|
||||
|
||||
### Const-correctness and sizes
|
||||
|
||||
```c
|
||||
// ✅ Good: explicit size and const input
|
||||
int hash_bytes(const uint8_t *data, size_t len, uint8_t *out);
|
||||
```
|
||||
|
||||
### Document nullability
|
||||
|
||||
Clearly document whether pointers may be NULL. Prefer returning error codes instead of NULL when possible.
|
||||
|
||||
---
|
||||
|
||||
## Tooling and Build Checks
|
||||
|
||||
```bash
|
||||
# Warnings
|
||||
clang -Wall -Wextra -Werror -Wconversion -Wshadow -std=c11 ...
|
||||
|
||||
# Sanitizers (debug builds)
|
||||
clang -fsanitize=address,undefined -fno-omit-frame-pointer -g ...
|
||||
clang -fsanitize=thread -fno-omit-frame-pointer -g ...
|
||||
|
||||
# Static analysis
|
||||
clang-tidy src/*.c -- -std=c11
|
||||
cppcheck --enable=warning,performance,portability src/
|
||||
|
||||
# Formatting
|
||||
clang-format -i src/*.c include/*.h
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Review Checklist
|
||||
|
||||
### Memory and UB
|
||||
- [ ] All buffers have explicit size parameters
|
||||
- [ ] No out-of-bounds access or pointer arithmetic past objects
|
||||
- [ ] No use after free or uninitialized reads
|
||||
- [ ] Signed overflow and shift rules are respected
|
||||
|
||||
### API and Design
|
||||
- [ ] Ownership rules are documented and consistent
|
||||
- [ ] const-correctness is applied for inputs
|
||||
- [ ] Error contracts are clear and consistent
|
||||
|
||||
### Concurrency
|
||||
- [ ] No data races on shared state
|
||||
- [ ] volatile is not used for synchronization
|
||||
- [ ] Locks are held for minimal time
|
||||
|
||||
### Tooling and Tests
|
||||
- [ ] Builds clean with warnings enabled
|
||||
- [ ] Sanitizers run on critical code paths
|
||||
- [ ] Static analysis results are addressed
|
||||
@@ -0,0 +1,488 @@
|
||||
# Universal Code Quality Anti-Patterns
|
||||
|
||||
> 语言无关的代码质量反模式指南,覆盖代码复用、抽象泄漏、参数膨胀、嵌套条件、字符串类型化、TOCTOU、空操作更新等核心主题。适用于所有语言的 PR 审查。
|
||||
|
||||
## 目录
|
||||
|
||||
- [代码复用审查](#代码复用审查)
|
||||
- [参数膨胀](#参数膨胀)
|
||||
- [抽象泄漏](#抽象泄漏)
|
||||
- [字符串类型化](#字符串类型化)
|
||||
- [嵌套条件表达式](#嵌套条件表达式)
|
||||
- [复制粘贴变种](#复制粘贴变种)
|
||||
- [空操作更新](#空操作更新)
|
||||
- [TOCTOU 竞争条件](#toctou-竞争条件)
|
||||
- [过度宽泛操作](#过度宽泛操作)
|
||||
- [冗余状态](#冗余状态)
|
||||
- [通用质量审查清单](#通用质量审查清单)
|
||||
|
||||
---
|
||||
|
||||
## 代码复用审查
|
||||
|
||||
Before accepting new code, search the existing codebase for reusable utilities.
|
||||
|
||||
### 搜索现有工具函数
|
||||
|
||||
```python
|
||||
# ❌ 新写的路径拼接逻辑——项目中已有 PathBuilder
|
||||
def get_config_path(name):
|
||||
base = os.environ.get("APP_ROOT", ".")
|
||||
return os.path.join(base, "config", name + ".json")
|
||||
|
||||
# ✅ 使用已有的 PathBuilder
|
||||
def get_config_path(name):
|
||||
return PathBuilder.config(f"{name}.json")
|
||||
```
|
||||
|
||||
```javascript
|
||||
// ❌ 手写 debounce——项目已有 lodash 或 utils/debounce.ts
|
||||
function debounce(fn, ms) {
|
||||
let timer;
|
||||
return (...args) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
}
|
||||
|
||||
// ✅ 使用已有的工具函数
|
||||
import { debounce } from "@/utils/debounce";
|
||||
```
|
||||
|
||||
**审查要点:**
|
||||
- 新增函数是否与已有 utility 重名或功能重叠?
|
||||
- inline 逻辑是否可以提取为已有模块的调用?
|
||||
- 检查相邻文件和 shared/utils 目录
|
||||
|
||||
---
|
||||
|
||||
## 参数膨胀
|
||||
|
||||
### 函数参数不断增长
|
||||
|
||||
```python
|
||||
# ❌ 每次新需求加一个参数
|
||||
def create_user(name, email, role, team, active, avatar_url, timezone):
|
||||
...
|
||||
|
||||
# ✅ 使用配置对象 / dataclass
|
||||
@dataclass
|
||||
class CreateUserParams:
|
||||
name: str
|
||||
email: str
|
||||
role: Role = Role.MEMBER
|
||||
team: str | None = None
|
||||
active: bool = True
|
||||
avatar_url: str | None = None
|
||||
timezone: str = "UTC"
|
||||
|
||||
def create_user(params: CreateUserParams) -> User:
|
||||
...
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ 6+ 个 positional 参数
|
||||
function renderWidget(
|
||||
title: string, width: number, height: number,
|
||||
theme: string, collapsible: boolean, icon: string
|
||||
) { ... }
|
||||
|
||||
// ✅ Options object pattern
|
||||
interface WidgetOptions {
|
||||
title: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
theme?: "light" | "dark";
|
||||
collapsible?: boolean;
|
||||
icon?: string;
|
||||
}
|
||||
function renderWidget(options: WidgetOptions) { ... }
|
||||
```
|
||||
|
||||
**审查要点:**
|
||||
- 函数参数是否 ≥ 4 个?考虑 options object / dataclass
|
||||
- 新参数是否只是布尔标志?考虑 enum 或 strategy pattern
|
||||
- 是否有 `enable_x`, `disable_y` 这类互斥参数?
|
||||
|
||||
---
|
||||
|
||||
## 抽象泄漏
|
||||
|
||||
### 暴露内部实现细节
|
||||
|
||||
```python
|
||||
# ❌ 返回内部 ORM 对象——调用者被迫了解 SQLAlchemy
|
||||
def get_users():
|
||||
return session.query(User).filter(User.active == True).all()
|
||||
|
||||
# ✅ 返回 domain 对象,隐藏持久化层
|
||||
def get_active_users() -> list[UserDTO]:
|
||||
rows = user_repo.find_active()
|
||||
return [UserDTO.from_row(r) for r in rows]
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ 组件接收 API response 原始结构
|
||||
<UserCard user={apiResponse.data.results[0]} />
|
||||
|
||||
// ✅ 组件接收 domain 类型,adapter 处理映射
|
||||
interface UserSummary {
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
<UserCard user={adaptUser(apiResponse)} />
|
||||
```
|
||||
|
||||
**审查要点:**
|
||||
- 函数返回类型是否泄露底层实现(ORM, HTTP client, file format)?
|
||||
- 组件/函数是否依赖外部系统的数据结构?
|
||||
- 是否破坏了已有的抽象边界?
|
||||
|
||||
---
|
||||
|
||||
## 字符串类型化
|
||||
|
||||
### 用原始字符串代替常量/枚举
|
||||
|
||||
```python
|
||||
# ❌ Magic strings 散落各处
|
||||
if status == "active":
|
||||
...
|
||||
if role == "admin":
|
||||
...
|
||||
|
||||
# ✅ 使用 enum
|
||||
class Status(StrEnum):
|
||||
ACTIVE = "active"
|
||||
SUSPENDED = "suspended"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
if user.status == Status.ACTIVE:
|
||||
...
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ Raw string event names——拼写错误不会报错
|
||||
emitter.emit("userCreated", data);
|
||||
emitter.on("usercreated", handler); // bug: typo
|
||||
|
||||
// ✅ 常量或 branded type
|
||||
const Events = {
|
||||
USER_CREATED: "userCreated",
|
||||
USER_SUSPENDED: "userSuspended",
|
||||
} as const;
|
||||
emitter.emit(Events.USER_CREATED, data);
|
||||
```
|
||||
|
||||
**审查要点:**
|
||||
- 是否用字符串代替了已有的 enum/union type?
|
||||
- 事件名、action type、status 值是否散落在多个文件?
|
||||
- 字符串比较是否 case-sensitive 但未验证?
|
||||
|
||||
---
|
||||
|
||||
## 嵌套条件表达式
|
||||
|
||||
### 三元链和嵌套 if/else
|
||||
|
||||
```python
|
||||
# ❌ 三元链难以阅读
|
||||
label = (
|
||||
"Admin" if role == "admin" else
|
||||
"Manager" if role == "manager" else
|
||||
"Viewer" if role == "viewer" else
|
||||
"Unknown"
|
||||
)
|
||||
|
||||
# ✅ 查找表或 match
|
||||
ROLE_LABELS = {
|
||||
"admin": "Admin",
|
||||
"manager": "Manager",
|
||||
"viewer": "Viewer",
|
||||
}
|
||||
label = ROLE_LABELS.get(role, "Unknown")
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ 嵌套三元
|
||||
const bg = isHovered
|
||||
? isSelected ? "blue" : "gray"
|
||||
: isSelected ? "navy" : "white";
|
||||
|
||||
// ✅ 查找表(lookup map)
|
||||
const bgMap: Record<string, string> = {
|
||||
"true-true": "blue",
|
||||
"true-false": "gray",
|
||||
"false-true": "navy",
|
||||
"false-false": "white",
|
||||
};
|
||||
const bg = bgMap[`${isHovered}-${isSelected}`];
|
||||
```
|
||||
|
||||
```python
|
||||
# ❌ 嵌套 if 3+ 层
|
||||
def process(order):
|
||||
if order is not None:
|
||||
if order.items:
|
||||
for item in order.items:
|
||||
if item.price > 0:
|
||||
...
|
||||
|
||||
# ✅ Early return + guard clauses
|
||||
def process(order):
|
||||
if not order or not order.items:
|
||||
return
|
||||
for item in order.items:
|
||||
if item.price <= 0:
|
||||
continue
|
||||
...
|
||||
```
|
||||
|
||||
**审查要点:**
|
||||
- 三元表达式是否嵌套 ≥ 2 层?
|
||||
- if/else 嵌套是否 ≥ 3 层?
|
||||
- 能否用 lookup table、early return 或 match 替换?
|
||||
|
||||
---
|
||||
|
||||
## 复制粘贴变种
|
||||
|
||||
### 近乎重复的代码块
|
||||
|
||||
```python
|
||||
# ❌ 两个函数几乎一样,只有字段名不同
|
||||
def format_user(user):
|
||||
return f"{user.first_name} {user.last_name} ({user.email})"
|
||||
|
||||
def format_employee(emp):
|
||||
return f"{emp.first_name} {emp.last_name} ({emp.work_email})"
|
||||
|
||||
# ✅ 统一抽象
|
||||
def format_person(first: str, last: str, email: str) -> str:
|
||||
return f"{first} {last} ({email})"
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ Copy-paste handler 只改了 URL
|
||||
async function deletePost(id: string) {
|
||||
await fetch(`/api/posts/${id}`, { method: "DELETE" });
|
||||
router.push("/posts");
|
||||
}
|
||||
async function deleteComment(id: string) {
|
||||
await fetch(`/api/comments/${id}`, { method: "DELETE" });
|
||||
router.push("/comments");
|
||||
}
|
||||
|
||||
// ✅ 参数化
|
||||
async function deleteResource(resource: string, id: string) {
|
||||
await fetch(`/api/${resource}/${id}`, { method: "DELETE" });
|
||||
router.push(`/${resource}`);
|
||||
}
|
||||
```
|
||||
|
||||
**审查要点:**
|
||||
- 是否有 ≥ 2 段代码仅变量名/URL/字符串不同?
|
||||
- 能否提取参数化的共享函数?
|
||||
- 是否可以用 template method 或 strategy 消除变种?
|
||||
|
||||
---
|
||||
|
||||
## 空操作更新
|
||||
|
||||
### 无条件触发状态更新
|
||||
|
||||
```typescript
|
||||
// ❌ 每次 poll 都触发 update——即使数据未变
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
fetch("/api/status").then(r => r.json()).then(setStatus);
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// ✅ 仅在值变化时更新
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
fetch("/api/status")
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
setStatus(prev => isEqual(prev, data) ? prev : data);
|
||||
});
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
```
|
||||
|
||||
```python
|
||||
# ❌ 每次 loop 都写 DB——即使值未变
|
||||
for item in items:
|
||||
item.status = compute_status(item)
|
||||
session.commit()
|
||||
|
||||
# ✅ 仅在变化时写入
|
||||
for item in items:
|
||||
new_status = compute_status(item)
|
||||
if item.status != new_status:
|
||||
item.status = new_status
|
||||
session.commit()
|
||||
```
|
||||
|
||||
**审查要点:**
|
||||
- polling / interval / event handler 是否无条件更新?
|
||||
- wrapper function 是否尊重 same-reference return?
|
||||
- DB 写入是否检查了实际变化?
|
||||
|
||||
---
|
||||
|
||||
## TOCTOU 竞争条件
|
||||
|
||||
### Time-of-Check-to-Time-of-Use
|
||||
|
||||
```python
|
||||
# ❌ 先检查后操作——中间文件可能被删除/创建
|
||||
if os.path.exists(path):
|
||||
with open(path) as f:
|
||||
data = f.read()
|
||||
|
||||
# ✅ 直接操作 + 处理异常
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = f.read()
|
||||
except FileNotFoundError:
|
||||
data = None
|
||||
```
|
||||
|
||||
```python
|
||||
# ❌ 检查余额 → 扣款 两步操作不是原子的
|
||||
if account.balance >= amount:
|
||||
account.balance -= amount
|
||||
|
||||
# ✅ 原子操作或锁
|
||||
with account.lock:
|
||||
if account.balance < amount:
|
||||
raise InsufficientFundsError()
|
||||
account.balance -= amount
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ Check-then-act 在 async 环境中不安全
|
||||
if (!fileExists(path)) {
|
||||
await writeFile(path, content);
|
||||
}
|
||||
|
||||
// ✅ 直接操作 + catch
|
||||
try {
|
||||
await writeFile(path, content, { flag: "wx" });
|
||||
} catch (e) {
|
||||
if (e.code === "EEXIST") { /* handle */ }
|
||||
else throw e;
|
||||
}
|
||||
```
|
||||
|
||||
**审查要点:**
|
||||
- `if exists → operate` 模式是否可替换为 `try operate → catch`?
|
||||
- 多步状态变更是否在事务/锁内?
|
||||
- async 操作中 check 和 act 之间是否有 await?
|
||||
|
||||
---
|
||||
|
||||
## 过度宽泛操作
|
||||
|
||||
### 读取过多数据
|
||||
|
||||
```python
|
||||
# ❌ 读取整个文件再取第一行
|
||||
content = Path("log.txt").read_text()
|
||||
first_line = content.split("\n")[0]
|
||||
|
||||
# ✅ 只读第一行,不加载整个文件
|
||||
with open("log.txt") as f:
|
||||
first_line = f.readline()
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ 加载所有 items 再过滤
|
||||
const allItems = await db.query("SELECT * FROM orders");
|
||||
const pending = allItems.filter(o => o.status === "pending");
|
||||
|
||||
// ✅ 数据库层过滤
|
||||
const pending = await db.query(
|
||||
"SELECT * FROM orders WHERE status = ?", ["pending"]
|
||||
);
|
||||
```
|
||||
|
||||
```python
|
||||
# ❌ 读取整个列表找一条记录
|
||||
users = list(User.objects.all())
|
||||
user = next(u for u in users if u.id == user_id)
|
||||
|
||||
# ✅ 精确查询
|
||||
user = User.objects.get(id=user_id)
|
||||
```
|
||||
|
||||
**审查要点:**
|
||||
- 是否读取了整个集合/文件再只用一小部分?
|
||||
- 能否将过滤推到数据库/存储层?
|
||||
- API 调用是否支持 pagination/limit 参数?
|
||||
|
||||
---
|
||||
|
||||
## 冗余状态
|
||||
|
||||
### 状态可以被推导
|
||||
|
||||
```typescript
|
||||
// ❌ 同时存储 fullName 和 firstName + lastName
|
||||
interface User {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
fullName: string; // redundant
|
||||
}
|
||||
|
||||
// ✅ fullName 是推导值
|
||||
interface User {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
const fullName = `${user.firstName} ${user.lastName}`;
|
||||
```
|
||||
|
||||
```python
|
||||
# ❌ 缓存值在源数据变化时可能过时
|
||||
class Order:
|
||||
total: float
|
||||
item_count: int # redundant if len(items) gives the same
|
||||
items: list[Item]
|
||||
|
||||
# ✅ 推导或 property
|
||||
class Order:
|
||||
items: list[Item]
|
||||
|
||||
@property
|
||||
def total(self) -> float:
|
||||
return sum(item.price for item in self.items)
|
||||
|
||||
@property
|
||||
def item_count(self) -> int:
|
||||
return len(self.items)
|
||||
```
|
||||
|
||||
**审查要点:**
|
||||
- 是否有字段可以从其他字段推导?
|
||||
- 缓存值是否有 invalidation 机制?
|
||||
- observer/effect 是否可以替换为直接调用?
|
||||
|
||||
---
|
||||
|
||||
## 通用质量审查清单
|
||||
|
||||
- [ ] **复用审查**: 搜索了现有 utility/helper,没有重复造轮子?
|
||||
- [ ] **参数数量**: 函数参数 ≤ 3 个?超过则用 options object / dataclass?
|
||||
- [ ] **抽象边界**: 返回类型没有暴露内部实现细节(ORM、HTTP client、file format)?
|
||||
- [ ] **类型安全**: 没有 magic strings 代替已有的 enum/constant/union type?
|
||||
- [ ] **条件深度**: 三元嵌套 ≤ 1 层?if/else 嵌套 ≤ 2 层?
|
||||
- [ ] **DRY**: 没有 copy-paste-with-variation(≥ 2 段近似代码)?
|
||||
- [ ] **空操作防护**: polling / interval / event handler 有 change-detection guard?
|
||||
- [ ] **TOCTOU**: `if exists → operate` 替换为 `try operate → catch`?
|
||||
- [ ] **数据精度**: 没有读取整个集合/文件只为了取子集?
|
||||
- [ ] **冗余状态**: 没有可以从其他字段推导的存储字段?
|
||||
@@ -0,0 +1,136 @@
|
||||
# Code Review Best Practices
|
||||
|
||||
Comprehensive guidelines for conducting effective code reviews.
|
||||
|
||||
## Review Philosophy
|
||||
|
||||
### Goals of Code Review
|
||||
|
||||
**Primary Goals:**
|
||||
- Catch bugs and edge cases before production
|
||||
- Ensure code maintainability and readability
|
||||
- Share knowledge across the team
|
||||
- Enforce coding standards consistently
|
||||
- Improve design and architecture decisions
|
||||
|
||||
**Secondary Goals:**
|
||||
- Mentor junior developers
|
||||
- Build team culture and trust
|
||||
- Document design decisions through discussions
|
||||
|
||||
### What Code Review is NOT
|
||||
|
||||
- A gatekeeping mechanism to block progress
|
||||
- An opportunity to show off knowledge
|
||||
- A place to nitpick formatting (use linters)
|
||||
- A way to rewrite code to personal preference
|
||||
|
||||
## Review Timing
|
||||
|
||||
### When to Review
|
||||
|
||||
| Trigger | Action |
|
||||
|---------|--------|
|
||||
| PR opened | Review within 24 hours, ideally same day |
|
||||
| Changes requested | Re-review within 4 hours |
|
||||
| Blocking issue found | Communicate immediately |
|
||||
|
||||
### Time Allocation
|
||||
|
||||
- **Small PR (<100 lines)**: 10-15 minutes
|
||||
- **Medium PR (100-400 lines)**: 20-40 minutes
|
||||
- **Large PR (>400 lines)**: Request to split, or 60+ minutes
|
||||
|
||||
## Review Depth Levels
|
||||
|
||||
### Level 1: Skim Review (5 minutes)
|
||||
- Check PR description and linked issues
|
||||
- Verify CI/CD status
|
||||
- Look at file changes overview
|
||||
- Identify if deeper review needed
|
||||
|
||||
### Level 2: Standard Review (20-30 minutes)
|
||||
- Full code walkthrough
|
||||
- Logic verification
|
||||
- Test coverage check
|
||||
- Security scan
|
||||
|
||||
### Level 3: Deep Review (60+ minutes)
|
||||
- Architecture evaluation
|
||||
- Performance analysis
|
||||
- Security audit
|
||||
- Edge case exploration
|
||||
|
||||
## Communication Guidelines
|
||||
|
||||
### Tone and Language
|
||||
|
||||
**Use collaborative language:**
|
||||
- "What do you think about..." instead of "You should..."
|
||||
- "Could we consider..." instead of "This is wrong"
|
||||
- "I'm curious about..." instead of "Why didn't you..."
|
||||
|
||||
**Be specific and actionable:**
|
||||
- Include code examples when suggesting changes
|
||||
- Link to documentation or past discussions
|
||||
- Explain the "why" behind suggestions
|
||||
|
||||
### Handling Disagreements
|
||||
|
||||
1. **Seek to understand**: Ask clarifying questions
|
||||
2. **Acknowledge valid points**: Show you've considered their perspective
|
||||
3. **Provide data**: Use benchmarks, docs, or examples
|
||||
4. **Escalate if needed**: Involve senior dev or architect
|
||||
5. **Know when to let go**: Not every hill is worth dying on
|
||||
|
||||
## Review Prioritization
|
||||
|
||||
### Must Fix (Blocking)
|
||||
- Security vulnerabilities
|
||||
- Data corruption risks
|
||||
- Breaking changes without migration
|
||||
- Critical performance issues
|
||||
- Missing error handling for user-facing features
|
||||
|
||||
### Should Fix (Important)
|
||||
- Test coverage gaps
|
||||
- Moderate performance concerns
|
||||
- Code duplication
|
||||
- Unclear naming or structure
|
||||
- Missing documentation for complex logic
|
||||
|
||||
### Nice to Have (Non-blocking)
|
||||
- Style preferences beyond linting
|
||||
- Minor optimizations
|
||||
- Additional test cases
|
||||
- Documentation improvements
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
### Reviewer Anti-Patterns
|
||||
- **Rubber stamping**: Approving without actually reviewing
|
||||
- **Bike shedding**: Debating trivial details extensively
|
||||
- **Scope creep**: "While you're at it, can you also..."
|
||||
- **Ghosting**: Requesting changes then disappearing
|
||||
- **Perfectionism**: Blocking for minor style preferences
|
||||
|
||||
### Author Anti-Patterns
|
||||
- **Mega PRs**: Submitting 1000+ line changes
|
||||
- **No context**: Missing PR description or linked issues
|
||||
- **Defensive responses**: Arguing every suggestion
|
||||
- **Silent updates**: Making changes without responding to comments
|
||||
|
||||
## Metrics and Improvement
|
||||
|
||||
### Track These Metrics
|
||||
- Time to first review
|
||||
- Review cycle time
|
||||
- Number of review rounds
|
||||
- Defect escape rate
|
||||
- Review coverage percentage
|
||||
|
||||
### Continuous Improvement
|
||||
- Hold retrospectives on review process
|
||||
- Share learnings from escaped bugs
|
||||
- Update checklists based on common issues
|
||||
- Celebrate good reviews and catches
|
||||
@@ -0,0 +1,248 @@
|
||||
# Common Bugs Checklist
|
||||
|
||||
Quick-reference bug patterns organized by category. For detailed code examples, explanations, and comprehensive review checklists, see the dedicated language guides linked below.
|
||||
|
||||
## Universal Issues
|
||||
|
||||
### Logic Errors
|
||||
- [ ] Off-by-one errors in loops and array access
|
||||
- [ ] Incorrect boolean logic (De Morgan's law violations)
|
||||
- [ ] Missing null/undefined checks
|
||||
- [ ] Race conditions in concurrent code
|
||||
- [ ] Incorrect comparison operators (`==` vs `===`, `=` vs `==`)
|
||||
- [ ] Integer overflow/underflow
|
||||
- [ ] Floating point comparison issues
|
||||
|
||||
### Resource Management
|
||||
- [ ] Memory leaks (unclosed connections, listeners)
|
||||
- [ ] File handles not closed
|
||||
- [ ] Database connections not released
|
||||
- [ ] Event listeners not removed
|
||||
- [ ] Timers/intervals not cleared
|
||||
|
||||
### Error Handling
|
||||
- [ ] Swallowed exceptions (empty catch blocks)
|
||||
- [ ] Generic exception handling hiding specific errors
|
||||
- [ ] Missing error propagation
|
||||
- [ ] Incorrect error types thrown
|
||||
- [ ] Missing finally/cleanup blocks
|
||||
|
||||
## TypeScript/JavaScript
|
||||
|
||||
- [ ] `==` instead of `===`
|
||||
- [ ] Using `any` — prefer proper types or `unknown` with type guards
|
||||
- [ ] Missing `await` on async calls
|
||||
- [ ] Unhandled promise rejections (no try-catch around await)
|
||||
- [ ] `this` context lost in callbacks
|
||||
- [ ] Missing `key` prop in lists
|
||||
- [ ] Closure capturing stale loop variable
|
||||
- [ ] `parseInt` without radix parameter
|
||||
- [ ] Modifying array/object during iteration
|
||||
|
||||
**Full guide:** [TypeScript Review Guide](typescript.md)
|
||||
|
||||
## React / React 19
|
||||
|
||||
- [ ] Hooks called conditionally or in loops (violates Rules of Hooks)
|
||||
- [ ] `useEffect` dependency array incomplete or incorrect
|
||||
- [ ] `useEffect` missing cleanup function (subscriptions, timers, fetches)
|
||||
- [ ] `useEffect` used for derived state (use `useMemo` instead)
|
||||
- [ ] `useMemo`/`useCallback` over-used or used without `React.memo`
|
||||
- [ ] Component defined inside another component (re-mounts every render)
|
||||
- [ ] Unstable props (inline objects/functions passed to memo components)
|
||||
- [ ] Direct mutation of props
|
||||
- [ ] List missing `key` or using array index as key (reorderable lists)
|
||||
- [ ] Server Component using client APIs (`useState`, `useEffect`, `onClick`)
|
||||
- [ ] `'use client'` on parent making entire subtree client-side
|
||||
- [ ] `useActionState` calling `setState` instead of returning new state
|
||||
- [ ] `useFormStatus` called in same component as `<form>` (must be in child)
|
||||
- [ ] `useOptimistic` used for critical operations (payments, deletions)
|
||||
- [ ] Single Suspense boundary for entire page (slow blocks fast)
|
||||
- [ ] Missing Error Boundary wrapping Suspense
|
||||
- [ ] `use()` Hook receiving a new Promise each render
|
||||
|
||||
**TanStack Query v5:**
|
||||
- [ ] `queryKey` missing parameters that affect data
|
||||
- [ ] Default `staleTime: 0` causing excessive refetches
|
||||
- [ ] `useSuspenseQuery` with `enabled` option (not supported)
|
||||
- [ ] Mutation not invalidating related queries on success
|
||||
- [ ] Optimistic update missing rollback in `onError`
|
||||
- [ ] Using v4 array syntax (`useQuery(['key'], fn)`) instead of v5 object syntax
|
||||
|
||||
**Testing:**
|
||||
- [ ] Using `container.querySelector` instead of `screen.getByRole`
|
||||
- [ ] Using `fireEvent` instead of `userEvent`
|
||||
- [ ] Testing implementation details instead of user-visible behavior
|
||||
- [ ] Using `getBy*` for async content (use `findBy*`)
|
||||
|
||||
**Full guide:** [React Review Guide](react.md)
|
||||
|
||||
## Vue 3
|
||||
|
||||
- [ ] Destructuring `reactive()` object loses reactivity (use `toRefs`)
|
||||
- [ ] Passing `props.x` to composable instead of `() => props.x` or `toRef(props, 'x')`
|
||||
- [ ] `watch` with async callback missing `onCleanup` (race condition)
|
||||
- [ ] `computed` with side effects (mutations, API calls)
|
||||
- [ ] `v-for` using index as `:key` when list can reorder
|
||||
- [ ] `v-if` and `v-for` on the same element
|
||||
- [ ] `defineProps` without TypeScript type declaration
|
||||
- [ ] `withDefaults` object default values not using factory functions
|
||||
- [ ] Directly mutating props instead of emitting events
|
||||
- [ ] `watchEffect` with unclear dependencies causing over-triggering
|
||||
|
||||
**Full guide:** [Vue 3 Review Guide](vue.md)
|
||||
|
||||
## Python
|
||||
|
||||
- [ ] Mutable default arguments (`def f(x=[])`)
|
||||
- [ ] Bare `except:` catching `KeyboardInterrupt` and `SystemExit`
|
||||
- [ ] Shared mutable class attributes (`class C: items = []`)
|
||||
- [ ] Using `is` instead of `==` for value comparison
|
||||
- [ ] Forgetting `self` parameter in methods
|
||||
- [ ] Modifying list while iterating
|
||||
- [ ] String concatenation in loops (use `"".join()`)
|
||||
- [ ] Not closing files (use `with` statement)
|
||||
- [ ] Missing type annotations on public functions
|
||||
|
||||
**Full guide:** [Python Review Guide](python.md)
|
||||
|
||||
## Rust
|
||||
|
||||
**Ownership & Borrowing:**
|
||||
- [ ] Unnecessary `clone()` to work around borrow checker
|
||||
- [ ] `Arc<Mutex<T>>` when single-owner would suffice
|
||||
- [ ] Storing borrows in structs when owned data is simpler
|
||||
- [ ] Unnecessary `RefCell` (runtime checks vs compile-time)
|
||||
|
||||
**Unsafe Code:**
|
||||
- [ ] `unsafe` block without `SAFETY:` comment explaining invariants
|
||||
- [ ] `unsafe fn` without `# Safety` doc section
|
||||
- [ ] Unsafe invariants split across modules
|
||||
|
||||
**Async & Concurrency:**
|
||||
- [ ] Blocking in async context (`std::fs`, `std::thread::sleep`)
|
||||
- [ ] Holding `std::sync::Mutex` across `.await`
|
||||
- [ ] Spawned task missing `'static` lifetime bound
|
||||
- [ ] Dropping a Future without awaiting (forgotten work)
|
||||
|
||||
**Error Handling:**
|
||||
- [ ] `unwrap()`/`expect()` in production code
|
||||
- [ ] Library using `anyhow` instead of `thiserror` (callers can't match)
|
||||
- [ ] Swallowing error context (`map_err(|_| ...)`)
|
||||
- [ ] Ignoring `must_use` return values
|
||||
|
||||
**Performance:**
|
||||
- [ ] Unnecessary `.collect()` — prefer lazy iterators
|
||||
- [ ] String concatenation in loops without `with_capacity`
|
||||
- [ ] `Box<dyn Trait>` when `impl Trait` would work
|
||||
|
||||
**Full guide:** [Rust Review Guide](rust.md)
|
||||
|
||||
## Go
|
||||
|
||||
- [ ] Ignoring errors (`result, _ := SomeFunction()`)
|
||||
- [ ] Goroutine with no exit mechanism (leak)
|
||||
- [ ] Missing or incorrect `context.Context` propagation
|
||||
- [ ] Loop variable capture issue (Go < 1.22)
|
||||
- [ ] `defer` in loops (deferred until function, not loop iteration)
|
||||
- [ ] Variable shadowing
|
||||
- [ ] Map used before initialization
|
||||
- [ ] Error wrapping with `%v` instead of `%w` (breaks `errors.Is`/`errors.As`)
|
||||
|
||||
**Full guide:** [Go Review Guide](go.md)
|
||||
|
||||
## Java / Spring Boot
|
||||
|
||||
- [ ] POJO/DTO with manual boilerplate instead of `record`
|
||||
- [ ] Traditional switch missing `break` (use switch expressions)
|
||||
- [ ] Field injection instead of constructor injection
|
||||
- [ ] JPA N+1 query (missing `fetch join` or `@EntityGraph`)
|
||||
- [ ] Incorrect `equals`/`hashCode` on JPA entities (use business key, not ID)
|
||||
- [ ] `Optional.get()` without `isPresent()` check
|
||||
- [ ] Stream operations with side effects
|
||||
|
||||
**Full guide:** [Java Review Guide](java.md)
|
||||
|
||||
## PHP
|
||||
|
||||
- [ ] Missing `declare(strict_types=1);` in new files
|
||||
- [ ] Weak comparison (`==`, `!=`) in auth, token, payment, or state logic
|
||||
- [ ] `in_array()` / `array_search()` used without strict mode
|
||||
- [ ] SQL built with string concatenation instead of prepared statements
|
||||
- [ ] User input echoed without context-aware escaping
|
||||
- [ ] Passwords stored with `md5()` / `sha1()` instead of `password_hash()`
|
||||
- [ ] Untrusted data passed to `unserialize()`
|
||||
- [ ] PHP 8.2+ dynamic properties used instead of declared properties
|
||||
- [ ] Errors hidden with `@` or swallowed in empty `catch` blocks
|
||||
- [ ] File uploads using client-provided names or missing MIME/size validation
|
||||
|
||||
**Full guide:** [PHP Review Guide](php.md)
|
||||
|
||||
## Swift
|
||||
|
||||
- [ ] Force-unwrap (`!`) or `try!` where safe unwrapping is possible
|
||||
- [ ] Closure capturing `self` strongly without `[weak self]` (retain cycle)
|
||||
- [ ] Reference type (`class`) used where a value type (`struct`) is intended
|
||||
- [ ] Errors swallowed instead of propagated via `throws` / `Result`
|
||||
- [ ] Data race across concurrency boundaries (missing `Sendable`, `@MainActor`, actor isolation)
|
||||
- [ ] Fire-and-forget `Task {}` that is never cancelled or leaks
|
||||
- [ ] `@ObservedObject` used where `@StateObject` is required for ownership
|
||||
- [ ] Implicitly unwrapped optional (`var x: T!`) outside IBOutlets
|
||||
- [ ] Over-broad access control (`public` / `open` where `internal` suffices)
|
||||
|
||||
**Full guide:** [Swift Review Guide](swift.md)
|
||||
|
||||
## C
|
||||
|
||||
- [ ] Pointer/buffer overflow or underflow
|
||||
- [ ] Undefined behavior (use-after-free, double-free, null deref)
|
||||
- [ ] Missing error handling after allocation (`malloc` can return `NULL`)
|
||||
- [ ] Integer overflow in size calculations
|
||||
- [ ] Resource leaks (missing `free`, `fclose`, etc.)
|
||||
- [ ] Missing `static` on file-local functions/variables
|
||||
|
||||
**Full guide:** [C Review Guide](c.md)
|
||||
|
||||
## C++
|
||||
|
||||
- [ ] Missing RAII wrapper for resources
|
||||
- [ ] Violating Rule of 0/3/5 (destructor, copy, move)
|
||||
- [ ] Exception safety issues (no `noexcept` where applicable)
|
||||
- [ ] Dangling references from returned iterators or references
|
||||
- [ ] Unnecessary copies (missing `std::move` or pass-by-reference)
|
||||
|
||||
**Full guide:** [C++ Review Guide](cpp.md)
|
||||
|
||||
## SQL
|
||||
|
||||
- [ ] String concatenation for queries (SQL injection risk) — use parameterized queries
|
||||
- [ ] Missing indexes on filtered/joined columns
|
||||
- [ ] `SELECT *` instead of specific columns
|
||||
- [ ] N+1 query patterns
|
||||
- [ ] Missing `LIMIT` on large tables
|
||||
- [ ] Not handling `NULL` comparisons correctly (`IS NULL` vs `= NULL`)
|
||||
- [ ] Missing transactions for related operations
|
||||
- [ ] Incorrect JOIN types
|
||||
- [ ] Collation / case sensitivity surprises across databases (MySQL vs Postgres defaults)
|
||||
- [ ] Date and timezone handling errors (naive timestamps, server-local `NOW()`, DST)
|
||||
|
||||
**See also:** [Security Review Guide](security-review-guide.md) for SQL injection prevention
|
||||
|
||||
## API Design
|
||||
|
||||
- [ ] Inconsistent resource naming
|
||||
- [ ] Wrong HTTP methods (POST for idempotent operations)
|
||||
- [ ] Missing pagination for list endpoints
|
||||
- [ ] Incorrect status codes
|
||||
- [ ] Missing rate limiting
|
||||
- [ ] Missing input validation and sanitization
|
||||
- [ ] Trusting client-side validation only
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] Testing implementation details instead of behavior
|
||||
- [ ] Missing edge case tests
|
||||
- [ ] Flaky tests (non-deterministic)
|
||||
- [ ] Tests with external dependencies (no mocks)
|
||||
- [ ] Missing negative tests (error cases)
|
||||
- [ ] Overly complex test setup
|
||||
+385
@@ -0,0 +1,385 @@
|
||||
# C++ Code Review Guide
|
||||
|
||||
> C++ code review guide focused on memory safety, lifetime, API design, and performance. Examples assume C++17/20.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Ownership and RAII](#ownership-and-raii)
|
||||
- [Lifetime and References](#lifetime-and-references)
|
||||
- [Copy and Move Semantics](#copy-and-move-semantics)
|
||||
- [Const-Correctness and API Design](#const-correctness-and-api-design)
|
||||
- [Error Handling and Exception Safety](#error-handling-and-exception-safety)
|
||||
- [Concurrency](#concurrency)
|
||||
- [Performance and Allocation](#performance-and-allocation)
|
||||
- [Templates and Type Safety](#templates-and-type-safety)
|
||||
- [Tooling and Build Checks](#tooling-and-build-checks)
|
||||
- [Review Checklist](#review-checklist)
|
||||
|
||||
---
|
||||
|
||||
## Ownership and RAII
|
||||
|
||||
### Prefer RAII and smart pointers
|
||||
|
||||
Use RAII to express ownership. Default to `std::unique_ptr`, use `std::shared_ptr` only for shared lifetime.
|
||||
|
||||
```cpp
|
||||
// ❌ Bad: manual new/delete with early returns
|
||||
Foo* make_foo() {
|
||||
Foo* foo = new Foo();
|
||||
if (!foo->Init()) {
|
||||
delete foo;
|
||||
return nullptr;
|
||||
}
|
||||
return foo;
|
||||
}
|
||||
|
||||
// ✅ Good: RAII with unique_ptr
|
||||
std::unique_ptr<Foo> make_foo() {
|
||||
auto foo = std::make_unique<Foo>();
|
||||
if (!foo->Init()) {
|
||||
return {};
|
||||
}
|
||||
return foo;
|
||||
}
|
||||
```
|
||||
|
||||
### Wrap C resources
|
||||
|
||||
```cpp
|
||||
// ✅ Good: wrap FILE* with unique_ptr
|
||||
using FilePtr = std::unique_ptr<FILE, decltype(&fclose)>;
|
||||
|
||||
FilePtr open_file(const char* path) {
|
||||
return FilePtr(fopen(path, "rb"), &fclose);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lifetime and References
|
||||
|
||||
### Avoid dangling references and views
|
||||
|
||||
`std::string_view` and `std::span` do not own data. Make sure the owner outlives the view.
|
||||
|
||||
```cpp
|
||||
// ❌ Bad: returning string_view to a temporary
|
||||
std::string_view bad_view() {
|
||||
std::string s = make_name();
|
||||
return s; // dangling
|
||||
}
|
||||
|
||||
// ✅ Good: return owning string
|
||||
std::string good_name() {
|
||||
return make_name();
|
||||
}
|
||||
|
||||
// ✅ Good: view tied to caller-owned data
|
||||
std::string_view good_view(const std::string& s) {
|
||||
return s;
|
||||
}
|
||||
```
|
||||
|
||||
### Lambda captures
|
||||
|
||||
```cpp
|
||||
// ❌ Bad: capture reference that escapes
|
||||
std::function<void()> make_task() {
|
||||
int value = 42;
|
||||
return [&]() { use(value); }; // dangling
|
||||
}
|
||||
|
||||
// ✅ Good: capture by value
|
||||
std::function<void()> make_task() {
|
||||
int value = 42;
|
||||
return [value]() { use(value); };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Copy and Move Semantics
|
||||
|
||||
### Rule of 0/3/5
|
||||
|
||||
Prefer the Rule of 0 by using RAII types. If you own a resource, define or delete copy and move operations.
|
||||
|
||||
```cpp
|
||||
// ❌ Bad: raw ownership with default copy
|
||||
struct Buffer {
|
||||
int* data;
|
||||
size_t size;
|
||||
explicit Buffer(size_t n) : data(new int[n]), size(n) {}
|
||||
~Buffer() { delete[] data; }
|
||||
// copy ctor/assign are implicitly generated -> double delete
|
||||
};
|
||||
|
||||
// ✅ Good: Rule of 0 with std::vector
|
||||
struct Buffer {
|
||||
std::vector<int> data;
|
||||
explicit Buffer(size_t n) : data(n) {}
|
||||
};
|
||||
```
|
||||
|
||||
### Delete unwanted copies
|
||||
|
||||
```cpp
|
||||
struct Socket {
|
||||
Socket() = default;
|
||||
~Socket() { close(); }
|
||||
|
||||
Socket(const Socket&) = delete;
|
||||
Socket& operator=(const Socket&) = delete;
|
||||
Socket(Socket&&) noexcept = default;
|
||||
Socket& operator=(Socket&&) noexcept = default;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Const-Correctness and API Design
|
||||
|
||||
### Use const and explicit
|
||||
|
||||
```cpp
|
||||
class User {
|
||||
public:
|
||||
const std::string& name() const { return name_; }
|
||||
void set_name(std::string name) { name_ = std::move(name); }
|
||||
|
||||
private:
|
||||
std::string name_;
|
||||
};
|
||||
|
||||
struct Millis {
|
||||
explicit Millis(int v) : value(v) {}
|
||||
int value;
|
||||
};
|
||||
```
|
||||
|
||||
### Avoid object slicing
|
||||
|
||||
```cpp
|
||||
struct Shape { virtual ~Shape() = default; };
|
||||
struct Circle : Shape { void draw() const; };
|
||||
|
||||
// ❌ Bad: slices Circle into Shape
|
||||
void draw(Shape shape);
|
||||
|
||||
// ✅ Good: pass by reference
|
||||
void draw(const Shape& shape);
|
||||
```
|
||||
|
||||
### Use override and final
|
||||
|
||||
```cpp
|
||||
struct Base {
|
||||
virtual void run() = 0;
|
||||
};
|
||||
|
||||
struct Worker final : Base {
|
||||
void run() override {}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling and Exception Safety
|
||||
|
||||
### Prefer RAII for cleanup
|
||||
|
||||
```cpp
|
||||
// ✅ Good: RAII handles cleanup on exceptions
|
||||
void process() {
|
||||
std::vector<int> data = load_data(); // safe cleanup
|
||||
do_work(data);
|
||||
}
|
||||
```
|
||||
|
||||
### Do not throw from destructors
|
||||
|
||||
```cpp
|
||||
struct File {
|
||||
~File() noexcept { close(); }
|
||||
void close();
|
||||
};
|
||||
```
|
||||
|
||||
### Use expected results for normal failures
|
||||
|
||||
```cpp
|
||||
// ✅ Expected error: use optional or expected
|
||||
std::optional<int> parse_int(const std::string& s) {
|
||||
try {
|
||||
return std::stoi(s);
|
||||
} catch (...) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Concurrency
|
||||
|
||||
### Protect shared data
|
||||
|
||||
```cpp
|
||||
// ❌ Bad: data race
|
||||
int counter = 0;
|
||||
void inc() { counter++; }
|
||||
|
||||
// ✅ Good: atomic
|
||||
std::atomic<int> counter{0};
|
||||
void inc() { counter.fetch_add(1, std::memory_order_relaxed); }
|
||||
```
|
||||
|
||||
### Use RAII locks
|
||||
|
||||
```cpp
|
||||
std::mutex mu;
|
||||
std::vector<int> data;
|
||||
|
||||
void add(int v) {
|
||||
std::lock_guard<std::mutex> lock(mu);
|
||||
data.push_back(v);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance and Allocation
|
||||
|
||||
### Avoid repeated allocations
|
||||
|
||||
```cpp
|
||||
// ❌ Bad: repeated reallocation
|
||||
std::vector<int> build(int n) {
|
||||
std::vector<int> out;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
out.push_back(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ✅ Good: reserve upfront
|
||||
std::vector<int> build(int n) {
|
||||
std::vector<int> out;
|
||||
out.reserve(static_cast<size_t>(n));
|
||||
for (int i = 0; i < n; ++i) {
|
||||
out.push_back(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
```
|
||||
|
||||
### String concatenation
|
||||
|
||||
```cpp
|
||||
// ❌ Bad: repeated allocation
|
||||
std::string join(const std::vector<std::string>& parts) {
|
||||
std::string out;
|
||||
for (const auto& p : parts) {
|
||||
out += p;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ✅ Good: reserve total size
|
||||
std::string join(const std::vector<std::string>& parts) {
|
||||
size_t total = 0;
|
||||
for (const auto& p : parts) {
|
||||
total += p.size();
|
||||
}
|
||||
std::string out;
|
||||
out.reserve(total);
|
||||
for (const auto& p : parts) {
|
||||
out += p;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Templates and Type Safety
|
||||
|
||||
### Prefer constrained templates (C++20)
|
||||
|
||||
```cpp
|
||||
// ❌ Bad: overly generic
|
||||
template <typename T>
|
||||
T add(T a, T b) {
|
||||
return a + b;
|
||||
}
|
||||
|
||||
// ✅ Good: constrained
|
||||
template <typename T>
|
||||
requires std::is_integral_v<T>
|
||||
T add(T a, T b) {
|
||||
return a + b;
|
||||
}
|
||||
```
|
||||
|
||||
### Use static_assert for invariants
|
||||
|
||||
```cpp
|
||||
template <typename T>
|
||||
struct Packet {
|
||||
static_assert(std::is_trivially_copyable_v<T>,
|
||||
"Packet payload must be trivially copyable");
|
||||
T payload;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tooling and Build Checks
|
||||
|
||||
```bash
|
||||
# Warnings
|
||||
clang++ -Wall -Wextra -Werror -Wconversion -Wshadow -std=c++20 ...
|
||||
|
||||
# Sanitizers (debug builds)
|
||||
clang++ -fsanitize=address,undefined -fno-omit-frame-pointer -g ...
|
||||
clang++ -fsanitize=thread -fno-omit-frame-pointer -g ...
|
||||
|
||||
# Static analysis
|
||||
clang-tidy src/*.cpp -- -std=c++20
|
||||
|
||||
# Formatting
|
||||
clang-format -i src/*.cpp include/*.h
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Review Checklist
|
||||
|
||||
### Safety and Lifetime
|
||||
- [ ] Ownership is explicit (RAII, unique_ptr by default)
|
||||
- [ ] No dangling references or views
|
||||
- [ ] Rule of 0/3/5 followed for resource-owning types
|
||||
- [ ] No raw new/delete in business logic
|
||||
- [ ] Destructors are noexcept and do not throw
|
||||
|
||||
### API and Design
|
||||
- [ ] const-correctness is applied consistently
|
||||
- [ ] Constructors are explicit where needed
|
||||
- [ ] Override/final used for virtual functions
|
||||
- [ ] No object slicing (pass by ref or pointer)
|
||||
|
||||
### Concurrency
|
||||
- [ ] Shared data is protected (mutex or atomics)
|
||||
- [ ] Locking order is consistent
|
||||
- [ ] No blocking while holding locks
|
||||
|
||||
### Performance
|
||||
- [ ] Unnecessary allocations avoided (reserve, move)
|
||||
- [ ] Copies avoided in hot paths
|
||||
- [ ] Algorithmic complexity is reasonable
|
||||
|
||||
### Tooling and Tests
|
||||
- [ ] Builds clean with warnings enabled
|
||||
- [ ] Sanitizers run on critical code paths
|
||||
- [ ] Static analysis (clang-tidy) results are addressed
|
||||
+521
@@ -0,0 +1,521 @@
|
||||
# C# / .NET Code Review Guide
|
||||
|
||||
> C# / .NET 8 代码审查指南,覆盖 C# 12 新特性、异步编程、EF Core 性能、ASP.NET Core 最佳实践、依赖注入、LINQ 等核心主题。
|
||||
|
||||
## 目录
|
||||
|
||||
- [C# 12 新特性](#c-12-新特性)
|
||||
- [异步编程](#异步编程)
|
||||
- [EF Core 性能](#ef-core-性能)
|
||||
- [ASP.NET Core 最佳实践](#aspnet-core-最佳实践)
|
||||
- [依赖注入](#依赖注入)
|
||||
- [LINQ 最佳实践](#linq-最佳实践)
|
||||
- [Review Checklist](#review-checklist)
|
||||
|
||||
---
|
||||
|
||||
## C# 12 新特性
|
||||
|
||||
### Primary Constructors(非 record 类型)
|
||||
|
||||
```csharp
|
||||
// ❌ 样板代码过多的传统构造函数
|
||||
public class ProductService
|
||||
{
|
||||
private readonly ProductDbContext _db;
|
||||
private readonly ILogger<ProductService> _logger;
|
||||
|
||||
public ProductService(ProductDbContext db, ILogger<ProductService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Primary Constructor——简洁的依赖注入
|
||||
public class ProductService(ProductDbContext db, ILogger<ProductService> logger)
|
||||
{
|
||||
public async Task<Product?> GetAsync(int id)
|
||||
=> await db.Products.FindAsync(id);
|
||||
}
|
||||
|
||||
// ⚠️ 注意:primary constructor 参数不是属性,不能被重新赋值
|
||||
// ⚠️ 如果需要长期存储,显式声明字段
|
||||
public class OrderService(OrderDbContext db)
|
||||
{
|
||||
private readonly OrderDbContext _db = db; // 显式捕获
|
||||
}
|
||||
```
|
||||
|
||||
### Collection Expressions
|
||||
|
||||
```csharp
|
||||
// ❌ 传统集合初始化
|
||||
int[] nums = new int[] { 1, 2, 3 };
|
||||
List<string> names = new List<string> { "alice", "bob" };
|
||||
|
||||
// ✅ 集合表达式
|
||||
int[] nums = [1, 2, 3];
|
||||
List<string> names = ["alice", "bob"];
|
||||
Span<char> span = ['a', 'b'];
|
||||
|
||||
// ✅ 展开运算符
|
||||
int[] merged = [..nums, 4, 5];
|
||||
```
|
||||
|
||||
### Default Lambda Parameters
|
||||
|
||||
```csharp
|
||||
// ❌ 重载 lambda
|
||||
var add = (int a, int b) => a + b;
|
||||
var addDefault = (int a) => a + 1;
|
||||
|
||||
// ✅ 默认参数
|
||||
var add = (int a, int b = 1) => a + b;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 异步编程
|
||||
|
||||
### Task.Wait() / .Result / async void 是严重反模式
|
||||
|
||||
```csharp
|
||||
// ❌ Task.Wait() —— 死锁风险(同步阻塞异步操作)
|
||||
public ActionResult<Data> Get(int id)
|
||||
{
|
||||
var data = _service.GetDataAsync(id).Result; // 死锁!
|
||||
return Ok(data);
|
||||
}
|
||||
|
||||
// ❌ async void —— 异常无法捕获,会崩溃进程
|
||||
public async void HandleEvent()
|
||||
{
|
||||
await _service.ProcessAsync(); // 异常直接崩溃
|
||||
}
|
||||
|
||||
// ✅ async Task —— 全链路异步
|
||||
public async Task<ActionResult<Data>> Get(int id)
|
||||
{
|
||||
var data = await _service.GetDataAsync(id);
|
||||
return Ok(data);
|
||||
}
|
||||
```
|
||||
|
||||
### ConfigureAwait(false) 用于库代码
|
||||
|
||||
```csharp
|
||||
// ❌ 库代码不必要地捕获 SynchronizationContext
|
||||
public class LibraryService
|
||||
{
|
||||
public async Task<string> GetDataAsync()
|
||||
{
|
||||
var response = await _httpClient.GetAsync("/api/data");
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 库代码使用 ConfigureAwait(false) 避免死锁
|
||||
public class LibraryService
|
||||
{
|
||||
public async Task<string> GetDataAsync()
|
||||
{
|
||||
var response = await _httpClient.GetAsync("/api/data").ConfigureAwait(false);
|
||||
return await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CancellationToken 传播
|
||||
|
||||
```csharp
|
||||
// ❌ 丢弃 CancellationToken
|
||||
public async Task<List<User>> SearchAsync(string query)
|
||||
{
|
||||
return await _db.Users.Where(u => u.Name.Contains(query)).ToListAsync();
|
||||
}
|
||||
|
||||
// ✅ 全链路传递 CancellationToken
|
||||
public async Task<List<User>> SearchAsync(string query, CancellationToken ct = default)
|
||||
{
|
||||
return await _db.Users
|
||||
.Where(u => u.Name.Contains(query))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
```
|
||||
|
||||
### Async Disposal
|
||||
|
||||
```csharp
|
||||
// ❌ 同步 dispose 异步资源
|
||||
public class DataClient : IDisposable
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient.Dispose(); // 可能丢弃正在进行的请求
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ IAsyncDisposable
|
||||
public class DataClient : IAsyncDisposable
|
||||
{
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _stream.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 调用方使用 await using
|
||||
await using var client = new DataClient();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## EF Core 性能
|
||||
|
||||
### N+1 查询问题
|
||||
|
||||
```csharp
|
||||
// ❌ 经典 N+1——每个 Blog 触发一次查询获取 Posts
|
||||
foreach (var blog in await context.Blogs.ToListAsync())
|
||||
{
|
||||
foreach (var post in blog.Posts) // 每次循环都查询数据库!
|
||||
{
|
||||
Console.WriteLine(post.Title);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Eager Loading + 投影
|
||||
await foreach (var blog in context.Blogs
|
||||
.Select(b => new { b.Url, b.Posts })
|
||||
.AsAsyncEnumerable())
|
||||
{
|
||||
foreach (var post in blog.Posts)
|
||||
Console.WriteLine(post.Title);
|
||||
}
|
||||
```
|
||||
|
||||
### 过度获取(不投影)
|
||||
|
||||
```csharp
|
||||
// ❌ 加载所有列——只需要 Url 时加载了全部字段
|
||||
var urls = await context.Blogs.ToListAsync();
|
||||
|
||||
// ✅ 只投影需要的字段
|
||||
var urls = await context.Blogs
|
||||
.Select(b => b.Url)
|
||||
.ToListAsync();
|
||||
```
|
||||
|
||||
### 缺少分页
|
||||
|
||||
```csharp
|
||||
// ❌ 无界结果集
|
||||
var posts = await context.Posts
|
||||
.Where(p => p.Title.StartsWith("A"))
|
||||
.ToListAsync(); // 可能有百万条记录!
|
||||
|
||||
// ✅ 限制结果数量
|
||||
var posts = await context.Posts
|
||||
.Where(p => p.Title.StartsWith("A"))
|
||||
.OrderBy(p => p.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
```
|
||||
|
||||
### Cartesian Explosion(JOIN 笛卡尔爆炸)
|
||||
|
||||
```csharp
|
||||
// ❌ 多个 Include 创建大量重复数据
|
||||
var blogs = await context.Blogs
|
||||
.Include(b => b.Posts)
|
||||
.Include(b => b.Tags)
|
||||
.ToListAsync(); // 每行重复 Blog 数据
|
||||
|
||||
// ✅ 使用 AsSplitQuery 拆分查询
|
||||
var blogs = await context.Blogs
|
||||
.Include(b => b.Posts)
|
||||
.Include(b => b.Tags)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync();
|
||||
```
|
||||
|
||||
### 只读场景缺少 AsNoTracking
|
||||
|
||||
```csharp
|
||||
// ❌ 默认跟踪——只读查询也付出跟踪开销
|
||||
var products = await context.Products.ToListAsync();
|
||||
|
||||
// ✅ AsNoTracking——跳过变更跟踪,更快且更省内存
|
||||
var products = await context.Products
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
```
|
||||
|
||||
### 列上函数阻止索引使用
|
||||
|
||||
```csharp
|
||||
// ✅ 可以使用索引——sargable
|
||||
var posts1 = await context.Posts
|
||||
.Where(p => p.Title.StartsWith("A"))
|
||||
.ToListAsync();
|
||||
|
||||
// ❌ 无法使用索引——全表扫描
|
||||
var posts2 = await context.Posts
|
||||
.Where(p => p.Title.EndsWith("A"))
|
||||
.ToListAsync();
|
||||
|
||||
// ❌ 列上套函数——全表扫描
|
||||
var posts3 = await context.Posts
|
||||
.Where(p => p.Title.ToLower() == "foo")
|
||||
.ToListAsync();
|
||||
```
|
||||
|
||||
### 同步 vs 异步数据库访问
|
||||
|
||||
```csharp
|
||||
// ❌ 同步数据库调用——阻塞线程
|
||||
var products = context.Products.ToList();
|
||||
context.SaveChanges();
|
||||
|
||||
// ✅ 异步数据库调用
|
||||
var products = await context.Products.ToListAsync();
|
||||
await context.SaveChangesAsync();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ASP.NET Core 最佳实践
|
||||
|
||||
### HttpClient 误用
|
||||
|
||||
```csharp
|
||||
// ❌ 每次请求创建新的 HttpClient——socket 耗尽
|
||||
using var client = new HttpClient();
|
||||
var response = await client.GetAsync("https://api.example.com/data");
|
||||
|
||||
// ✅ IHttpClientFactory 注入
|
||||
public class MyService
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
public MyService(HttpClient client) => _client = client; // 从工厂注入
|
||||
}
|
||||
```
|
||||
|
||||
### HttpContext 在后台线程中使用
|
||||
|
||||
```csharp
|
||||
// ❌ 在后台任务中捕获 scoped 服务——请求结束后已释放
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await context.SaveChangesAsync(); // ObjectDisposedException!
|
||||
});
|
||||
|
||||
// ✅ 创建新的 scope
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await using var scope = serviceScopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
```
|
||||
|
||||
### Request.Form 同步访问
|
||||
|
||||
```csharp
|
||||
// ❌ 同步读取 Form——sync over async
|
||||
var form = HttpContext.Request.Form;
|
||||
|
||||
// ✅ 异步读取
|
||||
var form = await HttpContext.Request.ReadFormAsync();
|
||||
```
|
||||
|
||||
### 异常用于控制流
|
||||
|
||||
```csharp
|
||||
// ❌ 用异常判断是否存在——异常开销大,比直接检查慢得多
|
||||
try
|
||||
{
|
||||
var user = await _db.Users.FirstAsync(u => u.Id == id);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// ✅ 使用检查而非异常
|
||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == id);
|
||||
if (user is null) return NotFound();
|
||||
```
|
||||
|
||||
### 响应头在 Body 之后设置
|
||||
|
||||
```csharp
|
||||
// ❌ body 已发送后再设置 header——抛异常
|
||||
await next(context);
|
||||
context.Response.Headers["X-Custom"] = "value"; // 可能抛异常!
|
||||
|
||||
// ✅ 使用 OnStarting 回调
|
||||
context.Response.OnStarting(() =>
|
||||
{
|
||||
context.Response.Headers["X-Custom"] = "value";
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
await next(context);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖注入
|
||||
|
||||
### Scoped 服务注入 Singleton
|
||||
|
||||
```csharp
|
||||
// ❌ Scoped 服务注入 Singleton——生命周期不匹配
|
||||
services.AddSingleton<BackgroundWorker>();
|
||||
services.AddScoped<IUserRepository, UserRepository>();
|
||||
|
||||
// BackgroundWorker 是 Singleton,UserRepository 是 Scoped
|
||||
// → UserRepository 在多个请求间共享或已释放
|
||||
|
||||
// ✅ 在 Singleton 中通过 IServiceProvider 创建 scope
|
||||
public class BackgroundWorker : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
|
||||
public BackgroundWorker(IServiceScopeFactory scopeFactory)
|
||||
=> _scopeFactory = scopeFactory;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IUserRepository>();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## LINQ 最佳实践
|
||||
|
||||
### ToList 之后再 LINQ
|
||||
|
||||
```csharp
|
||||
// ❌ 先 ToList 再过滤——全表加载到内存
|
||||
var results = context.Posts
|
||||
.Where(p => p.Title.StartsWith("A"))
|
||||
.ToList()
|
||||
.Where(p => SomeClientFilter(p)); // 客户端过滤,已加载全部行
|
||||
|
||||
// ✅ 尽可能让数据库执行过滤
|
||||
var results = await context.Posts
|
||||
.Where(p => p.Title.StartsWith("A") && SomeDbFilter(p))
|
||||
.AsAsyncEnumerable()
|
||||
.Where(p => SomeClientFilter(p)) // 只过滤数据库返回的行
|
||||
.ToListAsync();
|
||||
```
|
||||
|
||||
### Count() vs Any()
|
||||
|
||||
```csharp
|
||||
// ❌ Count() 执行完整查询
|
||||
if (context.Users.Count() > 0) { /* ... */ }
|
||||
|
||||
// ✅ Any() 更高效——遇到第一条记录就返回
|
||||
if (await context.Users.AnyAsync()) { /* ... */ }
|
||||
```
|
||||
|
||||
### 多次枚举 IEnumerable
|
||||
|
||||
```csharp
|
||||
// ❌ IEnumerable 被枚举两次
|
||||
public void Process(IEnumerable<int> numbers)
|
||||
{
|
||||
if (numbers.Any()) // 第一次枚举
|
||||
{
|
||||
foreach (var n in numbers) // 第二次枚举(可能是重新查询)
|
||||
{
|
||||
Console.WriteLine(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 如果需要多次使用,先物化
|
||||
public void Process(IEnumerable<int> numbers)
|
||||
{
|
||||
var list = numbers.ToList(); // 只枚举一次
|
||||
if (list.Any())
|
||||
{
|
||||
foreach (var n in list)
|
||||
{
|
||||
Console.WriteLine(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Select 中的副作用
|
||||
|
||||
```csharp
|
||||
// ❌ Select 中执行副作用——不可预测的执行时机
|
||||
var results = users.Select(u =>
|
||||
{
|
||||
_logger.LogInformation($"Processing {u.Name}"); // 副作用!
|
||||
return u.Email;
|
||||
}).ToList();
|
||||
|
||||
// ✅ 副作用放在 foreach 中
|
||||
foreach (var user in users)
|
||||
{
|
||||
_logger.LogInformation("Processing {Name}", user.Name);
|
||||
}
|
||||
var results = users.Select(u => u.Email).ToList();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Review Checklist
|
||||
|
||||
### C# 12 新特性
|
||||
|
||||
- [ ] Primary constructor 参数不被重新赋值
|
||||
- [ ] 集合表达式语法一致(不混用新旧风格)
|
||||
|
||||
### 异步编程
|
||||
|
||||
- [ ] 无 `Task.Wait()`、`.Result`、`async void`
|
||||
- [ ] 库代码使用 `ConfigureAwait(false)`
|
||||
- [ ] `CancellationToken` 全链路传递
|
||||
- [ ] 异步资源使用 `IAsyncDisposable` / `await using`
|
||||
- [ ] 不混用同步和异步数据访问
|
||||
|
||||
### EF Core
|
||||
|
||||
- [ ] 无 N+1 查询(导航属性在循环中访问)
|
||||
- [ ] 投影 `Select()` 避免过度获取
|
||||
- [ ] 分页:`ToListAsync()` 前有 `Take()`/`Skip()`
|
||||
- [ ] 多个 `Include()` 使用 `AsSplitQuery()`
|
||||
- [ ] 只读查询使用 `AsNoTracking()`
|
||||
- [ ] 列上无函数调用阻止索引使用
|
||||
- [ ] 数据库调用全部异步
|
||||
|
||||
### ASP.NET Core
|
||||
|
||||
- [ ] HttpClient 通过 `IHttpClientFactory` 获取
|
||||
- [ ] 后台任务中不直接使用 scoped 服务
|
||||
- [ ] 使用 `ReadFormAsync` 代替 `Request.Form`
|
||||
- [ ] 异常不用于控制流
|
||||
- [ ] 响应头通过 `OnStarting` 设置
|
||||
|
||||
### 依赖注入
|
||||
|
||||
- [ ] Scoped 服务不注入 Singleton
|
||||
- [ ] 后台任务创建新 scope
|
||||
|
||||
### LINQ
|
||||
|
||||
- [ ] 无不必要的 `ToList()` 后再 LINQ
|
||||
- [ ] `Any()` 代替 `Count() > 0`
|
||||
- [ ] IEnumerable 不被多次枚举(或先物化)
|
||||
- [ ] Select 中无副作用
|
||||
+661
@@ -0,0 +1,661 @@
|
||||
# CSS / Less / Sass Review Guide
|
||||
|
||||
CSS 及预处理器代码审查指南,覆盖性能、可维护性、响应式设计和浏览器兼容性。
|
||||
|
||||
## CSS 变量 vs 硬编码
|
||||
|
||||
### 应该使用变量的场景
|
||||
|
||||
```css
|
||||
/* ❌ 硬编码 - 难以维护 */
|
||||
.button {
|
||||
background: #3b82f6;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid #3b82f6;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ✅ 使用 CSS 变量 */
|
||||
:root {
|
||||
--color-primary: #3b82f6;
|
||||
--radius-md: 8px;
|
||||
}
|
||||
.button {
|
||||
background: var(--color-primary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.card {
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
```
|
||||
|
||||
### 变量命名规范
|
||||
|
||||
```css
|
||||
/* 推荐的变量分类 */
|
||||
:root {
|
||||
/* 颜色 */
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary-hover: #2563eb;
|
||||
--color-text: #1f2937;
|
||||
--color-text-muted: #6b7280;
|
||||
--color-bg: #ffffff;
|
||||
--color-border: #e5e7eb;
|
||||
|
||||
/* 间距 */
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
|
||||
/* 字体 */
|
||||
--font-size-sm: 14px;
|
||||
--font-size-base: 16px;
|
||||
--font-size-lg: 18px;
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
/* 圆角 */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* 阴影 */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* 过渡 */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-normal: 300ms ease;
|
||||
}
|
||||
```
|
||||
|
||||
### 变量作用域建议
|
||||
|
||||
```css
|
||||
/* ✅ 组件级变量 - 减少全局污染 */
|
||||
.card {
|
||||
--card-padding: var(--spacing-md);
|
||||
--card-radius: var(--radius-md);
|
||||
|
||||
padding: var(--card-padding);
|
||||
border-radius: var(--card-radius);
|
||||
}
|
||||
|
||||
/* ⚠️ 避免频繁用 JS 动态修改变量 - 影响性能 */
|
||||
```
|
||||
|
||||
### 审查清单
|
||||
|
||||
- [ ] 颜色值是否使用变量?
|
||||
- [ ] 间距是否来自设计系统?
|
||||
- [ ] 重复值是否提取为变量?
|
||||
- [ ] 变量命名是否语义化?
|
||||
|
||||
---
|
||||
|
||||
## !important 使用规范
|
||||
|
||||
### 何时可以使用
|
||||
|
||||
```css
|
||||
/* ✅ 工具类 - 明确需要覆盖 */
|
||||
.hidden { display: none !important; }
|
||||
.sr-only { position: absolute !important; }
|
||||
|
||||
/* ✅ 覆盖第三方库样式(无法修改源码时) */
|
||||
.third-party-modal {
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
|
||||
/* ✅ 打印样式 */
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
}
|
||||
```
|
||||
|
||||
### 何时禁止使用
|
||||
|
||||
```css
|
||||
/* ❌ 解决特异性问题 - 应该重构选择器 */
|
||||
.button {
|
||||
background: blue !important; /* 为什么需要 !important? */
|
||||
}
|
||||
|
||||
/* ❌ 覆盖自己写的样式 */
|
||||
.card { padding: 20px; }
|
||||
.card { padding: 30px !important; } /* 直接修改原规则 */
|
||||
|
||||
/* ❌ 在组件样式中 */
|
||||
.my-component .title {
|
||||
font-size: 24px !important; /* 破坏组件封装 */
|
||||
}
|
||||
```
|
||||
|
||||
### 替代方案
|
||||
|
||||
```css
|
||||
/* 问题:需要覆盖 .btn 的样式 */
|
||||
|
||||
/* ❌ 使用 !important */
|
||||
.my-btn {
|
||||
background: red !important;
|
||||
}
|
||||
|
||||
/* ✅ 提高特异性 */
|
||||
button.my-btn {
|
||||
background: red;
|
||||
}
|
||||
|
||||
/* ✅ 使用更具体的选择器 */
|
||||
.container .my-btn {
|
||||
background: red;
|
||||
}
|
||||
|
||||
/* ✅ 使用 :where() 降低被覆盖样式的特异性 */
|
||||
:where(.btn) {
|
||||
background: blue; /* 特异性为 0 */
|
||||
}
|
||||
.my-btn {
|
||||
background: red; /* 可以正常覆盖 */
|
||||
}
|
||||
```
|
||||
|
||||
### 审查问题
|
||||
|
||||
```markdown
|
||||
🔴 [blocking] "发现 15 处 !important,请说明每处的必要性"
|
||||
🟡 [important] "这个 !important 可以通过调整选择器特异性来解决"
|
||||
💡 [suggestion] "考虑使用 CSS Layers (@layer) 来管理样式优先级"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 性能考虑
|
||||
|
||||
### 🔴 高危性能问题
|
||||
|
||||
#### 1. `transition: all` 问题
|
||||
|
||||
```css
|
||||
/* ❌ 性能杀手 - 浏览器检查所有可动画属性 */
|
||||
.button {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* ✅ 明确指定属性 */
|
||||
.button {
|
||||
transition: background-color 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
/* ✅ 多属性时使用变量 */
|
||||
.button {
|
||||
--transition-duration: 0.3s;
|
||||
transition:
|
||||
background-color var(--transition-duration) ease,
|
||||
box-shadow var(--transition-duration) ease,
|
||||
transform var(--transition-duration) ease;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. box-shadow 动画
|
||||
|
||||
```css
|
||||
/* ❌ 每帧触发重绘 - 严重影响性能 */
|
||||
.card {
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
.card:hover {
|
||||
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* ✅ 使用伪元素 + opacity */
|
||||
.card {
|
||||
position: relative;
|
||||
}
|
||||
.card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.card:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 触发布局(Reflow)的属性
|
||||
|
||||
```css
|
||||
/* ❌ 动画这些属性会触发布局重计算 */
|
||||
.bad-animation {
|
||||
transition: width 0.3s, height 0.3s, top 0.3s, left 0.3s, margin 0.3s;
|
||||
}
|
||||
|
||||
/* ✅ 只动画 transform 和 opacity(仅触发合成) */
|
||||
.good-animation {
|
||||
transition: transform 0.3s, opacity 0.3s;
|
||||
}
|
||||
|
||||
/* 位移用 translate 代替 top/left */
|
||||
.move {
|
||||
transform: translateX(100px); /* ✅ */
|
||||
/* left: 100px; */ /* ❌ */
|
||||
}
|
||||
|
||||
/* 缩放用 scale 代替 width/height */
|
||||
.grow {
|
||||
transform: scale(1.1); /* ✅ */
|
||||
/* width: 110%; */ /* ❌ */
|
||||
}
|
||||
```
|
||||
|
||||
### 🟡 中等性能问题
|
||||
|
||||
#### 复杂选择器
|
||||
|
||||
```css
|
||||
/* ❌ 过深的嵌套 - 选择器匹配慢 */
|
||||
.page .container .content .article .section .paragraph span {
|
||||
color: red;
|
||||
}
|
||||
|
||||
/* ✅ 扁平化 */
|
||||
.article-text {
|
||||
color: red;
|
||||
}
|
||||
|
||||
/* ❌ 通配符选择器 */
|
||||
* { box-sizing: border-box; } /* 影响所有元素 */
|
||||
[class*="icon-"] { display: inline; } /* 属性选择器较慢 */
|
||||
|
||||
/* ✅ 限制范围 */
|
||||
.icon-box * { box-sizing: border-box; }
|
||||
```
|
||||
|
||||
#### 大量阴影和滤镜
|
||||
|
||||
```css
|
||||
/* ⚠️ 复杂阴影影响渲染性能 */
|
||||
.heavy-shadow {
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0,0,0,0.1),
|
||||
0 2px 4px rgba(0,0,0,0.1),
|
||||
0 4px 8px rgba(0,0,0,0.1),
|
||||
0 8px 16px rgba(0,0,0,0.1),
|
||||
0 16px 32px rgba(0,0,0,0.1); /* 5 层阴影 */
|
||||
}
|
||||
|
||||
/* ⚠️ 滤镜消耗 GPU */
|
||||
.blur-heavy {
|
||||
filter: blur(20px) brightness(1.2) contrast(1.1);
|
||||
backdrop-filter: blur(10px); /* 更消耗性能 */
|
||||
}
|
||||
```
|
||||
|
||||
### 性能优化建议
|
||||
|
||||
```css
|
||||
/* 使用 will-change 提示浏览器(谨慎使用) */
|
||||
.animated-element {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* 动画完成后移除 will-change */
|
||||
.animated-element.idle {
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
/* 使用 contain 限制重绘范围 */
|
||||
.card {
|
||||
contain: layout paint; /* 告诉浏览器内部变化不影响外部 */
|
||||
}
|
||||
```
|
||||
|
||||
### 审查清单
|
||||
|
||||
- [ ] 是否使用 `transition: all`?
|
||||
- [ ] 是否动画 width/height/top/left?
|
||||
- [ ] box-shadow 是否被动画?
|
||||
- [ ] 选择器嵌套是否超过 3 层?
|
||||
- [ ] 是否有不必要的 `will-change`?
|
||||
|
||||
---
|
||||
|
||||
## 响应式设计检查点
|
||||
|
||||
### Mobile First 原则
|
||||
|
||||
```css
|
||||
/* ✅ Mobile First - 基础样式针对移动端 */
|
||||
.container {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 逐步增强 */
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
padding: 24px;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container {
|
||||
padding: 32px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ❌ Desktop First - 需要覆盖更多样式 */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
padding: 32px;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.container {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.container {
|
||||
padding: 16px;
|
||||
flex-direction: column;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 断点建议
|
||||
|
||||
```css
|
||||
/* 推荐断点(基于内容而非设备) */
|
||||
:root {
|
||||
--breakpoint-sm: 640px; /* 大手机 */
|
||||
--breakpoint-md: 768px; /* 平板竖屏 */
|
||||
--breakpoint-lg: 1024px; /* 平板横屏/小笔记本 */
|
||||
--breakpoint-xl: 1280px; /* 桌面 */
|
||||
--breakpoint-2xl: 1536px; /* 大桌面 */
|
||||
}
|
||||
|
||||
/* 使用示例 */
|
||||
@media (min-width: 768px) { /* md */ }
|
||||
@media (min-width: 1024px) { /* lg */ }
|
||||
```
|
||||
|
||||
### 响应式审查清单
|
||||
|
||||
- [ ] 是否采用 Mobile First?
|
||||
- [ ] 断点是否基于内容断裂点而非设备?
|
||||
- [ ] 是否避免断点重叠?
|
||||
- [ ] 文字是否使用相对单位(rem/em)?
|
||||
- [ ] 触摸目标是否足够大(≥44px)?
|
||||
- [ ] 是否测试了横竖屏切换?
|
||||
|
||||
### 常见问题
|
||||
|
||||
```css
|
||||
/* ❌ 固定宽度 */
|
||||
.container {
|
||||
width: 1200px;
|
||||
}
|
||||
|
||||
/* ✅ 最大宽度 + 弹性 */
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
/* ❌ 固定高度的文本容器 */
|
||||
.text-box {
|
||||
height: 100px; /* 文字可能溢出 */
|
||||
}
|
||||
|
||||
/* ✅ 最小高度 */
|
||||
.text-box {
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
/* ❌ 小触摸目标 */
|
||||
.small-button {
|
||||
padding: 4px 8px; /* 太小,难以点击 */
|
||||
}
|
||||
|
||||
/* ✅ 足够的触摸区域 */
|
||||
.touch-button {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 浏览器兼容性
|
||||
|
||||
### 需要检查的特性
|
||||
|
||||
| 特性 | 兼容性 | 建议 |
|
||||
|------|--------|------|
|
||||
| CSS Grid | 现代浏览器 ✅ | IE 需要 Autoprefixer + 测试 |
|
||||
| Flexbox | 广泛支持 ✅ | 旧版需要前缀 |
|
||||
| CSS Variables | 现代浏览器 ✅ | IE 不支持,需要回退 |
|
||||
| `gap` (flexbox) | 较新 ⚠️ | Safari 14.1+ |
|
||||
| `:has()` | 较新 ⚠️ | Firefox 121+ |
|
||||
| `container queries` | 较新 ⚠️ | 2023 年后的浏览器 |
|
||||
| `@layer` | 较新 ⚠️ | 检查目标浏览器 |
|
||||
|
||||
### 回退策略
|
||||
|
||||
```css
|
||||
/* CSS 变量回退 */
|
||||
.button {
|
||||
background: #3b82f6; /* 回退值 */
|
||||
background: var(--color-primary); /* 现代浏览器 */
|
||||
}
|
||||
|
||||
/* Flexbox gap 回退 */
|
||||
.flex-container {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
/* 旧浏览器回退 */
|
||||
.flex-container > * + * {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
/* Grid 回退 */
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@supports (display: grid) {
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Autoprefixer 配置
|
||||
|
||||
```javascript
|
||||
// postcss.config.js
|
||||
module.exports = {
|
||||
plugins: [
|
||||
require('autoprefixer')({
|
||||
// 根据 browserslist 配置
|
||||
grid: 'autoplace', // 启用 Grid 前缀(IE 支持)
|
||||
flexbox: 'no-2009', // 只用现代 flexbox 语法
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
// package.json
|
||||
{
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"not dead",
|
||||
"not ie 11" // 根据项目需求
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 审查清单
|
||||
|
||||
- [ ] 是否检查了 [Can I Use](https://caniuse.com)?
|
||||
- [ ] 新特性是否有回退方案?
|
||||
- [ ] 是否配置了 Autoprefixer?
|
||||
- [ ] browserslist 是否符合项目要求?
|
||||
- [ ] 是否在目标浏览器中测试?
|
||||
|
||||
---
|
||||
|
||||
## Less / Sass 特定问题
|
||||
|
||||
### 嵌套深度
|
||||
|
||||
```scss
|
||||
/* ❌ 过深嵌套 - 编译后选择器过长 */
|
||||
.page {
|
||||
.container {
|
||||
.content {
|
||||
.article {
|
||||
.title {
|
||||
color: red; // 编译为 .page .container .content .article .title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ✅ 最多 3 层 */
|
||||
.article {
|
||||
&__title {
|
||||
color: red;
|
||||
}
|
||||
|
||||
&__content {
|
||||
p { margin-bottom: 1em; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Mixin vs Extend vs 变量
|
||||
|
||||
```scss
|
||||
@use 'sass:color';
|
||||
|
||||
/* 变量 - 用于单个值 */
|
||||
$primary-color: #3b82f6;
|
||||
|
||||
/* Mixin - 用于可配置的代码块 */
|
||||
@mixin button-variant($bg, $text) {
|
||||
background: $bg;
|
||||
color: $text;
|
||||
&:hover {
|
||||
// Dart Sass 已弃用全局 darken()/lighten(),改用 color 模块
|
||||
background: color.adjust($bg, $lightness: -10%);
|
||||
// color.scale($bg, $lightness: -10%) 按比例调整,深浅过渡更自然
|
||||
}
|
||||
}
|
||||
|
||||
/* Extend - 用于共享相同样式(谨慎使用) */
|
||||
%visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%); /* clip: rect() 已弃用,改用 clip-path */
|
||||
white-space: nowrap; /* 避免内容被挤成一列后撑开布局 */
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
@extend %visually-hidden;
|
||||
}
|
||||
|
||||
/* ⚠️ @extend 的问题 */
|
||||
// 可能产生意外的选择器组合
|
||||
// 不能在 @media 中使用
|
||||
// 优先使用 mixin
|
||||
```
|
||||
|
||||
### 审查清单
|
||||
|
||||
- [ ] 嵌套是否超过 3 层?
|
||||
- [ ] 是否滥用 @extend?
|
||||
- [ ] Mixin 是否过于复杂?
|
||||
- [ ] 编译后的 CSS 大小是否合理?
|
||||
|
||||
---
|
||||
|
||||
## 快速审查清单
|
||||
|
||||
### 🔴 必须修复
|
||||
|
||||
```markdown
|
||||
□ transition: all
|
||||
□ 动画 width/height/top/left/margin
|
||||
□ 大量 !important
|
||||
□ 硬编码的颜色/间距重复 >3 次
|
||||
□ 选择器嵌套 >4 层
|
||||
```
|
||||
|
||||
### 🟡 建议修复
|
||||
|
||||
```markdown
|
||||
□ 缺少响应式处理
|
||||
□ 使用 Desktop First
|
||||
□ 复杂 box-shadow 被动画
|
||||
□ 缺少浏览器兼容回退
|
||||
□ CSS 变量作用域过大
|
||||
```
|
||||
|
||||
### 🟢 优化建议
|
||||
|
||||
```markdown
|
||||
□ 可以使用 CSS Grid 简化布局
|
||||
□ 可以使用 CSS 变量提取重复值
|
||||
□ 可以使用 @layer 管理优先级
|
||||
□ 可以添加 contain 优化性能
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工具推荐
|
||||
|
||||
| 工具 | 用途 |
|
||||
|------|------|
|
||||
| [Stylelint](https://stylelint.io/) | CSS 代码检查 |
|
||||
| [PurgeCSS](https://purgecss.com/) | 移除未使用 CSS |
|
||||
| [Autoprefixer](https://autoprefixer.github.io/) | 自动添加前缀 |
|
||||
| [CSS Stats](https://cssstats.com/) | 分析 CSS 统计 |
|
||||
| [Can I Use](https://caniuse.com/) | 浏览器兼容性查询 |
|
||||
|
||||
---
|
||||
|
||||
## 参考资源
|
||||
|
||||
- [CSS Performance Optimization - MDN](https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Performance/CSS)
|
||||
- [What a CSS Code Review Might Look Like - CSS-Tricks](https://css-tricks.com/what-a-css-code-review-might-look-like/)
|
||||
- [How to Animate Box-Shadow - Tobias Ahlin](https://tobiasahlin.com/blog/how-to-animate-box-shadow/)
|
||||
- [Media Query Fundamentals - MDN](https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/CSS_layout/Media_queries)
|
||||
- [Autoprefixer - GitHub](https://github.com/postcss/autoprefixer)
|
||||
+1030
File diff suppressed because it is too large
Load Diff
+584
@@ -0,0 +1,584 @@
|
||||
# FastAPI Code Review Guide
|
||||
|
||||
> FastAPI code review guide covering dependency injection (`Depends`), Pydantic v2 validation boundaries, async correctness, database session lifecycle and N+1, security, and a test-driven verification workflow that turns the reviewer's in-process test client into a tool for *proving* bugs rather than guessing at them.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Dependency Injection (`Depends`)](#dependency-injection-depends)
|
||||
- [Pydantic v2 Models & Validation](#pydantic-v2-models--validation)
|
||||
- [Async Correctness](#async-correctness)
|
||||
- [Database Sessions & N+1](#database-sessions--n1)
|
||||
- [Security](#security)
|
||||
- [Test-Driven Verification](#test-driven-verification)
|
||||
- [Review Checklist](#review-checklist)
|
||||
- [References](#references)
|
||||
|
||||
---
|
||||
|
||||
## Dependency Injection (`Depends`)
|
||||
|
||||
FastAPI's `Depends` is the seam that keeps routes thin and testable. Most review problems here come from doing real work in the route function instead of behind a dependency.
|
||||
|
||||
### Business logic belongs behind a dependency or service, not in the route
|
||||
|
||||
```python
|
||||
# ❌ Bad — DB access, auth, and business rules all inline in the route
|
||||
@app.get("/orders/{order_id}")
|
||||
async def get_order(order_id: int):
|
||||
conn = await asyncpg.connect(DATABASE_URL) # connection created per request
|
||||
row = await conn.fetchrow("SELECT * FROM orders WHERE id = $1", order_id)
|
||||
await conn.close()
|
||||
if row is None:
|
||||
raise HTTPException(404)
|
||||
return dict(row)
|
||||
|
||||
# ✅ Good — the route declares what it needs; the session is injected and pooled
|
||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
|
||||
@app.get("/orders/{order_id}", response_model=OrderOut)
|
||||
async def get_order(order_id: int, session: AsyncSession = Depends(get_session)):
|
||||
order = await session.get(Order, order_id)
|
||||
if order is None:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
return order
|
||||
```
|
||||
|
||||
The injected version is also the version you can override in tests (see [Test-Driven Verification](#test-driven-verification)).
|
||||
|
||||
### `yield` dependencies must clean up, and cleanup runs even on error
|
||||
|
||||
```python
|
||||
# ❌ Bad — no cleanup; the session leaks if the route raises
|
||||
async def get_session() -> AsyncSession:
|
||||
return SessionLocal()
|
||||
|
||||
# ✅ Good — the context manager closes the session on success AND on exception
|
||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
```
|
||||
|
||||
Review point: confirm any `yield` dependency holding a resource (DB session, file handle, lock) releases it through a context manager or `try/finally`, so an exception in the route does not leak it.
|
||||
|
||||
### Don't re-create singletons per request
|
||||
|
||||
```python
|
||||
# ❌ Bad — a new HTTP client (and connection pool) per request
|
||||
@app.get("/proxy")
|
||||
async def proxy(client: httpx.AsyncClient = Depends(lambda: httpx.AsyncClient())):
|
||||
...
|
||||
|
||||
# ✅ Good — one client for the app lifetime, injected by reference
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
app.state.http = httpx.AsyncClient()
|
||||
yield
|
||||
await app.state.http.aclose()
|
||||
|
||||
def get_http(request: Request) -> httpx.AsyncClient:
|
||||
return request.app.state.http
|
||||
```
|
||||
|
||||
### Prefer the `Annotated` form and async dependencies
|
||||
|
||||
Since FastAPI 0.95 the idiomatic way to declare a dependency is `Annotated[T, Depends(...)]`, not the default-value form. It is reusable across routes and plays well with type checkers. Also prefer `async def` dependencies: a sync (`def`) dependency runs in the threadpool, which is wasted overhead for a small non-I/O check.
|
||||
|
||||
```python
|
||||
# ⚠️ Older form — still works, but not the current idiom
|
||||
@app.get("/items")
|
||||
async def list_items(session: AsyncSession = Depends(get_session)): ...
|
||||
|
||||
# ✅ Good — Annotated form; define once, reuse everywhere
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
@app.get("/items")
|
||||
async def list_items(session: SessionDep): ...
|
||||
```
|
||||
|
||||
### Use dependencies to validate existence and permissions — they're cached per request
|
||||
|
||||
A dependency is the natural place to answer "does this resource exist and may this caller touch it?" Pydantic validates *shape*; a dependency validates against the database. FastAPI caches each dependency's result within a single request, so chaining small dependencies costs nothing extra and removes duplicated lookups.
|
||||
|
||||
```python
|
||||
# ✅ Good — small dependencies chain; valid_post is resolved once per request
|
||||
async def valid_post(post_id: int, session: SessionDep) -> Post:
|
||||
post = await session.get(Post, post_id)
|
||||
if post is None:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
return post
|
||||
|
||||
async def owned_post(post: Annotated[Post, Depends(valid_post)], user: CurrentUser) -> Post:
|
||||
if post.owner_id != user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
return post
|
||||
|
||||
@app.delete("/posts/{post_id}", status_code=204)
|
||||
async def delete_post(post: Annotated[Post, Depends(owned_post)], session: SessionDep):
|
||||
await session.delete(post) # existence + ownership already enforced
|
||||
await session.commit()
|
||||
```
|
||||
|
||||
This is also the cleanest place to fix the auth-vs-authorization bug from the [Security](#security) section: the ownership check moves into a reusable `owned_post` dependency.
|
||||
|
||||
---
|
||||
|
||||
## Pydantic v2 Models & Validation
|
||||
|
||||
### Separate input and output models; never echo the ORM object directly
|
||||
|
||||
```python
|
||||
# ❌ Bad — response_model is the DB model, so hashed_password leaks to the client
|
||||
@app.post("/users", response_model=UserTable)
|
||||
async def create_user(user: UserTable): # also accepts client-set id, is_admin...
|
||||
...
|
||||
|
||||
# ✅ Good — distinct schemas draw the trust boundary
|
||||
class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
email: EmailStr
|
||||
model_config = ConfigDict(from_attributes=True) # read from ORM safely
|
||||
|
||||
@app.post("/users", response_model=UserOut, status_code=201)
|
||||
async def create_user(payload: UserCreate, session: AsyncSession = Depends(get_session)):
|
||||
...
|
||||
```
|
||||
|
||||
`response_model` is a filter, not just documentation — fields absent from the output model are stripped from the response. Reusing the DB model as the response is the most common way sensitive fields leak.
|
||||
|
||||
### Use distinct Create and Update schemas
|
||||
|
||||
```python
|
||||
# ❌ Bad — one schema for create and update means every field is required on PATCH
|
||||
class ItemSchema(BaseModel):
|
||||
name: str
|
||||
price: float
|
||||
|
||||
# ✅ Good — update is a partial; create requires the full payload
|
||||
class ItemCreate(BaseModel):
|
||||
name: str
|
||||
price: float = Field(gt=0)
|
||||
|
||||
class ItemUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
price: float | None = Field(default=None, gt=0)
|
||||
```
|
||||
|
||||
### Validate at the boundary, not after the DB write
|
||||
|
||||
```python
|
||||
# ❌ Bad — negative quantity reaches the database before anything checks it
|
||||
@app.post("/cart")
|
||||
async def add_to_cart(item_id: int, quantity: int):
|
||||
await save(item_id, quantity) # quantity = -5 silently accepted
|
||||
|
||||
# ✅ Good — the type system rejects it before the handler body runs
|
||||
class CartLine(BaseModel):
|
||||
item_id: int
|
||||
quantity: int = Field(gt=0)
|
||||
|
||||
@app.post("/cart")
|
||||
async def add_to_cart(line: CartLine):
|
||||
await save(line.item_id, line.quantity)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Async Correctness
|
||||
|
||||
This is the axis on which FastAPI differs most from Django and Flask, and the one most worth a reviewer's attention. FastAPI's throughput comes from a single event loop interleaving many concurrent requests. That model only holds if the loop is **never blocked**: one synchronous call on the loop stalls *every* in-flight request, not just its own. Get this wrong across the codebase and FastAPI does not just lose its edge — it performs *worse* than a sync framework like Flask, because Flask's worker-per-request model has no shared loop to choke. The reviewer's job is to keep work on the loop genuinely non-blocking and to treat every escape hatch as a cost, not a fix.
|
||||
|
||||
### Never call blocking code inside an `async def` route
|
||||
|
||||
```python
|
||||
# ❌ Bad — blocking I/O on the loop freezes ALL concurrent requests, not just this one
|
||||
@app.get("/report")
|
||||
async def report():
|
||||
data = requests.get("https://slow-api.example.com").json() # blocking socket
|
||||
time.sleep(2) # blocks the loop
|
||||
return data
|
||||
|
||||
# ✅ Good — await a native-async client; the loop serves other requests meanwhile
|
||||
@app.get("/report")
|
||||
async def report(client: httpx.AsyncClient = Depends(get_http)):
|
||||
resp = await client.get("https://slow-api.example.com")
|
||||
return resp.json()
|
||||
```
|
||||
|
||||
### Prefer native-async SDKs over sync libraries
|
||||
|
||||
The right fix for blocking I/O is almost always a library that speaks `async` natively — not wrapping a sync one. Reach for the async client first; the threadpool is the last resort, not the default.
|
||||
|
||||
| Sync (blocks the loop) | Native-async replacement |
|
||||
|------------------------|--------------------------|
|
||||
| `requests` | `httpx.AsyncClient`, `aiohttp` |
|
||||
| `psycopg2` (sync) | `asyncpg`, SQLAlchemy async engine |
|
||||
| `redis-py` (sync) | `redis.asyncio` |
|
||||
| `pymongo` | `motor` |
|
||||
| `boto3` | `aioboto3` |
|
||||
|
||||
If you find `asyncio.run(...)`, a new event loop, or a manually started thread *inside* a route, that is a red flag — it's an attempt to bolt sync code onto the loop. `asyncio.run()` inside a running loop raises `RuntimeError` outright; the rest quietly burns the performance you adopted FastAPI for.
|
||||
|
||||
```python
|
||||
# ❌ Bad — spinning up a loop/thread to call an async SDK from a sync context
|
||||
@app.get("/users/{uid}")
|
||||
def get_user(uid: int):
|
||||
return asyncio.run(repo.fetch(uid)) # RuntimeError under the running loop
|
||||
|
||||
# ✅ Good — let the route be async and await the native client directly
|
||||
@app.get("/users/{uid}")
|
||||
async def get_user(uid: int):
|
||||
return await repo.fetch(uid)
|
||||
```
|
||||
|
||||
### The threadpool is a bounded escape hatch, not a default
|
||||
|
||||
A plain `def` route — and `run_in_threadpool(...)` — does not run on the loop; FastAPI runs it in a **bounded** worker threadpool (AnyIO's default cap is 40 threads). For an occasional, genuinely-unavoidable blocking call this is the correct tool:
|
||||
|
||||
```python
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
@app.get("/legacy")
|
||||
async def legacy():
|
||||
return await run_in_threadpool(blocking_library_call) # only if no async SDK exists
|
||||
```
|
||||
|
||||
But it does not scale the way the loop does. Route every hot path through the threadpool and, under load, all workers block at once; further requests queue behind the cap and throughput collapses. Spawning your own threads or processes to "add concurrency" makes it worse: once live threads exceed the machine's core count, context-switch and GIL contention degrade performance sharply rather than improving it. The escape hatch is for the rare blocking dependency you cannot replace — not a substitute for choosing async SDKs.
|
||||
|
||||
Review heuristic: a `def` route is acceptable for a low-traffic endpoint with no async equivalent. A high-traffic endpoint doing blocking work in a `def` route (or via `run_in_threadpool`) is a scaling bug — flag it and ask for an async SDK.
|
||||
|
||||
### CPU-bound work belongs in a worker process, not the loop or the threadpool
|
||||
|
||||
Neither the event loop nor the threadpool helps CPU-bound work: under the GIL only one thread runs Python bytecode at a time, so a heavy computation blocks just as badly from a threadpool as from the loop. Offload it to a separate process (Celery, Arq, RQ, or `multiprocessing`).
|
||||
|
||||
```python
|
||||
# ❌ Bad — a CPU-heavy job pins a worker; throughput drops for everyone
|
||||
@app.post("/render")
|
||||
async def render(doc: Doc):
|
||||
return heavy_pdf_render(doc) # seconds of pure CPU on the loop
|
||||
|
||||
# ✅ Good — enqueue to a worker process; return a job handle
|
||||
@app.post("/render", status_code=202)
|
||||
async def render(doc: Doc):
|
||||
job = await queue.enqueue(heavy_pdf_render, doc)
|
||||
return {"job_id": job.id}
|
||||
```
|
||||
|
||||
### Don't fire-and-forget unawaited coroutines
|
||||
|
||||
```python
|
||||
# ❌ Bad — coroutine never awaited; the email is never sent (and no error surfaces)
|
||||
@app.post("/signup")
|
||||
async def signup(user: UserCreate):
|
||||
send_welcome_email(user.email) # returns a coroutine, silently dropped
|
||||
|
||||
# ✅ Good — defer post-response work with BackgroundTasks
|
||||
@app.post("/signup")
|
||||
async def signup(user: UserCreate, tasks: BackgroundTasks):
|
||||
tasks.add_task(send_welcome_email, user.email)
|
||||
```
|
||||
|
||||
`BackgroundTasks` runs in-process and offers no retries or persistence — use it only for short, fire-and-forget work (send an email, log an event). Anything long-running or retry-critical (data processing, payments) belongs in a real task queue (Celery/Arq/RQ).
|
||||
|
||||
---
|
||||
|
||||
## Database Sessions & N+1
|
||||
|
||||
### One session per request, injected — not a global
|
||||
|
||||
```python
|
||||
# ❌ Bad — a module-level session is shared across concurrent requests (not safe)
|
||||
session = SessionLocal()
|
||||
|
||||
# ✅ Good — request-scoped session via dependency (see get_session above)
|
||||
@app.get("/items")
|
||||
async def list_items(session: AsyncSession = Depends(get_session)):
|
||||
...
|
||||
```
|
||||
|
||||
### Eager-load relationships to avoid N+1
|
||||
|
||||
```python
|
||||
# ❌ Bad — one query for orders, then one query per order for its customer
|
||||
orders = (await session.execute(select(Order))).scalars().all()
|
||||
return [{"id": o.id, "customer": o.customer.name} for o in orders] # N+1
|
||||
|
||||
# ✅ Good — a single query with the relationship eager-loaded
|
||||
stmt = select(Order).options(selectinload(Order.customer))
|
||||
orders = (await session.execute(stmt)).scalars().all()
|
||||
return [{"id": o.id, "customer": o.customer.name} for o in orders]
|
||||
```
|
||||
|
||||
With async SQLAlchemy, lazy attribute access outside the session often raises instead of silently querying — but the design issue is the same. Look for relationship access inside a loop without an `options(...)` eager load.
|
||||
|
||||
### Paginate list endpoints
|
||||
|
||||
```python
|
||||
# ❌ Bad — returns every row; degrades as the table grows
|
||||
@app.get("/users")
|
||||
async def list_users(session: AsyncSession = Depends(get_session)):
|
||||
return (await session.execute(select(User))).scalars().all()
|
||||
|
||||
# ✅ Good — bounded page with a sane cap
|
||||
@app.get("/users", response_model=list[UserOut])
|
||||
async def list_users(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
limit: int = Query(default=50, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
):
|
||||
stmt = select(User).limit(limit).offset(offset)
|
||||
return (await session.execute(stmt)).scalars().all()
|
||||
```
|
||||
|
||||
### Aggregate and join in SQL, not in Python
|
||||
|
||||
If a handler pulls rows into memory and then loops to group, count, or join them, the database is being used as dumb storage. Push the work down — the database does set operations far faster, and you transfer less data.
|
||||
|
||||
```python
|
||||
# ❌ Bad — fetch every order, then tally per customer in Python
|
||||
orders = (await session.execute(select(Order))).scalars().all()
|
||||
totals: dict[int, float] = {}
|
||||
for o in orders:
|
||||
totals[o.customer_id] = totals.get(o.customer_id, 0) + o.amount
|
||||
|
||||
# ✅ Good — let the database group and sum
|
||||
stmt = select(Order.customer_id, func.sum(Order.amount)).group_by(Order.customer_id)
|
||||
totals = dict((await session.execute(stmt)).all())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
### A declared auth dependency is not an enforced authorization check
|
||||
|
||||
This is the highest-value thing to look for. `Depends(get_current_user)` proves *who* the caller is — it does **not** prove they may touch *this* resource.
|
||||
|
||||
```python
|
||||
# ❌ Bad — any authenticated user can delete any other user's document
|
||||
@app.delete("/documents/{doc_id}")
|
||||
async def delete_document(
|
||||
doc_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
doc = await session.get(Document, doc_id)
|
||||
await session.delete(doc) # never checks doc.owner_id == user.id
|
||||
await session.commit()
|
||||
|
||||
# ✅ Good — ownership is verified before the mutation
|
||||
@app.delete("/documents/{doc_id}", status_code=204)
|
||||
async def delete_document(
|
||||
doc_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
doc = await session.get(Document, doc_id)
|
||||
if doc is None:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if doc.owner_id != user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
await session.delete(doc)
|
||||
await session.commit()
|
||||
```
|
||||
|
||||
The [Test-Driven Verification](#test-driven-verification) section reproduces exactly this bug with a failing test.
|
||||
|
||||
### Parameterize SQL; never f-string user input
|
||||
|
||||
```python
|
||||
# ❌ Bad — SQL injection
|
||||
await session.execute(text(f"SELECT * FROM users WHERE email = '{email}'"))
|
||||
|
||||
# ✅ Good — bound parameter
|
||||
await session.execute(text("SELECT * FROM users WHERE email = :email"), {"email": email})
|
||||
```
|
||||
|
||||
### Don't widen CORS to credentials + wildcard
|
||||
|
||||
```python
|
||||
# ❌ Bad — wildcard origin together with credentials is rejected by browsers and unsafe
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True)
|
||||
|
||||
# ✅ Good — enumerate trusted origins when credentials are allowed
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["https://app.example.com"],
|
||||
allow_credentials=True,
|
||||
)
|
||||
```
|
||||
|
||||
Also check: secrets read from config/env (not hard-coded), `HTTPException` details that don't leak internals (stack traces, SQL), and rate limiting on auth endpoints.
|
||||
|
||||
---
|
||||
|
||||
## Test-Driven Verification
|
||||
|
||||
> Inspired by the test-driven development discipline: *if you didn't watch the test fail, you don't know it tests the right thing.* This matters even more for a coding agent than for a human reviewer. An agent's reading and reasoning are fallible — it can misread control flow, hallucinate a guarantee that isn't there, or rationalize a comfortable conclusion — so a prose verdict like "this looks safe" carries little weight on its own. An executable test is the one piece of **objective ground truth** the agent fully controls: it either passes or it doesn't, regardless of how confident the reasoning felt. That is what makes tests the agent's anchor of confidence. Reviewing the same way the discipline writes code — reproduce, don't assert — turns a hunch into proof.
|
||||
|
||||
A natural-language review comment ("this might let users delete each other's data") is exactly that kind of fallible hypothesis. FastAPI makes the ground truth cheap to obtain: an in-process client (`httpx.AsyncClient` over `ASGITransport`) runs the whole app, and `app.dependency_overrides` swaps out auth and the database without patching internals. So instead of trusting its own read of the code, the agent settles the question by reproduction.
|
||||
|
||||
### Reproduce a suspected bug with a failing test (Verify RED)
|
||||
|
||||
Suppose the reviewer suspects the `DELETE /documents/{doc_id}` route above never checks ownership. Write the test that asserts the *secure* behavior, then run it and **watch it fail** — the failure is the proof.
|
||||
|
||||
```python
|
||||
# test_document_authorization.py
|
||||
import pytest
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from fastapi import Header
|
||||
from app.main import app
|
||||
from app.deps import get_current_user, get_session
|
||||
|
||||
# Two users; the override picks one based on a test header.
|
||||
USERS = {"alice": User(id=1, email="alice@example.com"),
|
||||
"bob": User(id=2, email="bob@example.com")}
|
||||
|
||||
def fake_current_user(x_test_user: str = Header(default="alice")) -> User:
|
||||
return USERS[x_test_user]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_cannot_delete_another_users_document(session): # async fixture
|
||||
# Arrange: a document owned by Alice (id=1)
|
||||
session.add(Document(id=10, owner_id=1, title="Alice's doc"))
|
||||
await session.commit()
|
||||
|
||||
app.dependency_overrides[get_current_user] = fake_current_user
|
||||
app.dependency_overrides[get_session] = lambda: session
|
||||
|
||||
# Act: Bob tries to delete Alice's document
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.delete("/documents/10", headers={"X-Test-User": "bob"})
|
||||
|
||||
# Assert the SECURE behavior we expect
|
||||
assert resp.status_code == 403
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
```
|
||||
|
||||
Run it against the unfixed code and confirm the failure is the bug, not a typo:
|
||||
|
||||
```bash
|
||||
$ pytest test_document_authorization.py
|
||||
FAILED assert 204 == 403
|
||||
# ^ the endpoint deleted Alice's document for Bob — vulnerability confirmed
|
||||
```
|
||||
|
||||
A failure of `204 == 403` (not an import error, not a 404) is what makes the finding credible: the route returned success for an action that should have been forbidden. Now the fix from the [Security](#security) section turns it green:
|
||||
|
||||
```bash
|
||||
$ pytest test_document_authorization.py
|
||||
PASSED
|
||||
```
|
||||
|
||||
Attach this test to the review. It documents the vulnerability, proves the fix, and guards against regression — far stronger than "consider checking ownership here."
|
||||
|
||||
### Prefer `dependency_overrides` over `patch`/`mock`
|
||||
|
||||
FastAPI's DI is the seam the TDD discipline asks for: when something is hard to test without mocking everything, that usually signals coupling — and `Depends` already gives you the injection point, so you rarely need `unittest.mock.patch`.
|
||||
|
||||
```python
|
||||
# ❌ Bad — patching internals: brittle, couples the test to import paths
|
||||
@patch("app.routes.orders.asyncpg.connect")
|
||||
def test_get_order(mock_connect): ...
|
||||
|
||||
# ✅ Good — override the dependency with a real in-memory fake
|
||||
app.dependency_overrides[get_session] = lambda: in_memory_session
|
||||
app.dependency_overrides[get_current_user] = lambda: test_user
|
||||
```
|
||||
|
||||
Always reset overrides between tests (`app.dependency_overrides.clear()` in a fixture teardown) so state doesn't leak across tests.
|
||||
|
||||
The reproduction above uses `httpx.AsyncClient` over `ASGITransport` with `@pytest.mark.asyncio` — the community convention for an async app, so the suite shares the app's event loop and you avoid loop-mismatch errors later. The synchronous `TestClient` is simpler and fine for a fully sync app, but standardizing on the async client from the start saves a painful migration once any route or fixture becomes async.
|
||||
|
||||
### Critique the PR's own tests, not just its source
|
||||
|
||||
A PR that ships tests is not automatically safe. Apply these checks to the *tests* in the diff:
|
||||
|
||||
```python
|
||||
# ❌ Bad — happy-path only. Proves the route works when everything is correct,
|
||||
# says nothing about the validation and authorization paths.
|
||||
def test_create_item():
|
||||
resp = client.post("/items", json={"name": "x", "price": 5})
|
||||
assert resp.status_code == 201
|
||||
|
||||
# ✅ Good — the boundary and failure paths are where bugs live
|
||||
def test_create_item_rejects_negative_price():
|
||||
resp = client.post("/items", json={"name": "x", "price": -5})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_item_requires_authentication():
|
||||
resp = client_without_auth.post("/items", json={"name": "x", "price": 5})
|
||||
assert resp.status_code == 401
|
||||
```
|
||||
|
||||
Review questions for the test suite:
|
||||
|
||||
- **Does it test behavior, or the mock?** An assertion that only confirms a mock was called proves the test's own setup, not the endpoint.
|
||||
- **Are the failure paths covered?** 401/403/404/422 — not just 200/201. Bugs cluster at the boundaries.
|
||||
- **Is the mock complete?** A partial mock of an external API response that omits fields the handler reads passes in the test and fails in production.
|
||||
- **Were the tests written after the fact?** Tests added alongside an implementation and passing on the first run never demonstrated that they can fail — and so prove little. A test that reproduces the bug (fails first, then passes) is worth more than one that was green from birth.
|
||||
|
||||
---
|
||||
|
||||
## Review Checklist
|
||||
|
||||
### Dependency Injection
|
||||
|
||||
- [ ] Routes stay thin — DB access and business rules live behind `Depends`/services
|
||||
- [ ] `yield` dependencies release resources via context manager or `try/finally`
|
||||
- [ ] Singletons (HTTP clients, pools) created once in `lifespan`, not per request
|
||||
- [ ] `Annotated[T, Depends(...)]` form used; dependencies are `async def` unless they do blocking I/O
|
||||
- [ ] Existence/permission checks live in (cached) dependencies, not copy-pasted into routes
|
||||
- [ ] Dependencies are overridable in tests (no resources created inline in the route)
|
||||
|
||||
### Validation
|
||||
|
||||
- [ ] Input and output use distinct Pydantic models; ORM objects are not the `response_model`
|
||||
- [ ] `response_model` set so sensitive fields can't leak
|
||||
- [ ] Separate Create vs Update schemas (update is partial)
|
||||
- [ ] Constraints (`gt`, `le`, `EmailStr`, ...) enforced at the boundary, before the DB write
|
||||
|
||||
### Async
|
||||
|
||||
- [ ] No blocking calls (`requests`, `time.sleep`, blocking DB drivers) inside `async def`
|
||||
- [ ] Native-async SDKs preferred (`httpx`, `asyncpg`, `redis.asyncio`, ...) over sync ones
|
||||
- [ ] No `asyncio.run`/manual event loops/manual threads inside routes
|
||||
- [ ] `run_in_threadpool`/`def` routes used only as a last resort, not on hot paths
|
||||
- [ ] CPU-bound work offloaded to a worker process (Celery/Arq/RQ), not the loop or threadpool
|
||||
- [ ] No unawaited coroutines; `BackgroundTasks` only for short fire-and-forget work
|
||||
|
||||
### Database
|
||||
|
||||
- [ ] One request-scoped session via dependency; no module-level shared session
|
||||
- [ ] Relationships eager-loaded (`selectinload`/`joinedload`) where accessed in a loop
|
||||
- [ ] Joins/aggregations done in SQL, not by looping in Python
|
||||
- [ ] List endpoints are paginated with a capped `limit`
|
||||
|
||||
### Security
|
||||
|
||||
- [ ] Authentication dependency is backed by an explicit **authorization** check (ownership/role)
|
||||
- [ ] All SQL parameterized; no f-string interpolation of user input
|
||||
- [ ] CORS does not combine `allow_origins=["*"]` with `allow_credentials=True`
|
||||
- [ ] Secrets come from config/env; error responses don't leak internals
|
||||
|
||||
### Tests
|
||||
|
||||
- [ ] Suspected bugs reproduced with a failing test (`TestClient`/`AsyncClient`) before being claimed
|
||||
- [ ] `dependency_overrides` used instead of patching internals; overrides reset between tests
|
||||
- [ ] Failure paths covered (401/403/404/422), not just the happy path
|
||||
- [ ] Mocks of external responses are complete, not partial
|
||||
- [ ] New tests demonstrate they can fail (reproduce-then-fix), not green from birth
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [FastAPI official documentation](https://fastapi.tiangolo.com/) — async, dependencies, testing
|
||||
- [zhanymkanov/fastapi-best-practices](https://github.com/zhanymkanov/fastapi-best-practices) — production conventions (async routes, dependency caching, project structure)
|
||||
+989
@@ -0,0 +1,989 @@
|
||||
# Go 代码审查指南
|
||||
|
||||
基于 Go 官方指南、Effective Go 和社区最佳实践的代码审查清单。
|
||||
|
||||
## 快速审查清单
|
||||
|
||||
### 必查项
|
||||
- [ ] 错误是否正确处理(不忽略、有上下文)
|
||||
- [ ] goroutine 是否有退出机制(避免泄漏)
|
||||
- [ ] context 是否正确传递和取消
|
||||
- [ ] 接收器类型选择是否合理(值/指针)
|
||||
- [ ] 是否使用 `gofmt` 格式化代码
|
||||
|
||||
### 高频问题
|
||||
- [ ] 循环变量捕获问题(Go < 1.22)
|
||||
- [ ] nil 检查是否完整
|
||||
- [ ] map 是否初始化后使用
|
||||
- [ ] defer 在循环中的使用
|
||||
- [ ] 变量遮蔽(shadowing)
|
||||
|
||||
---
|
||||
|
||||
## 1. 错误处理
|
||||
|
||||
### 1.1 永远不要忽略错误
|
||||
|
||||
```go
|
||||
// ❌ 错误:忽略错误
|
||||
result, _ := SomeFunction()
|
||||
|
||||
// ✅ 正确:处理错误
|
||||
result, err := SomeFunction()
|
||||
if err != nil {
|
||||
return fmt.Errorf("some function failed: %w", err)
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 错误包装与上下文
|
||||
|
||||
```go
|
||||
// ❌ 错误:丢失上下文
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ❌ 错误:使用 %v 丢失错误链
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed: %v", err)
|
||||
}
|
||||
|
||||
// ✅ 正确:使用 %w 保留错误链
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to process user %d: %w", userID, err)
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 使用 errors.Is 和 errors.As
|
||||
|
||||
```go
|
||||
// ❌ 错误:直接比较(无法处理包装错误)
|
||||
if err == sql.ErrNoRows {
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ 正确:使用 errors.Is(支持错误链)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
// ✅ 正确:使用 errors.As 提取特定类型
|
||||
var pathErr *os.PathError
|
||||
if errors.As(err, &pathErr) {
|
||||
log.Printf("path error: %s", pathErr.Path)
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 自定义错误类型
|
||||
|
||||
```go
|
||||
// ✅ 推荐:定义 sentinel 错误
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
)
|
||||
|
||||
// ✅ 推荐:带上下文的自定义错误
|
||||
type ValidationError struct {
|
||||
Field string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
return fmt.Sprintf("validation error on %s: %s", e.Field, e.Message)
|
||||
}
|
||||
```
|
||||
|
||||
### 1.5 错误处理只做一次
|
||||
|
||||
```go
|
||||
// ❌ 错误:既记录又返回(重复处理)
|
||||
if err != nil {
|
||||
log.Printf("error: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// ✅ 正确:只返回,让调用者决定
|
||||
if err != nil {
|
||||
return fmt.Errorf("operation failed: %w", err)
|
||||
}
|
||||
|
||||
// ✅ 或者:只记录并处理(不返回)
|
||||
if err != nil {
|
||||
log.Printf("non-critical error: %v", err)
|
||||
// 继续执行备用逻辑
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 并发与 Goroutine
|
||||
|
||||
### 2.1 避免 Goroutine 泄漏
|
||||
|
||||
```go
|
||||
// ❌ 错误:goroutine 永远无法退出
|
||||
func bad() {
|
||||
ch := make(chan int)
|
||||
go func() {
|
||||
val := <-ch // 永远阻塞,无人发送
|
||||
fmt.Println(val)
|
||||
}()
|
||||
// 函数返回,goroutine 泄漏
|
||||
}
|
||||
|
||||
// ✅ 正确:使用 context 或 done channel
|
||||
func good(ctx context.Context) {
|
||||
ch := make(chan int)
|
||||
go func() {
|
||||
select {
|
||||
case val := <-ch:
|
||||
fmt.Println(val)
|
||||
case <-ctx.Done():
|
||||
return // 优雅退出
|
||||
}
|
||||
}()
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Channel 使用规范
|
||||
|
||||
```go
|
||||
// ❌ 错误:向 nil channel 发送(永久阻塞)
|
||||
var ch chan int
|
||||
ch <- 1 // 永久阻塞
|
||||
|
||||
// ❌ 错误:向已关闭的 channel 发送(panic)
|
||||
close(ch)
|
||||
ch <- 1 // panic!
|
||||
|
||||
// ✅ 正确:发送方关闭 channel
|
||||
func producer(ch chan<- int) {
|
||||
defer close(ch) // 发送方负责关闭
|
||||
for i := 0; i < 10; i++ {
|
||||
ch <- i
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 正确:接收方检测关闭
|
||||
for val := range ch {
|
||||
process(val)
|
||||
}
|
||||
// 或者
|
||||
val, ok := <-ch
|
||||
if !ok {
|
||||
// channel 已关闭
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 使用 sync.WaitGroup
|
||||
|
||||
```go
|
||||
// ❌ 错误:Add 在 goroutine 内部
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
wg.Add(1) // 竞态条件!
|
||||
defer wg.Done()
|
||||
work()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// ✅ 正确:Add 在 goroutine 启动前
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
work()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
```
|
||||
|
||||
### 2.4 避免在循环中捕获变量(Go < 1.22)
|
||||
|
||||
```go
|
||||
// ❌ 错误(Go < 1.22):捕获循环变量
|
||||
for _, item := range items {
|
||||
go func() {
|
||||
process(item) // 所有 goroutine 可能使用同一个 item
|
||||
}()
|
||||
}
|
||||
|
||||
// ✅ 正确:传递参数
|
||||
for _, item := range items {
|
||||
go func(it Item) {
|
||||
process(it)
|
||||
}(item)
|
||||
}
|
||||
|
||||
// ✅ Go 1.22+:默认行为已修复,每次迭代创建新变量
|
||||
```
|
||||
|
||||
### 2.5 Worker Pool 模式
|
||||
|
||||
```go
|
||||
// ✅ 推荐:限制并发数量
|
||||
func processWithWorkerPool(ctx context.Context, items []Item, workers int) error {
|
||||
jobs := make(chan Item, len(items))
|
||||
results := make(chan error, len(items))
|
||||
|
||||
// 启动 worker
|
||||
for w := 0; w < workers; w++ {
|
||||
go func() {
|
||||
for item := range jobs {
|
||||
results <- process(item)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 发送任务
|
||||
for _, item := range items {
|
||||
jobs <- item
|
||||
}
|
||||
close(jobs)
|
||||
|
||||
// 收集结果
|
||||
for range items {
|
||||
if err := <-results; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Context 使用
|
||||
|
||||
### 3.1 Context 作为第一个参数
|
||||
|
||||
```go
|
||||
// ❌ 错误:context 不是第一个参数
|
||||
func Process(data []byte, ctx context.Context) error
|
||||
|
||||
// ❌ 错误:context 存储在 struct 中
|
||||
type Service struct {
|
||||
ctx context.Context // 不要这样做!
|
||||
}
|
||||
|
||||
// ✅ 正确:context 作为第一个参数,命名为 ctx
|
||||
func Process(ctx context.Context, data []byte) error
|
||||
```
|
||||
|
||||
### 3.2 传播而非创建新的根 Context
|
||||
|
||||
```go
|
||||
// ❌ 错误:在调用链中创建新的根 context
|
||||
func middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.Background() // 丢失了请求的 context!
|
||||
process(ctx)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ✅ 正确:从请求中获取并传播
|
||||
func middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, key, value)
|
||||
process(ctx)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 始终调用 cancel 函数
|
||||
|
||||
```go
|
||||
// ❌ 错误:未调用 cancel
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)
|
||||
// 缺少 cancel() 调用,可能资源泄漏
|
||||
|
||||
// ✅ 正确:使用 defer 确保调用
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)
|
||||
defer cancel() // 即使超时也要调用
|
||||
```
|
||||
|
||||
### 3.4 响应 Context 取消
|
||||
|
||||
```go
|
||||
// ✅ 推荐:在长时间操作中检查 context
|
||||
func LongRunningTask(ctx context.Context) error {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err() // 返回 context.Canceled 或 context.DeadlineExceeded
|
||||
default:
|
||||
// 执行一小部分工作
|
||||
if err := doChunk(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 区分取消原因
|
||||
|
||||
```go
|
||||
// ✅ 根据 ctx.Err() 区分取消原因
|
||||
if err := ctx.Err(); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled):
|
||||
log.Println("operation was canceled")
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
log.Println("operation timed out")
|
||||
}
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 接口设计
|
||||
|
||||
### 4.1 接受接口,返回结构体
|
||||
|
||||
```go
|
||||
// ❌ 不推荐:接受具体类型
|
||||
func SaveUser(db *sql.DB, user User) error
|
||||
|
||||
// ✅ 推荐:接受接口(解耦、易测试)
|
||||
type UserStore interface {
|
||||
Save(ctx context.Context, user User) error
|
||||
}
|
||||
|
||||
func SaveUser(store UserStore, user User) error
|
||||
|
||||
// ❌ 不推荐:返回接口
|
||||
func NewUserService() UserServiceInterface
|
||||
|
||||
// ✅ 推荐:返回具体类型
|
||||
func NewUserService(store UserStore) *UserService
|
||||
```
|
||||
|
||||
### 4.2 在消费者处定义接口
|
||||
|
||||
```go
|
||||
// ❌ 不推荐:在实现包中定义接口
|
||||
// package database
|
||||
type Database interface {
|
||||
Query(ctx context.Context, query string) ([]Row, error)
|
||||
// ... 20 个方法
|
||||
}
|
||||
|
||||
// ✅ 推荐:在消费者包中定义所需的最小接口
|
||||
// package userservice
|
||||
type UserQuerier interface {
|
||||
QueryUsers(ctx context.Context, filter Filter) ([]User, error)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 保持接口小而专注
|
||||
|
||||
```go
|
||||
// ❌ 不推荐:大而全的接口
|
||||
type Repository interface {
|
||||
GetUser(id int) (*User, error)
|
||||
CreateUser(u *User) error
|
||||
UpdateUser(u *User) error
|
||||
DeleteUser(id int) error
|
||||
GetOrder(id int) (*Order, error)
|
||||
CreateOrder(o *Order) error
|
||||
// ... 更多方法
|
||||
}
|
||||
|
||||
// ✅ 推荐:小而专注的接口
|
||||
type UserReader interface {
|
||||
GetUser(ctx context.Context, id int) (*User, error)
|
||||
}
|
||||
|
||||
type UserWriter interface {
|
||||
CreateUser(ctx context.Context, u *User) error
|
||||
UpdateUser(ctx context.Context, u *User) error
|
||||
}
|
||||
|
||||
// 组合接口
|
||||
type UserRepository interface {
|
||||
UserReader
|
||||
UserWriter
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 避免空接口滥用
|
||||
|
||||
```go
|
||||
// ❌ 不推荐:过度使用 interface{}
|
||||
func Process(data interface{}) interface{}
|
||||
|
||||
// ✅ 推荐:使用泛型(Go 1.18+)
|
||||
func Process[T any](data T) T
|
||||
|
||||
// ✅ 推荐:定义具体接口
|
||||
type Processor interface {
|
||||
Process() Result
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 接收器类型选择
|
||||
|
||||
### 5.1 使用指针接收器的情况
|
||||
|
||||
```go
|
||||
// ✅ 需要修改接收器时
|
||||
func (u *User) SetName(name string) {
|
||||
u.Name = name
|
||||
}
|
||||
|
||||
// ✅ 接收器包含 sync.Mutex 等同步原语
|
||||
type SafeCounter struct {
|
||||
mu sync.Mutex
|
||||
count int
|
||||
}
|
||||
|
||||
func (c *SafeCounter) Inc() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.count++
|
||||
}
|
||||
|
||||
// ✅ 接收器是大型结构体(避免复制开销)
|
||||
type LargeStruct struct {
|
||||
Data [1024]byte
|
||||
// ...
|
||||
}
|
||||
|
||||
func (l *LargeStruct) Process() { /* ... */ }
|
||||
```
|
||||
|
||||
### 5.2 使用值接收器的情况
|
||||
|
||||
```go
|
||||
// ✅ 接收器是小型不可变结构体
|
||||
type Point struct {
|
||||
X, Y float64
|
||||
}
|
||||
|
||||
func (p Point) Distance(other Point) float64 {
|
||||
return math.Sqrt(math.Pow(p.X-other.X, 2) + math.Pow(p.Y-other.Y, 2))
|
||||
}
|
||||
|
||||
// ✅ 接收器是基本类型的别名
|
||||
type Counter int
|
||||
|
||||
func (c Counter) String() string {
|
||||
return fmt.Sprintf("%d", c)
|
||||
}
|
||||
|
||||
// ✅ 接收器是 map、func、chan(本身是引用类型)
|
||||
type StringSet map[string]struct{}
|
||||
|
||||
func (s StringSet) Contains(key string) bool {
|
||||
_, ok := s[key]
|
||||
return ok
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 一致性原则
|
||||
|
||||
```go
|
||||
// ❌ 不推荐:混合使用接收器类型
|
||||
func (u User) GetName() string // 值接收器
|
||||
func (u *User) SetName(n string) // 指针接收器
|
||||
|
||||
// ✅ 推荐:如果有任何方法需要指针接收器,全部使用指针
|
||||
func (u *User) GetName() string { return u.Name }
|
||||
func (u *User) SetName(n string) { u.Name = n }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 性能优化
|
||||
|
||||
### 6.1 预分配 Slice
|
||||
|
||||
```go
|
||||
// ❌ 不推荐:动态增长
|
||||
var result []int
|
||||
for i := 0; i < 10000; i++ {
|
||||
result = append(result, i) // 多次分配和复制
|
||||
}
|
||||
|
||||
// ✅ 推荐:预分配已知大小
|
||||
result := make([]int, 0, 10000)
|
||||
for i := 0; i < 10000; i++ {
|
||||
result = append(result, i)
|
||||
}
|
||||
|
||||
// ✅ 或者直接初始化
|
||||
result := make([]int, 10000)
|
||||
for i := 0; i < 10000; i++ {
|
||||
result[i] = i
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 避免不必要的堆分配
|
||||
|
||||
```go
|
||||
// ❌ 可能逃逸到堆
|
||||
func NewUser() *User {
|
||||
return &User{} // 逃逸到堆
|
||||
}
|
||||
|
||||
// ✅ 考虑返回值(如果适用)
|
||||
func NewUser() User {
|
||||
return User{} // 可能在栈上分配
|
||||
}
|
||||
|
||||
// 检查逃逸分析
|
||||
// go build -gcflags '-m -m' ./...
|
||||
```
|
||||
|
||||
### 6.3 使用 sync.Pool 复用对象
|
||||
|
||||
```go
|
||||
// ✅ 推荐:高频创建/销毁的对象使用 sync.Pool
|
||||
var bufferPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
return new(bytes.Buffer)
|
||||
},
|
||||
}
|
||||
|
||||
func ProcessData(data []byte) string {
|
||||
buf := bufferPool.Get().(*bytes.Buffer)
|
||||
defer func() {
|
||||
buf.Reset()
|
||||
bufferPool.Put(buf)
|
||||
}()
|
||||
|
||||
buf.Write(data)
|
||||
return buf.String()
|
||||
}
|
||||
```
|
||||
|
||||
### 6.4 字符串拼接优化
|
||||
|
||||
```go
|
||||
// ❌ 不推荐:循环中使用 + 拼接
|
||||
var result string
|
||||
for _, s := range strings {
|
||||
result += s // 每次创建新字符串
|
||||
}
|
||||
|
||||
// ✅ 推荐:使用 strings.Builder
|
||||
var builder strings.Builder
|
||||
for _, s := range strings {
|
||||
builder.WriteString(s)
|
||||
}
|
||||
result := builder.String()
|
||||
|
||||
// ✅ 或者使用 strings.Join
|
||||
result := strings.Join(strings, "")
|
||||
```
|
||||
|
||||
### 6.5 避免 interface{} 转换开销
|
||||
|
||||
```go
|
||||
// ❌ 热路径中使用 interface{}
|
||||
func process(data interface{}) {
|
||||
switch v := data.(type) { // 类型断言有开销
|
||||
case int:
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 热路径中使用泛型或具体类型
|
||||
func process[T int | int64 | float64](data T) {
|
||||
// 编译时确定类型,无运行时开销
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 测试
|
||||
|
||||
### 7.1 表驱动测试
|
||||
|
||||
```go
|
||||
// ✅ 推荐:表驱动测试
|
||||
func TestAdd(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b int
|
||||
expected int
|
||||
}{
|
||||
{"positive numbers", 1, 2, 3},
|
||||
{"with zero", 0, 5, 5},
|
||||
{"negative numbers", -1, -2, -3},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := Add(tt.a, tt.b)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Add(%d, %d) = %d; want %d",
|
||||
tt.a, tt.b, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 并行测试
|
||||
|
||||
```go
|
||||
// ✅ 推荐:独立测试用例并行执行
|
||||
func TestParallel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"test1", "input1"},
|
||||
{"test2", "input2"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt // Go < 1.22 需要复制
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel() // 标记为可并行
|
||||
result := Process(tt.input)
|
||||
// assertions...
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 使用接口进行 Mock
|
||||
|
||||
```go
|
||||
// ✅ 定义接口以便测试
|
||||
type EmailSender interface {
|
||||
Send(to, subject, body string) error
|
||||
}
|
||||
|
||||
// 生产实现
|
||||
type SMTPSender struct { /* ... */ }
|
||||
|
||||
// 测试 Mock
|
||||
type MockEmailSender struct {
|
||||
SendFunc func(to, subject, body string) error
|
||||
}
|
||||
|
||||
func (m *MockEmailSender) Send(to, subject, body string) error {
|
||||
return m.SendFunc(to, subject, body)
|
||||
}
|
||||
|
||||
func TestUserRegistration(t *testing.T) {
|
||||
mock := &MockEmailSender{
|
||||
SendFunc: func(to, subject, body string) error {
|
||||
if to != "test@example.com" {
|
||||
t.Errorf("unexpected recipient: %s", to)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
service := NewUserService(mock)
|
||||
// test...
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 测试辅助函数
|
||||
|
||||
```go
|
||||
// ✅ 使用 t.Helper() 标记辅助函数
|
||||
func assertEqual(t *testing.T, got, want interface{}) {
|
||||
t.Helper() // 错误报告时显示调用者位置
|
||||
if got != want {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 使用 t.Cleanup() 清理资源
|
||||
func TestWithTempFile(t *testing.T) {
|
||||
f, err := os.CreateTemp("", "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
os.Remove(f.Name())
|
||||
})
|
||||
// test...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 常见陷阱
|
||||
|
||||
### 8.1 Nil Slice vs Empty Slice
|
||||
|
||||
```go
|
||||
var nilSlice []int // nil, len=0, cap=0
|
||||
emptySlice := []int{} // not nil, len=0, cap=0
|
||||
made := make([]int, 0) // not nil, len=0, cap=0
|
||||
|
||||
// ✅ JSON 编码差异
|
||||
json.Marshal(nilSlice) // null
|
||||
json.Marshal(emptySlice) // []
|
||||
|
||||
// ✅ 推荐:需要空数组 JSON 时显式初始化
|
||||
if slice == nil {
|
||||
slice = []int{}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 Map 初始化
|
||||
|
||||
```go
|
||||
// ❌ 错误:未初始化的 map
|
||||
var m map[string]int
|
||||
m["key"] = 1 // panic: assignment to entry in nil map
|
||||
|
||||
// ✅ 正确:使用 make 初始化
|
||||
m := make(map[string]int)
|
||||
m["key"] = 1
|
||||
|
||||
// ✅ 或者使用字面量
|
||||
m := map[string]int{}
|
||||
```
|
||||
|
||||
### 8.3 Defer 在循环中
|
||||
|
||||
```go
|
||||
// ❌ 潜在问题:defer 在函数结束时才执行
|
||||
func processFiles(files []string) error {
|
||||
for _, file := range files {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close() // 所有文件在函数结束时才关闭!
|
||||
// process...
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ✅ 正确:使用闭包或提取函数
|
||||
func processFiles(files []string) error {
|
||||
for _, file := range files {
|
||||
if err := processFile(file); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func processFile(file string) error {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
// process...
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### 8.4 Slice 底层数组共享
|
||||
|
||||
```go
|
||||
// ❌ 潜在问题:切片共享底层数组
|
||||
original := []int{1, 2, 3, 4, 5}
|
||||
slice := original[1:3] // [2, 3]
|
||||
slice[0] = 100 // 修改了 original!
|
||||
// original 变成 [1, 100, 3, 4, 5]
|
||||
|
||||
// ✅ 正确:需要独立副本时显式复制
|
||||
slice := make([]int, 2)
|
||||
copy(slice, original[1:3])
|
||||
slice[0] = 100 // 不影响 original
|
||||
```
|
||||
|
||||
### 8.5 字符串子串内存泄漏
|
||||
|
||||
```go
|
||||
// ❌ 潜在问题:子串持有整个底层数组
|
||||
func getPrefix(s string) string {
|
||||
return s[:10] // 仍引用整个 s 的底层数组
|
||||
}
|
||||
|
||||
// ✅ 正确:创建独立副本(Go 1.18+)
|
||||
func getPrefix(s string) string {
|
||||
return strings.Clone(s[:10])
|
||||
}
|
||||
|
||||
// ✅ Go 1.18 之前
|
||||
func getPrefix(s string) string {
|
||||
return string([]byte(s[:10]))
|
||||
}
|
||||
```
|
||||
|
||||
### 8.6 Interface Nil 陷阱
|
||||
|
||||
```go
|
||||
// ❌ 陷阱:interface 的 nil 判断
|
||||
type MyError struct{}
|
||||
func (e *MyError) Error() string { return "error" }
|
||||
|
||||
func returnsError() error {
|
||||
var e *MyError = nil
|
||||
return e // 返回的 error 不是 nil!
|
||||
}
|
||||
|
||||
func main() {
|
||||
err := returnsError()
|
||||
if err != nil { // true! interface{type: *MyError, value: nil}
|
||||
fmt.Println("error:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 正确:显式返回 nil
|
||||
func returnsError() error {
|
||||
var e *MyError = nil
|
||||
if e == nil {
|
||||
return nil // 显式返回 nil
|
||||
}
|
||||
return e
|
||||
}
|
||||
```
|
||||
|
||||
### 8.7 Time 比较
|
||||
|
||||
```go
|
||||
// ❌ 不推荐:直接使用 == 比较 time.Time
|
||||
if t1 == t2 { // 可能因为单调时钟差异而失败
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ 推荐:使用 Equal 方法
|
||||
if t1.Equal(t2) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ 比较时间范围
|
||||
if t1.Before(t2) || t1.After(t2) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 代码组织
|
||||
|
||||
### 9.1 包命名
|
||||
|
||||
```go
|
||||
// ❌ 不推荐
|
||||
package common // 过于宽泛
|
||||
package utils // 过于宽泛
|
||||
package helpers // 过于宽泛
|
||||
package models // 按类型分组
|
||||
|
||||
// ✅ 推荐:按功能命名
|
||||
package user // 用户相关功能
|
||||
package order // 订单相关功能
|
||||
package postgres // PostgreSQL 实现
|
||||
```
|
||||
|
||||
### 9.2 避免循环依赖
|
||||
|
||||
```go
|
||||
// ❌ 循环依赖
|
||||
// package a imports package b
|
||||
// package b imports package a
|
||||
|
||||
// ✅ 解决方案1:提取共享类型到独立包
|
||||
// package types (共享类型)
|
||||
// package a imports types
|
||||
// package b imports types
|
||||
|
||||
// ✅ 解决方案2:使用接口解耦
|
||||
// package a 定义接口
|
||||
// package b 实现接口
|
||||
```
|
||||
|
||||
### 9.3 导出标识符规范
|
||||
|
||||
```go
|
||||
// ✅ 只导出必要的标识符
|
||||
type UserService struct {
|
||||
db *sql.DB // 私有
|
||||
}
|
||||
|
||||
func (s *UserService) GetUser(id int) (*User, error) // 公开
|
||||
func (s *UserService) validate(u *User) error // 私有
|
||||
|
||||
// ✅ 内部包限制访问
|
||||
// internal/database/... 只能被同项目代码导入
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 工具与检查
|
||||
|
||||
### 10.1 必须使用的工具
|
||||
|
||||
```bash
|
||||
# 格式化(必须)
|
||||
gofmt -w .
|
||||
goimports -w .
|
||||
|
||||
# 静态分析
|
||||
go vet ./...
|
||||
|
||||
# 竞态检测
|
||||
go test -race ./...
|
||||
|
||||
# 逃逸分析
|
||||
go build -gcflags '-m -m' ./...
|
||||
```
|
||||
|
||||
### 10.2 推荐的 Linter
|
||||
|
||||
```bash
|
||||
# golangci-lint(集成多个 linter)
|
||||
golangci-lint run
|
||||
|
||||
# 常用检查项
|
||||
# - errcheck: 检查未处理的错误
|
||||
# - gosec: 安全检查
|
||||
# - ineffassign: 无效赋值
|
||||
# - staticcheck: 静态分析
|
||||
# - unused: 未使用的代码
|
||||
```
|
||||
|
||||
### 10.3 Benchmark 测试
|
||||
|
||||
```go
|
||||
// ✅ 性能基准测试
|
||||
func BenchmarkProcess(b *testing.B) {
|
||||
data := prepareData()
|
||||
b.ResetTimer() // 重置计时器
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
Process(data)
|
||||
}
|
||||
}
|
||||
|
||||
// 运行 benchmark
|
||||
// go test -bench=. -benchmem ./...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 参考资源
|
||||
|
||||
- [Effective Go](https://go.dev/doc/effective_go)
|
||||
- [Go Code Review Comments](https://go.dev/wiki/CodeReviewComments)
|
||||
- [Go Common Mistakes](https://go.dev/wiki/CommonMistakes)
|
||||
- [100 Go Mistakes](https://100go.co/)
|
||||
- [Go Proverbs](https://go-proverbs.github.io/)
|
||||
- [Uber Go Style Guide](https://github.com/uber-go/guide/blob/master/style.md)
|
||||
+405
@@ -0,0 +1,405 @@
|
||||
# Java Code Review Guide
|
||||
|
||||
Java 审查重点:Java 17/21 新特性、Spring Boot 3 最佳实践、并发编程(虚拟线程)、JPA 性能优化以及代码可维护性。
|
||||
|
||||
## 目录
|
||||
|
||||
- [现代 Java 特性 (17/21+)](#现代-java-特性-1721)
|
||||
- [Stream API & Optional](#stream-api--optional)
|
||||
- [Spring Boot 最佳实践](#spring-boot-最佳实践)
|
||||
- [JPA 与 数据库性能](#jpa-与-数据库性能)
|
||||
- [并发与虚拟线程](#并发与虚拟线程)
|
||||
- [Lombok 使用规范](#lombok-使用规范)
|
||||
- [异常处理](#异常处理)
|
||||
- [测试规范](#测试规范)
|
||||
- [Review Checklist](#review-checklist)
|
||||
|
||||
---
|
||||
|
||||
## 现代 Java 特性 (17/21+)
|
||||
|
||||
### Record (记录类)
|
||||
|
||||
```java
|
||||
// ❌ 传统的 POJO/DTO:样板代码多
|
||||
public class UserDto {
|
||||
private final String name;
|
||||
private final int age;
|
||||
|
||||
public UserDto(String name, int age) {
|
||||
this.name = name;
|
||||
this.age = age;
|
||||
}
|
||||
// getters, equals, hashCode, toString...
|
||||
}
|
||||
|
||||
// ✅ 使用 Record:简洁、不可变、语义清晰
|
||||
public record UserDto(String name, int age) {
|
||||
// 紧凑构造函数进行验证
|
||||
public UserDto {
|
||||
if (age < 0) throw new IllegalArgumentException("Age cannot be negative");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Switch 表达式与模式匹配
|
||||
|
||||
```java
|
||||
// ❌ 传统的 Switch:容易漏掉 break,不仅冗长且易错
|
||||
String type = "";
|
||||
switch (obj) {
|
||||
case Integer i: // Java 16+
|
||||
type = String.format("int %d", i);
|
||||
break;
|
||||
case String s:
|
||||
type = String.format("string %s", s);
|
||||
break;
|
||||
default:
|
||||
type = "unknown";
|
||||
}
|
||||
|
||||
// ✅ Switch 表达式:无穿透风险,强制返回值
|
||||
String type = switch (obj) {
|
||||
case Integer i -> "int %d".formatted(i);
|
||||
case String s -> "string %s".formatted(s);
|
||||
case null -> "null value"; // Java 21 处理 null
|
||||
default -> "unknown";
|
||||
};
|
||||
```
|
||||
|
||||
### 文本块 (Text Blocks)
|
||||
|
||||
```java
|
||||
// ❌ 拼接 SQL/JSON 字符串
|
||||
String json = "{\n" +
|
||||
" \"name\": \"Alice\",\n" +
|
||||
" \"age\": 20\n" +
|
||||
"}";
|
||||
|
||||
// ✅ 使用文本块:所见即所得
|
||||
String json = """
|
||||
{
|
||||
"name": "Alice",
|
||||
"age": 20
|
||||
}
|
||||
""";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stream API & Optional
|
||||
|
||||
### 避免滥用 Stream
|
||||
|
||||
```java
|
||||
// ❌ 简单的循环不需要 Stream(性能开销 + 可读性差)
|
||||
items.stream().forEach(item -> {
|
||||
process(item);
|
||||
});
|
||||
|
||||
// ✅ 简单场景直接用 for-each
|
||||
for (var item : items) {
|
||||
process(item);
|
||||
}
|
||||
|
||||
// ❌ 极其复杂的 Stream 链
|
||||
List<Dto> result = list.stream()
|
||||
.filter(...)
|
||||
.map(...)
|
||||
.peek(...)
|
||||
.sorted(...)
|
||||
.collect(...); // 难以调试
|
||||
|
||||
// ✅ 拆分为有意义的步骤
|
||||
var filtered = list.stream().filter(...).toList();
|
||||
// ...
|
||||
```
|
||||
|
||||
### Optional 正确用法
|
||||
|
||||
```java
|
||||
// ❌ 将 Optional 用作参数或字段(序列化问题,增加调用复杂度)
|
||||
public void process(Optional<String> name) { ... }
|
||||
public class User {
|
||||
private Optional<String> email; // 不推荐
|
||||
}
|
||||
|
||||
// ✅ Optional 仅用于返回值
|
||||
public Optional<User> findUser(String id) { ... }
|
||||
|
||||
// ❌ 既然用了 Optional 还在用 isPresent() + get()
|
||||
Optional<User> userOpt = findUser(id);
|
||||
if (userOpt.isPresent()) {
|
||||
return userOpt.get().getName();
|
||||
} else {
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
// ✅ 使用函数式 API
|
||||
return findUser(id)
|
||||
.map(User::getName)
|
||||
.orElse("Unknown");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spring Boot 最佳实践
|
||||
|
||||
### 依赖注入 (DI)
|
||||
|
||||
```java
|
||||
// ❌ 字段注入 (@Autowired)
|
||||
// 缺点:难以测试(需要反射注入),掩盖了依赖过多的问题,且不可变性差
|
||||
@Service
|
||||
public class UserService {
|
||||
@Autowired
|
||||
private UserRepository userRepo;
|
||||
}
|
||||
|
||||
// ✅ 构造器注入 (Constructor Injection)
|
||||
// 优点:依赖明确,易于单元测试 (Mock),字段可为 final
|
||||
@Service
|
||||
public class UserService {
|
||||
private final UserRepository userRepo;
|
||||
|
||||
public UserService(UserRepository userRepo) {
|
||||
this.userRepo = userRepo;
|
||||
}
|
||||
}
|
||||
// 💡 提示:结合 Lombok @RequiredArgsConstructor 可简化代码,但要小心循环依赖
|
||||
```
|
||||
|
||||
### 配置管理
|
||||
|
||||
```java
|
||||
// ❌ 硬编码配置值
|
||||
@Service
|
||||
public class PaymentService {
|
||||
private String apiKey = "sk_live_12345";
|
||||
}
|
||||
|
||||
// ❌ 直接使用 @Value 散落在代码中
|
||||
@Value("${app.payment.api-key}")
|
||||
private String apiKey;
|
||||
|
||||
// ✅ 使用 @ConfigurationProperties 类型安全配置
|
||||
@ConfigurationProperties(prefix = "app.payment")
|
||||
public record PaymentProperties(String apiKey, int timeout, String url) {}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## JPA 与 数据库性能
|
||||
|
||||
### N+1 查询问题
|
||||
|
||||
```java
|
||||
// ❌ FetchType.EAGER 或 循环中触发懒加载
|
||||
// Entity 定义
|
||||
@Entity
|
||||
public class User {
|
||||
@OneToMany(fetch = FetchType.EAGER) // 危险!
|
||||
private List<Order> orders;
|
||||
}
|
||||
|
||||
// 业务代码
|
||||
List<User> users = userRepo.findAll(); // 1 条 SQL
|
||||
for (User user : users) {
|
||||
// 如果是 Lazy,这里会触发 N 条 SQL
|
||||
System.out.println(user.getOrders().size());
|
||||
}
|
||||
|
||||
// ✅ 使用 @EntityGraph 或 JOIN FETCH
|
||||
@Query("SELECT u FROM User u JOIN FETCH u.orders")
|
||||
List<User> findAllWithOrders();
|
||||
```
|
||||
|
||||
### 事务管理
|
||||
|
||||
```java
|
||||
// ❌ 在 Controller 层开启事务(数据库连接占用时间过长)
|
||||
// ❌ 在 private 方法上加 @Transactional(AOP 不生效)
|
||||
@Transactional
|
||||
private void saveInternal() { ... }
|
||||
|
||||
// ✅ 在 Service 层公共方法加 @Transactional
|
||||
// ✅ 读操作显式标记 readOnly = true (性能优化)
|
||||
@Service
|
||||
public class UserService {
|
||||
@Transactional(readOnly = true)
|
||||
public User getUser(Long id) { ... }
|
||||
|
||||
@Transactional
|
||||
public void createUser(UserDto dto) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Entity 设计
|
||||
|
||||
```java
|
||||
// ❌ 在 Entity 中使用 Lombok @Data
|
||||
// @Data 生成的 equals/hashCode 包含所有字段,可能触发懒加载导致性能问题或异常
|
||||
@Entity
|
||||
@Data
|
||||
public class User { ... }
|
||||
|
||||
// ✅ 仅使用 @Getter, @Setter
|
||||
// ✅ 自定义 equals/hashCode (通常基于 ID)
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
public class User {
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof User)) return false;
|
||||
return id != null && id.equals(((User) o).id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return getClass().hashCode();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 并发与虚拟线程
|
||||
|
||||
### 虚拟线程 (Java 21+)
|
||||
|
||||
```java
|
||||
// ❌ 传统线程池处理大量 I/O 阻塞任务(资源耗尽)
|
||||
ExecutorService executor = Executors.newFixedThreadPool(100);
|
||||
|
||||
// ✅ 使用虚拟线程处理 I/O 密集型任务(高吞吐量)
|
||||
// Spring Boot 3.2+ 开启:spring.threads.virtual.enabled=true
|
||||
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
// 在虚拟线程中,阻塞操作(如 DB 查询、HTTP 请求)几乎不消耗 OS 线程资源
|
||||
```
|
||||
|
||||
### 线程安全
|
||||
|
||||
```java
|
||||
// ❌ SimpleDateFormat 是线程不安全的
|
||||
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
|
||||
|
||||
// ✅ 使用 DateTimeFormatter (Java 8+)
|
||||
private static final DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
// ❌ HashMap 在多线程环境会数据丢失(Java 7 及之前 resize 还可能死循环,Java 8 修复了死循环但仍非线程安全)
|
||||
// ✅ 使用 ConcurrentHashMap
|
||||
Map<String, String> cache = new ConcurrentHashMap<>();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lombok 使用规范
|
||||
|
||||
```java
|
||||
// ❌ 滥用 @Builder 导致无法强制校验必填字段
|
||||
@Builder
|
||||
public class Order {
|
||||
private String id; // 必填
|
||||
private String note; // 选填
|
||||
}
|
||||
// 调用者可能漏掉 id: Order.builder().note("hi").build();
|
||||
|
||||
// ✅ 关键业务对象建议手动编写 Builder 或构造函数以确保不变量
|
||||
// 或者在 build() 方法中添加校验逻辑 (Lombok @Builder.Default 等)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 异常处理
|
||||
|
||||
### 全局异常处理
|
||||
|
||||
```java
|
||||
// ❌ 到处 try-catch 吞掉异常或只打印日志
|
||||
try {
|
||||
userService.create(user);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace(); // 不应该在生产环境使用
|
||||
// return null; // 吞掉异常,上层不知道发生了什么
|
||||
}
|
||||
|
||||
// ✅ 自定义异常 + @ControllerAdvice (Spring Boot 3 ProblemDetail)
|
||||
public class UserNotFoundException extends RuntimeException { ... }
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
@ExceptionHandler(UserNotFoundException.class)
|
||||
public ProblemDetail handleNotFound(UserNotFoundException e) {
|
||||
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试规范
|
||||
|
||||
### 单元测试 vs 集成测试
|
||||
|
||||
```java
|
||||
// ❌ 单元测试依赖真实数据库或外部服务
|
||||
@SpringBootTest // 启动整个 Context,慢
|
||||
public class UserServiceTest { ... }
|
||||
|
||||
// ✅ 单元测试使用 Mockito
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserServiceTest {
|
||||
@Mock UserRepository repo;
|
||||
@InjectMocks UserService service;
|
||||
|
||||
@Test
|
||||
void shouldCreateUser() { ... }
|
||||
}
|
||||
|
||||
// ✅ 集成测试使用 Testcontainers
|
||||
@Testcontainers
|
||||
@SpringBootTest
|
||||
class UserRepositoryTest {
|
||||
@Container
|
||||
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Review Checklist
|
||||
|
||||
### 基础与规范
|
||||
- [ ] 遵循 Java 17/21 新特性(Switch 表达式, Records, 文本块)
|
||||
- [ ] 避免使用已过时的类(Date, Calendar, SimpleDateFormat)
|
||||
- [ ] 集合操作是否优先使用了 Stream API 或 Collections 方法?
|
||||
- [ ] Optional 仅用于返回值,未用于字段或参数
|
||||
|
||||
### Spring Boot
|
||||
- [ ] 使用构造器注入而非 @Autowired 字段注入
|
||||
- [ ] 配置属性使用了 @ConfigurationProperties
|
||||
- [ ] Controller 职责单一,业务逻辑下沉到 Service
|
||||
- [ ] 全局异常处理使用了 @ControllerAdvice / ProblemDetail
|
||||
|
||||
### 数据库 & 事务
|
||||
- [ ] 读操作事务标记了 `@Transactional(readOnly = true)`
|
||||
- [ ] 检查是否存在 N+1 查询(EAGER fetch 或循环调用)
|
||||
- [ ] Entity 类未使用 @Data,正确实现了 equals/hashCode
|
||||
- [ ] 数据库索引是否覆盖了查询条件
|
||||
|
||||
### 并发与性能
|
||||
- [ ] I/O 密集型任务是否考虑了虚拟线程?
|
||||
- [ ] 线程安全类是否使用正确(ConcurrentHashMap vs HashMap)
|
||||
- [ ] 锁的粒度是否合理?避免在锁内进行 I/O 操作
|
||||
|
||||
### 可维护性
|
||||
- [ ] 关键业务逻辑有充分的单元测试
|
||||
- [ ] 日志记录恰当(使用 Slf4j,避免 System.out)
|
||||
- [ ] 魔法值提取为常量或枚举
|
||||
+1016
File diff suppressed because it is too large
Load Diff
+593
@@ -0,0 +1,593 @@
|
||||
# NestJS Code Review Guide
|
||||
|
||||
> NestJS 代码审查指南,覆盖依赖注入与分层架构、模块组织、Guard/Interceptor/Pipe、DTO 验证、错误处理、循环依赖及测试模式等核心主题。
|
||||
|
||||
## 目录
|
||||
|
||||
- [依赖注入与分层架构](#依赖注入与分层架构)
|
||||
- [模块组织](#模块组织)
|
||||
- [Guard / Interceptor / Pipe](#guard--interceptor--pipe)
|
||||
- [验证模式 (DTO)](#验证模式-dto)
|
||||
- [错误处理](#错误处理)
|
||||
- [循环依赖](#循环依赖)
|
||||
- [测试模式](#测试模式)
|
||||
- [Review Checklist](#review-checklist)
|
||||
|
||||
---
|
||||
|
||||
## 依赖注入与分层架构
|
||||
|
||||
### 三层架构:Controller → Service → Repository
|
||||
|
||||
```typescript
|
||||
// ❌ ORM 直接注入 Controller,跳过 Service 层
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.prisma.user.findMany();
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Controller → Service → Repository
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.usersService.findAll();
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly usersRepo: UsersRepository) {}
|
||||
|
||||
findAll() {
|
||||
return this.usersRepo.findAll();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Repository 之间不应互相注入
|
||||
|
||||
```typescript
|
||||
// ❌ Repository 导入另一个 Repository——编排逻辑属于 Service
|
||||
@Injectable()
|
||||
export class OrdersRepository {
|
||||
constructor(private readonly usersRepository: UsersRepository) {}
|
||||
}
|
||||
|
||||
// ✅ 跨 Repository 编排在 Service 中完成
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(
|
||||
private readonly ordersRepo: OrdersRepository,
|
||||
private readonly usersRepo: UsersRepository,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
### God Service:依赖超过 8 个时拆分
|
||||
|
||||
```typescript
|
||||
// ❌ 9 个依赖的巨型 Service
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(
|
||||
private readonly ordersRepo: OrdersRepository,
|
||||
private readonly usersRepo: UsersRepository,
|
||||
private readonly productsRepo: ProductsRepository,
|
||||
private readonly paymentsService: PaymentsService,
|
||||
private readonly mailerService: MailerService,
|
||||
private readonly inventoryService: InventoryService,
|
||||
private readonly discountService: DiscountService,
|
||||
private readonly taxService: TaxService,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
}
|
||||
|
||||
// ✅ 拆分为 Use-Case Service(一个文件一个操作)
|
||||
@Injectable()
|
||||
export class CreateOrderService {
|
||||
constructor(
|
||||
private readonly ordersRepo: OrdersRepository,
|
||||
private readonly paymentsService: PaymentsService,
|
||||
) {}
|
||||
|
||||
async execute(dto: CreateOrderDto) { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### Symbol Token 实现依赖反转
|
||||
|
||||
```typescript
|
||||
// ❌ 直接依赖具体实现——测试时无法替换
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly repo: TypeOrmUserRepository) {}
|
||||
}
|
||||
|
||||
// ✅ 接口 + Symbol Token——可替换为内存实现
|
||||
export const USER_REPOSITORY = Symbol('USER_REPOSITORY');
|
||||
|
||||
export interface UserRepository {
|
||||
findAll(): Promise<User[]>;
|
||||
findById(id: string): Promise<User | null>;
|
||||
}
|
||||
|
||||
// module:
|
||||
{
|
||||
provide: USER_REPOSITORY,
|
||||
useClass: TypeOrmUserRepository,
|
||||
}
|
||||
|
||||
// service:
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(@Inject(USER_REPOSITORY) private readonly repo: UserRepository) {}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 模块组织
|
||||
|
||||
### 推荐四层结构
|
||||
|
||||
```
|
||||
src/
|
||||
common/ ← 全局技术基础设施(Guards、Filters、Interceptors、Decorators)
|
||||
core/ ← 内部基础设施(Config、Database、Queue 配置)
|
||||
integrations/ ← 外部服务封装(Mailer、Storage、Stripe、SMS)
|
||||
modules/ ← 按领域组织的业务逻辑
|
||||
[feature]/
|
||||
dtos/
|
||||
repositories/
|
||||
services/
|
||||
internal/ ← 模块内共享 Service
|
||||
use-cases/ ← 一个文件 = 一个操作
|
||||
types/
|
||||
[feature].controller.ts
|
||||
[feature].module.ts
|
||||
```
|
||||
|
||||
### Domain 必须框架无关
|
||||
|
||||
```typescript
|
||||
// ❌ Domain Entity 依赖 NestJS——不可独立测试
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class User {
|
||||
constructor(private readonly email: string) {}
|
||||
}
|
||||
|
||||
// ✅ Domain 是纯类,无框架装饰器
|
||||
export class User {
|
||||
private constructor(private readonly email: string) {}
|
||||
|
||||
static create(email: string): User {
|
||||
return new User(email);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 关键规则
|
||||
|
||||
- `common/` 必须 **不涉及业务**——如果需要知道"订单",它不属于这里
|
||||
- `integrations/` 封装每个外部服务;换 SendGrid → AWS SES 只改一个目录
|
||||
- 使用 **Use-Case Service**(一个文件一个操作)而非 15 个方法的巨型 `XxxService`
|
||||
|
||||
---
|
||||
|
||||
## Guard / Interceptor / Pipe
|
||||
|
||||
### 业务逻辑不应放在 Guard 中
|
||||
|
||||
```typescript
|
||||
// ❌ Guard 中查询数据库 + 业务判断
|
||||
@Injectable()
|
||||
export class OrderOwnershipGuard implements CanActivate {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: req.params.id },
|
||||
});
|
||||
if (order.userId !== req.user.id) {
|
||||
return false; // 数据获取 + 业务规则判断都在 Guard 里
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Guard 只做授权检查(角色/权限)
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>('roles', [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!requiredRoles) return true;
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
return requiredRoles.some((role) => user.roles?.includes(role));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Interceptor 只用于横切关注点
|
||||
|
||||
```typescript
|
||||
// ❌ Interceptor 中执行业务逻辑
|
||||
@Injectable()
|
||||
export class PricingInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler) {
|
||||
// 计算折扣——这不是横切关注点!
|
||||
return next.handle().pipe(map(data => applyDiscount(data)));
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Interceptor 用于日志、缓存、响应转换、计时
|
||||
@Injectable()
|
||||
export class LoggingInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler) {
|
||||
const now = Date.now();
|
||||
const req = context.switchToHttp().getRequest();
|
||||
return next.handle().pipe(
|
||||
tap(() => console.log(`${req.method} ${req.url} - ${Date.now() - now}ms`)),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 全局 ValidationPipe 必须配置 whitelist
|
||||
|
||||
```typescript
|
||||
// ❌ 没有 whitelist——请求体中的额外属性直接传入
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
await app.listen(3000);
|
||||
}
|
||||
|
||||
// ✅ 全局 ValidationPipe + whitelist 过滤未知属性
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
await app.listen(3000);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证模式 (DTO)
|
||||
|
||||
### @ValidateNested() 必须搭配 @Type()
|
||||
|
||||
```typescript
|
||||
// ❌ 只有 @ValidateNested——嵌套对象验证被静默跳过!
|
||||
export class CreateOrderDto {
|
||||
@ValidateNested()
|
||||
shipping: AddressDto;
|
||||
}
|
||||
|
||||
// ✅ @ValidateNested + @Type 配对使用
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CreateOrderDto {
|
||||
@ValidateNested()
|
||||
@Type(() => AddressDto)
|
||||
shipping: AddressDto;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => OrderItemDto)
|
||||
items: OrderItemDto[];
|
||||
}
|
||||
```
|
||||
|
||||
### 禁止裸 any Body
|
||||
|
||||
```typescript
|
||||
// ❌ 没有 DTO——无验证、无类型安全、无 Swagger 文档
|
||||
@Post()
|
||||
create(@Body() body: any) {
|
||||
return this.service.create(body);
|
||||
}
|
||||
|
||||
// ✅ 为每个操作创建 DTO
|
||||
export class CreateUserDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateUserDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
```
|
||||
|
||||
### Create 和 Update 应使用不同 DTO
|
||||
|
||||
```typescript
|
||||
// ❌ PATCH 也要求所有字段——不合理的 API 设计
|
||||
@Patch(':id')
|
||||
update(@Body() dto: CreateUserDto) { /* all fields required */ }
|
||||
|
||||
// ✅ Update 使用 PartialType
|
||||
export class UpdateUserDto extends PartialType(CreateUserDto) {}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Body() dto: UpdateUserDto) { /* all fields optional */ }
|
||||
```
|
||||
|
||||
### 可选嵌套对象
|
||||
|
||||
```typescript
|
||||
// ❌ 可选嵌套对象缺少 @IsOptional
|
||||
export class UpdateOrderDto {
|
||||
@ValidateNested()
|
||||
@Type(() => AddressDto)
|
||||
shipping?: AddressDto; // undefined 时仍尝试验证
|
||||
}
|
||||
|
||||
// ✅ @IsOptional + @ValidateNested + @Type
|
||||
export class UpdateOrderDto {
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => AddressDto)
|
||||
shipping?: AddressDto;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 禁止吞掉错误
|
||||
|
||||
```typescript
|
||||
// ❌ catch { return null }——隐藏了问题,调用者无法区分"不存在"和"出错了"
|
||||
async findOne(id: string) {
|
||||
try {
|
||||
return await this.repo.findById(id);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 抛出有意义的异常
|
||||
async findOne(id: string): Promise<User> {
|
||||
const user = await this.repo.findById(id);
|
||||
if (!user) {
|
||||
throw new NotFoundException(`User ${id} not found`);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
### 使用内置异常类
|
||||
|
||||
```typescript
|
||||
// ❌ 手动构造 HTTP 响应
|
||||
throw new HttpException('Bad request', 400);
|
||||
|
||||
// ✅ 使用语义化的内置异常
|
||||
throw new BadRequestException('Invalid email format');
|
||||
throw new NotFoundException('User not found');
|
||||
throw new ConflictException('Email already taken');
|
||||
throw new ForbiddenException('Insufficient permissions');
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
```
|
||||
|
||||
### 自定义异常过滤器
|
||||
|
||||
```typescript
|
||||
// ✅ 全局异常过滤器——统一响应格式
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(AllExceptionsFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse();
|
||||
const request = ctx.getRequest();
|
||||
|
||||
const status =
|
||||
exception instanceof HttpException
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
this.logger.error(`${request.method} ${request.url} - ${status}`, exception instanceof Error ? exception.stack : '');
|
||||
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 循环依赖
|
||||
|
||||
### 模块间循环引用
|
||||
|
||||
```typescript
|
||||
// ❌ Module A ↔ Module B
|
||||
@Module({ imports: [UsersModule] })
|
||||
export class OrdersModule {}
|
||||
|
||||
@Module({ imports: [OrdersModule] })
|
||||
export class UsersModule {}
|
||||
|
||||
// ✅ 提取共享逻辑到第三个模块
|
||||
@Module({
|
||||
providers: [SharedService],
|
||||
exports: [SharedService],
|
||||
})
|
||||
export class SharedModule {}
|
||||
|
||||
@Module({ imports: [SharedModule] })
|
||||
export class OrdersModule {}
|
||||
|
||||
@Module({ imports: [SharedModule] })
|
||||
export class UsersModule {}
|
||||
```
|
||||
|
||||
### forwardRef 是最后手段
|
||||
|
||||
```typescript
|
||||
// ⚠️ forwardRef 表示设计有问题——优先重新设计
|
||||
@Module({
|
||||
imports: [forwardRef(() => UsersModule)],
|
||||
})
|
||||
export class OrdersModule {}
|
||||
|
||||
// ✅ 重新设计消除循环:
|
||||
// 1. 提取共享模块
|
||||
// 2. 使用事件驱动(EventEmitter)代替直接调用
|
||||
// 3. 将共享逻辑提升到上层 Service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试模式
|
||||
|
||||
### Use-Case 可脱离 NestJS 测试
|
||||
|
||||
```typescript
|
||||
// ✅ 无需 NestFactory——直接 new
|
||||
describe('CreateUserHandler', () => {
|
||||
let handler: CreateUserHandler;
|
||||
let repo: InMemoryUserRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = new InMemoryUserRepository();
|
||||
handler = new CreateUserHandler(repo);
|
||||
});
|
||||
|
||||
it('creates a user', async () => {
|
||||
const id = await handler.execute(
|
||||
new CreateUserCommand('user@example.com', 'Alice'),
|
||||
);
|
||||
expect(id).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects duplicate email', async () => {
|
||||
await handler.execute(new CreateUserCommand('user@example.com', 'Alice'));
|
||||
await expect(
|
||||
handler.execute(new CreateUserCommand('user@example.com', 'Bob')),
|
||||
).rejects.toThrow('already exists');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### E2E 测试应配置与生产一致的 Pipes
|
||||
|
||||
```typescript
|
||||
describe('UsersController (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
// 必须与 main.ts 中相同的全局配置
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/POST users - valid', () => {
|
||||
return request(app.getHttpServer())
|
||||
.post('/users')
|
||||
.send({ email: 'test@test.com', name: 'Test' })
|
||||
.expect(201);
|
||||
});
|
||||
|
||||
it('/POST users - extra fields rejected', () => {
|
||||
return request(app.getHttpServer())
|
||||
.post('/users')
|
||||
.send({ email: 'test@test.com', name: 'Test', role: 'admin' })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Review Checklist
|
||||
|
||||
### 分层架构
|
||||
|
||||
- [ ] ORM/Prisma 未直接注入 Controller
|
||||
- [ ] 业务逻辑不在 Controller 中
|
||||
- [ ] Repository 之间无互相注入
|
||||
- [ ] Service 依赖数 ≤ 8(超出则拆分为 Use-Case)
|
||||
|
||||
### 依赖注入
|
||||
|
||||
- [ ] 接口 + Symbol Token 用于可替换的依赖
|
||||
- [ ] 无 `forwardRef()`(如有,需设计文档说明原因)
|
||||
- [ ] Scoped 服务未注入到 Singleton 中
|
||||
|
||||
### 验证
|
||||
|
||||
- [ ] 每个 `@ValidateNested()` 都有对应的 `@Type()`
|
||||
- [ ] 全局 `ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })` 已配置
|
||||
- [ ] 无 `@Body() body: any`——必须使用 DTO
|
||||
- [ ] Create 和 Update 使用不同 DTO(`PartialType`)
|
||||
- [ ] 数组验证使用 `{ each: true }`
|
||||
- [ ] 可选嵌套对象使用 `@IsOptional()` + `@ValidateNested()` + `@Type()`
|
||||
|
||||
### Guard / Interceptor / Pipe
|
||||
|
||||
- [ ] Guard 只做授权检查,不查询数据库
|
||||
- [ ] Interceptor 只用于横切关注点(日志、缓存、响应转换)
|
||||
- [ ] 业务规则在 Service 中
|
||||
|
||||
### 错误处理
|
||||
|
||||
- [ ] 无 `catch { return null }`——抛出有意义的异常
|
||||
- [ ] 使用 NestJS 内置异常类
|
||||
- [ ] 自定义异常过滤器在 `common/filters/` 中
|
||||
|
||||
### 模块
|
||||
|
||||
- [ ] 无循环模块引用
|
||||
- [ ] Domain Entity 无框架装饰器(`@Injectable` 等)
|
||||
- [ ] 外部服务调用在 `integrations/` 中
|
||||
|
||||
### 测试
|
||||
|
||||
- [ ] Use-Case Service 可脱离 NestJS 测试
|
||||
- [ ] E2E 测试配置与生产一致的全局 Pipes/Guards
|
||||
- [ ] Domain Entity 零框架依赖
|
||||
@@ -0,0 +1,816 @@
|
||||
# Performance Review Guide
|
||||
|
||||
性能审查指南,覆盖前端、后端、数据库、算法复杂度和 API 性能。
|
||||
|
||||
## 目录
|
||||
|
||||
- [前端性能 (Core Web Vitals)](#前端性能-core-web-vitals)
|
||||
- [JavaScript 性能](#javascript-性能)
|
||||
- [内存管理](#内存管理)
|
||||
- [数据库性能](#数据库性能)
|
||||
- [API 性能](#api-性能)
|
||||
- [算法复杂度](#算法复杂度)
|
||||
- [性能审查清单](#性能审查清单)
|
||||
|
||||
---
|
||||
|
||||
## 前端性能 (Core Web Vitals)
|
||||
|
||||
### 2024 核心指标
|
||||
|
||||
| 指标 | 全称 | 目标值 | 含义 |
|
||||
|------|------|--------|------|
|
||||
| **LCP** | Largest Contentful Paint | ≤ 2.5s | 最大内容绘制时间 |
|
||||
| **INP** | Interaction to Next Paint | ≤ 200ms | 交互响应时间(2024 年替代 FID)|
|
||||
| **CLS** | Cumulative Layout Shift | ≤ 0.1 | 累积布局偏移 |
|
||||
| **FCP** | First Contentful Paint | ≤ 1.8s | 首次内容绘制 |
|
||||
| **TBT** | Total Blocking Time | ≤ 200ms | 主线程阻塞时间 |
|
||||
|
||||
### LCP 优化检查
|
||||
|
||||
```javascript
|
||||
// ❌ LCP 图片懒加载 - 延迟关键内容
|
||||
<img src="hero.jpg" loading="lazy" />
|
||||
|
||||
// ✅ LCP 图片立即加载
|
||||
<img src="hero.jpg" fetchpriority="high" />
|
||||
|
||||
// ❌ 未优化的图片格式
|
||||
<img src="hero.png" /> // PNG 文件过大
|
||||
|
||||
// ✅ 现代图片格式 + 响应式
|
||||
<picture>
|
||||
<source srcset="hero.avif" type="image/avif" />
|
||||
<source srcset="hero.webp" type="image/webp" />
|
||||
<img src="hero.jpg" alt="Hero" />
|
||||
</picture>
|
||||
```
|
||||
|
||||
**审查要点:**
|
||||
- [ ] LCP 元素是否设置 `fetchpriority="high"`?
|
||||
- [ ] 是否使用 WebP/AVIF 格式?
|
||||
- [ ] 是否有服务端渲染或静态生成?
|
||||
- [ ] CDN 是否配置正确?
|
||||
|
||||
### FCP 优化检查
|
||||
|
||||
```html
|
||||
<!-- ❌ 阻塞渲染的 CSS -->
|
||||
<link rel="stylesheet" href="all-styles.css" />
|
||||
|
||||
<!-- ✅ 关键 CSS 内联 + 异步加载其余 -->
|
||||
<style>/* 首屏关键样式 */</style>
|
||||
<link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'" />
|
||||
|
||||
<!-- ❌ 阻塞渲染的字体 -->
|
||||
@font-face {
|
||||
font-family: 'CustomFont';
|
||||
src: url('font.woff2');
|
||||
}
|
||||
|
||||
<!-- ✅ 字体显示优化 -->
|
||||
@font-face {
|
||||
font-family: 'CustomFont';
|
||||
src: url('font.woff2');
|
||||
font-display: swap; /* 先用系统字体,加载后切换 */
|
||||
}
|
||||
```
|
||||
|
||||
### INP 优化检查
|
||||
|
||||
```javascript
|
||||
// ❌ 长任务阻塞主线程
|
||||
button.addEventListener('click', () => {
|
||||
// 耗时 500ms 的同步操作
|
||||
processLargeData(data);
|
||||
updateUI();
|
||||
});
|
||||
|
||||
// ✅ 拆分长任务
|
||||
button.addEventListener('click', async () => {
|
||||
// 让出主线程
|
||||
await scheduler.yield?.() ?? new Promise(r => setTimeout(r, 0));
|
||||
|
||||
// 分批处理
|
||||
for (const chunk of chunks) {
|
||||
processChunk(chunk);
|
||||
await scheduler.yield?.();
|
||||
}
|
||||
updateUI();
|
||||
});
|
||||
|
||||
// ✅ 使用 Web Worker 处理复杂计算
|
||||
const worker = new Worker('heavy-computation.js');
|
||||
worker.postMessage(data);
|
||||
worker.onmessage = (e) => updateUI(e.data);
|
||||
```
|
||||
|
||||
### CLS 优化检查
|
||||
|
||||
```css
|
||||
/* ❌ 未指定尺寸的媒体 */
|
||||
img { width: 100%; }
|
||||
|
||||
/* ✅ 预留空间 */
|
||||
img {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
/* ❌ 动态插入内容导致布局偏移 */
|
||||
.ad-container { }
|
||||
|
||||
/* ✅ 预留固定高度 */
|
||||
.ad-container {
|
||||
min-height: 250px;
|
||||
}
|
||||
```
|
||||
|
||||
**CLS 审查清单:**
|
||||
- [ ] 图片/视频是否有 width/height 或 aspect-ratio?
|
||||
- [ ] 字体加载是否使用 `font-display: swap`?
|
||||
- [ ] 动态内容是否预留空间?
|
||||
- [ ] 是否避免在现有内容上方插入内容?
|
||||
|
||||
---
|
||||
|
||||
## JavaScript 性能
|
||||
|
||||
### 代码分割与懒加载
|
||||
|
||||
```javascript
|
||||
// ❌ 一次性加载所有代码
|
||||
import { HeavyChart } from './charts';
|
||||
import { PDFExporter } from './pdf';
|
||||
import { AdminPanel } from './admin';
|
||||
|
||||
// ✅ 按需加载
|
||||
const HeavyChart = lazy(() => import('./charts'));
|
||||
const PDFExporter = lazy(() => import('./pdf'));
|
||||
|
||||
// ✅ 路由级代码分割
|
||||
const routes = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
component: lazy(() => import('./pages/Dashboard')),
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: lazy(() => import('./pages/Admin')),
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### Bundle 体积优化
|
||||
|
||||
```javascript
|
||||
// ❌ 导入整个库
|
||||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
|
||||
// ✅ 按需导入
|
||||
import debounce from 'lodash/debounce';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
// ❌ 未使用 Tree Shaking
|
||||
export default {
|
||||
fn1() {},
|
||||
fn2() {}, // 未使用但被打包
|
||||
};
|
||||
|
||||
// ✅ 命名导出支持 Tree Shaking
|
||||
export function fn1() {}
|
||||
export function fn2() {}
|
||||
```
|
||||
|
||||
**Bundle 审查清单:**
|
||||
- [ ] 是否使用动态 import() 进行代码分割?
|
||||
- [ ] 大型库是否按需导入?
|
||||
- [ ] 是否分析过 bundle 大小?(webpack-bundle-analyzer)
|
||||
- [ ] 是否有未使用的依赖?
|
||||
|
||||
### 列表渲染优化
|
||||
|
||||
```javascript
|
||||
// ❌ 渲染大列表
|
||||
function List({ items }) {
|
||||
return (
|
||||
<ul>
|
||||
{items.map(item => <li key={item.id}>{item.name}</li>)}
|
||||
</ul>
|
||||
); // 10000 条数据 = 10000 个 DOM 节点
|
||||
}
|
||||
|
||||
// ✅ 虚拟列表 - 只渲染可见项
|
||||
import { FixedSizeList } from 'react-window';
|
||||
|
||||
function VirtualList({ items }) {
|
||||
return (
|
||||
<FixedSizeList
|
||||
height={400}
|
||||
itemCount={items.length}
|
||||
itemSize={35}
|
||||
>
|
||||
{({ index, style }) => (
|
||||
<div style={style}>{items[index].name}</div>
|
||||
)}
|
||||
</FixedSizeList>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**大数据审查要点:**
|
||||
- [ ] 列表超过 100 项是否使用虚拟滚动?
|
||||
- [ ] 表格是否支持分页或虚拟化?
|
||||
- [ ] 是否有不必要的全量渲染?
|
||||
|
||||
---
|
||||
|
||||
## 内存管理
|
||||
|
||||
### 常见内存泄漏
|
||||
|
||||
#### 1. 未清理的事件监听
|
||||
|
||||
```javascript
|
||||
// ❌ 组件卸载后事件仍在监听
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// ✅ 清理事件监听
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
```
|
||||
|
||||
#### 2. 未清理的定时器
|
||||
|
||||
```javascript
|
||||
// ❌ 定时器未清理
|
||||
useEffect(() => {
|
||||
setInterval(fetchData, 5000);
|
||||
}, []);
|
||||
|
||||
// ✅ 清理定时器
|
||||
useEffect(() => {
|
||||
const timer = setInterval(fetchData, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
```
|
||||
|
||||
#### 3. 闭包引用
|
||||
|
||||
```javascript
|
||||
// ❌ 闭包持有大对象引用
|
||||
function createHandler() {
|
||||
const largeData = new Array(1000000).fill('x');
|
||||
|
||||
return function handler() {
|
||||
// largeData 被闭包引用,无法被回收
|
||||
console.log(largeData.length);
|
||||
};
|
||||
}
|
||||
|
||||
// ✅ 只保留必要数据
|
||||
function createHandler() {
|
||||
const largeData = new Array(1000000).fill('x');
|
||||
const length = largeData.length; // 只保留需要的值
|
||||
|
||||
return function handler() {
|
||||
console.log(length);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 未清理的订阅
|
||||
|
||||
```javascript
|
||||
// ❌ WebSocket/EventSource 未关闭
|
||||
useEffect(() => {
|
||||
const ws = new WebSocket('wss://...');
|
||||
ws.onmessage = handleMessage;
|
||||
}, []);
|
||||
|
||||
// ✅ 清理连接
|
||||
useEffect(() => {
|
||||
const ws = new WebSocket('wss://...');
|
||||
ws.onmessage = handleMessage;
|
||||
return () => ws.close();
|
||||
}, []);
|
||||
```
|
||||
|
||||
### 内存审查清单
|
||||
|
||||
```markdown
|
||||
- [ ] useEffect 是否都有清理函数?
|
||||
- [ ] 事件监听是否在组件卸载时移除?
|
||||
- [ ] 定时器是否被清理?
|
||||
- [ ] WebSocket/SSE 连接是否关闭?
|
||||
- [ ] 大对象是否及时释放?
|
||||
- [ ] 是否有全局变量累积数据?
|
||||
```
|
||||
|
||||
### 检测工具
|
||||
|
||||
| 工具 | 用途 |
|
||||
|------|------|
|
||||
| Chrome DevTools Memory | 堆快照分析 |
|
||||
| MemLab (Meta) | 自动化内存泄漏检测 |
|
||||
| Performance Monitor | 实时内存监控 |
|
||||
|
||||
---
|
||||
|
||||
## 数据库性能
|
||||
|
||||
### N+1 查询问题
|
||||
|
||||
```python
|
||||
# ❌ N+1 问题 - 1 + N 次查询
|
||||
users = User.objects.all() # 1 次查询
|
||||
for user in users:
|
||||
print(user.profile.bio) # N 次查询(每个用户一次)
|
||||
|
||||
# ✅ Eager Loading - 2 次查询
|
||||
users = User.objects.select_related('profile').all()
|
||||
for user in users:
|
||||
print(user.profile.bio) # 无额外查询
|
||||
|
||||
# ✅ 多对多关系用 prefetch_related
|
||||
posts = Post.objects.prefetch_related('tags').all()
|
||||
```
|
||||
|
||||
```javascript
|
||||
// TypeORM 示例
|
||||
// ❌ N+1 问题
|
||||
const users = await userRepository.find();
|
||||
for (const user of users) {
|
||||
const posts = await user.posts; // 每次循环都查询
|
||||
}
|
||||
|
||||
// ✅ Eager Loading
|
||||
const users = await userRepository.find({
|
||||
relations: ['posts'],
|
||||
});
|
||||
```
|
||||
|
||||
### 索引优化
|
||||
|
||||
```sql
|
||||
-- ❌ 全表扫描
|
||||
SELECT * FROM orders WHERE status = 'pending';
|
||||
|
||||
-- ✅ 添加索引
|
||||
CREATE INDEX idx_orders_status ON orders(status);
|
||||
|
||||
-- ❌ 索引失效:函数操作
|
||||
SELECT * FROM users WHERE YEAR(created_at) = 2024;
|
||||
|
||||
-- ✅ 范围查询可用索引
|
||||
SELECT * FROM users
|
||||
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';
|
||||
|
||||
-- ❌ 索引失效:LIKE 前缀通配符
|
||||
SELECT * FROM products WHERE name LIKE '%phone%';
|
||||
|
||||
-- ✅ 前缀匹配可用索引
|
||||
SELECT * FROM products WHERE name LIKE 'phone%';
|
||||
```
|
||||
|
||||
### 查询优化
|
||||
|
||||
```sql
|
||||
-- ❌ SELECT * 获取不需要的列
|
||||
SELECT * FROM users WHERE id = 1;
|
||||
|
||||
-- ✅ 只查询需要的列
|
||||
SELECT id, name, email FROM users WHERE id = 1;
|
||||
|
||||
-- ❌ 大表无 LIMIT
|
||||
SELECT * FROM logs WHERE type = 'error';
|
||||
|
||||
-- ✅ 分页查询
|
||||
SELECT * FROM logs WHERE type = 'error' LIMIT 100 OFFSET 0;
|
||||
|
||||
-- ❌ 在循环中执行查询
|
||||
for id in user_ids:
|
||||
cursor.execute("SELECT * FROM users WHERE id = %s", (id,))
|
||||
|
||||
-- ✅ 批量查询
|
||||
cursor.execute("SELECT * FROM users WHERE id IN %s", (tuple(user_ids),))
|
||||
```
|
||||
|
||||
### 数据库审查清单
|
||||
|
||||
```markdown
|
||||
🔴 必须检查:
|
||||
- [ ] 是否存在 N+1 查询?
|
||||
- [ ] WHERE 子句列是否有索引?
|
||||
- [ ] 是否避免了 SELECT *?
|
||||
- [ ] 大表查询是否有 LIMIT?
|
||||
|
||||
🟡 建议检查:
|
||||
- [ ] 是否使用了 EXPLAIN 分析查询计划?
|
||||
- [ ] 复合索引列顺序是否正确?
|
||||
- [ ] 是否有未使用的索引?
|
||||
- [ ] 是否有慢查询日志监控?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 性能
|
||||
|
||||
### 分页实现
|
||||
|
||||
```javascript
|
||||
// ❌ 返回全部数据
|
||||
app.get('/users', async (req, res) => {
|
||||
const users = await User.findAll(); // 可能返回 100000 条
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
// ✅ 分页 + 限制最大数量
|
||||
app.get('/users', async (req, res) => {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = Math.min(parseInt(req.query.limit) || 20, 100); // 最大 100
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { rows, count } = await User.findAndCountAll({
|
||||
limit,
|
||||
offset,
|
||||
order: [['id', 'ASC']],
|
||||
});
|
||||
|
||||
res.json({
|
||||
data: rows,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: count,
|
||||
totalPages: Math.ceil(count / limit),
|
||||
},
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 缓存策略
|
||||
|
||||
```javascript
|
||||
// ✅ Redis 缓存示例
|
||||
async function getUser(id) {
|
||||
const cacheKey = `user:${id}`;
|
||||
|
||||
// 1. 检查缓存
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
// 2. 查询数据库
|
||||
const user = await db.users.findById(id);
|
||||
|
||||
// 3. 写入缓存(设置过期时间)
|
||||
await redis.setex(cacheKey, 3600, JSON.stringify(user));
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
// ✅ HTTP 缓存头
|
||||
app.get('/static-data', (req, res) => {
|
||||
res.set({
|
||||
'Cache-Control': 'public, max-age=86400', // 24 小时
|
||||
'ETag': 'abc123',
|
||||
});
|
||||
res.json(data);
|
||||
});
|
||||
```
|
||||
|
||||
### 响应压缩
|
||||
|
||||
```javascript
|
||||
// ✅ 启用 Gzip/Brotli 压缩
|
||||
const compression = require('compression');
|
||||
app.use(compression());
|
||||
|
||||
// ✅ 只返回必要字段
|
||||
// 请求: GET /users?fields=id,name,email
|
||||
app.get('/users', async (req, res) => {
|
||||
const fields = req.query.fields?.split(',') || ['id', 'name'];
|
||||
const users = await User.findAll({
|
||||
attributes: fields,
|
||||
});
|
||||
res.json(users);
|
||||
});
|
||||
```
|
||||
|
||||
### 限流保护
|
||||
|
||||
```javascript
|
||||
// ✅ 速率限制
|
||||
const rateLimit = require('express-rate-limit');
|
||||
|
||||
const limiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 分钟
|
||||
max: 100, // 最多 100 次请求
|
||||
message: { error: 'Too many requests, please try again later.' },
|
||||
});
|
||||
|
||||
app.use('/api/', limiter);
|
||||
```
|
||||
|
||||
### API 审查清单
|
||||
|
||||
```markdown
|
||||
- [ ] 列表接口是否有分页?
|
||||
- [ ] 是否限制了每页最大数量?
|
||||
- [ ] 热点数据是否有缓存?
|
||||
- [ ] 是否启用了响应压缩?
|
||||
- [ ] 是否有速率限制?
|
||||
- [ ] 是否只返回必要字段?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 算法复杂度
|
||||
|
||||
### 常见复杂度对比
|
||||
|
||||
| 复杂度 | 名称 | 10 条 | 1000 条 | 100 万条 | 示例 |
|
||||
|--------|------|-------|---------|----------|------|
|
||||
| O(1) | 常数 | 1 | 1 | 1 | 哈希查找 |
|
||||
| O(log n) | 对数 | 3 | 10 | 20 | 二分查找 |
|
||||
| O(n) | 线性 | 10 | 1000 | 100 万 | 遍历数组 |
|
||||
| O(n log n) | 线性对数 | 33 | 10000 | 2000 万 | 快速排序 |
|
||||
| O(n²) | 平方 | 100 | 100 万 | 1 万亿 | 嵌套循环 |
|
||||
| O(2ⁿ) | 指数 | 1024 | ∞ | ∞ | 递归斐波那契 |
|
||||
|
||||
### 代码审查中的识别
|
||||
|
||||
```javascript
|
||||
// ❌ O(n²) - 嵌套循环
|
||||
function findDuplicates(arr) {
|
||||
const duplicates = [];
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
for (let j = i + 1; j < arr.length; j++) {
|
||||
if (arr[i] === arr[j]) {
|
||||
duplicates.push(arr[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return duplicates;
|
||||
}
|
||||
|
||||
// ✅ O(n) - 使用 Set
|
||||
function findDuplicates(arr) {
|
||||
const seen = new Set();
|
||||
const duplicates = new Set();
|
||||
for (const item of arr) {
|
||||
if (seen.has(item)) {
|
||||
duplicates.add(item);
|
||||
}
|
||||
seen.add(item);
|
||||
}
|
||||
return [...duplicates];
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// ❌ O(n²) - 每次循环都调用 includes
|
||||
function removeDuplicates(arr) {
|
||||
const result = [];
|
||||
for (const item of arr) {
|
||||
if (!result.includes(item)) { // includes 是 O(n)
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ✅ O(n) - 使用 Set
|
||||
function removeDuplicates(arr) {
|
||||
return [...new Set(arr)];
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// ❌ O(n) 查找 - 每次都遍历
|
||||
const users = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }, ...];
|
||||
|
||||
function getUser(id) {
|
||||
return users.find(u => u.id === id); // O(n)
|
||||
}
|
||||
|
||||
// ✅ O(1) 查找 - 使用 Map
|
||||
const userMap = new Map(users.map(u => [u.id, u]));
|
||||
|
||||
function getUser(id) {
|
||||
return userMap.get(id); // O(1)
|
||||
}
|
||||
```
|
||||
|
||||
### 空间复杂度考虑
|
||||
|
||||
```javascript
|
||||
// ⚠️ O(n) 空间 - 创建新数组
|
||||
const doubled = arr.map(x => x * 2);
|
||||
|
||||
// ✅ O(1) 空间 - 原地修改(如果允许)
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
arr[i] *= 2;
|
||||
}
|
||||
|
||||
// ⚠️ 递归深度过大可能栈溢出
|
||||
function factorial(n) {
|
||||
if (n <= 1) return 1;
|
||||
return n * factorial(n - 1); // O(n) 栈空间
|
||||
}
|
||||
|
||||
// ✅ 迭代版本 O(1) 空间
|
||||
function factorial(n) {
|
||||
let result = 1;
|
||||
for (let i = 2; i <= n; i++) {
|
||||
result *= i;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
### 复杂度审查问题
|
||||
|
||||
```markdown
|
||||
💡 "这个嵌套循环的复杂度是 O(n²),数据量大时会有性能问题"
|
||||
🔴 "这里用 Array.includes() 在循环中,整体是 O(n²),建议用 Set"
|
||||
🟡 "这个递归深度可能导致栈溢出,建议改为迭代或尾递归"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 性能审查清单
|
||||
|
||||
### 🔴 必须检查(阻塞级)
|
||||
|
||||
**前端:**
|
||||
- [ ] LCP 图片是否懒加载?(不应该)
|
||||
- [ ] 是否有 `transition: all`?
|
||||
- [ ] 是否动画 width/height/top/left?
|
||||
- [ ] 列表 >100 项是否虚拟化?
|
||||
|
||||
**后端:**
|
||||
- [ ] 是否存在 N+1 查询?
|
||||
- [ ] 列表接口是否有分页?
|
||||
- [ ] 是否有 SELECT * 查大表?
|
||||
|
||||
**通用:**
|
||||
- [ ] 是否有 O(n²) 或更差的嵌套循环?
|
||||
- [ ] useEffect/事件监听是否有清理?
|
||||
|
||||
### 🟡 建议检查(重要级)
|
||||
|
||||
**前端:**
|
||||
- [ ] 是否使用代码分割?
|
||||
- [ ] 大型库是否按需导入?
|
||||
- [ ] 图片是否使用 WebP/AVIF?
|
||||
- [ ] 是否有未使用的依赖?
|
||||
|
||||
**后端:**
|
||||
- [ ] 热点数据是否有缓存?
|
||||
- [ ] WHERE 列是否有索引?
|
||||
- [ ] 是否有慢查询监控?
|
||||
|
||||
**API:**
|
||||
- [ ] 是否启用响应压缩?
|
||||
- [ ] 是否有速率限制?
|
||||
- [ ] 是否只返回必要字段?
|
||||
|
||||
### 🟢 优化建议(建议级)
|
||||
|
||||
- [ ] 是否分析过 bundle 大小?
|
||||
- [ ] 是否使用 CDN?
|
||||
- [ ] 是否有性能监控?
|
||||
- [ ] 是否做过性能基准测试?
|
||||
|
||||
---
|
||||
|
||||
## 性能度量阈值
|
||||
|
||||
### 前端指标
|
||||
|
||||
| 指标 | 好 | 需改进 | 差 |
|
||||
|------|-----|--------|-----|
|
||||
| LCP | ≤ 2.5s | 2.5-4s | > 4s |
|
||||
| INP | ≤ 200ms | 200-500ms | > 500ms |
|
||||
| CLS | ≤ 0.1 | 0.1-0.25 | > 0.25 |
|
||||
| FCP | ≤ 1.8s | 1.8-3s | > 3s |
|
||||
| Bundle Size (JS) | < 200KB | 200-500KB | > 500KB |
|
||||
|
||||
### 后端指标
|
||||
|
||||
| 指标 | 好 | 需改进 | 差 |
|
||||
|------|-----|--------|-----|
|
||||
| API 响应时间 | < 100ms | 100-500ms | > 500ms |
|
||||
| 数据库查询 | < 50ms | 50-200ms | > 200ms |
|
||||
| 页面加载 | < 3s | 3-5s | > 5s |
|
||||
|
||||
---
|
||||
|
||||
## 工具推荐
|
||||
|
||||
### 前端性能
|
||||
|
||||
| 工具 | 用途 |
|
||||
|------|------|
|
||||
| [Lighthouse](https://developer.chrome.com/docs/lighthouse/) | Core Web Vitals 测试 |
|
||||
| [WebPageTest](https://www.webpagetest.org/) | 详细性能分析 |
|
||||
| [webpack-bundle-analyzer](https://github.com/webpack-contrib/webpack-bundle-analyzer) | Bundle 分析 |
|
||||
| [Chrome DevTools Performance](https://developer.chrome.com/docs/devtools/performance/) | 运行时性能分析 |
|
||||
|
||||
### 内存检测
|
||||
|
||||
| 工具 | 用途 |
|
||||
|------|------|
|
||||
| [MemLab](https://github.com/facebookincubator/memlab) | 自动化内存泄漏检测 |
|
||||
| Chrome Memory Tab | 堆快照分析 |
|
||||
|
||||
### 后端性能
|
||||
|
||||
| 工具 | 用途 |
|
||||
|------|------|
|
||||
| EXPLAIN | 数据库查询计划分析 |
|
||||
| [pganalyze](https://pganalyze.com/) | PostgreSQL 性能监控 |
|
||||
| [New Relic](https://newrelic.com/) / [Datadog](https://www.datadoghq.com/) | APM 监控 |
|
||||
|
||||
---
|
||||
|
||||
## 低级别效率反模式
|
||||
|
||||
代码层面的效率失误,独立于架构层面的性能问题。补充 [common-bugs-checklist.md](common-bugs-checklist.md) 中已涵盖的资源管理与并发缺陷。
|
||||
|
||||
### 不必要的重复工作
|
||||
|
||||
- [ ] 同一函数 / 查询是否在同一 request/render 中被重复调用?
|
||||
- [ ] 文件 / 配置是否在循环内重复读取(loop-invariant)?
|
||||
- [ ] 计算结果是否可以被缓存或向下游传递?
|
||||
|
||||
```typescript
|
||||
// ❌ loop-invariant 在循环内反复执行
|
||||
for (const path of paths) {
|
||||
const config = JSON.parse(fs.readFileSync("config.json", "utf-8"));
|
||||
processFile(path, config);
|
||||
}
|
||||
|
||||
// ✅ 提到循环外
|
||||
const config = JSON.parse(fs.readFileSync("config.json", "utf-8"));
|
||||
for (const path of paths) processFile(path, config);
|
||||
```
|
||||
|
||||
### 错失的并发机会
|
||||
|
||||
- [ ] 独立的 async 操作是否顺序 `await`?
|
||||
- [ ] 是否可以用 `Promise.all` / `asyncio.gather` / `tokio::join!` 并发?
|
||||
|
||||
```typescript
|
||||
// ❌ 顺序 await
|
||||
const a = await fetchA();
|
||||
const b = await fetchB();
|
||||
|
||||
// ✅ 并发
|
||||
const [a, b] = await Promise.all([fetchA(), fetchB()]);
|
||||
```
|
||||
|
||||
### 热路径膨胀
|
||||
|
||||
- [ ] 模块级 / import 时代码是否执行重操作(文件 I/O、网络、大对象构造)?
|
||||
- [ ] per-request 路径是否有可延迟的初始化?
|
||||
- [ ] 启动时代码是否阻塞首次请求?
|
||||
|
||||
### 无界数据结构
|
||||
|
||||
> 资源生命周期相关缺陷(未关闭的连接、未移除的监听器、未清除的定时器)见 [common-bugs-checklist.md → Resource Management](common-bugs-checklist.md#resource-management)。本节聚焦 *容量边界*。
|
||||
|
||||
- [ ] 全局 dict / list / 缓存是否有 `max-size` 或 TTL?
|
||||
- [ ] 累积型数据结构(队列、日志、metrics buffer)是否有上限?
|
||||
- [ ] 每请求分配的对象是否会被持久引用而无法 GC?
|
||||
|
||||
```python
|
||||
# ❌ 无界缓存
|
||||
_cache: dict[str, Any] = {}
|
||||
|
||||
# ✅ 有界 LRU
|
||||
from functools import lru_cache
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def get_cached(key: str) -> Any:
|
||||
return expensive_computation(key)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 参考资源
|
||||
|
||||
- [Core Web Vitals - web.dev](https://web.dev/articles/vitals)
|
||||
- [Optimizing Core Web Vitals - Vercel](https://vercel.com/guides/optimizing-core-web-vitals-in-2024)
|
||||
- [MemLab - Meta Engineering](https://engineering.fb.com/2022/09/12/open-source/memlab/)
|
||||
- [Big O Cheat Sheet](https://www.bigocheatsheet.com/)
|
||||
- [N+1 Query Problem - Stack Overflow](https://stackoverflow.com/questions/97197/what-is-the-n1-selects-problem-in-orm-object-relational-mapping)
|
||||
- [API Performance Optimization](https://algorithmsin60days.com/blog/optimizing-api-performance/)
|
||||
+704
@@ -0,0 +1,704 @@
|
||||
# PHP Code Review Guide
|
||||
|
||||
> PHP 8.x code review guide covering the type system, modern language features, OOP modeling, PDO data access, security, error handling, Composer dependencies, performance, and testing.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Quick Review Checklist](#quick-review-checklist)
|
||||
- [Type System & Modern PHP](#type-system--modern-php)
|
||||
- [Object Modeling](#object-modeling)
|
||||
- [Input, Output & Security](#input-output--security)
|
||||
- [Database Access](#database-access)
|
||||
- [Error Handling](#error-handling)
|
||||
- [Composer & Dependencies](#composer--dependencies)
|
||||
- [Performance & Resource Management](#performance--resource-management)
|
||||
- [Testing & Static Analysis](#testing--static-analysis)
|
||||
- [Review Checklist](#review-checklist)
|
||||
- [References](#references)
|
||||
|
||||
---
|
||||
|
||||
## Quick Review Checklist
|
||||
|
||||
### Must-check
|
||||
|
||||
- [ ] New files enable `declare(strict_types=1);`
|
||||
- [ ] Public APIs have parameter, return, and property types
|
||||
- [ ] User input is validated; output is escaped per context
|
||||
- [ ] SQL uses parameterized queries or ORM binding
|
||||
- [ ] Passwords use `password_hash()` / `password_verify()`
|
||||
- [ ] File uploads validate MIME, size, extension, and storage path
|
||||
- [ ] `composer.lock` is committed; dependency ranges are reasonable
|
||||
- [ ] PHPUnit/Pest tests and PHPStan/Psalm static analysis are present
|
||||
|
||||
### Common issues
|
||||
|
||||
- [ ] Loose comparison `==` / `!=` causing type-juggling vulnerabilities
|
||||
- [ ] `md5()` / `sha1()` used to store passwords
|
||||
- [ ] Concatenating SQL, HTML, shell commands, or file paths
|
||||
- [ ] Using `@` to suppress errors
|
||||
- [ ] `unserialize()` on untrusted data
|
||||
- [ ] `$_GET` / `$_POST` / `$_FILES` flowing straight into business logic
|
||||
- [ ] PHP 8.2+ dynamic properties trigger a deprecation; PHP 9 may turn it into an error
|
||||
|
||||
---
|
||||
|
||||
## Type System & Modern PHP
|
||||
|
||||
### strict_types and explicit types
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ❌ weak boundary: passing "42" gets silently coerced
|
||||
function findUser($id) {
|
||||
return User::find($id);
|
||||
}
|
||||
|
||||
// ✅ enable strict_types at the top of the file; type the public API
|
||||
declare(strict_types=1);
|
||||
|
||||
function findUser(int $id): ?User
|
||||
{
|
||||
return User::find($id);
|
||||
}
|
||||
```
|
||||
|
||||
Don't leave type checking entirely to runtime input validation. Type declarations express an internal contract; input validation expresses how much to trust the boundary. You need both.
|
||||
|
||||
### Avoid loose comparisons
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ❌ strings like "0e12345" can be treated as 0 under loose comparison
|
||||
if ($providedHash == $storedHash) {
|
||||
grantAccess();
|
||||
}
|
||||
|
||||
// ✅ strict comparison; use hash_equals() for secrets or tokens
|
||||
if (hash_equals($storedHash, $providedHash)) {
|
||||
grantAccess();
|
||||
}
|
||||
|
||||
// ✅ match uses identity checks, so fewer type-juggling surprises than switch
|
||||
$status = match ($code) {
|
||||
200 => 'ok',
|
||||
404 => 'not_found',
|
||||
default => 'unknown',
|
||||
};
|
||||
```
|
||||
|
||||
Pay attention to `==`, `!=`, and `in_array($x, $list)` (loose by default) in auth, payment, state machine, and permission logic. Use `===`, `!==`, and `in_array($x, $list, true)` where it matters.
|
||||
|
||||
### Union / intersection / nullable types
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ❌ mixed or untyped makes callers guess the return shape
|
||||
function loadConfig($source) {
|
||||
return parseConfig($source);
|
||||
}
|
||||
|
||||
// ✅ express the real contract with types
|
||||
function loadConfig(string|PathInfo $source): Config
|
||||
{
|
||||
return parseConfig($source);
|
||||
}
|
||||
|
||||
// ✅ make null explicit when it's a real business state
|
||||
function currentUser(): ?User
|
||||
{
|
||||
return Auth::user();
|
||||
}
|
||||
```
|
||||
|
||||
`mixed` can show up at the boundary or while migrating legacy code, but in core business services it usually signals missing modeling.
|
||||
|
||||
### The nullsafe operator shouldn't hide missing state
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ❌ chained nullsafe blurs the reason for failure
|
||||
$country = $order?->customer?->profile?->country;
|
||||
|
||||
// ✅ branch explicitly on critical business state
|
||||
if ($order === null) {
|
||||
throw new OrderNotFound();
|
||||
}
|
||||
|
||||
$customer = $order->customer();
|
||||
if ($customer === null) {
|
||||
throw new MissingCustomer($order->id);
|
||||
}
|
||||
|
||||
$country = $customer->profile()?->country;
|
||||
```
|
||||
|
||||
Distinguish "optional display field" from "business invariant that must exist." The former is a good fit for `?->`; the latter should fail loudly.
|
||||
|
||||
---
|
||||
|
||||
## Object Modeling
|
||||
|
||||
### Use readonly properties and value objects
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ❌ public mutable fields let callers change state at will
|
||||
class Money
|
||||
{
|
||||
public $amount;
|
||||
public $currency;
|
||||
}
|
||||
|
||||
// ✅ express an immutable value object with types and readonly
|
||||
final readonly class Money
|
||||
{
|
||||
public function __construct(
|
||||
public int $amount,
|
||||
public string $currency,
|
||||
) {
|
||||
if ($amount < 0) {
|
||||
throw new InvalidArgumentException('Amount must be non-negative');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For DTOs, config, and domain value objects, check first whether a `readonly class` or readonly properties can remove hidden side effects.
|
||||
|
||||
### Enums instead of string states
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ❌ string states are easy to typo and can't enumerate the legal set
|
||||
if ($order->status === 'paied') {
|
||||
ship($order);
|
||||
}
|
||||
|
||||
// ✅ an enum surfaces illegal states earlier
|
||||
enum OrderStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Paid = 'paid';
|
||||
case Cancelled = 'cancelled';
|
||||
}
|
||||
|
||||
if ($order->status === OrderStatus::Paid) {
|
||||
ship($order);
|
||||
}
|
||||
```
|
||||
|
||||
When reviewing state machines, permissions, or type fields, look for "magic string values." If the value set is stable, suggest an enum; if it comes from an external system, convert it to an internal enum before it enters the business layer.
|
||||
|
||||
### Don't rely on dynamic properties
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ❌ PHP 8.2+ triggers a deprecation when creating a dynamic property
|
||||
$user = new User();
|
||||
$user->emali = 'a@example.com'; // a typo also silently creates a property
|
||||
|
||||
// ✅ declare properties or use a dedicated data structure
|
||||
final class User
|
||||
{
|
||||
public function __construct(
|
||||
public string $email,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
`#[AllowDynamicProperties]` should be an exception for legacy compatibility, not the default for new code. Watch for serialization, ORM hydration, and test doubles that secretly rely on dynamic properties.
|
||||
|
||||
### Don't do heavy I/O in constructors
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ❌ quietly connecting to the DB on construction makes testing and error handling hard
|
||||
final class ReportService
|
||||
{
|
||||
private PDO $pdo;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->pdo = new PDO($_ENV['DSN']);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ inject dependencies from the outside
|
||||
final class ReportService
|
||||
{
|
||||
public function __construct(
|
||||
private PDO $pdo,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
A constructor should establish the object's invariants — not send HTTP requests, open connections, read large files, or run complex queries.
|
||||
|
||||
---
|
||||
|
||||
## Input, Output & Security
|
||||
|
||||
### Validate input at the boundary
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ❌ superglobals flow straight into business logic
|
||||
$user = $service->create($_POST['email'], $_POST['age']);
|
||||
|
||||
// ✅ validate and coerce types at the boundary first
|
||||
$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
|
||||
$age = filter_input(INPUT_POST, 'age', FILTER_VALIDATE_INT, [
|
||||
'options' => ['min_range' => 0, 'max_range' => 130],
|
||||
]);
|
||||
|
||||
if ($email === false || $email === null || $age === false || $age === null) {
|
||||
throw new InvalidInput();
|
||||
}
|
||||
|
||||
$user = $service->create($email, $age);
|
||||
```
|
||||
|
||||
`filter_input()` only handles a slice of basic validation. Complex rules, cross-field constraints, and business constraints still need a dedicated validator or request DTO.
|
||||
|
||||
### Escape output per context
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ❌ user input goes straight into HTML
|
||||
echo "<h1>Hello {$_GET['name']}</h1>";
|
||||
|
||||
// ✅ use htmlspecialchars in an HTML text context
|
||||
$name = (string) ($_GET['name'] ?? '');
|
||||
echo '<h1>Hello ' . htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</h1>';
|
||||
```
|
||||
|
||||
Different contexts need different escaping: HTML text, HTML attributes, URLs, JavaScript strings, and CSS are all different. When a template engine's default escaping is turned off, treat it as a security risk.
|
||||
|
||||
### Passwords and randomness
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ❌ md5/sha1 must not be used for password storage
|
||||
$hash = md5($password);
|
||||
|
||||
// ✅ use PHP's built-in password API
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
|
||||
if (!password_verify($password, $hash)) {
|
||||
throw new InvalidCredentials();
|
||||
}
|
||||
|
||||
// ✅ use a CSPRNG for tokens
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$code = random_int(100000, 999999);
|
||||
```
|
||||
|
||||
Don't hand-roll salts, round migration, or password comparison. Use `password_needs_rehash()` when you need to upgrade the cost factor.
|
||||
|
||||
### Deserialization and object injection
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ❌ untrusted input into unserialize can trigger object injection
|
||||
$payload = unserialize($_COOKIE['state']);
|
||||
|
||||
// ✅ prefer JSON for external data, and validate its schema/shape
|
||||
$payload = json_decode($_COOKIE['state'] ?? '{}', true, flags: JSON_THROW_ON_ERROR);
|
||||
```
|
||||
|
||||
If you must process historical serialized data, at least restrict `allowed_classes` and make sure the relevant classes' magic methods can't produce dangerous side effects.
|
||||
|
||||
### File uploads and paths
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ❌ building the path from the raw filename
|
||||
$target = __DIR__ . '/uploads/' . $_FILES['avatar']['name'];
|
||||
move_uploaded_file($_FILES['avatar']['tmp_name'], $target);
|
||||
|
||||
// ✅ generate a server-side filename, check the upload error and MIME
|
||||
$file = $_FILES['avatar'];
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
throw new UploadFailed();
|
||||
}
|
||||
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo->file($file['tmp_name']);
|
||||
if (!in_array($mime, ['image/png', 'image/jpeg'], true)) {
|
||||
throw new InvalidFileType();
|
||||
}
|
||||
|
||||
$target = __DIR__ . '/uploads/' . bin2hex(random_bytes(16)) . '.jpg';
|
||||
move_uploaded_file($file['tmp_name'], $target);
|
||||
```
|
||||
|
||||
When reviewing upload features, check size limits, MIME detection, extensions, a non-executable storage directory, path traversal, overwrite protection, and any virus-scan or async-processing requirements.
|
||||
|
||||
---
|
||||
|
||||
## Database Access
|
||||
|
||||
### Use parameterized queries
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ❌ concatenated SQL is an injection risk
|
||||
$sql = "SELECT * FROM users WHERE email = '" . $_GET['email'] . "'";
|
||||
$user = $pdo->query($sql)->fetch();
|
||||
|
||||
// ✅ PDO prepared statement + bound value
|
||||
$stmt = $pdo->prepare('SELECT id, email FROM users WHERE email = :email');
|
||||
$stmt->execute(['email' => $email]);
|
||||
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
```
|
||||
|
||||
Parameters can only bind values — not table names, column names, or sort direction. Dynamic identifiers must go through a whitelist mapping.
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ✅ whitelist the dynamic sort column
|
||||
$columns = [
|
||||
'created' => 'created_at',
|
||||
'email' => 'email',
|
||||
];
|
||||
|
||||
$column = $columns[$_GET['sort'] ?? 'created'] ?? $columns['created'];
|
||||
$stmt = $pdo->query("SELECT id, email FROM users ORDER BY {$column} DESC");
|
||||
```
|
||||
|
||||
### Wrap multi-step writes in transactions
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ❌ multi-step writes with no transaction leave half-finished state on failure
|
||||
$orderId = $orders->create($cart);
|
||||
$inventory->reserve($cart);
|
||||
$payments->charge($orderId);
|
||||
|
||||
// ✅ explicit transaction boundary
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$orderId = $orders->create($cart);
|
||||
$inventory->reserve($cart);
|
||||
$payments->recordIntent($orderId);
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
```
|
||||
|
||||
Don't casually put external, non-rollbackable side effects (an actual charge, an email, a message dispatch) inside a database transaction. Common patterns are an outbox, an idempotency key, or triggering after the transaction commits.
|
||||
|
||||
### Avoid N+1 queries
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ❌ querying inside a loop
|
||||
foreach ($orders as $order) {
|
||||
$customer = $customerRepo->find($order->customerId);
|
||||
render($order, $customer);
|
||||
}
|
||||
|
||||
// ✅ batch-load, then map
|
||||
$customerIds = array_unique(array_map(fn ($o) => $o->customerId, $orders));
|
||||
$customers = $customerRepo->findByIds($customerIds);
|
||||
|
||||
foreach ($orders as $order) {
|
||||
render($order, $customers[$order->customerId] ?? null);
|
||||
}
|
||||
```
|
||||
|
||||
In ORMs like Laravel/Doctrine, check eager loading, join fetch, selected columns, pagination, and indexes.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Catch specific exceptions, keep context
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ❌ swallowing the exception leaves callers unable to know it failed
|
||||
try {
|
||||
$mailer->send($message);
|
||||
} catch (Exception $e) {
|
||||
}
|
||||
|
||||
// ✅ catch a specific exception, keep context, and rethrow
|
||||
try {
|
||||
$mailer->send($message);
|
||||
} catch (TransportException $e) {
|
||||
throw new NotificationFailed($userId, previous: $e);
|
||||
}
|
||||
```
|
||||
|
||||
Empty `catch` blocks, `error_log()`-and-continue without surfacing the error, and turning every exception into `RuntimeException('failed')` in production code all deserve a question.
|
||||
|
||||
### Don't suppress errors with @
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ❌ hides the real error and makes debugging hard
|
||||
$content = @file_get_contents($path);
|
||||
|
||||
// ✅ handle failure explicitly
|
||||
$content = file_get_contents($path);
|
||||
if ($content === false) {
|
||||
throw new RuntimeException("Unable to read file: {$path}");
|
||||
}
|
||||
```
|
||||
|
||||
`@` is common around file, network, array access, and legacy library calls. Push for an explicit branch, or convert third-party errors into project exceptions.
|
||||
|
||||
### Don't leak sensitive data in logs
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ❌ writing tokens, passwords, or the full request body to the log
|
||||
$logger->error('Login failed', ['request' => $_POST]);
|
||||
|
||||
// ✅ log non-sensitive context that still helps locate the problem
|
||||
$logger->warning('Login failed', [
|
||||
'email_hash' => hash('sha256', strtolower($email)),
|
||||
'ip' => $requestIp,
|
||||
]);
|
||||
```
|
||||
|
||||
Check logs, exception messages, the debug toolbar, error pages, and failed-queue records. Sensitive data includes passwords, tokens, sessions, PII, payment data, and full cookies.
|
||||
|
||||
---
|
||||
|
||||
## Composer & Dependencies
|
||||
|
||||
### Lock reproducible dependencies
|
||||
|
||||
```json
|
||||
{
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"monolog/monolog": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.0",
|
||||
"phpstan/phpstan": "^1.10"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When reviewing `composer.json` / `composer.lock`, watch for:
|
||||
|
||||
- Application repos commit `composer.lock`; library repos usually don't
|
||||
- `require-dev` shouldn't make it into the production image
|
||||
- The PHP platform version matches the CI version
|
||||
- Autoload rules aren't too broad (don't load test or script directories)
|
||||
- `scripts` commands don't depend on a developer's local secret config
|
||||
|
||||
### Dependency security and maintenance
|
||||
|
||||
```bash
|
||||
composer audit
|
||||
composer outdated --direct
|
||||
composer validate --strict
|
||||
```
|
||||
|
||||
When adding a package, look at its maintenance status — download count isn't the only signal. What matters is its security history, release cadence, minimal dependency footprint, and whether it duplicates the standard library or a framework built-in.
|
||||
|
||||
---
|
||||
|
||||
## Performance & Resource Management
|
||||
|
||||
### Stream large datasets with generators or pagination
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ❌ loading every record at once
|
||||
$rows = $repo->all();
|
||||
foreach ($rows as $row) {
|
||||
exportRow($row);
|
||||
}
|
||||
|
||||
// ✅ paginate or use a generator to avoid a memory spike
|
||||
foreach ($repo->cursor() as $row) {
|
||||
exportRow($row);
|
||||
}
|
||||
```
|
||||
|
||||
A PHP request lifecycle is short, but CLI jobs, queue workers, and export tasks run for a long time. For that kind of code, watch memory growth, unclosed resources, and global-state pollution especially closely.
|
||||
|
||||
### Avoid expensive work inside loops
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ❌ re-parsing config or opening a connection on every iteration
|
||||
foreach ($items as $item) {
|
||||
$client = new ApiClient($_ENV['API_KEY']);
|
||||
$client->send($item);
|
||||
}
|
||||
|
||||
// ✅ create reusable dependencies outside the loop
|
||||
$client = new ApiClient($_ENV['API_KEY']);
|
||||
foreach ($items as $item) {
|
||||
$client->send($item);
|
||||
}
|
||||
```
|
||||
|
||||
Watch for database queries, HTTP requests, regex compilation, large array copies, accumulating `array_merge()` appends, and repeatedly reading env vars or config files inside loops.
|
||||
|
||||
### Release or scope resources
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ✅ close file handles after use
|
||||
$handle = fopen($path, 'rb');
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('Unable to open file');
|
||||
}
|
||||
|
||||
try {
|
||||
while (($line = fgets($handle)) !== false) {
|
||||
process($line);
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
```
|
||||
|
||||
PDO connections are usually managed by the container, but file handles, curl handles, temp files, locks, and cached objects in queue workers still need an explicit lifecycle.
|
||||
|
||||
---
|
||||
|
||||
## Testing & Static Analysis
|
||||
|
||||
### Test behavior, not implementation details
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ❌ asserting an internal method call makes refactoring expensive
|
||||
$mailer->expects($this->once())->method('buildTemplate');
|
||||
|
||||
// ✅ assert observable results
|
||||
$service->sendWelcomeEmail($user);
|
||||
|
||||
$this->assertTrue($mailbox->hasMessageFor($user->email));
|
||||
```
|
||||
|
||||
For business services, controllers, and queue jobs, prefer covering observable behavior: inputs/outputs, database state, published events, and dispatched messages.
|
||||
|
||||
### Static analysis and formatting
|
||||
|
||||
```bash
|
||||
vendor/bin/phpunit
|
||||
vendor/bin/phpstan analyse
|
||||
vendor/bin/psalm
|
||||
vendor/bin/php-cs-fixer fix --dry-run --diff
|
||||
vendor/bin/rector process --dry-run
|
||||
```
|
||||
|
||||
When reviewing a PR, check whether the new code lowers the PHPStan/Psalm level, leans heavily on baseline ignores, or uses `@phpstan-ignore-next-line` to paper over a real type problem.
|
||||
|
||||
### Isolate test data
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// ❌ the test depends on real time and external services
|
||||
$service->expireOldSessions();
|
||||
|
||||
// ✅ inject a clock and a fake gateway
|
||||
$clock->setNow(new DateTimeImmutable('2026-01-01T00:00:00Z'));
|
||||
$service->expireOldSessions();
|
||||
```
|
||||
|
||||
Watch for database transaction rollback, fixture cleanup, randomness, time, queues, caches, and external APIs. Slow PHP tests are usually not a language problem — it's that the boundaries aren't isolated.
|
||||
|
||||
---
|
||||
|
||||
## Review Checklist
|
||||
|
||||
### Types & modeling
|
||||
|
||||
- [ ] `declare(strict_types=1);` at the top of the file
|
||||
- [ ] Parameters, return values, and properties have explicit types
|
||||
- [ ] `===` / `!==` used; collection lookups use strict mode
|
||||
- [ ] Stable state sets use an enum, not magic strings
|
||||
- [ ] New code doesn't rely on dynamic properties
|
||||
- [ ] Value objects are readonly or otherwise immutable
|
||||
|
||||
### Security
|
||||
|
||||
- [ ] Input is validated and type-coerced at the boundary
|
||||
- [ ] Output is escaped per HTML/URL/JS/CSS context
|
||||
- [ ] SQL uses prepared statements or ORM binding
|
||||
- [ ] Dynamic table/column/sort names go through a whitelist
|
||||
- [ ] Passwords use `password_hash()` / `password_verify()`
|
||||
- [ ] Tokens, codes, and filenames use `random_bytes()` / `random_int()`
|
||||
- [ ] Untrusted input never reaches `unserialize()`
|
||||
- [ ] File uploads check the error code, size, MIME, extension, and storage directory
|
||||
- [ ] No injection or leakage risk in shell commands, path building, or log output
|
||||
|
||||
### Data & transactions
|
||||
|
||||
- [ ] Multi-step writes have a transaction or compensation mechanism
|
||||
- [ ] External side effects are designed to be idempotent
|
||||
- [ ] N+1 queries avoided
|
||||
- [ ] Pagination, indexes, and selected columns are reasonable
|
||||
- [ ] Database errors aren't swallowed
|
||||
|
||||
### Maintainability
|
||||
|
||||
- [ ] Constructors don't do heavy I/O
|
||||
- [ ] Dependency injection is clear; no hidden global state
|
||||
- [ ] No `@` error suppression
|
||||
- [ ] Exceptions preserve context and `previous`
|
||||
- [ ] Composer dependency ranges, autoload, and scripts are reasonable
|
||||
- [ ] Application repos commit `composer.lock`
|
||||
|
||||
### Testing & tooling
|
||||
|
||||
- [ ] PHPUnit/Pest cover the critical and failure paths
|
||||
- [ ] PHPStan/Psalm config doesn't lower strictness
|
||||
- [ ] New ignores/baselines are explained
|
||||
- [ ] Formatting tools and CI commands are reproducible
|
||||
- [ ] Tests isolate time, randomness, the database, queues, and external APIs
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [PHP Manual: Type declarations](https://www.php.net/manual/en/language.types.declarations.php)
|
||||
- [PHP Manual: match](https://www.php.net/match)
|
||||
- [PHP Manual: Enumerations](https://www.php.net/manual/en/language.enumerations.overview.php)
|
||||
- [PHP Manual: Properties](https://www.php.net/manual/en/language.oop5.properties.php)
|
||||
- [PHP Manual: PDO](https://www.php.net/manual/en/class.pdo.php)
|
||||
- [PHP Manual: password_hash](https://www.php.net/manual/en/function.password-hash.php)
|
||||
- [PHP Manual: random_bytes](https://www.php.net/manual/en/function.random-bytes.php)
|
||||
- [Composer documentation](https://getcomposer.org/doc/)
|
||||
- [PHPUnit documentation](https://docs.phpunit.de/)
|
||||
- [PHPStan documentation](https://phpstan.org/user-guide/getting-started)
|
||||
- [Psalm documentation](https://psalm.dev/docs/)
|
||||
+1069
File diff suppressed because it is too large
Load Diff
+186
@@ -0,0 +1,186 @@
|
||||
# Qt Code Review Guide
|
||||
|
||||
> Code review guidelines focusing on object model, signals/slots, event loop, and GUI performance. Examples based on Qt 5.15 / Qt 6.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Object Model & Memory Management](#object-model--memory-management)
|
||||
- [Signals & Slots](#signals--slots)
|
||||
- [Containers & Strings](#containers--strings)
|
||||
- [Threads & Concurrency](#threads--concurrency)
|
||||
- [GUI & Widgets](#gui--widgets)
|
||||
- [Meta-Object System](#meta-object-system)
|
||||
- [Review Checklist](#review-checklist)
|
||||
|
||||
---
|
||||
|
||||
## Object Model & Memory Management
|
||||
|
||||
### Use Parent-Child Ownership Mechanism
|
||||
Qt's `QObject` hierarchy automatically manages memory. For `QObject`, prefer setting a parent object over manual `delete` or smart pointers.
|
||||
|
||||
```cpp
|
||||
// ❌ Manual management prone to memory leaks
|
||||
QWidget* w = new QWidget();
|
||||
QLabel* l = new QLabel();
|
||||
l->setParent(w);
|
||||
// ... If w is deleted, l is automatically deleted. But if w leaks, l also leaks.
|
||||
|
||||
// ✅ Specify parent in constructor
|
||||
QWidget* w = new QWidget(this); // Owned by 'this'
|
||||
QLabel* l = new QLabel(w); // Owned by 'w'
|
||||
```
|
||||
|
||||
### Use Smart Pointers with QObject
|
||||
If a `QObject` has no parent, use `QScopedPointer` or `std::unique_ptr` with a custom deleter (use `deleteLater` if cross-thread). Avoid `std::shared_ptr` for `QObject` unless necessary, as it confuses the parent-child ownership system.
|
||||
|
||||
```cpp
|
||||
// ✅ Scoped pointer for local/member QObject without parent
|
||||
QScopedPointer<MyObject> obj(new MyObject());
|
||||
|
||||
// ✅ Safe pointer to prevent dangling pointers
|
||||
QPointer<MyObject> safePtr = obj.data();
|
||||
if (safePtr) {
|
||||
safePtr->doSomething();
|
||||
}
|
||||
```
|
||||
|
||||
### Use `deleteLater()`
|
||||
For asynchronous deletion, especially in slots or event handlers, use `deleteLater()` instead of `delete` to ensure pending events in the event loop are processed.
|
||||
|
||||
---
|
||||
|
||||
## Signals & Slots
|
||||
|
||||
### Prefer Function Pointer Syntax
|
||||
Use compile-time checked syntax (Qt 5+).
|
||||
|
||||
```cpp
|
||||
// ❌ String-based (runtime check only, slower)
|
||||
connect(sender, SIGNAL(valueChanged(int)), receiver, SLOT(updateValue(int)));
|
||||
|
||||
// ✅ Compile-time check
|
||||
connect(sender, &Sender::valueChanged, receiver, &Receiver::updateValue);
|
||||
```
|
||||
|
||||
### Connection Types
|
||||
Be explicit or aware of connection types when crossing threads.
|
||||
- `Qt::AutoConnection` (Default): Direct if same thread, Queued if different thread.
|
||||
- `Qt::QueuedConnection`: Always posts event (thread-safe across threads).
|
||||
- `Qt::DirectConnection`: Immediate call (dangerous if accessing non-thread-safe data across threads).
|
||||
|
||||
### Avoid Loops
|
||||
Check logic that might cause infinite signal loops (e.g., `valueChanged` -> `setValue` -> `valueChanged`). Block signals or check for equality before setting values.
|
||||
|
||||
```cpp
|
||||
void MyClass::setValue(int v) {
|
||||
if (m_value == v) return; // ✅ Good: Break loop
|
||||
m_value = v;
|
||||
emit valueChanged(v);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Containers & Strings
|
||||
|
||||
### QString Efficiency
|
||||
- Use `QStringLiteral("...")` for compile-time string creation to avoid runtime allocation.
|
||||
- Use `QLatin1String` for comparison with ASCII literals (in Qt 5).
|
||||
- Prefer `arg()` for formatting (or `QStringBuilder`'s `%` operator).
|
||||
|
||||
```cpp
|
||||
// ❌ Runtime conversion
|
||||
if (str == "test") ...
|
||||
|
||||
// ✅ Prefer QLatin1String for comparison with ASCII literals (in Qt 5)
|
||||
if (str == QLatin1String("test")) ... // Qt 5
|
||||
if (str == u"test"_s) ... // Qt 6
|
||||
```
|
||||
|
||||
### Container Selection
|
||||
- **Qt 6**: `QList` is now the default choice (unified with `QVector`).
|
||||
- **Qt 5**: Prefer `QVector` over `QList` for contiguous memory and cache performance, unless stable references are needed.
|
||||
- Be aware of Implicit Sharing (Copy-on-Write). Passing containers by value is cheap *until* modified. Use `const &` for read-only access.
|
||||
|
||||
```cpp
|
||||
// ❌ Forces deep copy if function modifies 'list'
|
||||
void process(QVector<int> list) {
|
||||
list[0] = 1;
|
||||
}
|
||||
|
||||
// ✅ Read-only reference
|
||||
void process(const QVector<int>& list) { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Threads & Concurrency
|
||||
|
||||
### Subclassing QThread vs Worker Object
|
||||
Prefer the "Worker Object" pattern over subclassing `QThread` implementation details.
|
||||
|
||||
```cpp
|
||||
// ❌ Business logic inside QThread::run()
|
||||
class MyThread : public QThread {
|
||||
void run() override { ... }
|
||||
};
|
||||
|
||||
// ✅ Worker object moved to thread
|
||||
QThread* thread = new QThread;
|
||||
Worker* worker = new Worker;
|
||||
worker->moveToThread(thread);
|
||||
connect(thread, &QThread::started, worker, &Worker::process);
|
||||
thread->start();
|
||||
```
|
||||
|
||||
### GUI Thread Safety
|
||||
**NEVER** access UI widgets (`QWidget` and subclasses) from a background thread. Use signals/slots to communicate updates to the main thread.
|
||||
|
||||
---
|
||||
|
||||
## GUI & Widgets
|
||||
|
||||
### Logic Separation
|
||||
Keep business logic out of UI classes (`MainWindow`, `Dialog`). UI classes should only handle display and user input forwarding.
|
||||
|
||||
### Layouts
|
||||
Avoid fixed sizes (`setGeometry`, `resize`). Use layouts (`QVBoxLayout`, `QGridLayout`) to handle different DPIs and window resizing gracefully.
|
||||
|
||||
### Blocking Event Loop
|
||||
Never execute long-running operations on the main thread (freezes GUI).
|
||||
- **Bad**: `Sleep()`, `while(busy)`, synchronous network calls.
|
||||
- **Good**: `QProcess`, `QThread`, `QtConcurrent`, or asynchronous APIs (`QNetworkAccessManager`).
|
||||
|
||||
---
|
||||
|
||||
## Meta-Object System
|
||||
|
||||
### Properties & Enums
|
||||
Use `Q_PROPERTY` for values exposed to QML or needing introspection.
|
||||
Use `Q_ENUM` to enable string conversion for enums.
|
||||
|
||||
```cpp
|
||||
class MyObject : public QObject {
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(int value READ value WRITE setValue NOTIFY valueChanged)
|
||||
public:
|
||||
enum State { Idle, Running };
|
||||
Q_ENUM(State)
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### qobject_cast
|
||||
Use `qobject_cast<T*>` for QObjects instead of `dynamic_cast`. It is faster and doesn't require RTTI.
|
||||
|
||||
---
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- [ ] **Memory**: Is parent-child relationship correct? Are dangling pointers avoided (using `QPointer`)?
|
||||
- [ ] **Signals**: Are connections checked? Do lambdas use safe captures (context object)?
|
||||
- [ ] **Threads**: Is UI accessed only from main thread? Are long tasks offloaded?
|
||||
- [ ] **Strings**: Are `QStringLiteral` or `tr()` used appropriately?
|
||||
- [ ] **Style**: Naming conventions (camelCase for methods, PascalCase for classes).
|
||||
- [ ] **Resources**: Are resources (images, styles) loaded from `.qrc`?
|
||||
+871
@@ -0,0 +1,871 @@
|
||||
# React Code Review Guide
|
||||
|
||||
React 审查重点:Hooks 规则、性能优化的适度性、组件设计、以及现代 React 19/RSC 模式。
|
||||
|
||||
## 目录
|
||||
|
||||
- [基础 Hooks 规则](#基础-hooks-规则)
|
||||
- [useEffect 模式](#useeffect-模式)
|
||||
- [useMemo / useCallback](#usememo--usecallback)
|
||||
- [组件设计](#组件设计)
|
||||
- [Error Boundaries & Suspense](#error-boundaries--suspense)
|
||||
- [Server Components (RSC)](#server-components-rsc)
|
||||
- [React 19 Actions & Forms](#react-19-actions--forms)
|
||||
- [Suspense & Streaming SSR](#suspense--streaming-ssr)
|
||||
- [TanStack Query v5](#tanstack-query-v5)
|
||||
- [Review Checklists](#review-checklists)
|
||||
|
||||
---
|
||||
|
||||
## 基础 Hooks 规则
|
||||
|
||||
```tsx
|
||||
// ❌ 条件调用 Hooks — 违反 Hooks 规则
|
||||
function BadComponent({ isLoggedIn }) {
|
||||
if (isLoggedIn) {
|
||||
const [user, setUser] = useState(null); // Error!
|
||||
}
|
||||
return <div>...</div>;
|
||||
}
|
||||
|
||||
// ✅ Hooks 必须在组件顶层调用
|
||||
function GoodComponent({ isLoggedIn }) {
|
||||
const [user, setUser] = useState(null);
|
||||
if (!isLoggedIn) return <LoginPrompt />;
|
||||
return <div>{user?.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## useEffect 模式
|
||||
|
||||
```tsx
|
||||
// ❌ 依赖数组缺失或不完整
|
||||
function BadEffect({ userId }) {
|
||||
const [user, setUser] = useState(null);
|
||||
useEffect(() => {
|
||||
fetchUser(userId).then(setUser);
|
||||
}, []); // 缺少 userId 依赖!
|
||||
}
|
||||
|
||||
// ✅ 完整的依赖数组
|
||||
function GoodEffect({ userId }) {
|
||||
const [user, setUser] = useState(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchUser(userId).then(data => {
|
||||
if (!cancelled) setUser(data);
|
||||
});
|
||||
return () => { cancelled = true; }; // 清理函数
|
||||
}, [userId]);
|
||||
}
|
||||
|
||||
// ❌ useEffect 用于派生状态(反模式)
|
||||
function BadDerived({ items }) {
|
||||
const [filteredItems, setFilteredItems] = useState([]);
|
||||
useEffect(() => {
|
||||
setFilteredItems(items.filter(i => i.active));
|
||||
}, [items]); // 不必要的 effect + 额外渲染
|
||||
return <List items={filteredItems} />;
|
||||
}
|
||||
|
||||
// ✅ 直接在渲染时计算,或用 useMemo
|
||||
function GoodDerived({ items }) {
|
||||
const filteredItems = useMemo(
|
||||
() => items.filter(i => i.active),
|
||||
[items]
|
||||
);
|
||||
return <List items={filteredItems} />;
|
||||
}
|
||||
|
||||
// ❌ useEffect 用于事件响应
|
||||
function BadEventEffect() {
|
||||
const [query, setQuery] = useState('');
|
||||
useEffect(() => {
|
||||
if (query) {
|
||||
analytics.track('search', { query }); // 应该在事件处理器中
|
||||
}
|
||||
}, [query]);
|
||||
}
|
||||
|
||||
// ✅ 在事件处理器中执行副作用
|
||||
function GoodEvent() {
|
||||
const [query, setQuery] = useState('');
|
||||
const handleSearch = (q: string) => {
|
||||
setQuery(q);
|
||||
analytics.track('search', { query: q });
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## useMemo / useCallback
|
||||
|
||||
```tsx
|
||||
// ❌ 过度优化 — 常量不需要 useMemo
|
||||
function OverOptimized() {
|
||||
const config = useMemo(() => ({ timeout: 5000 }), []); // 无意义
|
||||
const handleClick = useCallback(() => {
|
||||
console.log('clicked');
|
||||
}, []); // 如果不传给 memo 组件,无意义
|
||||
}
|
||||
|
||||
// ✅ 只在需要时优化
|
||||
function ProperlyOptimized() {
|
||||
const config = { timeout: 5000 }; // 简单对象直接定义
|
||||
const handleClick = () => console.log('clicked');
|
||||
}
|
||||
|
||||
// ❌ useCallback 依赖总是变化
|
||||
function BadCallback({ data }) {
|
||||
// data 每次渲染都是新对象,useCallback 无效
|
||||
const process = useCallback(() => {
|
||||
return data.map(transform);
|
||||
}, [data]);
|
||||
}
|
||||
|
||||
// ✅ useMemo + useCallback 配合 React.memo 使用
|
||||
const MemoizedChild = React.memo(function Child({ onClick, items }) {
|
||||
return <div onClick={onClick}>{items.length}</div>;
|
||||
});
|
||||
|
||||
function Parent({ rawItems }) {
|
||||
const items = useMemo(() => processItems(rawItems), [rawItems]);
|
||||
const handleClick = useCallback(() => {
|
||||
console.log(items.length);
|
||||
}, [items]);
|
||||
return <MemoizedChild onClick={handleClick} items={items} />;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 组件设计
|
||||
|
||||
```tsx
|
||||
// ❌ 在组件内定义组件 — 每次渲染都创建新组件
|
||||
function BadParent() {
|
||||
function ChildComponent() { // 每次渲染都是新函数!
|
||||
return <div>child</div>;
|
||||
}
|
||||
return <ChildComponent />;
|
||||
}
|
||||
|
||||
// ✅ 组件定义在外部
|
||||
function ChildComponent() {
|
||||
return <div>child</div>;
|
||||
}
|
||||
function GoodParent() {
|
||||
return <ChildComponent />;
|
||||
}
|
||||
|
||||
// ❌ Props 总是新对象引用
|
||||
function BadProps() {
|
||||
return (
|
||||
<MemoizedComponent
|
||||
style={{ color: 'red' }} // 每次渲染新对象
|
||||
onClick={() => {}} // 每次渲染新函数
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ 稳定的引用
|
||||
const style = { color: 'red' };
|
||||
function GoodProps() {
|
||||
const handleClick = useCallback(() => {}, []);
|
||||
return <MemoizedComponent style={style} onClick={handleClick} />;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Boundaries & Suspense
|
||||
|
||||
```tsx
|
||||
// ❌ 没有错误边界
|
||||
function BadApp() {
|
||||
return (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<DataComponent /> {/* 错误会导致整个应用崩溃 */}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ Error Boundary 包裹 Suspense
|
||||
function GoodApp() {
|
||||
return (
|
||||
<ErrorBoundary fallback={<ErrorUI />}>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<DataComponent />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server Components (RSC)
|
||||
|
||||
```tsx
|
||||
// ❌ 在 Server Component 中使用客户端特性
|
||||
// app/page.tsx (Server Component by default)
|
||||
function BadServerComponent() {
|
||||
const [count, setCount] = useState(0); // Error! No hooks in RSC
|
||||
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
|
||||
}
|
||||
|
||||
// ✅ 交互逻辑提取到 Client Component
|
||||
// app/counter.tsx
|
||||
'use client';
|
||||
function Counter() {
|
||||
const [count, setCount] = useState(0);
|
||||
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
|
||||
}
|
||||
|
||||
// app/page.tsx (Server Component)
|
||||
async function GoodServerComponent() {
|
||||
const data = await fetchData(); // 可以直接 await
|
||||
return (
|
||||
<div>
|
||||
<h1>{data.title}</h1>
|
||||
<Counter /> {/* 客户端组件 */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ❌ 'use client' 放置不当 — 整个树都变成客户端
|
||||
// layout.tsx
|
||||
'use client'; // 这会让所有子组件都成为客户端组件
|
||||
export default function Layout({ children }) { ... }
|
||||
|
||||
// ✅ 只在需要交互的组件使用 'use client'
|
||||
// 将客户端逻辑隔离到叶子组件
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## React 19 Actions & Forms
|
||||
|
||||
React 19 引入了 Actions 系统和新的表单处理 Hooks,简化异步操作和乐观更新。
|
||||
|
||||
### useActionState
|
||||
|
||||
```tsx
|
||||
// ❌ 传统方式:多个状态变量
|
||||
function OldForm() {
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await submitForm(formData);
|
||||
setData(result);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ✅ React 19: useActionState 统一管理
|
||||
import { useActionState } from 'react';
|
||||
|
||||
function NewForm() {
|
||||
const [state, formAction, isPending] = useActionState(
|
||||
async (prevState, formData: FormData) => {
|
||||
try {
|
||||
const result = await submitForm(formData);
|
||||
return { success: true, data: result };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
},
|
||||
{ success: false, data: null, error: null }
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction}>
|
||||
<input name="email" />
|
||||
<button disabled={isPending}>
|
||||
{isPending ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
{state.error && <p className="error">{state.error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### useFormStatus
|
||||
|
||||
```tsx
|
||||
// ❌ Props 透传表单状态
|
||||
function BadSubmitButton({ isSubmitting }) {
|
||||
return <button disabled={isSubmitting}>Submit</button>;
|
||||
}
|
||||
|
||||
// ✅ useFormStatus 访问父 <form> 状态(无需 props)
|
||||
import { useFormStatus } from 'react-dom';
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending, data, method, action } = useFormStatus();
|
||||
// 注意:必须在 <form> 内部的子组件中使用
|
||||
return (
|
||||
<button disabled={pending}>
|
||||
{pending ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ❌ useFormStatus 在 form 同级组件中调用——不工作
|
||||
function BadForm() {
|
||||
const { pending } = useFormStatus(); // 这里无法获取状态!
|
||||
return (
|
||||
<form action={action}>
|
||||
<button disabled={pending}>Submit</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ useFormStatus 必须在 form 的子组件中
|
||||
function GoodForm() {
|
||||
return (
|
||||
<form action={action}>
|
||||
<SubmitButton /> {/* useFormStatus 在这里面调用 */}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### useOptimistic
|
||||
|
||||
```tsx
|
||||
// ❌ 等待服务器响应再更新 UI
|
||||
function SlowLike({ postId, likes }) {
|
||||
const [likeCount, setLikeCount] = useState(likes);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
const handleLike = async () => {
|
||||
setIsPending(true);
|
||||
const newCount = await likePost(postId); // 等待...
|
||||
setLikeCount(newCount);
|
||||
setIsPending(false);
|
||||
};
|
||||
}
|
||||
|
||||
// ✅ useOptimistic 即时反馈,失败自动回滚
|
||||
import { useOptimistic } from 'react';
|
||||
|
||||
function FastLike({ postId, likes }) {
|
||||
const [optimisticLikes, addOptimisticLike] = useOptimistic(
|
||||
likes,
|
||||
(currentLikes, increment: number) => currentLikes + increment
|
||||
);
|
||||
|
||||
const handleLike = async () => {
|
||||
addOptimisticLike(1); // 立即更新 UI
|
||||
try {
|
||||
await likePost(postId); // 后台同步
|
||||
} catch {
|
||||
// React 自动回滚到 likes 原值
|
||||
}
|
||||
};
|
||||
|
||||
return <button onClick={handleLike}>{optimisticLikes} likes</button>;
|
||||
}
|
||||
```
|
||||
|
||||
### Server Actions (Next.js 15+)
|
||||
|
||||
```tsx
|
||||
// ❌ 客户端调用 API
|
||||
'use client';
|
||||
function ClientForm() {
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
const res = await fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
// ...
|
||||
};
|
||||
}
|
||||
|
||||
// ✅ Server Action + useActionState
|
||||
// actions.ts
|
||||
'use server';
|
||||
export async function createPost(prevState: any, formData: FormData) {
|
||||
const title = formData.get('title');
|
||||
await db.posts.create({ title });
|
||||
revalidatePath('/posts');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// form.tsx
|
||||
'use client';
|
||||
import { createPost } from './actions';
|
||||
|
||||
function PostForm() {
|
||||
const [state, formAction, isPending] = useActionState(createPost, null);
|
||||
return (
|
||||
<form action={formAction}>
|
||||
<input name="title" />
|
||||
<SubmitButton />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Suspense & Streaming SSR
|
||||
|
||||
Suspense 和 Streaming 是 React 18+ 的核心特性,在 2025 年的 Next.js 15 等框架中广泛使用。
|
||||
|
||||
### 基础 Suspense
|
||||
|
||||
```tsx
|
||||
// ❌ 传统加载状态管理
|
||||
function OldComponent() {
|
||||
const [data, setData] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData().then(setData).finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
return <DataView data={data} />;
|
||||
}
|
||||
|
||||
// ✅ Suspense 声明式加载状态
|
||||
function NewComponent() {
|
||||
return (
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<DataView /> {/* 内部使用 use() 或支持 Suspense 的数据获取 */}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 多个独立 Suspense 边界
|
||||
|
||||
```tsx
|
||||
// ❌ 单一边界——所有内容一起加载
|
||||
function BadLayout() {
|
||||
return (
|
||||
<Suspense fallback={<FullPageSpinner />}>
|
||||
<Header />
|
||||
<MainContent /> {/* 慢 */}
|
||||
<Sidebar /> {/* 快 */}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ 独立边界——各部分独立流式传输
|
||||
function GoodLayout() {
|
||||
return (
|
||||
<>
|
||||
<Header /> {/* 立即显示 */}
|
||||
<div className="flex">
|
||||
<Suspense fallback={<ContentSkeleton />}>
|
||||
<MainContent /> {/* 独立加载 */}
|
||||
</Suspense>
|
||||
<Suspense fallback={<SidebarSkeleton />}>
|
||||
<Sidebar /> {/* 独立加载 */}
|
||||
</Suspense>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Next.js 15 Streaming
|
||||
|
||||
```tsx
|
||||
// app/page.tsx - 自动 Streaming
|
||||
export default async function Page() {
|
||||
// 这个 await 不会阻塞整个页面
|
||||
const data = await fetchSlowData();
|
||||
return <div>{data}</div>;
|
||||
}
|
||||
|
||||
// app/loading.tsx - 自动 Suspense 边界
|
||||
export default function Loading() {
|
||||
return <Skeleton />;
|
||||
}
|
||||
```
|
||||
|
||||
### use() Hook (React 19)
|
||||
|
||||
```tsx
|
||||
// ✅ 在组件中读取 Promise
|
||||
import { use } from 'react';
|
||||
|
||||
function Comments({ commentsPromise }) {
|
||||
const comments = use(commentsPromise); // 自动触发 Suspense
|
||||
return (
|
||||
<ul>
|
||||
{comments.map(c => <li key={c.id}>{c.text}</li>)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
// 父组件创建 Promise,子组件消费
|
||||
function Post({ postId }) {
|
||||
const commentsPromise = fetchComments(postId); // 不 await
|
||||
return (
|
||||
<article>
|
||||
<PostContent id={postId} />
|
||||
<Suspense fallback={<CommentsSkeleton />}>
|
||||
<Comments commentsPromise={commentsPromise} />
|
||||
</Suspense>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TanStack Query v5
|
||||
|
||||
TanStack Query 是 React 生态中最流行的数据获取库,v5 是当前稳定版本。
|
||||
|
||||
### 基础配置
|
||||
|
||||
```tsx
|
||||
// ❌ 不正确的默认配置
|
||||
const queryClient = new QueryClient(); // 默认配置可能不适合
|
||||
|
||||
// ✅ 生产环境推荐配置
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 分钟内数据视为新鲜
|
||||
gcTime: 1000 * 60 * 30, // 30 分钟后垃圾回收(v5 重命名)
|
||||
retry: 3,
|
||||
refetchOnWindowFocus: false, // 根据需求决定
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### queryOptions (v5 新增)
|
||||
|
||||
```tsx
|
||||
// ❌ 重复定义 queryKey 和 queryFn
|
||||
function Component1() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['users', userId],
|
||||
queryFn: () => fetchUser(userId),
|
||||
});
|
||||
}
|
||||
|
||||
function prefetchUser(queryClient, userId) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['users', userId], // 重复!
|
||||
queryFn: () => fetchUser(userId), // 重复!
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ queryOptions 统一定义,类型安全
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
|
||||
const userQueryOptions = (userId: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['users', userId],
|
||||
queryFn: () => fetchUser(userId),
|
||||
});
|
||||
|
||||
function Component1({ userId }) {
|
||||
const { data } = useQuery(userQueryOptions(userId));
|
||||
}
|
||||
|
||||
function prefetchUser(queryClient, userId) {
|
||||
queryClient.prefetchQuery(userQueryOptions(userId));
|
||||
}
|
||||
|
||||
// getQueryData 也是类型安全的
|
||||
const user = queryClient.getQueryData(userQueryOptions(userId).queryKey);
|
||||
```
|
||||
|
||||
### 常见陷阱
|
||||
|
||||
```tsx
|
||||
// ❌ staleTime 为 0 导致过度请求
|
||||
useQuery({
|
||||
queryKey: ['data'],
|
||||
queryFn: fetchData,
|
||||
// staleTime 默认为 0,每次组件挂载都会 refetch
|
||||
});
|
||||
|
||||
// ✅ 设置合理的 staleTime
|
||||
useQuery({
|
||||
queryKey: ['data'],
|
||||
queryFn: fetchData,
|
||||
staleTime: 1000 * 60, // 1 分钟内不会重新请求
|
||||
});
|
||||
|
||||
// ❌ 在 queryFn 中使用不稳定的引用
|
||||
function BadQuery({ filters }) {
|
||||
useQuery({
|
||||
queryKey: ['items'], // queryKey 没有包含 filters!
|
||||
queryFn: () => fetchItems(filters), // filters 变化不会触发重新请求
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ queryKey 包含所有影响数据的参数
|
||||
function GoodQuery({ filters }) {
|
||||
useQuery({
|
||||
queryKey: ['items', filters], // filters 是 queryKey 的一部分
|
||||
queryFn: () => fetchItems(filters),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### useSuspenseQuery
|
||||
|
||||
> **重要限制**:useSuspenseQuery 与 useQuery 有显著差异,选择前需了解其限制。
|
||||
|
||||
#### useSuspenseQuery 的限制
|
||||
|
||||
| 特性 | useQuery | useSuspenseQuery |
|
||||
|------|----------|------------------|
|
||||
| `enabled` 选项 | ✅ 支持 | ❌ 不支持 |
|
||||
| `placeholderData` | ✅ 支持 | ❌ 不支持 |
|
||||
| `data` 类型 | `T \| undefined` | `T`(保证有值)|
|
||||
| 错误处理 | `error` 属性 | 抛出到 Error Boundary |
|
||||
| 加载状态 | `isLoading` 属性 | 挂起到 Suspense |
|
||||
|
||||
#### 不支持 enabled 的替代方案
|
||||
|
||||
```tsx
|
||||
// ❌ 使用 useQuery + enabled 实现条件查询
|
||||
function BadSuspenseQuery({ userId }) {
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['user', userId],
|
||||
queryFn: () => fetchUser(userId),
|
||||
enabled: !!userId, // useSuspenseQuery 不支持 enabled!
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ 组件组合实现条件渲染
|
||||
function GoodSuspenseQuery({ userId }) {
|
||||
// useSuspenseQuery 保证 data 是 T 不是 T | undefined
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['user', userId],
|
||||
queryFn: () => fetchUser(userId),
|
||||
});
|
||||
return <UserProfile user={data} />;
|
||||
}
|
||||
|
||||
function Parent({ userId }) {
|
||||
if (!userId) return <NoUserSelected />;
|
||||
return (
|
||||
<Suspense fallback={<UserSkeleton />}>
|
||||
<GoodSuspenseQuery userId={userId} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 错误处理差异
|
||||
|
||||
```tsx
|
||||
// ❌ useSuspenseQuery 没有 error 属性
|
||||
function BadErrorHandling() {
|
||||
const { data, error } = useSuspenseQuery({...});
|
||||
if (error) return <Error />; // error 总是 null!
|
||||
}
|
||||
|
||||
// ✅ 使用 Error Boundary 处理错误
|
||||
function GoodErrorHandling() {
|
||||
return (
|
||||
<ErrorBoundary fallback={<ErrorMessage />}>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<DataComponent />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function DataComponent() {
|
||||
// 错误会抛出到 Error Boundary
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['data'],
|
||||
queryFn: fetchData,
|
||||
});
|
||||
return <Display data={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
#### 何时选择 useSuspenseQuery
|
||||
|
||||
```tsx
|
||||
// ✅ 适合场景:
|
||||
// 1. 数据总是需要的(无条件查询)
|
||||
// 2. 组件必须有数据才能渲染
|
||||
// 3. 使用 React 19 的 Suspense 模式
|
||||
// 4. 服务端组件 + 客户端 hydration
|
||||
|
||||
// ❌ 不适合场景:
|
||||
// 1. 条件查询(根据用户操作触发)
|
||||
// 2. 需要 placeholderData 或初始数据
|
||||
// 3. 需要在组件内处理 loading/error 状态
|
||||
// 4. 多个查询有依赖关系
|
||||
|
||||
// ✅ 多个独立查询用 useSuspenseQueries
|
||||
function MultipleQueries({ userId }) {
|
||||
const [userQuery, postsQuery] = useSuspenseQueries({
|
||||
queries: [
|
||||
{ queryKey: ['user', userId], queryFn: () => fetchUser(userId) },
|
||||
{ queryKey: ['posts', userId], queryFn: () => fetchPosts(userId) },
|
||||
],
|
||||
});
|
||||
// 两个查询并行执行,都完成后组件渲染
|
||||
return <Profile user={userQuery.data} posts={postsQuery.data} />;
|
||||
}
|
||||
```
|
||||
|
||||
### 乐观更新 (v5 简化)
|
||||
|
||||
```tsx
|
||||
// ❌ 手动管理缓存的乐观更新(复杂)
|
||||
const mutation = useMutation({
|
||||
mutationFn: updateTodo,
|
||||
onMutate: async (newTodo) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['todos'] });
|
||||
const previousTodos = queryClient.getQueryData(['todos']);
|
||||
queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
|
||||
return { previousTodos };
|
||||
},
|
||||
onError: (err, newTodo, context) => {
|
||||
queryClient.setQueryData(['todos'], context.previousTodos);
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||
},
|
||||
});
|
||||
|
||||
// ✅ v5 简化:使用 variables 进行乐观 UI
|
||||
function TodoList() {
|
||||
const { data: todos } = useQuery(todosQueryOptions);
|
||||
const { mutate, variables, isPending } = useMutation({
|
||||
mutationFn: addTodo,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{todos?.map(todo => <TodoItem key={todo.id} todo={todo} />)}
|
||||
{/* 乐观显示正在添加的 todo */}
|
||||
{isPending && <TodoItem todo={variables} isOptimistic />}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### v5 状态字段变化
|
||||
|
||||
```tsx
|
||||
// v4: isLoading 表示首次加载或后续获取
|
||||
// v5: isPending 表示没有数据,isLoading = isPending && isFetching
|
||||
|
||||
const { data, isPending, isFetching, isLoading } = useQuery({...});
|
||||
|
||||
// isPending: 缓存中没有数据(首次加载)
|
||||
// isFetching: 正在请求中(包括后台刷新)
|
||||
// isLoading: isPending && isFetching(首次加载中)
|
||||
|
||||
// ❌ v4 代码直接迁移
|
||||
if (isLoading) return <Spinner />; // v5 中行为可能不同
|
||||
|
||||
// ✅ 明确意图
|
||||
if (isPending) return <Spinner />; // 没有数据时显示加载
|
||||
// 或
|
||||
if (isLoading) return <Spinner />; // 首次加载中
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Review Checklists
|
||||
|
||||
### Hooks 规则
|
||||
|
||||
- [ ] Hooks 在组件/自定义 Hook 顶层调用
|
||||
- [ ] 没有条件/循环中调用 Hooks
|
||||
- [ ] useEffect 依赖数组完整
|
||||
- [ ] useEffect 有清理函数(订阅/定时器/请求)
|
||||
- [ ] 没有用 useEffect 计算派生状态
|
||||
|
||||
### 性能优化(适度原则)
|
||||
|
||||
- [ ] useMemo/useCallback 只用于真正需要的场景
|
||||
- [ ] React.memo 配合稳定的 props 引用
|
||||
- [ ] 没有在组件内定义子组件
|
||||
- [ ] 没有在 JSX 中创建新对象/函数(除非传给非 memo 组件)
|
||||
- [ ] 长列表使用虚拟化(react-window/react-virtual)
|
||||
|
||||
### 组件设计
|
||||
|
||||
- [ ] 组件职责单一,不超过 200 行
|
||||
- [ ] 逻辑与展示分离(Custom Hooks)
|
||||
- [ ] Props 接口清晰,使用 TypeScript
|
||||
- [ ] 避免 Props Drilling(考虑 Context 或组合)
|
||||
|
||||
### 状态管理
|
||||
|
||||
- [ ] 状态就近原则(最小必要范围)
|
||||
- [ ] 复杂状态用 useReducer
|
||||
- [ ] 全局状态用 Context 或状态库
|
||||
- [ ] 避免不必要的状态(派生 > 存储)
|
||||
|
||||
### 错误处理
|
||||
|
||||
- [ ] 关键区域有 Error Boundary
|
||||
- [ ] Suspense 配合 Error Boundary 使用
|
||||
- [ ] 异步操作有错误处理
|
||||
|
||||
### Server Components (RSC)
|
||||
|
||||
- [ ] 'use client' 只用于需要交互的组件
|
||||
- [ ] Server Component 不使用 Hooks/事件处理
|
||||
- [ ] 客户端组件尽量放在叶子节点
|
||||
- [ ] 数据获取在 Server Component 中进行
|
||||
|
||||
### React 19 Forms
|
||||
|
||||
- [ ] 使用 useActionState 替代多个 useState
|
||||
- [ ] useFormStatus 在 form 子组件中调用
|
||||
- [ ] useOptimistic 不用于关键业务(支付等)
|
||||
- [ ] Server Action 正确标记 'use server'
|
||||
|
||||
### Suspense & Streaming
|
||||
|
||||
- [ ] 按用户体验需求划分 Suspense 边界
|
||||
- [ ] 每个 Suspense 有对应的 Error Boundary
|
||||
- [ ] 提供有意义的 fallback(骨架屏 > Spinner)
|
||||
- [ ] 避免在 layout 层级 await 慢数据
|
||||
|
||||
### TanStack Query
|
||||
|
||||
- [ ] queryKey 包含所有影响数据的参数
|
||||
- [ ] 设置合理的 staleTime(不是默认 0)
|
||||
- [ ] useSuspenseQuery 不使用 enabled
|
||||
- [ ] Mutation 成功后 invalidate 相关查询
|
||||
- [ ] 理解 isPending vs isLoading 区别
|
||||
|
||||
### 测试
|
||||
|
||||
- [ ] 使用 @testing-library/react
|
||||
- [ ] 用 screen 查询元素
|
||||
- [ ] 用 userEvent 代替 fireEvent
|
||||
- [ ] 优先使用 *ByRole 查询
|
||||
- [ ] 测试行为而非实现细节
|
||||
+842
@@ -0,0 +1,842 @@
|
||||
# Rust Code Review Guide
|
||||
|
||||
> Rust 代码审查指南。编译器能捕获内存安全问题,但审查者需要关注编译器无法检测的问题——业务逻辑、API 设计、性能、取消安全性和可维护性。
|
||||
|
||||
## 目录
|
||||
|
||||
- [所有权与借用](#所有权与借用)
|
||||
- [Unsafe 代码审查](#unsafe-代码审查最关键)
|
||||
- [异步代码](#异步代码)
|
||||
- [取消安全性](#取消安全性)
|
||||
- [spawn vs await](#spawn-vs-await)
|
||||
- [错误处理](#错误处理)
|
||||
- [性能](#性能)
|
||||
- [Trait 设计](#trait-设计)
|
||||
- [Review Checklist](#rust-review-checklist)
|
||||
|
||||
---
|
||||
|
||||
## 所有权与借用
|
||||
|
||||
### 避免不必要的 clone()
|
||||
|
||||
```rust
|
||||
// ❌ clone() 是"Rust 的胶带"——用于绕过借用检查器
|
||||
fn bad_process(data: &Data) -> Result<()> {
|
||||
let owned = data.clone(); // 为什么需要 clone?
|
||||
expensive_operation(owned)
|
||||
}
|
||||
|
||||
// ✅ 审查时问:clone 是否必要?能否用借用?
|
||||
fn good_process(data: &Data) -> Result<()> {
|
||||
expensive_operation(data) // 传递引用
|
||||
}
|
||||
|
||||
// ✅ 如果确实需要 clone,添加注释说明原因
|
||||
fn justified_clone(data: &Data) -> Result<()> {
|
||||
// Clone needed: data will be moved to spawned task
|
||||
let owned = data.clone();
|
||||
tokio::spawn(async move {
|
||||
process(owned).await
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Arc<Mutex<T>> 的使用
|
||||
|
||||
```rust
|
||||
// ❌ Arc<Mutex<T>> 可能隐藏不必要的共享状态
|
||||
struct BadService {
|
||||
cache: Arc<Mutex<HashMap<String, Data>>>, // 真的需要共享?
|
||||
}
|
||||
|
||||
// ✅ 考虑是否需要共享,或者设计可以避免
|
||||
struct GoodService {
|
||||
cache: HashMap<String, Data>, // 单一所有者
|
||||
}
|
||||
|
||||
// ✅ 如果确实需要并发访问,考虑更好的数据结构
|
||||
use dashmap::DashMap;
|
||||
|
||||
struct ConcurrentService {
|
||||
cache: DashMap<String, Data>, // 更细粒度的锁
|
||||
}
|
||||
```
|
||||
|
||||
### Cow (Copy-on-Write) 模式
|
||||
|
||||
```rust
|
||||
use std::borrow::Cow;
|
||||
|
||||
// ❌ 总是分配新字符串
|
||||
fn bad_process_name(name: &str) -> String {
|
||||
if name.is_empty() {
|
||||
"Unknown".to_string() // 分配
|
||||
} else {
|
||||
name.to_string() // 不必要的分配
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 使用 Cow 避免不必要的分配
|
||||
fn good_process_name(name: &str) -> Cow<'_, str> {
|
||||
if name.is_empty() {
|
||||
Cow::Borrowed("Unknown") // 静态字符串,无分配
|
||||
} else {
|
||||
Cow::Borrowed(name) // 借用原始数据
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 只在需要修改时才分配
|
||||
fn normalize_name(name: &str) -> Cow<'_, str> {
|
||||
if name.chars().any(|c| c.is_uppercase()) {
|
||||
Cow::Owned(name.to_lowercase()) // 需要修改,分配
|
||||
} else {
|
||||
Cow::Borrowed(name) // 无需修改,借用
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Unsafe 代码审查(最关键!)
|
||||
|
||||
### 基本要求
|
||||
|
||||
```rust
|
||||
// ❌ unsafe 没有安全文档——这是红旗
|
||||
unsafe fn bad_transmute<T, U>(t: T) -> U {
|
||||
std::mem::transmute(t)
|
||||
}
|
||||
|
||||
// ✅ 每个 unsafe 必须解释:为什么安全?什么不变量?
|
||||
/// Transmutes `T` to `U`.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// - `T` and `U` must have the same size and alignment
|
||||
/// - `T` must be a valid bit pattern for `U`
|
||||
/// - The caller ensures no references to `t` exist after this call
|
||||
unsafe fn documented_transmute<T, U>(t: T) -> U {
|
||||
// SAFETY: Caller guarantees size/alignment match and bit validity
|
||||
std::mem::transmute(t)
|
||||
}
|
||||
```
|
||||
|
||||
### Unsafe 块注释
|
||||
|
||||
```rust
|
||||
// ❌ 没有解释的 unsafe 块
|
||||
fn bad_get_unchecked(slice: &[u8], index: usize) -> u8 {
|
||||
unsafe { *slice.get_unchecked(index) }
|
||||
}
|
||||
|
||||
// ✅ 每个 unsafe 块必须有 SAFETY 注释
|
||||
fn good_get_unchecked(slice: &[u8], index: usize) -> u8 {
|
||||
debug_assert!(index < slice.len(), "index out of bounds");
|
||||
// SAFETY: We verified index < slice.len() via debug_assert.
|
||||
// In release builds, callers must ensure valid index.
|
||||
unsafe { *slice.get_unchecked(index) }
|
||||
}
|
||||
|
||||
// ✅ 封装 unsafe 提供安全 API
|
||||
pub fn checked_get(slice: &[u8], index: usize) -> Option<u8> {
|
||||
if index < slice.len() {
|
||||
// SAFETY: bounds check performed above
|
||||
Some(unsafe { *slice.get_unchecked(index) })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 常见 unsafe 模式
|
||||
|
||||
```rust
|
||||
// ✅ FFI 边界
|
||||
extern "C" {
|
||||
fn external_function(ptr: *const u8, len: usize) -> i32;
|
||||
}
|
||||
|
||||
pub fn safe_wrapper(data: &[u8]) -> Result<i32, Error> {
|
||||
// SAFETY: data.as_ptr() is valid for data.len() bytes,
|
||||
// and external_function only reads from the buffer.
|
||||
let result = unsafe {
|
||||
external_function(data.as_ptr(), data.len())
|
||||
};
|
||||
if result < 0 {
|
||||
Err(Error::from_code(result))
|
||||
} else {
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 性能关键路径的 unsafe
|
||||
pub fn fast_copy(src: &[u8], dst: &mut [u8]) {
|
||||
assert_eq!(src.len(), dst.len(), "slices must be equal length");
|
||||
// SAFETY: src and dst are valid slices of equal length,
|
||||
// and dst is mutable so no aliasing.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(
|
||||
src.as_ptr(),
|
||||
dst.as_mut_ptr(),
|
||||
src.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 异步代码
|
||||
|
||||
### 避免阻塞操作
|
||||
|
||||
```rust
|
||||
// ❌ 在 async 上下文中阻塞——会饿死其他任务
|
||||
async fn bad_async() {
|
||||
let data = std::fs::read_to_string("file.txt").unwrap(); // 阻塞!
|
||||
std::thread::sleep(Duration::from_secs(1)); // 阻塞!
|
||||
}
|
||||
|
||||
// ✅ 使用异步 API
|
||||
async fn good_async() -> Result<String> {
|
||||
let data = tokio::fs::read_to_string("file.txt").await?;
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
// ✅ 如果必须使用阻塞操作,用 spawn_blocking
|
||||
async fn with_blocking() -> Result<Data> {
|
||||
let result = tokio::task::spawn_blocking(|| {
|
||||
// 这里可以安全地进行阻塞操作
|
||||
expensive_cpu_computation()
|
||||
}).await?;
|
||||
Ok(result)
|
||||
}
|
||||
```
|
||||
|
||||
### Mutex 和 .await
|
||||
|
||||
```rust
|
||||
// ❌ 跨 .await 持有 std::sync::Mutex——可能死锁
|
||||
async fn bad_lock(mutex: &std::sync::Mutex<Data>) {
|
||||
let guard = mutex.lock().unwrap();
|
||||
async_operation().await; // 持锁等待!
|
||||
process(&guard);
|
||||
}
|
||||
|
||||
// ✅ 方案1:最小化锁范围
|
||||
async fn good_lock_scoped(mutex: &std::sync::Mutex<Data>) {
|
||||
let data = {
|
||||
let guard = mutex.lock().unwrap();
|
||||
guard.clone() // 立即释放锁
|
||||
};
|
||||
async_operation().await;
|
||||
process(&data);
|
||||
}
|
||||
|
||||
// ✅ 方案2:使用 tokio::sync::Mutex(可跨 await)
|
||||
async fn good_lock_tokio(mutex: &tokio::sync::Mutex<Data>) {
|
||||
let guard = mutex.lock().await;
|
||||
async_operation().await; // OK: tokio Mutex 设计为可跨 await
|
||||
process(&guard);
|
||||
}
|
||||
|
||||
// 💡 选择指南:
|
||||
// - std::sync::Mutex:低竞争、短临界区、不跨 await
|
||||
// - tokio::sync::Mutex:需要跨 await、高竞争场景
|
||||
```
|
||||
|
||||
### 异步 trait 方法
|
||||
|
||||
```rust
|
||||
// ❌ async trait 方法的陷阱(旧版本)
|
||||
#[async_trait]
|
||||
trait BadRepository {
|
||||
async fn find(&self, id: i64) -> Option<Entity>; // 隐式 Box
|
||||
}
|
||||
|
||||
// ✅ Rust 1.75+:原生 async trait 方法
|
||||
trait Repository {
|
||||
async fn find(&self, id: i64) -> Option<Entity>;
|
||||
|
||||
// 返回具体 Future 类型以避免 allocation
|
||||
fn find_many(&self, ids: &[i64]) -> impl Future<Output = Vec<Entity>> + Send;
|
||||
}
|
||||
|
||||
// ✅ 对于需要 dyn 的场景
|
||||
trait DynRepository: Send + Sync {
|
||||
fn find(&self, id: i64) -> Pin<Box<dyn Future<Output = Option<Entity>> + Send + '_>>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 取消安全性
|
||||
|
||||
### 什么是取消安全
|
||||
|
||||
```rust
|
||||
// 当一个 Future 在 .await 点被 drop 时,它处于什么状态?
|
||||
// 取消安全的 Future:可以在任何 await 点安全取消
|
||||
// 取消不安全的 Future:取消可能导致数据丢失或不一致状态
|
||||
|
||||
// ❌ 取消不安全的例子
|
||||
async fn cancel_unsafe(conn: &mut Connection) -> Result<()> {
|
||||
let data = receive_data().await; // 如果这里被取消...
|
||||
conn.send_ack().await; // ...确认永远不会发送,数据可能丢失
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ✅ 取消安全的版本
|
||||
async fn cancel_safe(conn: &mut Connection) -> Result<()> {
|
||||
// 使用事务或原子操作确保一致性
|
||||
let transaction = conn.begin_transaction().await?;
|
||||
let data = receive_data().await;
|
||||
transaction.commit_with_ack(data).await?; // 原子操作
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### select! 中的取消安全
|
||||
|
||||
```rust
|
||||
use tokio::select;
|
||||
|
||||
// ❌ 在 select! 中使用取消不安全的 Future
|
||||
async fn bad_select(stream: &mut TcpStream) {
|
||||
let mut buffer = vec![0u8; 1024];
|
||||
loop {
|
||||
select! {
|
||||
// read_exact 不是取消安全的:timeout 先完成时,
|
||||
// 已经读进 buffer 的部分字节会随 Future 一起丢弃
|
||||
result = stream.read_exact(&mut buffer) => {
|
||||
result?;
|
||||
handle_data(&buffer);
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_secs(5)) => {
|
||||
println!("Timeout");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 使用取消安全的 API
|
||||
async fn good_select(stream: &mut TcpStream) {
|
||||
let mut buffer = vec![0u8; 1024];
|
||||
loop {
|
||||
select! {
|
||||
// read 是取消安全的:被取消时未读取的数据仍留在流中
|
||||
// 真的需要按定长读取时,把 read_exact 丢到单独的 task 里,
|
||||
// 这里 select! 它的 JoinHandle,取消就不会丢字节
|
||||
result = stream.read(&mut buffer) => {
|
||||
match result {
|
||||
Ok(0) => break, // EOF
|
||||
Ok(n) => handle_data(&buffer[..n]),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_secs(5)) => {
|
||||
println!("Timeout, retrying...");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 使用 tokio::pin! 确保 Future 可以安全重用
|
||||
async fn pinned_select() {
|
||||
let sleep = tokio::time::sleep(Duration::from_secs(10));
|
||||
tokio::pin!(sleep);
|
||||
|
||||
loop {
|
||||
select! {
|
||||
_ = &mut sleep => {
|
||||
println!("Timer elapsed");
|
||||
break;
|
||||
}
|
||||
data = receive_data() => {
|
||||
process(data).await;
|
||||
// sleep 继续倒计时,不会重置
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 文档化取消安全性
|
||||
|
||||
```rust
|
||||
/// Reads a complete message from the stream.
|
||||
///
|
||||
/// # Cancel Safety
|
||||
///
|
||||
/// This method is **not** cancel safe. If cancelled while reading,
|
||||
/// partial data may be lost and the stream state becomes undefined.
|
||||
/// Use `read_message_cancel_safe` if cancellation is expected.
|
||||
async fn read_message(stream: &mut TcpStream) -> Result<Message> {
|
||||
let len = stream.read_u32().await?;
|
||||
let mut buffer = vec![0u8; len as usize];
|
||||
stream.read_exact(&mut buffer).await?;
|
||||
Ok(Message::from_bytes(&buffer))
|
||||
}
|
||||
|
||||
/// Reads a message with cancel safety.
|
||||
///
|
||||
/// # Cancel Safety
|
||||
///
|
||||
/// This method is cancel safe. If cancelled, any partial data
|
||||
/// is preserved in the internal buffer for the next call.
|
||||
async fn read_message_cancel_safe(reader: &mut BufferedReader) -> Result<Message> {
|
||||
reader.read_message_buffered().await
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## spawn vs await
|
||||
|
||||
### 何时使用 spawn
|
||||
|
||||
```rust
|
||||
// ❌ 不必要的 spawn——增加开销,失去结构化并发
|
||||
async fn bad_unnecessary_spawn() {
|
||||
let handle = tokio::spawn(async {
|
||||
simple_operation().await
|
||||
});
|
||||
handle.await.unwrap(); // 为什么不直接 await?
|
||||
}
|
||||
|
||||
// ✅ 直接 await 简单操作
|
||||
async fn good_direct_await() {
|
||||
simple_operation().await;
|
||||
}
|
||||
|
||||
// ✅ spawn 用于真正的并行执行
|
||||
async fn good_parallel_spawn() {
|
||||
let task1 = tokio::spawn(fetch_from_service_a());
|
||||
let task2 = tokio::spawn(fetch_from_service_b());
|
||||
|
||||
// 两个请求并行执行
|
||||
let (result1, result2) = tokio::try_join!(task1, task2)?;
|
||||
}
|
||||
|
||||
// ✅ spawn 用于后台任务(fire-and-forget)
|
||||
async fn good_background_spawn() {
|
||||
// 启动后台任务,不等待完成
|
||||
tokio::spawn(async {
|
||||
cleanup_old_sessions().await;
|
||||
log_metrics().await;
|
||||
});
|
||||
|
||||
// 继续执行其他工作
|
||||
handle_request().await;
|
||||
}
|
||||
```
|
||||
|
||||
### spawn 的 'static 要求
|
||||
|
||||
```rust
|
||||
// ❌ spawn 的 Future 必须是 'static
|
||||
async fn bad_spawn_borrow(data: &Data) {
|
||||
tokio::spawn(async {
|
||||
process(data).await; // Error: `data` 不是 'static
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ 方案1:克隆数据
|
||||
async fn good_spawn_clone(data: &Data) {
|
||||
let owned = data.clone();
|
||||
tokio::spawn(async move {
|
||||
process(&owned).await;
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ 方案2:使用 Arc 共享
|
||||
async fn good_spawn_arc(data: Arc<Data>) {
|
||||
let data = Arc::clone(&data);
|
||||
tokio::spawn(async move {
|
||||
process(&data).await;
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ 方案3:使用作用域任务(tokio-scoped 或 async-scoped)
|
||||
async fn good_scoped_spawn(data: &Data) {
|
||||
// 假设使用 async-scoped crate
|
||||
async_scoped::scope(|s| async {
|
||||
s.spawn(async {
|
||||
process(data).await; // 可以借用
|
||||
});
|
||||
}).await;
|
||||
}
|
||||
```
|
||||
|
||||
### JoinHandle 错误处理
|
||||
|
||||
```rust
|
||||
// ❌ 忽略 spawn 的错误
|
||||
async fn bad_ignore_spawn_error() {
|
||||
let handle = tokio::spawn(async {
|
||||
risky_operation().await
|
||||
});
|
||||
let _ = handle.await; // 忽略了 panic 和错误
|
||||
}
|
||||
|
||||
// ✅ 正确处理 JoinHandle 结果
|
||||
async fn good_handle_spawn_error() -> Result<()> {
|
||||
let handle = tokio::spawn(async {
|
||||
risky_operation().await
|
||||
});
|
||||
|
||||
match handle.await {
|
||||
Ok(Ok(result)) => {
|
||||
// 任务成功完成
|
||||
process_result(result);
|
||||
Ok(())
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
// 任务内部错误
|
||||
Err(e.into())
|
||||
}
|
||||
Err(join_err) => {
|
||||
// 任务 panic 或被取消
|
||||
if join_err.is_panic() {
|
||||
error!("Task panicked: {:?}", join_err);
|
||||
}
|
||||
Err(anyhow!("Task failed: {}", join_err))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 结构化并发 vs spawn
|
||||
|
||||
```rust
|
||||
// ✅ 优先使用 join!(结构化并发)
|
||||
async fn structured_concurrency() -> Result<(A, B, C)> {
|
||||
// 所有任务在同一个作用域内
|
||||
// 如果任何一个失败,其他的会被取消
|
||||
tokio::try_join!(
|
||||
fetch_a(),
|
||||
fetch_b(),
|
||||
fetch_c()
|
||||
)
|
||||
}
|
||||
|
||||
// ✅ 使用 spawn 时考虑任务生命周期
|
||||
struct TaskManager {
|
||||
handles: Vec<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl TaskManager {
|
||||
async fn shutdown(self) {
|
||||
// 优雅关闭:等待所有任务完成
|
||||
for handle in self.handles {
|
||||
if let Err(e) = handle.await {
|
||||
error!("Task failed during shutdown: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn abort_all(self) {
|
||||
// 强制关闭:取消所有任务
|
||||
for handle in self.handles {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 库 vs 应用的错误类型
|
||||
|
||||
```rust
|
||||
// ❌ 库代码用 anyhow——调用者无法 match 错误
|
||||
pub fn parse_config(s: &str) -> anyhow::Result<Config> { ... }
|
||||
|
||||
// ✅ 库用 thiserror,应用用 anyhow
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("invalid syntax at line {line}: {message}")]
|
||||
Syntax { line: usize, message: String },
|
||||
#[error("missing required field: {0}")]
|
||||
MissingField(String),
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
pub fn parse_config(s: &str) -> Result<Config, ConfigError> { ... }
|
||||
```
|
||||
|
||||
### 保留错误上下文
|
||||
|
||||
```rust
|
||||
// ❌ 吞掉错误上下文
|
||||
fn bad_error() -> Result<()> {
|
||||
operation().map_err(|_| anyhow!("failed"))?; // 原始错误丢失
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ✅ 使用 context 保留错误链
|
||||
fn good_error() -> Result<()> {
|
||||
operation().context("failed to perform operation")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ✅ 使用 with_context 进行懒计算
|
||||
fn good_error_lazy() -> Result<()> {
|
||||
operation()
|
||||
.with_context(|| format!("failed to process file: {}", filename))?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### 错误类型设计
|
||||
|
||||
```rust
|
||||
// ✅ 使用 #[source] 保留错误链
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ServiceError {
|
||||
#[error("database error")]
|
||||
Database(#[source] sqlx::Error),
|
||||
|
||||
#[error("network error: {message}")]
|
||||
Network {
|
||||
message: String,
|
||||
#[source]
|
||||
source: reqwest::Error,
|
||||
},
|
||||
|
||||
#[error("validation failed: {0}")]
|
||||
Validation(String),
|
||||
}
|
||||
|
||||
// ✅ 为常见转换实现 From
|
||||
impl From<sqlx::Error> for ServiceError {
|
||||
fn from(err: sqlx::Error) -> Self {
|
||||
ServiceError::Database(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 性能
|
||||
|
||||
### 避免不必要的 collect()
|
||||
|
||||
```rust
|
||||
// ❌ 不必要的 collect——中间分配
|
||||
fn bad_sum(items: &[i32]) -> i32 {
|
||||
items.iter()
|
||||
.filter(|x| **x > 0)
|
||||
.collect::<Vec<_>>() // 不必要!
|
||||
.iter()
|
||||
.sum()
|
||||
}
|
||||
|
||||
// ✅ 惰性迭代
|
||||
fn good_sum(items: &[i32]) -> i32 {
|
||||
items.iter().filter(|x| **x > 0).copied().sum()
|
||||
}
|
||||
```
|
||||
|
||||
### 字符串拼接
|
||||
|
||||
```rust
|
||||
// ❌ 字符串拼接在循环中重复分配
|
||||
fn bad_concat(items: &[&str]) -> String {
|
||||
let mut s = String::new();
|
||||
for item in items {
|
||||
s = s + item; // 每次都重新分配!
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
// ✅ 预分配或用 join
|
||||
fn good_concat(items: &[&str]) -> String {
|
||||
items.join("")
|
||||
}
|
||||
|
||||
// ✅ 使用 with_capacity 预分配
|
||||
fn good_concat_capacity(items: &[&str]) -> String {
|
||||
let total_len: usize = items.iter().map(|s| s.len()).sum();
|
||||
let mut result = String::with_capacity(total_len);
|
||||
for item in items {
|
||||
result.push_str(item);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ✅ 使用 write! 宏
|
||||
use std::fmt::Write;
|
||||
|
||||
fn good_concat_write(items: &[&str]) -> String {
|
||||
let mut result = String::new();
|
||||
for item in items {
|
||||
write!(result, "{}", item).unwrap();
|
||||
}
|
||||
result
|
||||
}
|
||||
```
|
||||
|
||||
### 避免不必要的分配
|
||||
|
||||
```rust
|
||||
// ❌ 不必要的 Vec 分配
|
||||
fn bad_check_any(items: &[Item]) -> bool {
|
||||
let filtered: Vec<_> = items.iter()
|
||||
.filter(|i| i.is_valid())
|
||||
.collect();
|
||||
!filtered.is_empty()
|
||||
}
|
||||
|
||||
// ✅ 使用迭代器方法
|
||||
fn good_check_any(items: &[Item]) -> bool {
|
||||
items.iter().any(|i| i.is_valid())
|
||||
}
|
||||
|
||||
// ❌ String::from 用于静态字符串
|
||||
fn bad_static() -> String {
|
||||
String::from("error message") // 运行时分配
|
||||
}
|
||||
|
||||
// ✅ 返回 &'static str
|
||||
fn good_static() -> &'static str {
|
||||
"error message" // 无分配
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Trait 设计
|
||||
|
||||
### 避免过度抽象
|
||||
|
||||
```rust
|
||||
// ❌ 过度抽象——不是 Java,不需要 Interface 一切
|
||||
trait Processor { fn process(&self); }
|
||||
trait Handler { fn handle(&self); }
|
||||
trait Manager { fn manage(&self); } // Trait 过多
|
||||
|
||||
// ✅ 只在需要多态时创建 trait
|
||||
// 具体类型通常更简单、更快
|
||||
struct DataProcessor {
|
||||
config: Config,
|
||||
}
|
||||
|
||||
impl DataProcessor {
|
||||
fn process(&self, data: &Data) -> Result<Output> {
|
||||
// 直接实现
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Trait 对象 vs 泛型
|
||||
|
||||
```rust
|
||||
// ❌ 不必要的 trait 对象(动态分发)
|
||||
fn bad_process(handler: &dyn Handler) {
|
||||
handler.handle(); // 虚表调用
|
||||
}
|
||||
|
||||
// ✅ 使用泛型(静态分发,可内联)
|
||||
fn good_process<H: Handler>(handler: &H) {
|
||||
handler.handle(); // 可能被内联
|
||||
}
|
||||
|
||||
// ✅ trait 对象适用场景:异构集合
|
||||
fn store_handlers(handlers: Vec<Box<dyn Handler>>) {
|
||||
// 需要存储不同类型的 handlers
|
||||
}
|
||||
|
||||
// ✅ 使用 impl Trait 返回类型
|
||||
fn create_handler() -> impl Handler {
|
||||
ConcreteHandler::new()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rust Review Checklist
|
||||
|
||||
### 编译器不能捕获的问题
|
||||
|
||||
**业务逻辑正确性**
|
||||
- [ ] 边界条件处理正确
|
||||
- [ ] 状态机转换完整
|
||||
- [ ] 并发场景下的竞态条件
|
||||
|
||||
**API 设计**
|
||||
- [ ] 公共 API 难以误用
|
||||
- [ ] 类型签名清晰表达意图
|
||||
- [ ] 错误类型粒度合适
|
||||
|
||||
### 所有权与借用
|
||||
|
||||
- [ ] clone() 是有意为之,文档说明了原因
|
||||
- [ ] Arc<Mutex<T>> 真的需要共享状态吗?
|
||||
- [ ] RefCell 的使用有正当理由
|
||||
- [ ] 生命周期不过度复杂
|
||||
- [ ] 考虑使用 Cow 避免不必要的分配
|
||||
|
||||
### Unsafe 代码(最重要)
|
||||
|
||||
- [ ] 每个 unsafe 块有 SAFETY 注释
|
||||
- [ ] unsafe fn 有 # Safety 文档节
|
||||
- [ ] 解释了为什么是安全的,不只是做什么
|
||||
- [ ] 列出了必须维护的不变量
|
||||
- [ ] unsafe 边界尽可能小
|
||||
- [ ] 考虑过是否有 safe 替代方案
|
||||
|
||||
### 异步/并发
|
||||
|
||||
- [ ] 没有在 async 中阻塞(std::fs、thread::sleep)
|
||||
- [ ] 没有跨 .await 持有 std::sync 锁
|
||||
- [ ] spawn 的任务满足 'static
|
||||
- [ ] 锁的获取顺序一致
|
||||
- [ ] Channel 缓冲区大小合理
|
||||
|
||||
### 取消安全性
|
||||
|
||||
- [ ] select! 中的 Future 是取消安全的
|
||||
- [ ] 文档化了 async 函数的取消安全性
|
||||
- [ ] 取消不会导致数据丢失或不一致状态
|
||||
- [ ] 使用 tokio::pin! 正确处理需要重用的 Future
|
||||
|
||||
### spawn vs await
|
||||
|
||||
- [ ] spawn 只用于真正需要并行的场景
|
||||
- [ ] 简单操作直接 await,不要 spawn
|
||||
- [ ] spawn 的 JoinHandle 结果被正确处理
|
||||
- [ ] 考虑任务的生命周期和关闭策略
|
||||
- [ ] 优先使用 join!/try_join! 进行结构化并发
|
||||
|
||||
### 错误处理
|
||||
|
||||
- [ ] 库:thiserror 定义结构化错误
|
||||
- [ ] 应用:anyhow + context
|
||||
- [ ] 没有生产代码 unwrap/expect
|
||||
- [ ] 错误消息对调试有帮助
|
||||
- [ ] must_use 返回值被处理
|
||||
- [ ] 使用 #[source] 保留错误链
|
||||
|
||||
### 性能
|
||||
|
||||
- [ ] 避免不必要的 collect()
|
||||
- [ ] 大数据传引用
|
||||
- [ ] 字符串用 with_capacity 或 write!
|
||||
- [ ] impl Trait vs Box<dyn Trait> 选择合理
|
||||
- [ ] 热路径避免分配
|
||||
- [ ] 考虑使用 Cow 减少克隆
|
||||
|
||||
### 代码质量
|
||||
|
||||
- [ ] cargo clippy 零警告
|
||||
- [ ] cargo fmt 格式化
|
||||
- [ ] 文档注释完整
|
||||
- [ ] 测试覆盖边界条件
|
||||
- [ ] 公共 API 有文档示例
|
||||
@@ -0,0 +1,266 @@
|
||||
# Security Review Guide
|
||||
|
||||
Security-focused code review checklist based on OWASP Top 10 and best practices.
|
||||
|
||||
## Authentication & Authorization
|
||||
|
||||
### Authentication
|
||||
- [ ] Passwords hashed with strong algorithm (bcrypt, argon2)
|
||||
- [ ] Password complexity requirements enforced
|
||||
- [ ] Account lockout after failed attempts
|
||||
- [ ] Secure password reset flow
|
||||
- [ ] Multi-factor authentication for sensitive operations
|
||||
- [ ] Session tokens are cryptographically random
|
||||
- [ ] Session timeout implemented
|
||||
|
||||
### Authorization
|
||||
- [ ] Authorization checks on every request
|
||||
- [ ] Principle of least privilege applied
|
||||
- [ ] Role-based access control (RBAC) properly implemented
|
||||
- [ ] No privilege escalation paths
|
||||
- [ ] Direct object reference checks (IDOR prevention)
|
||||
- [ ] API endpoints protected appropriately
|
||||
|
||||
### JWT Security
|
||||
```typescript
|
||||
// ❌ Insecure JWT configuration
|
||||
jwt.sign(payload, 'weak-secret');
|
||||
|
||||
// ✅ Secure JWT configuration
|
||||
jwt.sign(payload, process.env.JWT_SECRET, {
|
||||
algorithm: 'RS256',
|
||||
expiresIn: '15m',
|
||||
issuer: 'your-app',
|
||||
audience: 'your-api'
|
||||
});
|
||||
|
||||
// ❌ Not verifying JWT properly
|
||||
const decoded = jwt.decode(token); // No signature verification!
|
||||
|
||||
// ✅ Verify signature and claims
|
||||
const decoded = jwt.verify(token, publicKey, {
|
||||
algorithms: ['RS256'],
|
||||
issuer: 'your-app',
|
||||
audience: 'your-api'
|
||||
});
|
||||
```
|
||||
|
||||
## Input Validation
|
||||
|
||||
### SQL Injection Prevention
|
||||
```python
|
||||
# ❌ Vulnerable to SQL injection
|
||||
query = f"SELECT * FROM users WHERE id = {user_id}"
|
||||
|
||||
# ✅ Use parameterized queries
|
||||
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
|
||||
|
||||
# ✅ Use ORM with proper escaping
|
||||
User.objects.filter(id=user_id)
|
||||
```
|
||||
|
||||
### XSS Prevention
|
||||
```typescript
|
||||
// ❌ Vulnerable to XSS
|
||||
element.innerHTML = userInput;
|
||||
|
||||
// ✅ Use textContent for plain text
|
||||
element.textContent = userInput;
|
||||
|
||||
// ✅ Use DOMPurify for HTML
|
||||
element.innerHTML = DOMPurify.sanitize(userInput);
|
||||
|
||||
// ✅ React automatically escapes (but watch dangerouslySetInnerHTML)
|
||||
return <div>{userInput}</div>; // Safe
|
||||
return <div dangerouslySetInnerHTML={{__html: userInput}} />; // Dangerous!
|
||||
```
|
||||
|
||||
### Command Injection Prevention
|
||||
```python
|
||||
# ❌ Vulnerable to command injection
|
||||
os.system(f"convert {filename} output.png")
|
||||
|
||||
# ✅ Use subprocess with list arguments
|
||||
subprocess.run(['convert', filename, 'output.png'], check=True)
|
||||
|
||||
# ✅ Validate and sanitize input
|
||||
import shlex
|
||||
safe_filename = shlex.quote(filename)
|
||||
```
|
||||
|
||||
### Path Traversal Prevention
|
||||
```typescript
|
||||
// ❌ Vulnerable to path traversal
|
||||
const filePath = `./uploads/${req.params.filename}`;
|
||||
|
||||
// ✅ Validate and sanitize path
|
||||
const path = require('path');
|
||||
const safeName = path.basename(req.params.filename);
|
||||
const uploadsDir = path.resolve('./uploads');
|
||||
const filePath = path.resolve(uploadsDir, safeName);
|
||||
|
||||
// Verify it's still within uploads directory (both sides absolute)
|
||||
if (!filePath.startsWith(uploadsDir + path.sep)) {
|
||||
throw new Error('Invalid path');
|
||||
}
|
||||
```
|
||||
|
||||
## Data Protection
|
||||
|
||||
### Sensitive Data Handling
|
||||
- [ ] No secrets in source code
|
||||
- [ ] Secrets stored in environment variables or secret manager
|
||||
- [ ] Sensitive data encrypted at rest
|
||||
- [ ] Sensitive data encrypted in transit (HTTPS)
|
||||
- [ ] PII handled according to regulations (GDPR, etc.)
|
||||
- [ ] Sensitive data not logged
|
||||
- [ ] Secure data deletion when required
|
||||
|
||||
### Configuration Security
|
||||
```yaml
|
||||
# ❌ Secrets in config files
|
||||
database:
|
||||
password: "super-secret-password"
|
||||
|
||||
# ✅ Reference environment variables
|
||||
database:
|
||||
password: ${DATABASE_PASSWORD}
|
||||
```
|
||||
|
||||
### Error Messages
|
||||
```typescript
|
||||
// ❌ Leaking sensitive information
|
||||
catch (error) {
|
||||
return res.status(500).json({
|
||||
error: error.stack, // Exposes internal details
|
||||
query: sqlQuery // Exposes database structure
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ Generic error messages
|
||||
catch (error) {
|
||||
logger.error('Database error', { error, userId }); // Log internally
|
||||
return res.status(500).json({
|
||||
error: 'An unexpected error occurred'
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Security
|
||||
|
||||
### Rate Limiting
|
||||
- [ ] Rate limiting on all public endpoints
|
||||
- [ ] Stricter limits on authentication endpoints
|
||||
- [ ] Per-user and per-IP limits
|
||||
- [ ] Graceful handling when limits exceeded
|
||||
|
||||
### CORS Configuration
|
||||
```typescript
|
||||
// ❌ Overly permissive CORS
|
||||
app.use(cors({ origin: '*' }));
|
||||
|
||||
// ✅ Restrictive CORS
|
||||
app.use(cors({
|
||||
origin: ['https://your-app.com'],
|
||||
methods: ['GET', 'POST'],
|
||||
credentials: true
|
||||
}));
|
||||
```
|
||||
|
||||
### HTTP Headers
|
||||
```typescript
|
||||
// Security headers to set
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
}
|
||||
},
|
||||
hsts: { maxAge: 31536000, includeSubDomains: true },
|
||||
noSniff: true,
|
||||
xssFilter: true,
|
||||
frameguard: { action: 'deny' }
|
||||
}));
|
||||
```
|
||||
|
||||
## Cryptography
|
||||
|
||||
### Secure Practices
|
||||
- [ ] Using well-established algorithms (AES-256, RSA-2048+)
|
||||
- [ ] Not implementing custom cryptography
|
||||
- [ ] Using cryptographically secure random number generation
|
||||
- [ ] Proper key management and rotation
|
||||
- [ ] Secure key storage (HSM, KMS)
|
||||
|
||||
### Common Mistakes
|
||||
```typescript
|
||||
// ❌ Weak random generation
|
||||
const token = Math.random().toString(36);
|
||||
|
||||
// ✅ Cryptographically secure random
|
||||
const crypto = require('crypto');
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
// ❌ MD5/SHA1 for passwords
|
||||
const hash = crypto.createHash('md5').update(password).digest('hex');
|
||||
|
||||
// ✅ Use bcrypt or argon2
|
||||
const bcrypt = require('bcrypt');
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
```
|
||||
|
||||
## Dependency Security
|
||||
|
||||
### Checklist
|
||||
- [ ] Dependencies from trusted sources only
|
||||
- [ ] No known vulnerabilities (npm audit, cargo audit)
|
||||
- [ ] Dependencies kept up to date
|
||||
- [ ] Lock files committed (package-lock.json, Cargo.lock)
|
||||
- [ ] Minimal dependency usage
|
||||
- [ ] License compliance verified
|
||||
|
||||
### Audit Commands
|
||||
```bash
|
||||
# Node.js
|
||||
npm audit
|
||||
npm audit fix
|
||||
|
||||
# Python
|
||||
pip-audit
|
||||
safety check
|
||||
|
||||
# Rust
|
||||
cargo audit
|
||||
|
||||
# General
|
||||
snyk test
|
||||
```
|
||||
|
||||
## Logging & Monitoring
|
||||
|
||||
### Secure Logging
|
||||
- [ ] No sensitive data in logs (passwords, tokens, PII)
|
||||
- [ ] Logs protected from tampering
|
||||
- [ ] Appropriate log retention
|
||||
- [ ] Security events logged (login attempts, permission changes)
|
||||
- [ ] Log injection prevented
|
||||
|
||||
```typescript
|
||||
// ❌ Logging sensitive data
|
||||
logger.info(`User login: ${email}, password: ${password}`);
|
||||
|
||||
// ✅ Safe logging
|
||||
logger.info('User login attempt', { email, success: true });
|
||||
```
|
||||
|
||||
## Security Review Severity Levels
|
||||
|
||||
| Severity | Description | Action |
|
||||
|----------|-------------|--------|
|
||||
| **Critical** | Immediate exploitation possible, data breach risk | Block merge, fix immediately |
|
||||
| **High** | Significant vulnerability, requires specific conditions | Block merge, fix before release |
|
||||
| **Medium** | Moderate risk, defense in depth concern | Should fix, can merge with tracking |
|
||||
| **Low** | Minor issue, best practice violation | Nice to fix, non-blocking |
|
||||
| **Info** | Suggestion for improvement | Optional enhancement |
|
||||
+1060
File diff suppressed because it is too large
Load Diff
+932
@@ -0,0 +1,932 @@
|
||||
# Swift Code Review Guide
|
||||
|
||||
A code review checklist for modern Swift (5.9+/6), covering SwiftUI, Swift Concurrency, and the Swift API Design Guidelines.
|
||||
|
||||
## Quick Review Checklist
|
||||
|
||||
### Must-Check Items
|
||||
- [ ] Are force-unwraps (`!`) and `try!` avoided in favor of safe unwrapping
|
||||
- [ ] Do closures that capture `self` use `[weak self]` to avoid retain cycles
|
||||
- [ ] Is the value vs reference type choice intentional (struct vs class)
|
||||
- [ ] Are errors propagated with `throws`/`Result` instead of being swallowed
|
||||
- [ ] Are concurrency boundaries data-race-safe (`Sendable`, `@MainActor`, actors)
|
||||
|
||||
### Common Issues
|
||||
- [ ] Fire-and-forget `Task {}` that leaks or is never cancelled
|
||||
- [ ] Wrong SwiftUI property wrapper (`@ObservedObject` where `@StateObject` is needed)
|
||||
- [ ] O(n^2) lookups in loops that could use a `Set` or `Dictionary`
|
||||
- [ ] Implicitly unwrapped optionals (`var x: T!`) outside of IBOutlets
|
||||
- [ ] Over-broad access control (`public`/`open` where `internal` suffices)
|
||||
- [ ] Naming that ignores the Swift API Design Guidelines
|
||||
|
||||
---
|
||||
|
||||
## 1. Optionals and Unwrapping
|
||||
|
||||
### 1.1 Avoid Force-Unwrapping
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: crashes at runtime if nil
|
||||
let name = user.name!
|
||||
let url = URL(string: urlString)!
|
||||
|
||||
// ✅ Correct: bind with guard let / if let
|
||||
guard let name = user.name else {
|
||||
return
|
||||
}
|
||||
|
||||
if let url = URL(string: urlString) {
|
||||
load(url)
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 Use Nil-Coalescing for Defaults
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: verbose and crash-prone
|
||||
let count: Int
|
||||
if let c = dictionary["count"] {
|
||||
count = c
|
||||
} else {
|
||||
count = 0
|
||||
}
|
||||
|
||||
// ✅ Correct: nil-coalescing
|
||||
let count = dictionary["count"] ?? 0
|
||||
```
|
||||
|
||||
### 1.3 Prefer guard let for Early Exit
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: deep nesting (pyramid of doom)
|
||||
func process(_ input: String?) {
|
||||
if let input = input {
|
||||
if let value = Int(input) {
|
||||
if value > 0 {
|
||||
handle(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Correct: guard keeps the happy path unindented
|
||||
func process(_ input: String?) {
|
||||
guard let input,
|
||||
let value = Int(input),
|
||||
value > 0 else {
|
||||
return
|
||||
}
|
||||
handle(value)
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 Avoid Implicitly Unwrapped Optionals
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: T! is a hidden force-unwrap on every access
|
||||
class ViewModel {
|
||||
var service: NetworkService!
|
||||
}
|
||||
|
||||
// ✅ Correct: inject a non-optional dependency
|
||||
class ViewModel {
|
||||
private let service: NetworkService
|
||||
|
||||
init(service: NetworkService) {
|
||||
self.service = service
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.5 Use Optional Chaining and map/flatMap
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: manual unwrapping just to transform
|
||||
var initial: String?
|
||||
if let name = user.name {
|
||||
initial = String(name.prefix(1))
|
||||
}
|
||||
|
||||
// ✅ Correct: optional chaining + map
|
||||
let initial = user.name.map { String($0.prefix(1)) }
|
||||
|
||||
// ✅ Correct: flatMap to avoid double optionals
|
||||
let port: Int? = components.port.flatMap { Int(exactly: $0) }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Memory Management and Retain Cycles
|
||||
|
||||
### 2.1 Use [weak self] in Escaping Closures
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: closure strongly captures self, creating a retain cycle
|
||||
class ImageLoader {
|
||||
var onComplete: (() -> Void)?
|
||||
|
||||
func load() {
|
||||
service.fetch { data in
|
||||
self.cache = data // self is retained by the closure
|
||||
self.onComplete?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Correct: capture self weakly and guard
|
||||
class ImageLoader {
|
||||
var onComplete: (() -> Void)?
|
||||
|
||||
func load() {
|
||||
service.fetch { [weak self] data in
|
||||
guard let self else { return }
|
||||
self.cache = data
|
||||
self.onComplete?()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 weak vs unowned
|
||||
|
||||
```swift
|
||||
// ✅ Use weak when the reference can legitimately become nil
|
||||
class Controller {
|
||||
weak var delegate: ControllerDelegate?
|
||||
}
|
||||
|
||||
// ✅ Use unowned only when the captured object is guaranteed to
|
||||
// outlive the closure (e.g. self owns the closure tightly).
|
||||
// unowned crashes if accessed after deallocation.
|
||||
class Owner {
|
||||
lazy var describe: () -> String = { [unowned self] in
|
||||
self.name
|
||||
}
|
||||
let name = "owner"
|
||||
}
|
||||
|
||||
// ❌ Wrong: unowned on something that can outlive self -> crash
|
||||
networkClient.onResponse = { [unowned self] in self.update() }
|
||||
// Prefer [weak self] here, since onResponse may fire after self is gone.
|
||||
```
|
||||
|
||||
### 2.3 Break Delegate Retain Cycles
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: strong delegate keeps both objects alive forever
|
||||
protocol DataSourceDelegate: AnyObject {}
|
||||
|
||||
class DataSource {
|
||||
var delegate: DataSourceDelegate? // strong by default
|
||||
}
|
||||
|
||||
// ✅ Correct: delegates should be weak (and protocol AnyObject-bound)
|
||||
class DataSource {
|
||||
weak var delegate: DataSourceDelegate?
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 Closures Stored as Properties
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: stored closure captures self strongly -> permanent cycle
|
||||
class Timer {
|
||||
var tick: (() -> Void)!
|
||||
func configure() {
|
||||
tick = { self.count += 1 }
|
||||
}
|
||||
var count = 0
|
||||
}
|
||||
|
||||
// ✅ Correct: weak capture for stored closures referencing self
|
||||
class Timer {
|
||||
var tick: (() -> Void)?
|
||||
func configure() {
|
||||
tick = { [weak self] in self?.count += 1 }
|
||||
}
|
||||
var count = 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Value vs Reference Types
|
||||
|
||||
### 3.1 Prefer Structs by Default
|
||||
|
||||
```swift
|
||||
// ✅ Use a struct for data/models with value semantics
|
||||
struct Coordinate {
|
||||
var latitude: Double
|
||||
var longitude: Double
|
||||
}
|
||||
|
||||
// Copies are independent; no shared mutable state, thread-friendly.
|
||||
var a = Coordinate(latitude: 1, longitude: 2)
|
||||
var b = a
|
||||
b.latitude = 99 // a is unchanged
|
||||
```
|
||||
|
||||
### 3.2 Use a Class for Identity or Shared State
|
||||
|
||||
```swift
|
||||
// ✅ Use a class when instances have identity or must be shared/mutated
|
||||
// by reference, or when you need inheritance / Objective-C interop.
|
||||
final class DatabaseConnection {
|
||||
private(set) var isOpen = false
|
||||
func open() { isOpen = true }
|
||||
}
|
||||
|
||||
// Two references point to the same connection.
|
||||
let conn1 = DatabaseConnection()
|
||||
let conn2 = conn1
|
||||
conn1.open()
|
||||
// conn2.isOpen == true
|
||||
```
|
||||
|
||||
### 3.3 Mark Classes final When Not Subclassed
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: open to subclassing unintentionally (slower dispatch, fragile API)
|
||||
class UserViewModel {}
|
||||
|
||||
// ✅ Correct: final enables static dispatch and signals intent
|
||||
final class UserViewModel {}
|
||||
```
|
||||
|
||||
### 3.4 Beware Reference Types Inside Structs
|
||||
|
||||
```swift
|
||||
// ❌ Surprising: struct copy still shares the inner class instance
|
||||
final class Box { var value = 0 }
|
||||
struct Container { var box = Box() }
|
||||
|
||||
var x = Container()
|
||||
var y = x
|
||||
y.box.value = 42 // x.box.value is also 42 (shared reference!)
|
||||
|
||||
// ✅ Correct: use value semantics throughout, or copy on write deliberately
|
||||
struct Container {
|
||||
var value = 0 // plain value type, copies are independent
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Error Handling
|
||||
|
||||
### 4.1 Avoid try! and try?
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: try! crashes on any thrown error
|
||||
let data = try! Data(contentsOf: url)
|
||||
|
||||
// ❌ Often wrong: try? silently discards the error and the cause
|
||||
let data = try? Data(contentsOf: url) // data is nil, you lose "why"
|
||||
|
||||
// ✅ Correct: propagate or handle with do-catch
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
process(data)
|
||||
} catch {
|
||||
log.error("failed to read \(url): \(error)")
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Define Meaningful Error Types
|
||||
|
||||
```swift
|
||||
// ✅ Recommended: an Error enum communicates failure modes precisely
|
||||
enum NetworkError: Error {
|
||||
case invalidURL
|
||||
case unauthorized
|
||||
case server(statusCode: Int)
|
||||
case decoding(underlying: Error)
|
||||
}
|
||||
|
||||
func fetch(_ path: String) throws -> Data {
|
||||
guard let url = URL(string: path) else {
|
||||
throw NetworkError.invalidURL
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Use Result for Stored or Deferred Outcomes
|
||||
|
||||
```swift
|
||||
// ✅ Result is useful at callback boundaries or when storing an outcome
|
||||
func load(completion: @escaping (Result<User, NetworkError>) -> Void) {
|
||||
// completion(.success(user)) or completion(.failure(.unauthorized))
|
||||
}
|
||||
|
||||
// ✅ Convert between Result and throws as needed
|
||||
let user = try result.get()
|
||||
```
|
||||
|
||||
### 4.4 Typed Throws (Swift 6)
|
||||
|
||||
```swift
|
||||
// ✅ Typed throws constrains the error type when it is fully known.
|
||||
// Use it for closed, exhaustive error domains; prefer untyped
|
||||
// `throws` for library APIs that may grow new error cases.
|
||||
func parse(_ raw: String) throws(ParsingError) -> Token {
|
||||
guard let token = Token(raw) else {
|
||||
throw ParsingError.malformed
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
do {
|
||||
let token = try parse(input)
|
||||
} catch {
|
||||
// `error` is statically known to be ParsingError
|
||||
handle(error)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 Don't Catch and Rethrow Without Value
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: catch that adds nothing but obscures the trace
|
||||
do {
|
||||
try work()
|
||||
} catch {
|
||||
throw error // pointless
|
||||
}
|
||||
|
||||
// ✅ Correct: only catch to add context or recover
|
||||
do {
|
||||
try work()
|
||||
} catch {
|
||||
throw AppError.workFailed(underlying: error)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Swift Concurrency
|
||||
|
||||
### 5.1 Prefer async/await Over Nested Callbacks
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: callback pyramid, error handling scattered
|
||||
func loadProfile(completion: @escaping (Result<Profile, Error>) -> Void) {
|
||||
fetchUser { userResult in
|
||||
switch userResult {
|
||||
case .success(let user):
|
||||
fetchAvatar(user) { avatarResult in /* ... */ }
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Correct: linear async/await
|
||||
func loadProfile() async throws -> Profile {
|
||||
let user = try await fetchUser()
|
||||
let avatar = try await fetchAvatar(user)
|
||||
return Profile(user: user, avatar: avatar)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Use @MainActor for UI State
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: mutating UI state from a background context (data race / crash)
|
||||
func refresh() async {
|
||||
let items = try? await api.load()
|
||||
self.items = items ?? [] // may run off the main thread
|
||||
}
|
||||
|
||||
// ✅ Correct: isolate UI-facing types to the main actor
|
||||
@MainActor
|
||||
final class FeedViewModel: ObservableObject {
|
||||
@Published var items: [Item] = []
|
||||
|
||||
func refresh() async {
|
||||
let loaded = (try? await api.load()) ?? []
|
||||
items = loaded // guaranteed on the main actor
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Protect Mutable State with Actors
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: shared mutable state without synchronization (data race)
|
||||
final class Counter {
|
||||
var value = 0
|
||||
func increment() { value += 1 }
|
||||
}
|
||||
|
||||
// ✅ Correct: an actor serializes access to its mutable state
|
||||
actor Counter {
|
||||
private(set) var value = 0
|
||||
func increment() { value += 1 }
|
||||
}
|
||||
|
||||
let counter = Counter()
|
||||
await counter.increment() // access is awaited and serialized
|
||||
```
|
||||
|
||||
### 5.4 Conform Shared Types to Sendable
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: passing a non-Sendable class across actors (Swift 6 error)
|
||||
final class Config { // mutable, not Sendable
|
||||
var retries = 3
|
||||
}
|
||||
|
||||
// ✅ Correct: make shared types Sendable (immutable value type is ideal)
|
||||
struct Config: Sendable {
|
||||
let retries: Int
|
||||
}
|
||||
|
||||
// ✅ For reference types, use final + immutable stored properties,
|
||||
// or @unchecked Sendable only with manual synchronization.
|
||||
final class Cache: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var storage: [String: Data] = [:]
|
||||
// all access guarded by lock
|
||||
}
|
||||
```
|
||||
|
||||
### 5.5 Handle Task Cancellation
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: ignores cancellation, keeps working after the view is gone
|
||||
func search(_ query: String) async -> [Result] {
|
||||
var results: [Result] = []
|
||||
for page in 0..<100 {
|
||||
results += await fetchPage(query, page) // never stops
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// ✅ Correct: check for cancellation cooperatively
|
||||
func search(_ query: String) async throws -> [Result] {
|
||||
var results: [Result] = []
|
||||
for page in 0..<100 {
|
||||
try Task.checkCancellation()
|
||||
results += try await fetchPage(query, page)
|
||||
}
|
||||
return results
|
||||
}
|
||||
```
|
||||
|
||||
### 5.6 Don't Leak Fire-and-Forget Tasks
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: unstructured Task with no handle, never cancelled
|
||||
final class ViewModel {
|
||||
func onAppear() {
|
||||
Task {
|
||||
await self.stream() // runs forever even after dismissal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Correct: retain the handle and cancel it (or use .task in SwiftUI)
|
||||
final class ViewModel {
|
||||
private var streamTask: Task<Void, Never>?
|
||||
|
||||
func onAppear() {
|
||||
streamTask = Task { [weak self] in
|
||||
await self?.stream()
|
||||
}
|
||||
}
|
||||
|
||||
func onDisappear() {
|
||||
streamTask?.cancel()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.7 Use Structured Concurrency for Parallelism
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: sequential awaits where work could run concurrently
|
||||
let a = await loadA()
|
||||
let b = await loadB() // waits for A to finish first
|
||||
|
||||
// ✅ Correct: async let runs them concurrently
|
||||
async let a = loadA()
|
||||
async let b = loadB()
|
||||
let (resultA, resultB) = await (a, b)
|
||||
|
||||
// ✅ For a dynamic number of children, use a task group
|
||||
try await withThrowingTaskGroup(of: Item.self) { group in
|
||||
for id in ids {
|
||||
group.addTask { try await fetch(id) }
|
||||
}
|
||||
for try await item in group {
|
||||
store(item)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. SwiftUI
|
||||
|
||||
### 6.1 Choose the Right State Wrapper
|
||||
|
||||
```swift
|
||||
// ✅ @State: simple value-type state owned by this view
|
||||
struct Toggle: View {
|
||||
@State private var isOn = false
|
||||
var body: some View { /* ... */ }
|
||||
}
|
||||
|
||||
// ✅ @StateObject: the view CREATES and OWNS a reference-type model
|
||||
struct ProfileScreen: View {
|
||||
@StateObject private var model = ProfileViewModel()
|
||||
var body: some View { /* ... */ }
|
||||
}
|
||||
|
||||
// ✅ @ObservedObject: the model is OWNED elsewhere and passed in
|
||||
struct ProfileHeader: View {
|
||||
@ObservedObject var model: ProfileViewModel
|
||||
var body: some View { /* ... */ }
|
||||
}
|
||||
|
||||
// ✅ @Binding: a two-way reference to state owned by a parent
|
||||
struct SearchField: View {
|
||||
@Binding var text: String
|
||||
var body: some View { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 @StateObject vs @ObservedObject
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: @ObservedObject for an object the view itself creates.
|
||||
// SwiftUI may recreate the view, re-instantiating the model and
|
||||
// losing its state on every re-render.
|
||||
struct CounterView: View {
|
||||
@ObservedObject var model = CounterModel() // recreated unexpectedly
|
||||
}
|
||||
|
||||
// ✅ Correct: @StateObject ties the model's lifetime to the view
|
||||
struct CounterView: View {
|
||||
@StateObject private var model = CounterModel()
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 Preserve View Identity
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: index-based id reuses identity when the array reorders,
|
||||
// causing wrong animations and stale state.
|
||||
ForEach(0..<items.count, id: \.self) { i in
|
||||
ItemRow(item: items[i])
|
||||
}
|
||||
|
||||
// ✅ Correct: use a stable, unique identifier
|
||||
ForEach(items) { item in // Item: Identifiable
|
||||
ItemRow(item: item)
|
||||
}
|
||||
|
||||
// ✅ Use .id(...) to deliberately reset a view's state
|
||||
ProfileView(user: user)
|
||||
.id(user.id) // new identity per user -> fresh state
|
||||
```
|
||||
|
||||
### 6.4 Avoid Over-Rendering
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: a single huge body re-renders everything on any change
|
||||
struct Dashboard: View {
|
||||
@ObservedObject var model: DashboardModel
|
||||
var body: some View {
|
||||
VStack {
|
||||
// header + heavy chart + list all recompute together
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Correct: extract subviews so only the affected part re-renders.
|
||||
// Each child observes only the state it needs.
|
||||
struct Dashboard: View {
|
||||
var body: some View {
|
||||
VStack {
|
||||
HeaderView()
|
||||
ChartView()
|
||||
ItemList()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.5 Do Async Work with .task
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: kicking off work in onAppear without cancellation
|
||||
.onAppear {
|
||||
Task { await model.load() } // not cancelled when view disappears
|
||||
}
|
||||
|
||||
// ✅ Correct: .task is tied to the view's lifetime and auto-cancels
|
||||
.task {
|
||||
await model.load()
|
||||
}
|
||||
|
||||
// ✅ Re-run when an input changes
|
||||
.task(id: query) {
|
||||
await model.search(query)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Protocols and Generics
|
||||
|
||||
### 7.1 Protocol-Oriented Design
|
||||
|
||||
```swift
|
||||
// ✅ Compose behavior with protocols and default implementations
|
||||
protocol Identifiable2 {
|
||||
var id: String { get }
|
||||
}
|
||||
|
||||
protocol Describable {
|
||||
var description: String { get }
|
||||
}
|
||||
|
||||
extension Describable {
|
||||
var description: String { "no description" } // default
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 Prefer some Over any
|
||||
|
||||
```swift
|
||||
// ❌ Slower: `any` is an existential box with dynamic dispatch
|
||||
func makeShape() -> any Shape { Circle() }
|
||||
|
||||
// ✅ Faster: `some` is an opaque type resolved at compile time,
|
||||
// preserving the concrete type and enabling static dispatch.
|
||||
func makeShape() -> some Shape { Circle() }
|
||||
|
||||
// Use `any` only when you genuinely need heterogeneous values:
|
||||
let shapes: [any Shape] = [Circle(), Square()]
|
||||
```
|
||||
|
||||
### 7.3 Generic Constraints Over Existentials
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: existential parameter loses the concrete type and is slower
|
||||
func logTotal(_ items: [any Numeric]) {
|
||||
// awkward: the concrete numeric type is erased, so arithmetic needs casts
|
||||
}
|
||||
|
||||
// ✅ Correct: a generic constraint keeps full type information
|
||||
func total<T: Numeric>(_ items: [T]) -> T {
|
||||
items.reduce(.zero, +)
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 Associated Types with Primary Associated Types
|
||||
|
||||
```swift
|
||||
// ✅ Primary associated types (Swift 5.7+) allow lightweight constraints
|
||||
protocol Container<Item> {
|
||||
associatedtype Item
|
||||
var count: Int { get }
|
||||
subscript(_ index: Int) -> Item { get }
|
||||
}
|
||||
|
||||
// Constrain the element type without a where-clause:
|
||||
func first(in container: some Container<Int>) -> Int {
|
||||
container[0]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Access Control and API Design
|
||||
|
||||
### 8.1 Use the Narrowest Access Level
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: everything public exposes internal details as API surface
|
||||
public class Service {
|
||||
public var cache: [String: Data] = [:]
|
||||
public func reset() {}
|
||||
}
|
||||
|
||||
// ✅ Correct: expose only the intended API; hide the rest
|
||||
public final class Service {
|
||||
private var cache: [String: Data] = [:]
|
||||
public func reset() { cache.removeAll() }
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 private vs fileprivate vs internal vs public/open
|
||||
|
||||
```swift
|
||||
// private: visible only within the enclosing declaration (and its extensions in the same file)
|
||||
// fileprivate: visible within the same source file
|
||||
// internal: visible within the module (the default)
|
||||
// public: visible outside the module, but not subclassable/overridable
|
||||
// open: visible outside the module AND subclassable/overridable
|
||||
|
||||
// ✅ Use private(set) to expose read-only state
|
||||
public final class Account {
|
||||
public private(set) var balance: Decimal = 0
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 Follow the Swift API Design Guidelines
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: redundant words, unclear argument roles
|
||||
func insertObject(_ object: Element, atIndex index: Int)
|
||||
list.removeElement(at: 0)
|
||||
|
||||
// ✅ Correct: read at the call site like a phrase; omit needless words
|
||||
func insert(_ element: Element, at index: Int)
|
||||
list.insert(item, at: 0) // reads as "insert item at 0"
|
||||
list.remove(at: 0)
|
||||
|
||||
// ✅ Boolean properties read as assertions
|
||||
var isEmpty: Bool
|
||||
var hasChanges: Bool
|
||||
```
|
||||
|
||||
### 8.4 Name Methods by Side Effects
|
||||
|
||||
```swift
|
||||
// ✅ Mutating verb vs non-mutating noun pairs (the "ed/ing" rule)
|
||||
var sorted = array.sorted() // returns a new value (non-mutating)
|
||||
array.sort() // mutates in place (imperative verb)
|
||||
|
||||
let reversed = text.reversed()
|
||||
text.reverse()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Collections and Functional Style
|
||||
|
||||
### 9.1 Prefer map/filter/compactMap
|
||||
|
||||
```swift
|
||||
// ❌ Verbose: manual loop with mutable accumulator
|
||||
var names: [String] = []
|
||||
for user in users {
|
||||
if user.isActive {
|
||||
names.append(user.name)
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Correct: declarative transform
|
||||
let names = users.filter(\.isActive).map(\.name)
|
||||
```
|
||||
|
||||
### 9.2 compactMap to Drop nils
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: map leaves an [Int?] you then have to unwrap
|
||||
let numbers = strings.map { Int($0) } // [Int?]
|
||||
|
||||
// ✅ Correct: compactMap removes nils and unwraps
|
||||
let numbers = strings.compactMap { Int($0) } // [Int]
|
||||
```
|
||||
|
||||
### 9.3 Avoid O(n^2) Membership Checks
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: contains on an Array is O(n); the loop is O(n*m)
|
||||
let result = candidates.filter { blocked.contains($0) } // blocked: [ID]
|
||||
|
||||
// ✅ Correct: a Set makes membership O(1)
|
||||
let blockedSet = Set(blocked)
|
||||
let result = candidates.filter { blockedSet.contains($0) }
|
||||
```
|
||||
|
||||
### 9.4 reduce and Dictionary Grouping
|
||||
|
||||
```swift
|
||||
// ✅ Group with Dictionary(grouping:)
|
||||
let byFirstLetter = Dictionary(grouping: words) { $0.first }
|
||||
|
||||
// ❌ Wrong: reduce(into:) is preferred over reduce that copies each step
|
||||
let total = numbers.reduce(0) { $0 + $1 } // fine for scalars
|
||||
|
||||
// ✅ Use reduce(into:) when accumulating into a collection (avoids copies)
|
||||
let counts = words.reduce(into: [:]) { acc, word in
|
||||
acc[word, default: 0] += 1
|
||||
}
|
||||
```
|
||||
|
||||
### 9.5 Use lazy for Chained Transforms on Large Sequences
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: each step allocates an intermediate array
|
||||
let firstMatch = bigArray.map(expensive).filter(isValid).first
|
||||
|
||||
// ✅ Correct: lazy avoids intermediate arrays and stops early
|
||||
let firstMatch = bigArray.lazy.map(expensive).filter(isValid).first
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Testing
|
||||
|
||||
### 10.1 Arrange-Act-Assert with XCTest
|
||||
|
||||
```swift
|
||||
import XCTest
|
||||
@testable import MyApp
|
||||
|
||||
final class PriceCalculatorTests: XCTestCase {
|
||||
func testDiscountApplied() {
|
||||
// Arrange
|
||||
let calculator = PriceCalculator(discount: 0.1)
|
||||
// Act
|
||||
let total = calculator.total(for: 100)
|
||||
// Assert
|
||||
XCTAssertEqual(total, 90, accuracy: 0.001)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10.2 Testing async Code
|
||||
|
||||
```swift
|
||||
// ✅ Mark the test method async and await directly
|
||||
func testFetchUser() async throws {
|
||||
let service = UserService(client: MockClient())
|
||||
let user = try await service.fetchUser(id: "42")
|
||||
XCTAssertEqual(user.id, "42")
|
||||
}
|
||||
|
||||
// ✅ Assert that an async call throws the expected error
|
||||
func testFetchUserUnauthorized() async {
|
||||
let service = UserService(client: UnauthorizedClient())
|
||||
do {
|
||||
_ = try await service.fetchUser(id: "42")
|
||||
XCTFail("expected to throw")
|
||||
} catch NetworkError.unauthorized {
|
||||
// expected
|
||||
} catch {
|
||||
XCTFail("unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10.3 Inject Dependencies via Protocols
|
||||
|
||||
```swift
|
||||
// ✅ Depend on a protocol so tests can substitute a mock
|
||||
protocol HTTPClient {
|
||||
func get(_ url: URL) async throws -> Data
|
||||
}
|
||||
|
||||
struct MockClient: HTTPClient {
|
||||
var result: Result<Data, Error>
|
||||
func get(_ url: URL) async throws -> Data {
|
||||
try result.get()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10.4 Avoid Sleeps; Await Expectations or Values
|
||||
|
||||
```swift
|
||||
// ❌ Wrong: arbitrary sleep makes tests slow and flaky
|
||||
func testCallback() {
|
||||
var done = false
|
||||
object.run { done = true }
|
||||
Thread.sleep(forTimeInterval: 1)
|
||||
XCTAssertTrue(done)
|
||||
}
|
||||
|
||||
// ✅ Correct: use XCTestExpectation for callback APIs
|
||||
func testCallback() {
|
||||
let expectation = expectation(description: "callback fired")
|
||||
object.run { expectation.fulfill() }
|
||||
wait(for: [expectation], timeout: 1.0)
|
||||
}
|
||||
|
||||
// ✅ Better: refactor to async and await the value directly
|
||||
func testCallback() async {
|
||||
let value = await object.run()
|
||||
XCTAssertEqual(value, expected)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Swift API Design Guidelines](https://www.swift.org/documentation/api-design-guidelines/)
|
||||
- [The Swift Programming Language](https://docs.swift.org/swift-book/)
|
||||
- [Swift Concurrency (TSPL)](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/)
|
||||
- [Migrating to Swift 6](https://www.swift.org/migration/documentation/migrationguide/)
|
||||
- [Apple: Managing Model Data in Your App (SwiftUI)](https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app)
|
||||
- [Apple: Automatic Reference Counting](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/automaticreferencecounting/)
|
||||
- [WWDC: Protocol-Oriented Programming in Swift](https://developer.apple.com/videos/play/wwdc2015/408/)
|
||||
- [Swift Evolution](https://github.com/apple/swift-evolution)
|
||||
+553
@@ -0,0 +1,553 @@
|
||||
# TypeScript/JavaScript Code Review Guide
|
||||
|
||||
> TypeScript 代码审查指南,覆盖类型系统、泛型、条件类型、strict 模式、async/await 模式等核心主题。
|
||||
|
||||
## 目录
|
||||
|
||||
- [类型安全基础](#类型安全基础)
|
||||
- [泛型模式](#泛型模式)
|
||||
- [高级类型](#高级类型)
|
||||
- [Strict 模式配置](#strict-模式配置)
|
||||
- [异步处理](#异步处理)
|
||||
- [不可变性](#不可变性)
|
||||
- [ESLint 规则](#eslint-规则)
|
||||
- [Review Checklist](#review-checklist)
|
||||
|
||||
---
|
||||
|
||||
## 类型安全基础
|
||||
|
||||
### 避免使用 any
|
||||
|
||||
```typescript
|
||||
// ❌ Using any defeats type safety
|
||||
function processData(data: any) {
|
||||
return data.value; // 无类型检查,运行时可能崩溃
|
||||
}
|
||||
|
||||
// ✅ Use proper types
|
||||
interface DataPayload {
|
||||
value: string;
|
||||
}
|
||||
function processData(data: DataPayload) {
|
||||
return data.value;
|
||||
}
|
||||
|
||||
// ✅ 未知类型用 unknown + 类型守卫
|
||||
function processUnknown(data: unknown) {
|
||||
if (typeof data === 'object' && data !== null && 'value' in data) {
|
||||
return (data as { value: string }).value;
|
||||
}
|
||||
throw new Error('Invalid data');
|
||||
}
|
||||
```
|
||||
|
||||
### 类型收窄
|
||||
|
||||
```typescript
|
||||
// ❌ 不安全的类型断言
|
||||
function getLength(value: string | string[]) {
|
||||
return (value as string[]).length; // 如果是 string 会出错
|
||||
}
|
||||
|
||||
// ✅ 使用类型守卫
|
||||
function getLength(value: string | string[]): number {
|
||||
if (Array.isArray(value)) {
|
||||
return value.length;
|
||||
}
|
||||
return value.length;
|
||||
}
|
||||
|
||||
// ✅ 使用 in 操作符
|
||||
interface Dog { bark(): void }
|
||||
interface Cat { meow(): void }
|
||||
|
||||
function speak(animal: Dog | Cat) {
|
||||
if ('bark' in animal) {
|
||||
animal.bark();
|
||||
} else {
|
||||
animal.meow();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 字面量类型与 as const
|
||||
|
||||
```typescript
|
||||
// ❌ 类型过于宽泛
|
||||
const config = {
|
||||
endpoint: '/api',
|
||||
method: 'GET' // 类型是 string
|
||||
};
|
||||
|
||||
// ✅ 使用 as const 获得字面量类型
|
||||
const config = {
|
||||
endpoint: '/api',
|
||||
method: 'GET'
|
||||
} as const; // method 类型是 'GET'
|
||||
|
||||
// ✅ 用于函数参数
|
||||
function request(method: 'GET' | 'POST', url: string) { ... }
|
||||
request(config.method, config.endpoint); // 正确!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 泛型模式
|
||||
|
||||
### 基础泛型
|
||||
|
||||
```typescript
|
||||
// ❌ 重复代码
|
||||
function getFirstString(arr: string[]): string | undefined {
|
||||
return arr[0];
|
||||
}
|
||||
function getFirstNumber(arr: number[]): number | undefined {
|
||||
return arr[0];
|
||||
}
|
||||
|
||||
// ✅ 使用泛型
|
||||
function getFirst<T>(arr: T[]): T | undefined {
|
||||
return arr[0];
|
||||
}
|
||||
```
|
||||
|
||||
### 泛型约束
|
||||
|
||||
```typescript
|
||||
// ❌ 泛型没有约束,无法访问属性
|
||||
function getProperty<T>(obj: T, key: string) {
|
||||
return obj[key]; // Error: 无法索引
|
||||
}
|
||||
|
||||
// ✅ 使用 keyof 约束
|
||||
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
|
||||
return obj[key];
|
||||
}
|
||||
|
||||
const user = { name: 'Alice', age: 30 };
|
||||
getProperty(user, 'name'); // 返回类型是 string
|
||||
getProperty(user, 'age'); // 返回类型是 number
|
||||
getProperty(user, 'foo'); // Error: 'foo' 不在 keyof User
|
||||
```
|
||||
|
||||
### 泛型默认值
|
||||
|
||||
```typescript
|
||||
// ✅ 提供合理的默认类型
|
||||
interface ApiResponse<T = unknown> {
|
||||
data: T;
|
||||
status: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// 可以不指定泛型参数
|
||||
const response: ApiResponse = { data: null, status: 200, message: 'OK' };
|
||||
// 也可以指定
|
||||
const userResponse: ApiResponse<User> = { ... };
|
||||
```
|
||||
|
||||
### 常见泛型工具类型
|
||||
|
||||
```typescript
|
||||
// ✅ 善用内置工具类型
|
||||
interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
type PartialUser = Partial<User>; // 所有属性可选
|
||||
type RequiredUser = Required<User>; // 所有属性必需
|
||||
type ReadonlyUser = Readonly<User>; // 所有属性只读
|
||||
type UserKeys = keyof User; // 'id' | 'name' | 'email'
|
||||
type NameOnly = Pick<User, 'name'>; // { name: string }
|
||||
type WithoutId = Omit<User, 'id'>; // { name: string; email: string }
|
||||
type UserRecord = Record<string, User>; // { [key: string]: User }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 高级类型
|
||||
|
||||
### 条件类型
|
||||
|
||||
```typescript
|
||||
// ✅ 根据输入类型返回不同类型
|
||||
type IsString<T> = T extends string ? true : false;
|
||||
|
||||
type A = IsString<string>; // true
|
||||
type B = IsString<number>; // false
|
||||
|
||||
// ✅ 提取数组元素类型
|
||||
type ElementType<T> = T extends (infer U)[] ? U : never;
|
||||
|
||||
type Elem = ElementType<string[]>; // string
|
||||
|
||||
// ✅ 提取函数返回类型(内置 ReturnType)
|
||||
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
|
||||
```
|
||||
|
||||
### 映射类型
|
||||
|
||||
```typescript
|
||||
// ✅ 转换对象类型的所有属性
|
||||
type Nullable<T> = {
|
||||
[K in keyof T]: T[K] | null;
|
||||
};
|
||||
|
||||
interface User {
|
||||
name: string;
|
||||
age: number;
|
||||
}
|
||||
|
||||
type NullableUser = Nullable<User>;
|
||||
// { name: string | null; age: number | null }
|
||||
|
||||
// ✅ 添加前缀
|
||||
type Getters<T> = {
|
||||
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
|
||||
};
|
||||
|
||||
type UserGetters = Getters<User>;
|
||||
// { getName: () => string; getAge: () => number }
|
||||
```
|
||||
|
||||
### 模板字面量类型
|
||||
|
||||
```typescript
|
||||
// ✅ 类型安全的事件名称
|
||||
type EventName = 'click' | 'focus' | 'blur';
|
||||
type HandlerName = `on${Capitalize<EventName>}`;
|
||||
// 'onClick' | 'onFocus' | 'onBlur'
|
||||
|
||||
// ✅ API 路由类型
|
||||
type ApiRoute = `/api/${string}`;
|
||||
const route: ApiRoute = '/api/users'; // OK
|
||||
const badRoute: ApiRoute = '/users'; // Error
|
||||
```
|
||||
|
||||
### Discriminated Unions
|
||||
|
||||
```typescript
|
||||
// ✅ 使用判别属性实现类型安全
|
||||
type Result<T, E> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: E };
|
||||
|
||||
function handleResult(result: Result<User, Error>) {
|
||||
if (result.success) {
|
||||
console.log(result.data.name); // TypeScript 知道 data 存在
|
||||
} else {
|
||||
console.log(result.error.message); // TypeScript 知道 error 存在
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Redux Action 模式
|
||||
type Action =
|
||||
| { type: 'INCREMENT'; payload: number }
|
||||
| { type: 'DECREMENT'; payload: number }
|
||||
| { type: 'RESET' };
|
||||
|
||||
function reducer(state: number, action: Action): number {
|
||||
switch (action.type) {
|
||||
case 'INCREMENT':
|
||||
return state + action.payload; // payload 类型已知
|
||||
case 'DECREMENT':
|
||||
return state - action.payload;
|
||||
case 'RESET':
|
||||
return 0; // 这里没有 payload
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Strict 模式配置
|
||||
|
||||
### 推荐的 tsconfig.json
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
// ✅ 必须开启的 strict 选项
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"useUnknownInCatchVariables": true,
|
||||
|
||||
// ✅ 额外推荐选项
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### noUncheckedIndexedAccess 的影响
|
||||
|
||||
```typescript
|
||||
// tsconfig: "noUncheckedIndexedAccess": true
|
||||
|
||||
const arr = [1, 2, 3];
|
||||
const first = arr[0]; // 类型是 number | undefined
|
||||
|
||||
// ❌ 直接使用可能出错
|
||||
console.log(first.toFixed(2)); // Error: 可能是 undefined
|
||||
|
||||
// ✅ 先检查
|
||||
if (first !== undefined) {
|
||||
console.log(first.toFixed(2));
|
||||
}
|
||||
|
||||
// ✅ 或使用非空断言(确定时)
|
||||
console.log(arr[0]!.toFixed(2));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 异步处理
|
||||
|
||||
### Promise 错误处理
|
||||
|
||||
```typescript
|
||||
// ❌ Not handling async errors
|
||||
async function fetchUser(id: string) {
|
||||
const response = await fetch(`/api/users/${id}`);
|
||||
return response.json(); // 网络错误未处理
|
||||
}
|
||||
|
||||
// ✅ Handle errors properly
|
||||
async function fetchUser(id: string): Promise<User> {
|
||||
try {
|
||||
const response = await fetch(`/api/users/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Failed to fetch user: ${error.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Promise.all vs Promise.allSettled
|
||||
|
||||
```typescript
|
||||
// ❌ Promise.all 一个失败全部失败
|
||||
async function fetchAllUsers(ids: string[]) {
|
||||
const users = await Promise.all(ids.map(fetchUser));
|
||||
return users; // 一个失败就全部失败
|
||||
}
|
||||
|
||||
// ✅ Promise.allSettled 获取所有结果
|
||||
async function fetchAllUsers(ids: string[]) {
|
||||
const results = await Promise.allSettled(ids.map(fetchUser));
|
||||
|
||||
const users: User[] = [];
|
||||
const errors: Error[] = [];
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
users.push(result.value);
|
||||
} else {
|
||||
errors.push(result.reason);
|
||||
}
|
||||
}
|
||||
|
||||
return { users, errors };
|
||||
}
|
||||
```
|
||||
|
||||
### 竞态条件处理
|
||||
|
||||
```typescript
|
||||
// ❌ 竞态条件:旧请求可能覆盖新请求
|
||||
function useSearch() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/search?q=${query}`)
|
||||
.then(r => r.json())
|
||||
.then(setResults); // 旧请求可能后返回!
|
||||
}, [query]);
|
||||
}
|
||||
|
||||
// ✅ 使用 AbortController
|
||||
function useSearch() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
fetch(`/api/search?q=${query}`, { signal: controller.signal })
|
||||
.then(r => r.json())
|
||||
.then(setResults)
|
||||
.catch(e => {
|
||||
if (e.name !== 'AbortError') throw e;
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [query]);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 不可变性
|
||||
|
||||
### Readonly 与 ReadonlyArray
|
||||
|
||||
```typescript
|
||||
// ❌ 可变参数可能被意外修改
|
||||
function processUsers(users: User[]) {
|
||||
users.sort((a, b) => a.name.localeCompare(b.name)); // 修改了原数组!
|
||||
return users;
|
||||
}
|
||||
|
||||
// ✅ 使用 readonly 防止修改
|
||||
function processUsers(users: readonly User[]): User[] {
|
||||
return [...users].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// ✅ 深度只读
|
||||
type DeepReadonly<T> = {
|
||||
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
|
||||
};
|
||||
```
|
||||
|
||||
### 不变式函数参数
|
||||
|
||||
```typescript
|
||||
// ✅ 使用 as const 和 readonly 保护数据
|
||||
function createConfig<T extends readonly string[]>(routes: T) {
|
||||
return routes;
|
||||
}
|
||||
|
||||
const routes = createConfig(['home', 'about', 'contact'] as const);
|
||||
// 类型是 readonly ['home', 'about', 'contact']
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ESLint 规则
|
||||
|
||||
### 推荐的 @typescript-eslint 规则
|
||||
|
||||
```javascript
|
||||
// eslint.config.js(flat config,typescript-eslint v8)
|
||||
import eslint from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
// 需要类型信息的规则集,对应旧的 recommended-requiring-type-checking
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
tseslint.configs.strictTypeChecked,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
// 让带类型的规则自动找到对应 tsconfig
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// ✅ 类型安全
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'error',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'error',
|
||||
'@typescript-eslint/no-unsafe-call': 'error',
|
||||
'@typescript-eslint/no-unsafe-return': 'error',
|
||||
|
||||
// ✅ 最佳实践
|
||||
'@typescript-eslint/explicit-function-return-type': 'warn',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/await-thenable': 'error',
|
||||
'@typescript-eslint/no-misused-promises': 'error',
|
||||
|
||||
// ✅ 代码风格
|
||||
'@typescript-eslint/consistent-type-imports': 'error',
|
||||
'@typescript-eslint/prefer-nullish-coalescing': 'error',
|
||||
'@typescript-eslint/prefer-optional-chain': 'error',
|
||||
},
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### 常见 ESLint 错误修复
|
||||
|
||||
```typescript
|
||||
// ❌ no-floating-promises: Promise 必须被处理
|
||||
async function save() { ... }
|
||||
save(); // Error: 未处理的 Promise
|
||||
|
||||
// ✅ 显式处理
|
||||
await save();
|
||||
// 或
|
||||
save().catch(console.error);
|
||||
// 或明确忽略
|
||||
void save();
|
||||
|
||||
// ❌ no-misused-promises: 不能在非 async 位置使用 Promise
|
||||
const items = [1, 2, 3];
|
||||
items.forEach(async (item) => { // Error!
|
||||
await processItem(item);
|
||||
});
|
||||
|
||||
// ✅ 使用 for...of
|
||||
for (const item of items) {
|
||||
await processItem(item);
|
||||
}
|
||||
// 或 Promise.all
|
||||
await Promise.all(items.map(processItem));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Review Checklist
|
||||
|
||||
### 类型系统
|
||||
- [ ] 没有使用 `any`(使用 `unknown` + 类型守卫代替)
|
||||
- [ ] 接口和类型定义完整且有意义的命名
|
||||
- [ ] 使用泛型提高代码复用性
|
||||
- [ ] 联合类型有正确的类型收窄
|
||||
- [ ] 善用工具类型(Partial、Pick、Omit 等)
|
||||
|
||||
### 泛型
|
||||
- [ ] 泛型有适当的约束(extends)
|
||||
- [ ] 泛型参数有合理的默认值
|
||||
- [ ] 避免过度泛型化(KISS 原则)
|
||||
|
||||
### Strict 模式
|
||||
- [ ] tsconfig.json 启用了 strict: true
|
||||
- [ ] 启用了 noUncheckedIndexedAccess
|
||||
- [ ] 没有使用 @ts-ignore(改用 @ts-expect-error)
|
||||
|
||||
### 异步代码
|
||||
- [ ] async 函数有错误处理
|
||||
- [ ] Promise rejection 被正确处理
|
||||
- [ ] 没有 floating promises(未处理的 Promise)
|
||||
- [ ] 并发请求使用 Promise.all 或 Promise.allSettled
|
||||
- [ ] 竞态条件使用 AbortController 处理
|
||||
|
||||
### 不可变性
|
||||
- [ ] 不直接修改函数参数
|
||||
- [ ] 使用 spread 操作符创建新对象/数组
|
||||
- [ ] 考虑使用 readonly 修饰符
|
||||
|
||||
### ESLint
|
||||
- [ ] 使用 @typescript-eslint/recommended
|
||||
- [ ] 没有 ESLint 警告或错误
|
||||
- [ ] 使用 consistent-type-imports
|
||||
+924
@@ -0,0 +1,924 @@
|
||||
# Vue 3 Code Review Guide
|
||||
|
||||
> Vue 3 Composition API 代码审查指南,覆盖响应性系统、Props/Emits、Watchers、Composables、Vue 3.5 新特性等核心主题。
|
||||
|
||||
## 目录
|
||||
|
||||
- [响应性系统](#响应性系统)
|
||||
- [Props & Emits](#props--emits)
|
||||
- [Vue 3.5 新特性](#vue-35-新特性)
|
||||
- [Watchers](#watchers)
|
||||
- [模板最佳实践](#模板最佳实践)
|
||||
- [Composables](#composables)
|
||||
- [性能优化](#性能优化)
|
||||
- [Review Checklist](#review-checklist)
|
||||
|
||||
---
|
||||
|
||||
## 响应性系统
|
||||
|
||||
### ref vs reactive 选择
|
||||
|
||||
```vue
|
||||
<!-- ✅ 基本类型用 ref -->
|
||||
<script setup lang="ts">
|
||||
const count = ref(0)
|
||||
const name = ref('Vue')
|
||||
|
||||
// ref 需要 .value 访问
|
||||
count.value++
|
||||
</script>
|
||||
|
||||
<!-- ✅ 对象/数组用 reactive(可选)-->
|
||||
<script setup lang="ts">
|
||||
const state = reactive({
|
||||
user: null,
|
||||
loading: false,
|
||||
error: null
|
||||
})
|
||||
|
||||
// reactive 直接访问
|
||||
state.loading = true
|
||||
</script>
|
||||
|
||||
<!-- 💡 现代最佳实践:全部使用 ref,保持一致性 -->
|
||||
<script setup lang="ts">
|
||||
const user = ref<User | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<Error | null>(null)
|
||||
</script>
|
||||
```
|
||||
|
||||
### 解构 reactive 对象
|
||||
|
||||
```vue
|
||||
<!-- ❌ 解构 reactive 会丢失响应性 -->
|
||||
<script setup lang="ts">
|
||||
const state = reactive({ count: 0, name: 'Vue' })
|
||||
const { count, name } = state // 丢失响应性!
|
||||
</script>
|
||||
|
||||
<!-- ✅ 使用 toRefs 保持响应性 -->
|
||||
<script setup lang="ts">
|
||||
const state = reactive({ count: 0, name: 'Vue' })
|
||||
const { count, name } = toRefs(state) // 保持响应性
|
||||
// 或者直接使用 ref
|
||||
const count = ref(0)
|
||||
const name = ref('Vue')
|
||||
</script>
|
||||
```
|
||||
|
||||
### computed 副作用
|
||||
|
||||
```vue
|
||||
<!-- ❌ computed 中产生副作用 -->
|
||||
<script setup lang="ts">
|
||||
const fullName = computed(() => {
|
||||
console.log('Computing...') // 副作用!
|
||||
otherRef.value = 'changed' // 修改其他状态!
|
||||
return `${firstName.value} ${lastName.value}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- ✅ computed 只用于派生状态 -->
|
||||
<script setup lang="ts">
|
||||
const fullName = computed(() => {
|
||||
return `${firstName.value} ${lastName.value}`
|
||||
})
|
||||
// 副作用放在 watch 或事件处理中
|
||||
watch(fullName, (name) => {
|
||||
console.log('Name changed:', name)
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### shallowRef 优化
|
||||
|
||||
```vue
|
||||
<!-- ❌ 大型对象使用 ref 会深度转换 -->
|
||||
<script setup lang="ts">
|
||||
const largeData = ref(hugeNestedObject) // 深度响应式,性能开销大
|
||||
</script>
|
||||
|
||||
<!-- ✅ 使用 shallowRef 避免深度转换 -->
|
||||
<script setup lang="ts">
|
||||
const largeData = shallowRef(hugeNestedObject)
|
||||
|
||||
// 整体替换才会触发更新
|
||||
function updateData(newData) {
|
||||
largeData.value = newData // ✅ 触发更新
|
||||
}
|
||||
|
||||
// ❌ 修改嵌套属性不会触发更新
|
||||
// largeData.value.nested.prop = 'new'
|
||||
|
||||
// 需要手动触发时使用 triggerRef
|
||||
import { triggerRef } from 'vue'
|
||||
largeData.value.nested.prop = 'new'
|
||||
triggerRef(largeData)
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Props & Emits
|
||||
|
||||
### 直接修改 props
|
||||
|
||||
```vue
|
||||
<!-- ❌ 直接修改 props -->
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ user: User }>()
|
||||
props.user.name = 'New Name' // 永远不要直接修改 props!
|
||||
</script>
|
||||
|
||||
<!-- ✅ 使用 emit 通知父组件更新 -->
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ user: User }>()
|
||||
const emit = defineEmits<{
|
||||
update: [name: string]
|
||||
}>()
|
||||
const updateName = (name: string) => emit('update', name)
|
||||
</script>
|
||||
```
|
||||
|
||||
### defineProps 类型声明
|
||||
|
||||
```vue
|
||||
<!-- ❌ defineProps 缺少类型声明 -->
|
||||
<script setup lang="ts">
|
||||
const props = defineProps(['title', 'count']) // 无类型检查
|
||||
</script>
|
||||
|
||||
<!-- ✅ 使用类型声明 + withDefaults -->
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
count?: number
|
||||
items?: string[]
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
count: 0,
|
||||
items: () => [] // 对象/数组默认值需要工厂函数
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### defineEmits 类型安全
|
||||
|
||||
```vue
|
||||
<!-- ❌ defineEmits 缺少类型 -->
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits(['update', 'delete']) // 无类型检查
|
||||
emit('update', someValue) // 参数类型不安全
|
||||
</script>
|
||||
|
||||
<!-- ✅ 完整的类型定义 -->
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
update: [id: number, value: string]
|
||||
delete: [id: number]
|
||||
'custom-event': [payload: CustomPayload]
|
||||
}>()
|
||||
|
||||
// 现在有完整的类型检查
|
||||
emit('update', 1, 'new value') // ✅
|
||||
emit('update', 'wrong') // ❌ TypeScript 报错
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Vue 3.5 新特性
|
||||
|
||||
### Reactive Props Destructure (3.5+)
|
||||
|
||||
```vue
|
||||
<!-- Vue 3.5 之前:解构会丢失响应性 -->
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ count: number }>()
|
||||
// 需要使用 props.count 或 toRefs
|
||||
</script>
|
||||
|
||||
<!-- ✅ Vue 3.5+:解构保持响应性 -->
|
||||
<script setup lang="ts">
|
||||
const { count, name = 'default' } = defineProps<{
|
||||
count: number
|
||||
name?: string
|
||||
}>()
|
||||
|
||||
// count 和 name 自动保持响应性!
|
||||
// 可以直接在模板和 watch 中使用
|
||||
watch(() => count, (newCount) => {
|
||||
console.log('Count changed:', newCount)
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- ✅ 配合默认值使用 -->
|
||||
<script setup lang="ts">
|
||||
const {
|
||||
title,
|
||||
count = 0,
|
||||
items = () => [] // 函数作为默认值(对象/数组)
|
||||
} = defineProps<{
|
||||
title: string
|
||||
count?: number
|
||||
items?: () => string[]
|
||||
}>()
|
||||
</script>
|
||||
```
|
||||
|
||||
### defineModel (3.4+)
|
||||
|
||||
```vue
|
||||
<!-- ❌ 传统 v-model 实现:冗长 -->
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ modelValue: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
|
||||
// 需要 computed 来双向绑定
|
||||
const value = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- ✅ defineModel:简洁的 v-model 实现 -->
|
||||
<script setup lang="ts">
|
||||
// 自动处理 props 和 emit
|
||||
const model = defineModel<string>()
|
||||
|
||||
// 直接使用
|
||||
model.value = 'new value' // 自动 emit
|
||||
</script>
|
||||
<template>
|
||||
<input v-model="model" />
|
||||
</template>
|
||||
|
||||
<!-- ✅ 命名 v-model -->
|
||||
<script setup lang="ts">
|
||||
// v-model:title 的实现
|
||||
const title = defineModel<string>('title')
|
||||
|
||||
// 带默认值和选项
|
||||
const count = defineModel<number>('count', {
|
||||
default: 0,
|
||||
required: false
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- ✅ 多个 v-model -->
|
||||
<script setup lang="ts">
|
||||
const firstName = defineModel<string>('firstName')
|
||||
const lastName = defineModel<string>('lastName')
|
||||
</script>
|
||||
<template>
|
||||
<!-- 父组件使用:<MyInput v-model:first-name="first" v-model:last-name="last" /> -->
|
||||
</template>
|
||||
|
||||
<!-- ✅ v-model 修饰符 -->
|
||||
<script setup lang="ts">
|
||||
const [model, modifiers] = defineModel<string>()
|
||||
|
||||
// 检查修饰符
|
||||
if (modifiers.capitalize) {
|
||||
// 处理 .capitalize 修饰符
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### useTemplateRef (3.5+)
|
||||
|
||||
```vue
|
||||
<!-- 传统方式:ref 属性与变量同名 -->
|
||||
<script setup lang="ts">
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
</script>
|
||||
<template>
|
||||
<input ref="inputRef" />
|
||||
</template>
|
||||
|
||||
<!-- ✅ useTemplateRef:更清晰的模板引用 -->
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
const input = useTemplateRef<HTMLInputElement>('my-input')
|
||||
|
||||
onMounted(() => {
|
||||
input.value?.focus()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<input ref="my-input" />
|
||||
</template>
|
||||
|
||||
<!-- ✅ 动态 ref -->
|
||||
<script setup lang="ts">
|
||||
const refKey = ref('input-a')
|
||||
const dynamicInput = useTemplateRef<HTMLInputElement>(refKey)
|
||||
</script>
|
||||
```
|
||||
|
||||
### useId (3.5+)
|
||||
|
||||
```vue
|
||||
<!-- ❌ 手动生成 ID 可能冲突 -->
|
||||
<script setup lang="ts">
|
||||
const id = `input-${Math.random()}` // SSR 不一致!
|
||||
</script>
|
||||
|
||||
<!-- ✅ useId:SSR 安全的唯一 ID -->
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue'
|
||||
|
||||
const id = useId() // 例如:'v-0'
|
||||
</script>
|
||||
<template>
|
||||
<label :for="id">Name</label>
|
||||
<input :id="id" />
|
||||
</template>
|
||||
|
||||
<!-- ✅ 表单组件中使用 -->
|
||||
<script setup lang="ts">
|
||||
const inputId = useId()
|
||||
const errorId = useId()
|
||||
</script>
|
||||
<template>
|
||||
<label :for="inputId">Email</label>
|
||||
<input
|
||||
:id="inputId"
|
||||
:aria-describedby="errorId"
|
||||
/>
|
||||
<span :id="errorId" class="error">{{ error }}</span>
|
||||
</template>
|
||||
```
|
||||
|
||||
### onWatcherCleanup (3.5+)
|
||||
|
||||
```vue
|
||||
<!-- 传统方式:watch 第三个参数 -->
|
||||
<script setup lang="ts">
|
||||
watch(source, async (value, oldValue, onCleanup) => {
|
||||
const controller = new AbortController()
|
||||
onCleanup(() => controller.abort())
|
||||
// ...
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- ✅ onWatcherCleanup:更灵活的清理 -->
|
||||
<script setup lang="ts">
|
||||
import { onWatcherCleanup } from 'vue'
|
||||
|
||||
watch(source, async (value) => {
|
||||
const controller = new AbortController()
|
||||
onWatcherCleanup(() => controller.abort())
|
||||
|
||||
// 可以在任意位置调用,不限于回调开头
|
||||
if (someCondition) {
|
||||
const anotherResource = createResource()
|
||||
onWatcherCleanup(() => anotherResource.dispose())
|
||||
}
|
||||
|
||||
await fetchData(value, controller.signal)
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### Deferred Teleport (3.5+)
|
||||
|
||||
```vue
|
||||
<!-- ❌ Teleport 目标必须在挂载时存在 -->
|
||||
<template>
|
||||
<Teleport to="#modal-container">
|
||||
<!-- 如果 #modal-container 不存在会报错 -->
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<!-- ✅ defer 属性延迟挂载 -->
|
||||
<template>
|
||||
<Teleport to="#modal-container" defer>
|
||||
<!-- 等待目标元素存在后再挂载 -->
|
||||
<Modal />
|
||||
</Teleport>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Watchers
|
||||
|
||||
### watch vs watchEffect
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// ✅ watch:明确指定依赖,惰性执行
|
||||
watch(
|
||||
() => props.userId,
|
||||
async (userId) => {
|
||||
user.value = await fetchUser(userId)
|
||||
}
|
||||
)
|
||||
|
||||
// ✅ watchEffect:自动收集依赖,立即执行
|
||||
watchEffect(async () => {
|
||||
// 自动追踪 props.userId
|
||||
user.value = await fetchUser(props.userId)
|
||||
})
|
||||
|
||||
// 💡 选择指南:
|
||||
// - 需要旧值?用 watch
|
||||
// - 需要惰性执行?用 watch
|
||||
// - 依赖复杂?用 watchEffect
|
||||
</script>
|
||||
```
|
||||
|
||||
### watch 清理函数
|
||||
|
||||
```vue
|
||||
<!-- ❌ watch 缺少清理函数,可能内存泄漏 -->
|
||||
<script setup lang="ts">
|
||||
watch(searchQuery, async (query) => {
|
||||
const controller = new AbortController()
|
||||
const data = await fetch(`/api/search?q=${query}`, {
|
||||
signal: controller.signal
|
||||
})
|
||||
results.value = await data.json()
|
||||
// 如果 query 快速变化,旧请求不会被取消!
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- ✅ 使用 onCleanup 清理副作用 -->
|
||||
<script setup lang="ts">
|
||||
watch(searchQuery, async (query, _, onCleanup) => {
|
||||
const controller = new AbortController()
|
||||
onCleanup(() => controller.abort()) // 取消旧请求
|
||||
|
||||
try {
|
||||
const data = await fetch(`/api/search?q=${query}`, {
|
||||
signal: controller.signal
|
||||
})
|
||||
results.value = await data.json()
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') throw e
|
||||
}
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### watch 选项
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// ✅ immediate:立即执行一次
|
||||
watch(
|
||||
userId,
|
||||
async (id) => {
|
||||
user.value = await fetchUser(id)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// ✅ deep:深度监听(性能开销大,谨慎使用)
|
||||
watch(
|
||||
state,
|
||||
(newState) => {
|
||||
console.log('State changed deeply')
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// ✅ flush: 'post':DOM 更新后执行
|
||||
watch(
|
||||
source,
|
||||
() => {
|
||||
// 可以安全访问更新后的 DOM
|
||||
// nextTick 不再需要
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
// ✅ once: true (Vue 3.4+):只执行一次
|
||||
watch(
|
||||
source,
|
||||
(value) => {
|
||||
console.log('只会执行一次:', value)
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
</script>
|
||||
```
|
||||
|
||||
### 监听多个源
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// ✅ 监听多个 ref
|
||||
watch(
|
||||
[firstName, lastName],
|
||||
([newFirst, newLast], [oldFirst, oldLast]) => {
|
||||
console.log(`Name changed from ${oldFirst} ${oldLast} to ${newFirst} ${newLast}`)
|
||||
}
|
||||
)
|
||||
|
||||
// ✅ 监听 reactive 对象的特定属性
|
||||
watch(
|
||||
() => [state.count, state.name],
|
||||
([count, name]) => {
|
||||
console.log(`count: ${count}, name: ${name}`)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 模板最佳实践
|
||||
|
||||
### v-for 的 key
|
||||
|
||||
```vue
|
||||
<!-- ❌ v-for 中使用 index 作为 key -->
|
||||
<template>
|
||||
<li v-for="(item, index) in items" :key="index">
|
||||
{{ item.name }}
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<!-- ✅ 使用唯一标识作为 key -->
|
||||
<template>
|
||||
<li v-for="item in items" :key="item.id">
|
||||
{{ item.name }}
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<!-- ✅ 复合 key(当没有唯一 ID 时)-->
|
||||
<template>
|
||||
<li v-for="(item, index) in items" :key="`${item.name}-${item.type}-${index}`">
|
||||
{{ item.name }}
|
||||
</li>
|
||||
</template>
|
||||
```
|
||||
|
||||
### v-if 和 v-for 优先级
|
||||
|
||||
```vue
|
||||
<!-- ❌ v-if 和 v-for 同时使用 -->
|
||||
<template>
|
||||
<li v-for="user in users" v-if="user.active" :key="user.id">
|
||||
{{ user.name }}
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<!-- ✅ 使用 computed 过滤 -->
|
||||
<script setup lang="ts">
|
||||
const activeUsers = computed(() =>
|
||||
users.value.filter(user => user.active)
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<li v-for="user in activeUsers" :key="user.id">
|
||||
{{ user.name }}
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<!-- ✅ 或用 template 包裹 -->
|
||||
<template>
|
||||
<template v-for="user in users" :key="user.id">
|
||||
<li v-if="user.active">
|
||||
{{ user.name }}
|
||||
</li>
|
||||
</template>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 事件处理
|
||||
|
||||
```vue
|
||||
<!-- ❌ 内联复杂逻辑 -->
|
||||
<template>
|
||||
<button @click="items = items.filter(i => i.id !== item.id); count--">
|
||||
Delete
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- ✅ 使用方法 -->
|
||||
<script setup lang="ts">
|
||||
const deleteItem = (id: number) => {
|
||||
items.value = items.value.filter(i => i.id !== id)
|
||||
count.value--
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<button @click="deleteItem(item.id)">Delete</button>
|
||||
</template>
|
||||
|
||||
<!-- ✅ 事件修饰符 -->
|
||||
<template>
|
||||
<!-- 阻止默认行为 -->
|
||||
<form @submit.prevent="handleSubmit">...</form>
|
||||
|
||||
<!-- 阻止冒泡 -->
|
||||
<button @click.stop="handleClick">...</button>
|
||||
|
||||
<!-- 只执行一次 -->
|
||||
<button @click.once="handleOnce">...</button>
|
||||
|
||||
<!-- 键盘修饰符 -->
|
||||
<input @keyup.enter="submit" @keyup.esc="cancel" />
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Composables
|
||||
|
||||
### Composable 设计原则
|
||||
|
||||
```typescript
|
||||
// ✅ 好的 composable 设计
|
||||
export function useCounter(initialValue = 0) {
|
||||
const count = ref(initialValue)
|
||||
|
||||
const increment = () => count.value++
|
||||
const decrement = () => count.value--
|
||||
const reset = () => count.value = initialValue
|
||||
|
||||
// 返回响应式引用和方法
|
||||
return {
|
||||
count: readonly(count), // 只读防止外部修改
|
||||
increment,
|
||||
decrement,
|
||||
reset
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 不要返回 .value
|
||||
export function useBadCounter() {
|
||||
const count = ref(0)
|
||||
return {
|
||||
count: count.value // ❌ 丢失响应性!
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Props 传递给 composable
|
||||
|
||||
```vue
|
||||
<!-- ❌ 传递 props 到 composable 丢失响应性 -->
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ userId: string }>()
|
||||
const { user } = useUser(props.userId) // 丢失响应性!
|
||||
</script>
|
||||
|
||||
<!-- ✅ 使用 toRef 或 computed 保持响应性 -->
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ userId: string }>()
|
||||
const userIdRef = toRef(props, 'userId')
|
||||
const { user } = useUser(userIdRef) // 保持响应性
|
||||
// 或使用 computed
|
||||
const { user } = useUser(computed(() => props.userId))
|
||||
|
||||
// ✅ Vue 3.5+:直接解构使用
|
||||
const { userId } = defineProps<{ userId: string }>()
|
||||
const { user } = useUser(() => userId) // getter 函数
|
||||
</script>
|
||||
```
|
||||
|
||||
### 异步 Composable
|
||||
|
||||
```typescript
|
||||
// ✅ 异步 composable 模式
|
||||
export function useFetch<T>(url: MaybeRefOrGetter<string>) {
|
||||
const data = ref<T | null>(null)
|
||||
const error = ref<Error | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const execute = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await fetch(toValue(url))
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
data.value = await response.json()
|
||||
} catch (e) {
|
||||
error.value = e as Error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式 URL 时自动重新获取
|
||||
watchEffect(() => {
|
||||
toValue(url) // 追踪依赖
|
||||
execute()
|
||||
})
|
||||
|
||||
return {
|
||||
data: readonly(data),
|
||||
error: readonly(error),
|
||||
loading: readonly(loading),
|
||||
refetch: execute
|
||||
}
|
||||
}
|
||||
|
||||
// 使用
|
||||
const { data, loading, error, refetch } = useFetch<User[]>('/api/users')
|
||||
```
|
||||
|
||||
### 生命周期与清理
|
||||
|
||||
```typescript
|
||||
// ✅ Composable 中正确处理生命周期
|
||||
export function useEventListener(
|
||||
target: MaybeRefOrGetter<EventTarget>,
|
||||
event: string,
|
||||
handler: EventListener
|
||||
) {
|
||||
// 组件挂载后添加
|
||||
onMounted(() => {
|
||||
toValue(target).addEventListener(event, handler)
|
||||
})
|
||||
|
||||
// 组件卸载时移除
|
||||
onUnmounted(() => {
|
||||
toValue(target).removeEventListener(event, handler)
|
||||
})
|
||||
}
|
||||
|
||||
// ✅ 使用 effectScope 管理副作用
|
||||
export function useFeature() {
|
||||
const scope = effectScope()
|
||||
|
||||
scope.run(() => {
|
||||
// 所有响应式效果都在这个 scope 内
|
||||
const state = ref(0)
|
||||
watch(state, () => { /* ... */ })
|
||||
watchEffect(() => { /* ... */ })
|
||||
})
|
||||
|
||||
// 清理所有效果
|
||||
onUnmounted(() => scope.stop())
|
||||
|
||||
return { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 性能优化
|
||||
|
||||
### v-memo
|
||||
|
||||
```vue
|
||||
<!-- ✅ v-memo:缓存子树,避免重复渲染 -->
|
||||
<template>
|
||||
<div v-for="item in list" :key="item.id" v-memo="[item.id === selected]">
|
||||
<!-- 只有当 item.id === selected 变化时才重新渲染 -->
|
||||
<ExpensiveComponent :item="item" :selected="item.id === selected" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ✅ 配合 v-for 使用 -->
|
||||
<template>
|
||||
<div
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
v-memo="[item.name, item.status]"
|
||||
>
|
||||
<!-- 只有 name 或 status 变化时重新渲染 -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### defineAsyncComponent
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
// ✅ 懒加载组件
|
||||
const HeavyChart = defineAsyncComponent(() =>
|
||||
import('./components/HeavyChart.vue')
|
||||
)
|
||||
|
||||
// ✅ 带加载和错误状态
|
||||
const AsyncModal = defineAsyncComponent({
|
||||
loader: () => import('./components/Modal.vue'),
|
||||
loadingComponent: LoadingSpinner,
|
||||
errorComponent: ErrorDisplay,
|
||||
delay: 200, // 延迟显示 loading(避免闪烁)
|
||||
timeout: 3000 // 超时时间
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### KeepAlive
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- ✅ 缓存动态组件 -->
|
||||
<KeepAlive>
|
||||
<component :is="currentTab" />
|
||||
</KeepAlive>
|
||||
|
||||
<!-- ✅ 指定缓存的组件 -->
|
||||
<KeepAlive include="TabA,TabB">
|
||||
<component :is="currentTab" />
|
||||
</KeepAlive>
|
||||
|
||||
<!-- ✅ 限制缓存数量 -->
|
||||
<KeepAlive :max="10">
|
||||
<component :is="currentTab" />
|
||||
</KeepAlive>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// KeepAlive 组件的生命周期钩子
|
||||
onActivated(() => {
|
||||
// 组件被激活时(从缓存恢复)
|
||||
refreshData()
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
// 组件被停用时(进入缓存)
|
||||
pauseTimers()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### 虚拟列表
|
||||
|
||||
```vue
|
||||
<!-- ✅ 大型列表使用虚拟滚动 -->
|
||||
<script setup lang="ts">
|
||||
import { useVirtualList } from '@vueuse/core'
|
||||
|
||||
const { list, containerProps, wrapperProps } = useVirtualList(
|
||||
items,
|
||||
{ itemHeight: 50 }
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div v-bind="containerProps" style="height: 400px; overflow: auto">
|
||||
<div v-bind="wrapperProps">
|
||||
<div v-for="item in list" :key="item.data.id" style="height: 50px">
|
||||
{{ item.data.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Review Checklist
|
||||
|
||||
### 响应性系统
|
||||
- [ ] ref 用于基本类型,reactive 用于对象(或统一用 ref)
|
||||
- [ ] 没有解构 reactive 对象(或使用了 toRefs)
|
||||
- [ ] props 传递给 composable 时保持了响应性
|
||||
- [ ] shallowRef/shallowReactive 用于大型对象优化
|
||||
- [ ] computed 中没有副作用
|
||||
|
||||
### Props & Emits
|
||||
- [ ] defineProps 使用 TypeScript 类型声明
|
||||
- [ ] 复杂默认值使用 withDefaults + 工厂函数
|
||||
- [ ] defineEmits 有完整的类型定义
|
||||
- [ ] 没有直接修改 props
|
||||
- [ ] 考虑使用 defineModel 简化 v-model(Vue 3.4+)
|
||||
|
||||
### Vue 3.5 新特性(如适用)
|
||||
- [ ] 使用 Reactive Props Destructure 简化 props 访问
|
||||
- [ ] 使用 useTemplateRef 替代 ref 属性
|
||||
- [ ] 表单使用 useId 生成 SSR 安全的 ID
|
||||
- [ ] 使用 onWatcherCleanup 处理复杂清理逻辑
|
||||
|
||||
### Watchers
|
||||
- [ ] watch/watchEffect 有适当的清理函数
|
||||
- [ ] 异步 watch 处理了竞态条件
|
||||
- [ ] flush: 'post' 用于 DOM 操作的 watcher
|
||||
- [ ] 避免过度使用 watcher(优先用 computed)
|
||||
- [ ] 考虑 once: true 用于一次性监听
|
||||
|
||||
### 模板
|
||||
- [ ] v-for 使用唯一且稳定的 key
|
||||
- [ ] v-if 和 v-for 没有在同一元素上
|
||||
- [ ] 事件处理使用方法而非内联复杂逻辑
|
||||
- [ ] 大型列表使用虚拟滚动
|
||||
|
||||
### Composables
|
||||
- [ ] 相关逻辑提取到 composables
|
||||
- [ ] composables 返回响应式引用(不是 .value)
|
||||
- [ ] 纯函数不要包装成 composable
|
||||
- [ ] 副作用在组件卸载时清理
|
||||
- [ ] 使用 effectScope 管理复杂副作用
|
||||
|
||||
### 性能
|
||||
- [ ] 大型组件拆分为小组件
|
||||
- [ ] 使用 defineAsyncComponent 懒加载
|
||||
- [ ] 避免不必要的响应式转换
|
||||
- [ ] v-memo 用于昂贵的列表渲染
|
||||
- [ ] KeepAlive 用于缓存动态组件
|
||||
+388
@@ -0,0 +1,388 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PR Analyzer - Analyze PR complexity and suggest review approach.
|
||||
|
||||
Usage:
|
||||
python pr-analyzer.py [--diff-file FILE] [--stats]
|
||||
|
||||
Or pipe diff directly:
|
||||
git diff main...HEAD | python pr-analyzer.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
RISK_NO_TESTS = "NO_TEST_CHANGES"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileStats:
|
||||
"""Statistics for a single file."""
|
||||
filename: str
|
||||
additions: int = 0
|
||||
deletions: int = 0
|
||||
is_test: bool = False
|
||||
is_config: bool = False
|
||||
language: str = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PRAnalysis:
|
||||
"""Complete PR analysis results."""
|
||||
total_files: int
|
||||
total_additions: int
|
||||
total_deletions: int
|
||||
files: List[FileStats]
|
||||
complexity_score: float
|
||||
size_category: str
|
||||
estimated_review_time: int
|
||||
risk_factors: List[str]
|
||||
suggestions: List[str]
|
||||
|
||||
|
||||
def detect_language(filename: str) -> str:
|
||||
"""Detect programming language from filename."""
|
||||
_, ext = os.path.splitext(filename)
|
||||
extensions = {
|
||||
'.py': 'Python',
|
||||
'.js': 'JavaScript',
|
||||
'.ts': 'TypeScript',
|
||||
'.tsx': 'TypeScript/React',
|
||||
'.jsx': 'JavaScript/React',
|
||||
'.rs': 'Rust',
|
||||
'.go': 'Go',
|
||||
'.c': 'C',
|
||||
'.h': 'C/C++',
|
||||
'.cpp': 'C++',
|
||||
'.hpp': 'C++',
|
||||
'.cc': 'C++',
|
||||
'.cxx': 'C++',
|
||||
'.hh': 'C++',
|
||||
'.hxx': 'C++',
|
||||
'.java': 'Java',
|
||||
'.kt': 'Kotlin',
|
||||
'.swift': 'Swift',
|
||||
'.rb': 'Ruby',
|
||||
'.php': 'PHP',
|
||||
'.cs': 'C#',
|
||||
'.vue': 'Vue',
|
||||
'.svelte': 'Svelte',
|
||||
'.sql': 'SQL',
|
||||
'.md': 'Markdown',
|
||||
'.json': 'JSON',
|
||||
'.yaml': 'YAML',
|
||||
'.yml': 'YAML',
|
||||
'.toml': 'TOML',
|
||||
'.css': 'CSS',
|
||||
'.scss': 'SCSS',
|
||||
'.less': 'Less',
|
||||
'.html': 'HTML',
|
||||
'.zig': 'Zig',
|
||||
'.ex': 'Elixir',
|
||||
'.exs': 'Elixir',
|
||||
'.erl': 'Erlang',
|
||||
'.scala': 'Scala',
|
||||
'.lua': 'Lua',
|
||||
}
|
||||
return extensions.get(ext.lower(), 'unknown')
|
||||
|
||||
|
||||
def is_test_file(filename: str) -> bool:
|
||||
"""Check if file is a test file."""
|
||||
test_patterns = [
|
||||
r'test_.*\.py$',
|
||||
r'.*_test\.py$',
|
||||
r'.*\.test\.(js|ts|tsx)$',
|
||||
r'.*\.spec\.(js|ts|tsx)$',
|
||||
r'tests?/',
|
||||
r'__tests__/',
|
||||
]
|
||||
return any(re.search(p, filename) for p in test_patterns)
|
||||
|
||||
|
||||
def is_config_file(filename: str) -> bool:
|
||||
"""Check if file is a configuration file."""
|
||||
config_patterns = [
|
||||
r'\.env',
|
||||
r'config\.',
|
||||
r'\.json$',
|
||||
r'\.yaml$',
|
||||
r'\.yml$',
|
||||
r'\.toml$',
|
||||
r'Cargo\.toml$',
|
||||
r'package\.json$',
|
||||
r'tsconfig\.json$',
|
||||
]
|
||||
return any(re.search(p, filename) for p in config_patterns)
|
||||
|
||||
|
||||
def parse_diff(diff_content: str) -> List[FileStats]:
|
||||
"""Parse git diff output and extract file statistics."""
|
||||
files = []
|
||||
current_file = None
|
||||
|
||||
for line in diff_content.split('\n'):
|
||||
# New file header
|
||||
if line.startswith('diff --git'):
|
||||
if current_file:
|
||||
files.append(current_file)
|
||||
# "diff --git a/<path> b/<path>" — match the b/ side via a
|
||||
# backreference so a literal "b/" inside paths like lib/, web/ or
|
||||
# db/ can't be mistaken for the prefix. Renames have differing
|
||||
# paths, so fall back to the b/ side after the separating space.
|
||||
match = re.match(r'diff --git a/(.+?) b/\1', line)
|
||||
if not match:
|
||||
match = re.search(r' b/(.+)$', line)
|
||||
if match:
|
||||
filename = match.group(1)
|
||||
current_file = FileStats(
|
||||
filename=filename,
|
||||
language=detect_language(filename),
|
||||
is_test=is_test_file(filename),
|
||||
is_config=is_config_file(filename),
|
||||
)
|
||||
else:
|
||||
current_file = None
|
||||
elif current_file:
|
||||
if line.startswith('+') and not line.startswith('+++'):
|
||||
current_file.additions += 1
|
||||
elif line.startswith('-') and not line.startswith('---'):
|
||||
current_file.deletions += 1
|
||||
|
||||
if current_file:
|
||||
files.append(current_file)
|
||||
|
||||
return files
|
||||
|
||||
|
||||
def calculate_complexity(files: List[FileStats]) -> float:
|
||||
"""Calculate complexity score (0-1 scale)."""
|
||||
if not files:
|
||||
return 0.0
|
||||
|
||||
total_changes = sum(f.additions + f.deletions for f in files)
|
||||
|
||||
# Base complexity from size
|
||||
size_factor = min(total_changes / 1000, 1.0)
|
||||
|
||||
# Factor for number of files
|
||||
file_factor = min(len(files) / 20, 1.0)
|
||||
|
||||
# Factor for non-test code ratio
|
||||
test_lines = sum(f.additions + f.deletions for f in files if f.is_test)
|
||||
non_test_ratio = 1 - (test_lines / max(total_changes, 1))
|
||||
|
||||
# Factor for language diversity
|
||||
languages = set(f.language for f in files if f.language != 'unknown')
|
||||
lang_factor = min(len(languages) / 5, 1.0)
|
||||
|
||||
complexity = (
|
||||
size_factor * 0.4 +
|
||||
file_factor * 0.2 +
|
||||
non_test_ratio * 0.2 +
|
||||
lang_factor * 0.2
|
||||
)
|
||||
|
||||
return round(complexity, 2)
|
||||
|
||||
|
||||
def categorize_size(total_changes: int) -> str:
|
||||
"""Categorize PR size."""
|
||||
if total_changes < 50:
|
||||
return "XS (Extra Small)"
|
||||
elif total_changes < 200:
|
||||
return "S (Small)"
|
||||
elif total_changes < 400:
|
||||
return "M (Medium)"
|
||||
elif total_changes < 800:
|
||||
return "L (Large)"
|
||||
else:
|
||||
return "XL (Extra Large) - Consider splitting"
|
||||
|
||||
|
||||
def estimate_review_time(files: List[FileStats], complexity: float) -> int:
|
||||
"""Estimate review time in minutes."""
|
||||
total_changes = sum(f.additions + f.deletions for f in files)
|
||||
|
||||
# Base time: ~1 minute per 20 lines
|
||||
base_time = total_changes / 20
|
||||
|
||||
# Adjust for complexity
|
||||
adjusted_time = base_time * (1 + complexity)
|
||||
|
||||
# Minimum 5 minutes, maximum 120 minutes
|
||||
return max(5, min(120, int(adjusted_time)))
|
||||
|
||||
|
||||
def identify_risk_factors(files: List[FileStats]) -> List[str]:
|
||||
"""Identify potential risk factors in the PR."""
|
||||
risks = []
|
||||
|
||||
total_changes = sum(f.additions + f.deletions for f in files)
|
||||
test_changes = sum(f.additions + f.deletions for f in files if f.is_test)
|
||||
|
||||
if total_changes > 400:
|
||||
risks.append("Large PR (>400 lines) - harder to review thoroughly")
|
||||
|
||||
if test_changes == 0 and total_changes > 50:
|
||||
risks.append(f"{RISK_NO_TESTS}: No test changes - verify test coverage")
|
||||
|
||||
if total_changes > 100 and test_changes / max(total_changes, 1) < 0.2:
|
||||
risks.append("Low test ratio (<20%) - consider adding more tests")
|
||||
|
||||
# Security-sensitive files
|
||||
security_patterns = ['.env', 'auth', 'security', 'password', 'token', 'secret']
|
||||
for f in files:
|
||||
if any(p in f.filename.lower() for p in security_patterns):
|
||||
risks.append(f"Security-sensitive file: {f.filename}")
|
||||
break
|
||||
|
||||
# Database changes
|
||||
for f in files:
|
||||
if 'migration' in f.filename.lower() or f.language == 'SQL':
|
||||
risks.append("Database changes detected - review carefully")
|
||||
break
|
||||
|
||||
# Config changes
|
||||
config_files = [f for f in files if f.is_config]
|
||||
if config_files:
|
||||
risks.append(f"Configuration changes in {len(config_files)} file(s)")
|
||||
|
||||
return risks
|
||||
|
||||
|
||||
def generate_suggestions(files: List[FileStats], complexity: float, risks: List[str]) -> List[str]:
|
||||
"""Generate review suggestions."""
|
||||
suggestions = []
|
||||
|
||||
total_changes = sum(f.additions + f.deletions for f in files)
|
||||
|
||||
if total_changes > 800:
|
||||
suggestions.append("Consider splitting this PR into smaller, focused changes")
|
||||
|
||||
if complexity > 0.7:
|
||||
suggestions.append("High complexity - allocate extra review time")
|
||||
suggestions.append("Consider pair reviewing for critical sections")
|
||||
|
||||
if any(RISK_NO_TESTS in r for r in risks):
|
||||
suggestions.append("Request test additions before approval")
|
||||
|
||||
# Language-specific suggestions
|
||||
languages = set(f.language for f in files)
|
||||
if 'TypeScript' in languages or 'TypeScript/React' in languages:
|
||||
suggestions.append("Check for proper type usage (avoid 'any')")
|
||||
if 'Rust' in languages:
|
||||
suggestions.append("Check for unwrap() usage and error handling")
|
||||
if 'C' in languages or 'C++' in languages or 'C/C++' in languages:
|
||||
suggestions.append("Check for memory safety, bounds checks, and UB risks")
|
||||
if 'SQL' in languages:
|
||||
suggestions.append("Review for SQL injection and query performance")
|
||||
|
||||
if not suggestions:
|
||||
suggestions.append("Standard review process should suffice")
|
||||
|
||||
return suggestions
|
||||
|
||||
|
||||
def analyze_pr(diff_content: str) -> PRAnalysis:
|
||||
"""Perform complete PR analysis."""
|
||||
files = parse_diff(diff_content)
|
||||
|
||||
total_additions = sum(f.additions for f in files)
|
||||
total_deletions = sum(f.deletions for f in files)
|
||||
total_changes = total_additions + total_deletions
|
||||
|
||||
complexity = calculate_complexity(files)
|
||||
risks = identify_risk_factors(files)
|
||||
suggestions = generate_suggestions(files, complexity, risks)
|
||||
|
||||
return PRAnalysis(
|
||||
total_files=len(files),
|
||||
total_additions=total_additions,
|
||||
total_deletions=total_deletions,
|
||||
files=files,
|
||||
complexity_score=complexity,
|
||||
size_category=categorize_size(total_changes),
|
||||
estimated_review_time=estimate_review_time(files, complexity),
|
||||
risk_factors=risks,
|
||||
suggestions=suggestions,
|
||||
)
|
||||
|
||||
|
||||
def print_analysis(analysis: PRAnalysis, show_files: bool = False):
|
||||
"""Print analysis results."""
|
||||
print("\n" + "=" * 60)
|
||||
print("PR ANALYSIS REPORT")
|
||||
print("=" * 60)
|
||||
|
||||
print(f"\n📊 SUMMARY")
|
||||
print(f" Files changed: {analysis.total_files}")
|
||||
print(f" Additions: +{analysis.total_additions}")
|
||||
print(f" Deletions: -{analysis.total_deletions}")
|
||||
print(f" Total changes: {analysis.total_additions + analysis.total_deletions}")
|
||||
|
||||
print(f"\n📏 SIZE: {analysis.size_category}")
|
||||
print(f" Complexity score: {analysis.complexity_score}/1.0")
|
||||
print(f" Estimated review time: ~{analysis.estimated_review_time} minutes")
|
||||
|
||||
if analysis.risk_factors:
|
||||
print(f"\n⚠️ RISK FACTORS:")
|
||||
for risk in analysis.risk_factors:
|
||||
print(f" • {risk}")
|
||||
|
||||
print(f"\n💡 SUGGESTIONS:")
|
||||
for suggestion in analysis.suggestions:
|
||||
print(f" • {suggestion}")
|
||||
|
||||
if show_files:
|
||||
print(f"\n📁 FILES:")
|
||||
# Group by language
|
||||
by_lang: Dict[str, List[FileStats]] = defaultdict(list)
|
||||
for f in analysis.files:
|
||||
by_lang[f.language].append(f)
|
||||
|
||||
for lang, lang_files in sorted(by_lang.items()):
|
||||
print(f"\n [{lang}]")
|
||||
for f in lang_files:
|
||||
prefix = "🧪" if f.is_test else "⚙️" if f.is_config else "📄"
|
||||
print(f" {prefix} {f.filename} (+{f.additions}/-{f.deletions})")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Analyze PR complexity')
|
||||
parser.add_argument('--diff-file', '-f', help='Path to diff file')
|
||||
parser.add_argument('--stats', '-s', action='store_true', help='Show file details')
|
||||
args = parser.parse_args()
|
||||
|
||||
# Read diff from file or stdin
|
||||
try:
|
||||
if args.diff_file:
|
||||
with open(args.diff_file, 'r', encoding='utf-8', errors='replace') as f:
|
||||
diff_content = f.read()
|
||||
elif not sys.stdin.isatty():
|
||||
diff_content = sys.stdin.buffer.read().decode('utf-8', errors='replace')
|
||||
else:
|
||||
print("Usage: git diff main...HEAD | python pr-analyzer.py")
|
||||
print(" python pr-analyzer.py -f diff.txt")
|
||||
sys.exit(1)
|
||||
except OSError as e:
|
||||
print(f"Error reading diff input: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not diff_content.strip():
|
||||
print("No diff content provided")
|
||||
sys.exit(1)
|
||||
|
||||
analysis = analyze_pr(diff_content)
|
||||
print_analysis(analysis, show_files=args.stats)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for pr-analyzer.py diff parsing (stdlib unittest, no extra deps)."""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import unittest
|
||||
|
||||
# The script has a hyphen in its name, so load it by path.
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
'pr_analyzer', os.path.join(_HERE, 'pr-analyzer.py')
|
||||
)
|
||||
pr_analyzer = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(pr_analyzer)
|
||||
|
||||
|
||||
class ParseDiffFilenameTest(unittest.TestCase):
|
||||
def test_lib_prefixed_path(self):
|
||||
# "lib/" embeds a literal "b/" that the old regex swallowed.
|
||||
diff = (
|
||||
"diff --git a/lib/foo.py b/lib/foo.py\n"
|
||||
"index 1234567..89abcde 100644\n"
|
||||
"--- a/lib/foo.py\n"
|
||||
"+++ b/lib/foo.py\n"
|
||||
"@@ -1,2 +1,3 @@\n"
|
||||
" unchanged\n"
|
||||
"+added line\n"
|
||||
"-removed line\n"
|
||||
)
|
||||
files = pr_analyzer.parse_diff(diff)
|
||||
self.assertEqual(len(files), 1)
|
||||
self.assertEqual(files[0].filename, 'lib/foo.py')
|
||||
self.assertEqual(files[0].additions, 1)
|
||||
self.assertEqual(files[0].deletions, 1)
|
||||
|
||||
def test_normal_path(self):
|
||||
diff = (
|
||||
"diff --git a/src/main.py b/src/main.py\n"
|
||||
"index 1111111..2222222 100644\n"
|
||||
"--- a/src/main.py\n"
|
||||
"+++ b/src/main.py\n"
|
||||
"@@ -0,0 +1 @@\n"
|
||||
"+print('hi')\n"
|
||||
)
|
||||
files = pr_analyzer.parse_diff(diff)
|
||||
self.assertEqual(len(files), 1)
|
||||
self.assertEqual(files[0].filename, 'src/main.py')
|
||||
|
||||
def test_other_embedded_b_slash_prefixes(self):
|
||||
# web/ and db/ also contain a literal "b/".
|
||||
diff = (
|
||||
"diff --git a/web/x.js b/web/x.js\n"
|
||||
"+++ b/web/x.js\n"
|
||||
"+console.log(1)\n"
|
||||
"diff --git a/db/y.sql b/db/y.sql\n"
|
||||
"+++ b/db/y.sql\n"
|
||||
"+SELECT 1;\n"
|
||||
)
|
||||
files = pr_analyzer.parse_diff(diff)
|
||||
self.assertEqual([f.filename for f in files], ['web/x.js', 'db/y.sql'])
|
||||
|
||||
def test_rename_falls_back_to_b_side(self):
|
||||
diff = (
|
||||
"diff --git a/old/name.py b/new/name.py\n"
|
||||
"similarity index 100%\n"
|
||||
"rename from old/name.py\n"
|
||||
"rename to new/name.py\n"
|
||||
)
|
||||
files = pr_analyzer.parse_diff(diff)
|
||||
self.assertEqual(len(files), 1)
|
||||
self.assertEqual(files[0].filename, 'new/name.py')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,138 @@
|
||||
---
|
||||
name: "database-migration"
|
||||
description: "Wavelet 项目专用:当新增或修改数据库表结构、索引、初始化数据、系统配置 seed、模板 seed、默认管理员、goose SQL 迁移、internal/infra/persistence/migrator、ClickHouse 分析库 DDL 或数据库升级流程时必须使用。本技能指导在 internal/infra/persistence/migrator/goose 下编写 PostgreSQL/SQLite 双方言 SQL 迁移,以及在 goose/clickhouse 下编写 ClickHouse 单方言分析表迁移,并完成验证。"
|
||||
---
|
||||
|
||||
# Wavelet 数据库升级操作指南
|
||||
|
||||
Wavelet 使用 `github.com/pressly/goose/v3` 执行 SQL 迁移。迁移入口是 `internal/infra/persistence/migrator.Migrate()`,SQL 文件嵌入在二进制中。
|
||||
|
||||
## 基本规则
|
||||
|
||||
- SQL 迁移文件放在:
|
||||
- `internal/infra/persistence/migrator/goose/postgres/`
|
||||
- `internal/infra/persistence/migrator/goose/sqlite/`
|
||||
- PostgreSQL 和 SQLite 必须使用同一个版本号、同一个语义文件名。
|
||||
- 迁移文件使用 goose SQL 标记:
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
...
|
||||
|
||||
-- +goose Down
|
||||
...
|
||||
```
|
||||
|
||||
- 不要把表结构、默认系统配置、默认模板、默认管理员初始化写回 Go 代码。
|
||||
- 编辑表结构(DDL)和插入表数据(DML/Seed)不要放在同一个 SQL 文件里,必须分成两个独立的 SQL 文件完成(例如,先通过一个文件修改表结构,再通过下一个递增版本号的文件插入/初始化数据)。
|
||||
- 插入定时任务(schedules 表数据)时绝对不能指定 `id`,必须依靠数据库自增(Identity 或 AUTOINCREMENT)自动分配,防止与用户手动或后续插入的定时任务产生 ID 冲突。
|
||||
- 不要添加物理外键;关系字段使用显式索引。
|
||||
- 数据库默认值应匹配 Go model 零值或业务兜底值。
|
||||
- 系统配置仍然保存字符串值;布尔值写 `"true"` / `"false"`,数字写十进制字符串,复杂结构写合法 JSON 字符串。
|
||||
|
||||
## 新增迁移流程
|
||||
|
||||
1. 先确认涉及的 Go model、读写路径和前端/接口消费方。
|
||||
2. 选择下一个递增版本号,格式建议 `YYYYMMDDNNNN`,例如:
|
||||
|
||||
```text
|
||||
202606090002_add_example_column.sql
|
||||
```
|
||||
|
||||
3. 在 PostgreSQL 和 SQLite 目录各新增同名 SQL 文件。
|
||||
4. 写 `Up`:
|
||||
- 表结构变更使用 SQL DDL。
|
||||
- 初始化/seed 数据使用 SQL `INSERT`。
|
||||
- 需要幂等时使用 `IF NOT EXISTS` 或 `ON CONFLICT ... DO NOTHING`。
|
||||
5. 写 `Down`:
|
||||
- 能安全回滚的结构变更写反向 DDL。
|
||||
- seed 数据按 key/name 等稳定标识删除。
|
||||
6. 如果变更 API handler,运行 `make swagger`。
|
||||
7. 至少运行:
|
||||
|
||||
```bash
|
||||
go test ./internal/infra/persistence/migrator
|
||||
go test ./internal/model ./internal/apps/config ./internal/apps/admin/system_config
|
||||
make code-check
|
||||
```
|
||||
|
||||
## 方言注意事项
|
||||
|
||||
- PostgreSQL 自增主键用 `BIGSERIAL`;SQLite 自增主键用 `INTEGER PRIMARY KEY AUTOINCREMENT`。
|
||||
- PostgreSQL 时间类型优先 `TIMESTAMPTZ`;SQLite 使用 `DATETIME`。
|
||||
- PostgreSQL JSON 字段用 `JSONB`;SQLite 用 `JSON` 或 `TEXT`。
|
||||
- 两个方言目录的字段名、索引名、seed 数据语义必须保持一致。
|
||||
|
||||
## 修改默认系统配置
|
||||
|
||||
- 新增或调整系统配置 seed 时,更新两个方言的 SQL 文件。
|
||||
- `visibility` 使用常量语义:`0` 不公开,`1` 通过 `/api/v1/config/public` 返回。
|
||||
- 公共配置 API 直接返回所有 `visibility = 1` 的配置键值,不要在 handler 中重新硬编码 key 列表。
|
||||
|
||||
## 验证重点
|
||||
|
||||
- goose 能在空库上完整执行。
|
||||
- `system_configs`、默认 `admin`、内置模板能按预期初始化。
|
||||
- 新增表/列与 Go model 的列名、类型和默认值兼容。
|
||||
- 前端或接口消费的公共配置值仍按字符串解析。
|
||||
|
||||
## ClickHouse 分析库(辅助 OLAP)
|
||||
|
||||
ClickHouse 是**辅助 OLAP 存储**,与 PostgreSQL/SQLite 主库**完全独立**的迁移与访问管线:
|
||||
|
||||
- 主库(PG/SQLite):业务事务数据、`goose_db_version`、双方言 SQL。
|
||||
- 分析库(ClickHouse):分析型数据、`goose_clickhouse_version`、单方言 SQL。日志用途表还必须在主库建回落并走 `logstore`(见该 skill);CH 目录仍只放 CH DDL。
|
||||
|
||||
**不要**把 ClickHouse 表结构混入 PG/SQLite 迁移目录,也**不要**在 `support-files/`、`internal/apps/` 或 `internal/repository/` 中手写 DDL。
|
||||
|
||||
### 目录与职责
|
||||
|
||||
| 路径 | 职责 |
|
||||
| :--- | :--- |
|
||||
| `internal/infra/persistence/migrator/goose/clickhouse/` | **唯一** ClickHouse DDL 来源(goose SQL,嵌入二进制) |
|
||||
| `internal/model/analytics/` | 分析表 Go model,列名须与 goose DDL 一致 |
|
||||
| `internal/repository/analytics/` | 所有 ClickHouse 读写(批量写入、查询、聚合) |
|
||||
| `internal/infra/persistence/clickhouse.go` | 连接初始化(`ChConn` 原生批量、`ChDB` GORM 查询) |
|
||||
|
||||
### 迁移入口与版本表
|
||||
|
||||
- 入口:`migrator.MigrateClickHouse()`,在 `cmd/root.go` 的 `PreRun` 中于 `migrator.Migrate()` 之后调用。
|
||||
- 仅当 `clickhouse.enabled: true` 时执行;禁用时直接跳过(见 `TestMigrateClickHouseSkipsWhenDisabled`)。
|
||||
- 版本表:`goose_clickhouse_version`,与主库 `goose_db_version` **分离**,互不影响。
|
||||
- 方言:仅 ClickHouse,**无** SQLite 镜像目录。
|
||||
|
||||
### ClickHouse 迁移规则
|
||||
|
||||
1. **DDL 只写 goose SQL**:`CREATE TABLE IF NOT EXISTS ...`,禁止 GORM `AutoMigrate`、禁止在 repository 或 handler 中建表。
|
||||
2. **无事务**:ClickHouse 不支持 goose 事务包装;每个 `Up`/`Down` 语句独立提交。
|
||||
3. **幂等 Up**:表用 `IF NOT EXISTS`;`Down` 用 `DROP TABLE IF EXISTS`。
|
||||
4. **Down 谨慎**:MergeTree 等引擎上 `DROP TABLE` 会立即删除数据,生产环境通常只前滚;仅在开发/测试需要回滚时编写 `Down`。
|
||||
5. **DDL 与 DML 分离**:与主库相同,表结构变更与数据初始化分文件、分版本号;分析表通常无 seed,批量写入由 repository 在运行时完成。
|
||||
6. **引擎与排序键**:在 SQL 中显式声明 `ENGINE`、`PARTITION BY`、`ORDER BY` 等,与查询模式对齐(例如按 `created_at` 分区)。
|
||||
7. **禁止重复 DDL**:不要在 `support-files/`、`apps` 初始化逻辑或 `repository/analytics` 中复制建表语句。
|
||||
|
||||
### 新增分析表工作流
|
||||
|
||||
按以下顺序落地,避免列名或类型漂移:
|
||||
|
||||
1. **Model**:在 `internal/model/analytics/` 定义 struct,`gorm:"column:..."` 与 DDL 列名一一对应;实现 `TableName()`,批量写入表可提供 `InsertColumns()` / `BatchInsertSQL()`。
|
||||
2. **Goose SQL**:在 `internal/infra/persistence/migrator/goose/clickhouse/` 新增递增版本文件(格式同主库,如 `YYYYMMDDNNNN_create_xxx.sql`),编写 `-- +goose Up` / `-- +goose Down`。
|
||||
3. **Repository**:在 `internal/repository/analytics/` 实现 `BatchInsert*`(`db.ChConn` 一次 `PrepareBatch` + 多行 `Append` + 一次 `Send`)与查询(`db.ChDB`);连接未初始化时返回明确错误,**不要**在 handler 写 SQL,**不要**在 repository 内维护 channel/goroutine。
|
||||
4. **Apps**:在 `internal/apps/<domain>/` 编排采集与入队;高频写入通过 `internal/infra/persistence/batchwriter` 各域独立实例异步 flush(详见 `clickhouse-batchwriter` 技能)。**日志/分析用途表**还要同时建 PG/SQLite 回落并接入 `logstore`(见 `logstore` 技能),`FlushFunc` 调 `logstore.Active` 而不是 `analyticsrepo`;普通业务分析表仍只读 repository。
|
||||
|
||||
### ClickHouse 验证
|
||||
|
||||
至少运行:
|
||||
|
||||
```bash
|
||||
go test ./internal/infra/persistence/migrator
|
||||
go test ./internal/repository/analytics
|
||||
make code-check
|
||||
```
|
||||
|
||||
验证重点:
|
||||
|
||||
- goose 能在空 ClickHouse 实例上完整执行 `Up`。
|
||||
- `internal/model/analytics` 列名、类型与 goose SQL 一致。
|
||||
- repository 读写路径不依赖 handler 内联 SQL。
|
||||
- `clickhouse.enabled: false` 时启动不报错、不执行迁移。
|
||||
@@ -0,0 +1,265 @@
|
||||
---
|
||||
name: "file-upload"
|
||||
description: "Wavelet 项目专用:当业务需要上传文件、读取已上传文件、在 Worker/任务中程序化摄取字节流、选择存储引擎能力、或排查 w_uploads / 文件统计异常时必须使用。本技能指导 storage 与 upload 分层、upload.Ingest 策略选型、前后端接入与禁止旁路写表。"
|
||||
---
|
||||
|
||||
# 存储引擎与文件上传开发规范
|
||||
|
||||
本技能是 Wavelet **文件上传与对象存储**的唯一开发指导。开始开发前先阅读仓库根目录 [AGENTS.md](file:///Users/ryan/DEV/Go/Wavelet/AGENTS.md),遵守项目级核心规则。
|
||||
|
||||
---
|
||||
|
||||
## 架构分层(必须理解)
|
||||
|
||||
Wavelet 将「对象存储」与「上传业务」分为两层,**禁止混用职责**:
|
||||
|
||||
| 层级 | 包路径 | 职责 | 业务是否直接调用 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **对象存储引擎** | `internal/infra/objectstore` | `Backend` 接口:`Put` / `Get` / `Delete` / `Test`;按配置切换 Local / S3 / R2 / OSS / WebDAV | **禁止**(仅 upload 域内部使用) |
|
||||
| **上传域服务** | `internal/apps/upload` | `w_uploads` 记录、权限、秒传、统计、文件服务、`upload.Ingest` | **必须** |
|
||||
| **上传 HTTP 入口** | `internal/apps/upload/handler` | `POST /api/v1/upload` 等 multipart 接口 | 前端 / 用户侧上传 |
|
||||
| **文件访问** | `internal/apps/upload/filesrv` | `GET /f/:id` 流式响应、访问控制、图片 WebP 压缩 | 展示 / 下载 |
|
||||
|
||||
```text
|
||||
业务模块 ──► upload.Ingest / upload.Remove(唯一写入门禁)
|
||||
├── storage.Backend.Put/Get/Delete
|
||||
├── repository.CreateUpload(仅 upload 内部)
|
||||
└── RecordUploadStatsAdd/Remove(ingest 内置,禁止业务直调)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心防线(Guardrails)
|
||||
|
||||
以下写法**一律禁止**:
|
||||
|
||||
```go
|
||||
// ❌ 业务包直接写 blob
|
||||
storage.Active(ctx); backend.Put(...)
|
||||
|
||||
// ❌ 旁路写 w_uploads
|
||||
db.DB(ctx).Create(&model.Upload{})
|
||||
repository.CreateUpload(ctx, upload) // 仅 internal/apps/upload 允许
|
||||
|
||||
// ❌ 手动维护统计
|
||||
upload.ApplyUploadStatsAdd(ctx, upload) // 已 Deprecated
|
||||
|
||||
// ❌ 业务表存物理路径
|
||||
invoice.FilePath = "uploads/2026/01/02/123.pdf"
|
||||
```
|
||||
|
||||
**正确做法**:业务表只存 `upload_id`(`uint64` / JSON string),通过 `/f/{id}` 或 `upload.OpenStoredObject` 访问。
|
||||
|
||||
---
|
||||
|
||||
## Ingest 策略选型(Policy Decision)
|
||||
|
||||
根据场景选择 `upload.Ingest` 的 `Policy`:
|
||||
|
||||
| 场景 | Policy | 哈希命中时 | 未命中时 | 典型调用方 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| 用户 HTTP 上传(含秒传) | `PolicyDedupNewRecord` | 复用 path,**新建记录 + 统计** | 写 blob + 新建记录 + 统计 | `handler.UploadFile`(已内置) |
|
||||
| Worker 生成全新文件 | `PolicyCreate` | 不查重,始终写 blob + 记录 | 同左 | 报表导出、定时生成 |
|
||||
| 镜像 / 去重摄取(Pixez) | `PolicyResolveExisting` | **直接返回已有记录**,不建新记录、不加统计 | 写 blob + 新建记录 + 统计 | 异步镜像任务 |
|
||||
| 业务只需引用已有文件 | 不调 Ingest | — | — | 业务 API 校验 `upload_id` 即可 |
|
||||
|
||||
### Result 字段含义
|
||||
|
||||
| 字段 | 含义 |
|
||||
| :--- | :--- |
|
||||
| `Created` | 是否新建了 `w_uploads` 记录 |
|
||||
| `Stored` | 是否写入了新 blob |
|
||||
| `Resolved` | 是否通过哈希解析到已有记录(仅 `PolicyResolveExisting`) |
|
||||
|
||||
---
|
||||
|
||||
## 后端:程序化上传(Worker / 业务逻辑)
|
||||
|
||||
### 标准模板
|
||||
|
||||
在 `logics.go`(接受 `context.Context`,不依赖 `*gin.Context`)中调用:
|
||||
|
||||
```go
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/upload"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
)
|
||||
|
||||
func ingestMirrorFile(ctx context.Context, userID uint64, data []byte, hash, filename, mime, ext string) (model.Upload, error) {
|
||||
accessMode := 1
|
||||
result, err := upload.Ingest(ctx, upload.IngestRequest{
|
||||
UserID: userID,
|
||||
Reader: bytes.NewReader(data),
|
||||
Size: int64(len(data)),
|
||||
FileName: filename,
|
||||
MimeType: mime,
|
||||
Extension: ext,
|
||||
Hash: hash, // 必填:SHA-256 hex
|
||||
Type: "your_biz_type",
|
||||
AccessMode: &accessMode,
|
||||
Metadata: model.UploadMetadata{
|
||||
Extra: map[string]any{"source": "worker"},
|
||||
},
|
||||
Policy: upload.PolicyResolveExisting,
|
||||
})
|
||||
if err != nil {
|
||||
return model.Upload{}, err
|
||||
}
|
||||
return result.Upload, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Request 关键字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
| :--- | :--- |
|
||||
| `Hash` | **必填**,推荐 SHA-256 hex;用于秒传 / 镜像去重 |
|
||||
| `Type` | 业务分类(如 `avatar`、`invoice`、`pixez_mirror`),用于筛选与统计 |
|
||||
| `AccessMode` | `nil` 时按 type 默认:`avatar` → 公开(1),其余 → 私有(0) |
|
||||
| `SkipExtensionCheck` | Worker 场景若已自行校验扩展名,可设为 `true` |
|
||||
| `ObjectKeyFn` | 可选自定义存储路径;默认 `uploads/YYYY/MM/DD/{id}.{ext}` |
|
||||
|
||||
### 错误处理
|
||||
|
||||
| 错误 | 含义 | Handler 映射建议 |
|
||||
| :--- | :--- | :--- |
|
||||
| `upload.ErrIngestStorageReadOnly` | 存储迁移维护中 | `response.AbortConflict` |
|
||||
| `ingest.ErrForbidden` | 无权删除他人文件 | HTTP 403 |
|
||||
| `shared.ErrUnsupportedFormat` | 扩展名不在白名单 | `response.AbortBadRequest` |
|
||||
|
||||
### 删除
|
||||
|
||||
```go
|
||||
// 管理员 / 系统删除
|
||||
_, err := upload.Remove(ctx, uploadID)
|
||||
|
||||
// 用户删除自己的文件
|
||||
_, err := upload.RemoveOwned(ctx, userID, uploadID)
|
||||
```
|
||||
|
||||
### 读取已存储对象(不上传)
|
||||
|
||||
```go
|
||||
uploadRec, err := repository.GetActiveUploadByID(ctx, uploadID)
|
||||
obj, err := uploadstorage.OpenStoredObject(ctx, &uploadRec)
|
||||
defer obj.Body.Close()
|
||||
```
|
||||
|
||||
或通过门面(若已从 `exports` 暴露 `OpenStoredObject`)读取。HTTP 对外访问统一走 `GET /f/:id`。
|
||||
|
||||
---
|
||||
|
||||
## 后端:业务 API 引用已上传文件
|
||||
|
||||
推荐 **两步流程**(先上传、后提交业务):
|
||||
|
||||
1. 前端 `POST /api/v1/upload` → 获得 `upload.id`
|
||||
2. 业务 API 接收 `upload_id`,用 `repository.GetActiveUploadByID` 校验存在且 `status` 为 active
|
||||
3. (可选)校验 `upload.Type` 是否为预期业务类型
|
||||
4. 将 `upload_id` 写入业务表字段(如 `cover_file_id`)
|
||||
|
||||
**禁止**在业务 Handler 中重复实现 multipart 解析,除非有极强的特殊协议需求。
|
||||
|
||||
---
|
||||
|
||||
## 前端:用户侧上传
|
||||
|
||||
使用 `frontend/lib/services/upload/`:
|
||||
|
||||
```typescript
|
||||
import { services } from '@/lib/services'
|
||||
import { getFileUrl } from '@/lib/services/upload'
|
||||
|
||||
// 上传
|
||||
const upload = await services.upload.uploadFile(file, 'invoice', { orderId: '123' })
|
||||
|
||||
// 展示
|
||||
const url = getFileUrl(upload.id) // → /f/{id}
|
||||
|
||||
// Base64 图片(头像等)
|
||||
const res = await services.upload.uploadBase64Image(croppedBase64, 'avatar', 'avatar.png')
|
||||
```
|
||||
|
||||
### 前端规范
|
||||
|
||||
- 新增上传相关 API 时,扩展 `UploadService` / `AdminUploadService`,在 `frontend/lib/services/index.ts` 注册
|
||||
- 图片预览使用 `getFileUrl(id, quality?)` 或 `FileImagePreview` 组件
|
||||
- 业务表单项只提交 `upload_id`,不要提交 blob URL 或 `file_path`
|
||||
|
||||
---
|
||||
|
||||
## 统计与排查
|
||||
|
||||
`w_upload_stats` 由 `upload.Ingest` / `upload.Remove` **自动维护**,业务不得手动增量。
|
||||
|
||||
若发现 trend / total 与 `w_uploads` 不一致(常见于历史旁路写表):
|
||||
|
||||
```go
|
||||
upload.RebuildUploadStats(ctx) // 从 w_uploads 全量重建统计
|
||||
```
|
||||
|
||||
排查清单:
|
||||
|
||||
1. 业务是否绕过 `upload.Ingest` 直接 `db.Create(&model.Upload{})`?
|
||||
2. 是否手动调用已 Deprecated 的 `ApplyUploadStatsAdd`?
|
||||
3. 删除是否走 `upload.Remove`(须在软删**前**扣减统计)?
|
||||
|
||||
---
|
||||
|
||||
## 测试要求
|
||||
|
||||
### 后端 ingest 测试
|
||||
|
||||
- 使用 `testhelper.SetupTestEnvironment(t)` 初始化 DB
|
||||
- 存储 mock:`storage.MockStorage(...)` + `storage.IsEnabledFunc = func() bool { return true }`
|
||||
- **禁止**在源码目录硬编码 `uploads/test` 路径;本地文件测试用 `t.TempDir()` 或 mock backend
|
||||
- 覆盖:三种 Policy、Remove 后统计归零、ReadOnly 拒绝写入
|
||||
|
||||
参考:[internal/apps/upload/ingest/ingest_test.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/upload/ingest/ingest_test.go)
|
||||
|
||||
### Handler 回归
|
||||
|
||||
修改 upload handler 后运行:
|
||||
|
||||
```bash
|
||||
go test ./internal/apps/upload/...
|
||||
make code-check
|
||||
```
|
||||
|
||||
若变更 HTTP 接口,运行 `make swagger`。
|
||||
|
||||
---
|
||||
|
||||
## 存量代码迁移(旁路写表 → Ingest)
|
||||
|
||||
将以下模式:
|
||||
|
||||
```go
|
||||
storage.Active(ctx)
|
||||
backend.Put(ctx, key, reader, size, mime)
|
||||
db.DB(ctx).Create(&upload)
|
||||
```
|
||||
|
||||
替换为:
|
||||
|
||||
```go
|
||||
upload.Ingest(ctx, upload.IngestRequest{ Policy: upload.PolicyResolveExisting, ... })
|
||||
```
|
||||
|
||||
迁移完成后执行一次 `upload.RebuildUploadStats(ctx)` 修复历史统计偏差。
|
||||
|
||||
---
|
||||
|
||||
## 质量门禁 Checklist
|
||||
|
||||
完成文件上传相关开发后,确认:
|
||||
|
||||
- [ ] 业务模块无 `repository.CreateUpload` / `SoftDeleteUpload` 调用
|
||||
- [ ] 业务模块无 `storage.Active` + `Put` 直接写文件
|
||||
- [ ] 业务表存 `upload_id`,不存 `file_path`
|
||||
- [ ] Worker 摄取使用正确的 `Policy`
|
||||
- [ ] 新增测试覆盖 ingest 路径
|
||||
- [ ] `make code-check` 通过
|
||||
- [ ] HTTP 变更已 `make swagger`
|
||||
@@ -0,0 +1,187 @@
|
||||
---
|
||||
name: go-logging
|
||||
description: 在选择日志方案、配置 slog、编写结构化日志语句或决定日志级别时使用。也适用于设置生产日志、为日志添加请求作用域上下文或从 log 迁移到 slog 的场景,即使用户未明确提及日志。不涵盖错误处理策略(参见 go-error-handling)。
|
||||
license: Apache-2.0
|
||||
compatibility: slog requires Go 1.21+; slog/slogtest requires Go 1.22+
|
||||
metadata:
|
||||
sources: "Google Style Guide, Uber Style Guide"
|
||||
---
|
||||
|
||||
# Go 日志
|
||||
|
||||
## 核心原则
|
||||
|
||||
日志是给**运维人员**看的,不是给开发人员看的。每一行日志都应该帮助某人诊断生产问题。如果不能达到这个目的,就是噪音。
|
||||
|
||||
---
|
||||
|
||||
## 选择日志器
|
||||
|
||||
> **规范**:在新的 Go 代码中使用 `log/slog`。
|
||||
|
||||
`slog` 是结构化的、分级别的,并且在标准库中(Go 1.21+)。它涵盖了绝大多数生产日志需求。
|
||||
|
||||
```
|
||||
选择哪个日志器?
|
||||
├─ 新的生产代码 → log/slog
|
||||
├─ 简单 CLI / 一次性 → log(标准库)
|
||||
└─ 有性能瓶颈 → zerolog 或 zap(先做基准测试)
|
||||
```
|
||||
|
||||
除非性能分析显示 `slog` 在热路径中是瓶颈,否则不要引入第三方日志库。引入时,保持相同的结构化键值风格。
|
||||
|
||||
> 在设置 slog handler、配置 JSON/文本输出或从 log.Printf 迁移到 slog 时,阅读 [references/LOGGING-PATTERNS.md](references/LOGGING-PATTERNS.md)。
|
||||
|
||||
---
|
||||
|
||||
## 结构化日志
|
||||
|
||||
> **规范**:始终使用键值对。永远不要将值插值到消息字符串中。
|
||||
|
||||
消息是描述发生了什么的**静态描述**。动态数据放在键值属性中:
|
||||
|
||||
```go
|
||||
// 好:静态消息,结构化字段
|
||||
slog.Info("order placed", "order_id", orderID, "total", total)
|
||||
|
||||
// 不好:动态数据嵌入到消息字符串中
|
||||
slog.Info(fmt.Sprintf("order %d placed for $%.2f", orderID, total))
|
||||
```
|
||||
|
||||
### 键名
|
||||
|
||||
> **建议**:日志属性键使用 `snake_case`。
|
||||
|
||||
键应为小写、下划线分隔,并在整个代码库中保持一致:`user_id`、`request_id`、`elapsed_ms`。
|
||||
|
||||
### 类型化属性
|
||||
|
||||
对于性能关键路径,使用类型化构造函数以避免分配:
|
||||
|
||||
```go
|
||||
slog.LogAttrs(ctx, slog.LevelInfo, "request handled",
|
||||
slog.String("method", r.Method),
|
||||
slog.Int("status", code),
|
||||
slog.Duration("elapsed", elapsed),
|
||||
)
|
||||
```
|
||||
|
||||
> 在优化日志性能或使用 Enabled() 进行预检查时,阅读 [references/LEVELS-AND-CONTEXT.md](references/LEVELS-AND-CONTEXT.md)。
|
||||
|
||||
---
|
||||
|
||||
## 日志级别
|
||||
|
||||
> **建议**:一致地遵循这些级别语义。
|
||||
|
||||
| 级别 | 何时使用 | 生产默认 |
|
||||
|------|----------|----------|
|
||||
| Debug | 仅开发人员的诊断,跟踪内部状态 | 禁用 |
|
||||
| Info | 重要的生命周期事件:启动、关闭、配置加载 | 启用 |
|
||||
| Warn | 意外但可恢复:使用了弃用功能、重试成功 | 启用 |
|
||||
| Error | 操作失败,需要运维人员关注 | 启用 |
|
||||
|
||||
**经验法则**:
|
||||
- 如果没有人需要对其采取行动,那就不是 Error——使用 Warn 或 Info
|
||||
- 如果只在连接调试器时才有用,那就是 Debug
|
||||
- `slog.Error` 应始终包含 `"err"` 属性
|
||||
|
||||
```go
|
||||
slog.Error("payment failed", "err", err, "order_id", id)
|
||||
slog.Warn("retry succeeded", "attempt", n, "endpoint", url)
|
||||
slog.Info("server started", "addr", addr)
|
||||
slog.Debug("cache lookup", "key", key, "hit", hit)
|
||||
```
|
||||
|
||||
> 在 Warn 和 Error 之间选择或定义自定义详细级别时,阅读 [references/LEVELS-AND-CONTEXT.md](references/LEVELS-AND-CONTEXT.md)。
|
||||
|
||||
---
|
||||
|
||||
## 请求作用域日志
|
||||
|
||||
> **建议**:从 context 派生日志器以携带请求作用域字段。
|
||||
|
||||
使用中间件为日志器添加请求 ID、用户 ID 或跟踪 ID,然后通过 context 或作为显式参数将增强后的日志器传递给下游:
|
||||
|
||||
```go
|
||||
func middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
logger := slog.With("request_id", requestID(r))
|
||||
ctx := context.WithValue(r.Context(), loggerKey, logger)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
该请求中所有后续的日志调用都会自动携带 `request_id`。
|
||||
|
||||
> 在实现日志中间件或通过 context 传递日志器时,阅读 [references/LOGGING-PATTERNS.md](references/LOGGING-PATTERNS.md)。
|
||||
|
||||
---
|
||||
|
||||
## 日志或返回,不要同时
|
||||
|
||||
> **规范**:每个错误恰好处理一次——要么记录它,要么返回它。
|
||||
|
||||
记录错误然后返回它会导致重复噪音,因为栈上游的调用者也会处理该错误。
|
||||
|
||||
```go
|
||||
// 不好:在这里记录,并且栈上游的每个调用者也会记录
|
||||
if err != nil {
|
||||
slog.Error("query failed", "err", err)
|
||||
return fmt.Errorf("query: %w", err)
|
||||
}
|
||||
|
||||
// 好:包装并返回——让调用者决定
|
||||
if err != nil {
|
||||
return fmt.Errorf("query: %w", err)
|
||||
}
|
||||
```
|
||||
|
||||
**例外**:HTTP 处理器和其他栈顶边界可以在服务端记录详细错误,同时向客户端返回脱敏消息:
|
||||
|
||||
```go
|
||||
if err != nil {
|
||||
slog.Error("checkout failed", "err", err, "user_id", uid)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
参见 [go-error-handling](../go-error-handling/SKILL.md) 了解完整的处理一次模式和错误包装指导。
|
||||
|
||||
---
|
||||
|
||||
## 不应记录的内容
|
||||
|
||||
> **规范**:永远不要记录密钥、凭证、PII 或高基数无界数据。
|
||||
|
||||
- 密码、API 密钥、令牌、会话 ID
|
||||
- 完整的信用卡号、社会安全号
|
||||
- 可能包含用户数据的请求/响应体
|
||||
- 无界大小的完整切片或映射
|
||||
|
||||
> 在决定哪些数据可以安全包含在日志属性中时,阅读 [references/LEVELS-AND-CONTEXT.md](references/LEVELS-AND-CONTEXT.md)。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 应该 | 不应该 |
|
||||
|------|--------|
|
||||
| `slog.Info("msg", "key", val)` | `log.Printf("msg %v", val)` |
|
||||
| 静态消息 + 结构化字段 | 在消息中使用 `fmt.Sprintf` |
|
||||
| `snake_case` 键 | camelCase 或不一致的键 |
|
||||
| 日志或返回错误 | 同时日志和返回同一错误 |
|
||||
| 从 context 派生日志器 | 每次调用创建新日志器 |
|
||||
| `slog.Error` 配合 `"err"` 属性 | 用 `slog.Info` 记录错误 |
|
||||
| 在热路径上预检查 `Enabled()` | 始终分配日志参数 |
|
||||
|
||||
---
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **错误处理**:在决定是记录还是返回错误,或了解处理一次模式时,参见 [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- **上下文传播**:在通过 context 传递请求作用域值(包括日志器)时,参见 [go-context](../go-context/SKILL.md)
|
||||
- **性能**:在优化热路径日志或减少日志调用中的分配时,参见 [go-performance](../go-performance/SKILL.md)
|
||||
- **代码审查**:在审查 Go PR 中的日志实践时,参见 [go-code-review](../go-code-review/SKILL.md)
|
||||
@@ -0,0 +1,244 @@
|
||||
# 级别与上下文
|
||||
|
||||
关于日志级别语义、基于 context 的日志模式、性能考虑以及哪些内容不应出现在日志中的详细指导。
|
||||
|
||||
## 级别语义
|
||||
|
||||
### Debug
|
||||
|
||||
仅开发人员的诊断。生产中默认禁用。用于跟踪在开发或故障排查期间有帮助的内部状态:
|
||||
|
||||
```go
|
||||
slog.Debug("cache lookup", "key", key, "hit", hit)
|
||||
slog.Debug("parsed config", "fields", len(cfg.Fields))
|
||||
slog.Debug("SQL query", "query", q, "args", args)
|
||||
```
|
||||
|
||||
**何时使用**:内部状态转换、缓存行为、开发期间的详细请求/响应数据。
|
||||
|
||||
### Info
|
||||
|
||||
确认系统按预期运行的重要事件。这些应在生产中对理解系统行为有用:
|
||||
|
||||
```go
|
||||
slog.Info("server started", "addr", addr, "version", version)
|
||||
slog.Info("config loaded", "path", cfgPath, "env", env)
|
||||
slog.Info("migration completed", "version", v, "elapsed_ms", elapsed)
|
||||
slog.Info("user registered", "user_id", uid)
|
||||
```
|
||||
|
||||
**何时使用**:启动/关闭、配置变更、重要业务事件、周期性健康摘要。
|
||||
|
||||
### Warn
|
||||
|
||||
发生了意外的事情,但系统已恢复或优雅降级。运维人员可能想要调查但不需要立即行动:
|
||||
|
||||
```go
|
||||
slog.Warn("retry succeeded", "attempt", n, "endpoint", url)
|
||||
slog.Warn("deprecated endpoint called", "path", r.URL.Path, "user_id", uid)
|
||||
slog.Warn("rate limit approaching", "current", rate, "limit", max)
|
||||
slog.Warn("fallback to default config", "err", err)
|
||||
```
|
||||
|
||||
**何时使用**:最终成功的重试、弃用的代码路径、接近资源限制、回退行为。
|
||||
|
||||
### Error
|
||||
|
||||
操作失败并需要运维人员关注。系统无法完成请求或任务:
|
||||
|
||||
```go
|
||||
slog.Error("payment failed", "err", err, "order_id", id, "amount", amt)
|
||||
slog.Error("database connection lost", "err", err, "host", dbHost)
|
||||
slog.Error("message processing failed", "err", err, "msg_id", msgID)
|
||||
```
|
||||
|
||||
**何时使用**:影响用户的失败操作、丢失的连接、数据完整性问题、未恢复的外部服务故障。
|
||||
|
||||
**始终包含错误**:`slog.Error` 调用应始终带有包含实际错误值的 `"err"` 属性。
|
||||
|
||||
### 在 Warn 和 Error 之间选择
|
||||
|
||||
```
|
||||
操作最终是否成功?
|
||||
├─ 是(经过重试/回退后)→ Warn
|
||||
└─ 否(调用者收到错误)→ Error
|
||||
├─ 需要立即关注 → Error
|
||||
└─ 可以等到下次审查 → Warn
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 自定义详细级别
|
||||
|
||||
slog 级别是整数。在标准级别之间定义自定义子级别以实现细粒度控制:
|
||||
|
||||
```go
|
||||
const (
|
||||
LevelTrace = slog.Level(-8) // 低于 Debug
|
||||
LevelNotice = slog.Level(2) // 在 Info 和 Warn 之间
|
||||
)
|
||||
|
||||
slog.Log(ctx, LevelTrace, "detailed trace", "span_id", spanID)
|
||||
```
|
||||
|
||||
使用 `HandlerOptions.Level` 配合 `slog.LevelVar` 在运行时控制最低级别。
|
||||
|
||||
---
|
||||
|
||||
## 基于 Context 的日志
|
||||
|
||||
### 模式 1:Context 中的日志器
|
||||
|
||||
在 context 中存储增强后的 `*slog.Logger`。每个中间件层添加自己的字段:
|
||||
|
||||
```go
|
||||
func authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := authenticate(r)
|
||||
logger := loggerFromCtx(r.Context()).With("user_id", userID)
|
||||
ctx := context.WithValue(r.Context(), loggerKey, logger)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**优点**:简单,与任何 handler 链配合使用。
|
||||
**缺点**:需要纪律来始终使用 `loggerFromCtx`。
|
||||
|
||||
### 模式 2:显式日志器参数
|
||||
|
||||
将 `*slog.Logger` 作为函数参数与 context 一起传递:
|
||||
|
||||
```go
|
||||
func processOrder(ctx context.Context, logger *slog.Logger, order *Order) error {
|
||||
logger.Info("processing order", "order_id", order.ID)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**优点**:显式依赖,更易测试,无需 context 键。
|
||||
**缺点**:每个函数签名中都有额外参数。
|
||||
|
||||
### 何时使用哪种
|
||||
|
||||
| 场景 | 推荐 |
|
||||
|------|------|
|
||||
| HTTP 处理器 / 中间件链 | Context 中的日志器 |
|
||||
| 无 HTTP 依赖的库代码 | 显式参数 |
|
||||
| 后台工作器 / 批处理任务 | 显式参数 |
|
||||
| 深层调用链(5 层以上) | Context 中的日志器 |
|
||||
|
||||
---
|
||||
|
||||
## 性能考虑
|
||||
|
||||
### 使用 Enabled() 预检查
|
||||
|
||||
当日志级别被禁用时避免分配日志参数:
|
||||
|
||||
```go
|
||||
// 开销大:参数始终被求值,即使 Debug 被禁用
|
||||
slog.Debug("request details",
|
||||
"headers", fmt.Sprintf("%v", r.Header),
|
||||
"body", string(bodyBytes),
|
||||
)
|
||||
|
||||
// 更好:禁用时完全跳过
|
||||
if slog.Default().Enabled(ctx, slog.LevelDebug) {
|
||||
slog.Debug("request details",
|
||||
"headers", fmt.Sprintf("%v", r.Header),
|
||||
"body", string(bodyBytes),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
当参数构造开销大(格式化、序列化或读取数据)时,这很重要。对于简单属性(`slog.String`、`slog.Int`),开销可以忽略不计。
|
||||
|
||||
### 在热路径上使用 LogAttrs
|
||||
|
||||
`slog.LogAttrs` 避免了便捷方法(`slog.Info` 等)产生的 `[]any` 分配:
|
||||
|
||||
```go
|
||||
// 标准——为键值对分配一个 []any
|
||||
slog.Info("request handled", "method", r.Method, "status", code)
|
||||
|
||||
// 更快——类型化属性,无 []any 分配
|
||||
slog.LogAttrs(ctx, slog.LevelInfo, "request handled",
|
||||
slog.String("method", r.Method),
|
||||
slog.Int("status", code),
|
||||
)
|
||||
```
|
||||
|
||||
### 避免在紧凑循环中记录日志
|
||||
|
||||
如果循环处理数千个项目,记录摘要而不是每次迭代:
|
||||
|
||||
```go
|
||||
// 不好:10k 项目批次中每个项目一条日志
|
||||
for _, item := range items {
|
||||
slog.Debug("processing item", "id", item.ID)
|
||||
process(item)
|
||||
}
|
||||
|
||||
// 好:记录摘要
|
||||
slog.Info("batch started", "count", len(items))
|
||||
processed, failed := processBatch(items)
|
||||
slog.Info("batch completed", "processed", processed, "failed", failed)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 不应记录的内容
|
||||
|
||||
### 密钥和凭证
|
||||
|
||||
永远不要记录:
|
||||
- 密码、API 密钥、令牌(OAuth、JWT、会话)
|
||||
- 私钥、证书
|
||||
- 包含凭证的数据库连接字符串
|
||||
|
||||
```go
|
||||
// 不好
|
||||
slog.Info("connecting", "dsn", dsn) // 可能包含密码
|
||||
|
||||
// 好
|
||||
slog.Info("connecting", "host", dbHost, "database", dbName)
|
||||
```
|
||||
|
||||
### 个人身份信息(PII)
|
||||
|
||||
除非调试所需且你的保留策略允许,否则避免记录:
|
||||
- 电子邮件地址、电话号码
|
||||
- 完整姓名、物理地址
|
||||
- IP 地址(在某些司法管辖区)
|
||||
- 信用卡号、社会安全号
|
||||
|
||||
如果必须记录用户标识符,使用不透明 ID 而非 PII。
|
||||
|
||||
### 高基数无界数据
|
||||
|
||||
不要记录完整的请求体、Info 级别的完整栈跟踪或无界集合:
|
||||
|
||||
```go
|
||||
// 不好:无界数据
|
||||
slog.Info("received", "body", string(requestBody))
|
||||
slog.Info("users loaded", "users", users) // 可能有 10 万条记录
|
||||
|
||||
// 好:有界摘要
|
||||
slog.Info("received", "content_length", len(requestBody), "content_type", ct)
|
||||
slog.Info("users loaded", "count", len(users))
|
||||
```
|
||||
|
||||
### 决策表
|
||||
|
||||
| 数据类型 | 记录吗? | 替代方案 |
|
||||
|----------|----------|----------|
|
||||
| 请求 ID / 跟踪 ID | 是 | — |
|
||||
| 用户 ID(不透明的) | 是 | — |
|
||||
| HTTP 方法、路径、状态 | 是 | — |
|
||||
| 错误消息 | 是 | — |
|
||||
| 密码 / 令牌 | **永不** | 记录令牌前缀或 "已脱敏" |
|
||||
| 完整请求体 | **否** | 记录内容长度和类型 |
|
||||
| PII(邮箱、姓名) | **避免** | 记录不透明用户 ID |
|
||||
| 大型集合 | **否** | 记录数量或摘要 |
|
||||
| 栈跟踪 | 仅 Debug | 使用 `slog.Debug` |
|
||||
@@ -0,0 +1,314 @@
|
||||
# 日志模式
|
||||
|
||||
关于 slog 设置、handler 配置、测试、HTTP 中间件以及从旧版 `log` 包迁移的详细模式。
|
||||
|
||||
## 设置 slog
|
||||
|
||||
### 基本配置
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// JSON handler 用于生产(机器可解析)
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
slog.Info("server started", "addr", ":8080")
|
||||
// 输出:{"time":"...","level":"INFO","msg":"server started","addr":":8080"}
|
||||
}
|
||||
```
|
||||
|
||||
### 用于开发的 Text Handler
|
||||
|
||||
```go
|
||||
// 本地开发的人类可读输出
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
}))
|
||||
slog.SetDefault(logger)
|
||||
// 输出:time=... level=DEBUG msg="cache lookup" key=user:42 hit=true
|
||||
```
|
||||
|
||||
### 动态级别控制
|
||||
|
||||
使用 `slog.LevelVar` 在运行时更改最低级别(例如通过管理端点或信号处理器):
|
||||
|
||||
```go
|
||||
var programLevel = new(slog.LevelVar) // 默认 Info
|
||||
|
||||
func init() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: programLevel,
|
||||
}))
|
||||
slog.SetDefault(logger)
|
||||
}
|
||||
|
||||
// 从管理端点或信号处理器调用
|
||||
func enableDebug() {
|
||||
programLevel.Set(slog.LevelDebug)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 自定义 Handler 模式
|
||||
|
||||
### 添加源位置
|
||||
|
||||
```go
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
AddSource: true,
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
// 输出包含:"source":{"function":"main.handleRequest","file":"server.go","line":42}
|
||||
```
|
||||
|
||||
### 使用默认属性包装 Handler
|
||||
|
||||
使用 `slog.Handler` 中间件向每条日志记录注入字段:
|
||||
|
||||
```go
|
||||
type contextHandler struct {
|
||||
inner slog.Handler
|
||||
attrs []slog.Attr
|
||||
}
|
||||
|
||||
func (h *contextHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
return h.inner.Enabled(ctx, level)
|
||||
}
|
||||
|
||||
func (h *contextHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
r.AddAttrs(h.attrs...)
|
||||
return h.inner.Handle(ctx, r)
|
||||
}
|
||||
|
||||
func (h *contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return &contextHandler{inner: h.inner.WithAttrs(attrs), attrs: h.attrs}
|
||||
}
|
||||
|
||||
func (h *contextHandler) WithGroup(name string) slog.Handler {
|
||||
return &contextHandler{inner: h.inner.WithGroup(name), attrs: h.attrs}
|
||||
}
|
||||
```
|
||||
|
||||
### 多 Handler(扇出)
|
||||
|
||||
写入多个目标(例如 stdout + 文件):
|
||||
|
||||
```go
|
||||
type multiHandler struct {
|
||||
handlers []slog.Handler
|
||||
}
|
||||
|
||||
func (m *multiHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
for _, h := range m.handlers {
|
||||
if h.Enabled(ctx, level) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *multiHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
var errs []error
|
||||
for _, h := range m.handlers {
|
||||
if h.Enabled(ctx, r.Level) {
|
||||
if err := h.Handle(ctx, r); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (m *multiHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
handlers := make([]slog.Handler, len(m.handlers))
|
||||
for i, h := range m.handlers {
|
||||
handlers[i] = h.WithAttrs(attrs)
|
||||
}
|
||||
return &multiHandler{handlers: handlers}
|
||||
}
|
||||
|
||||
func (m *multiHandler) WithGroup(name string) slog.Handler {
|
||||
handlers := make([]slog.Handler, len(m.handlers))
|
||||
for i, h := range m.handlers {
|
||||
handlers[i] = h.WithGroup(name)
|
||||
}
|
||||
return &multiHandler{handlers: handlers}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 使用 slogtest 测试
|
||||
|
||||
Go 1.22+ 提供了 `testing/slogtest` 来验证 handler 实现:
|
||||
|
||||
```go
|
||||
package myhandler_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/slogtest"
|
||||
)
|
||||
|
||||
func TestHandler(t *testing.T) {
|
||||
// newHandler 返回你的自定义 slog.Handler 和一个
|
||||
// 将输出解析为 []map[string]any 的函数用于验证。
|
||||
results := func(t *testing.T) map[string]any {
|
||||
// 在此解析你的 handler 输出
|
||||
}
|
||||
|
||||
h := NewMyHandler(buf, nil)
|
||||
slogtest.Run(t, func(t *testing.T) slog.Handler { return h }, results)
|
||||
}
|
||||
```
|
||||
|
||||
### 在测试中捕获日志
|
||||
|
||||
对于断言日志输出的单元测试,写入 buffer:
|
||||
|
||||
```go
|
||||
func TestOrderProcessing(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||
|
||||
processOrder(logger, order)
|
||||
|
||||
if !strings.Contains(buf.String(), `"order_id"`) {
|
||||
t.Error("expected order_id in log output")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTTP 请求日志中间件
|
||||
|
||||
一个完整的中间件,记录每个请求的计时、状态和请求作用域字段:
|
||||
|
||||
```go
|
||||
func loggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
reqID := r.Header.Get("X-Request-ID")
|
||||
if reqID == "" {
|
||||
reqID = uuid.NewString()
|
||||
}
|
||||
|
||||
logger := slog.With(
|
||||
"request_id", reqID,
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
)
|
||||
|
||||
// 包装 response writer 以捕获状态码
|
||||
rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
|
||||
// 将日志器存入 context 供下游 handler 使用
|
||||
ctx := context.WithValue(r.Context(), loggerKey, logger)
|
||||
next.ServeHTTP(rw, r.WithContext(ctx))
|
||||
|
||||
logger.Info("request completed",
|
||||
"status", rw.status,
|
||||
"elapsed_ms", time.Since(start).Milliseconds(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.status = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
```
|
||||
|
||||
### 从 Context 获取日志器
|
||||
|
||||
```go
|
||||
type ctxKey struct{}
|
||||
|
||||
var loggerKey = ctxKey{}
|
||||
|
||||
func loggerFromCtx(ctx context.Context) *slog.Logger {
|
||||
if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
|
||||
return l
|
||||
}
|
||||
return slog.Default()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 从 log.Printf 迁移到 slog
|
||||
|
||||
### 第 1 步:替换直接调用
|
||||
|
||||
```go
|
||||
// 迁移前
|
||||
log.Printf("user %s logged in from %s", userID, ip)
|
||||
|
||||
// 迁移后
|
||||
slog.Info("user logged in", "user_id", userID, "ip", ip)
|
||||
```
|
||||
|
||||
### 第 2 步:替换 main() 中的 log.Fatalf
|
||||
|
||||
```go
|
||||
// 迁移前
|
||||
log.Fatalf("failed to connect: %v", err)
|
||||
|
||||
// 迁移后——slog 没有 Fatal;在 main 中使用 slog + os.Exit
|
||||
slog.Error("failed to connect", "err", err)
|
||||
os.Exit(1)
|
||||
```
|
||||
|
||||
### 第 3 步:桥接旧代码
|
||||
|
||||
如果逐步迁移,将标准 `log` 包的输出通过 slog 重定向:
|
||||
|
||||
```go
|
||||
// 在 main() 中,设置 slog 之后:
|
||||
slog.SetDefault(logger)
|
||||
|
||||
// 标准 log 包现在通过 slog 的默认 handler 写入。
|
||||
// 这是因为 slog.SetDefault 也会更新 log.Default()。
|
||||
```
|
||||
|
||||
### 第 4 步:替换日志器参数
|
||||
|
||||
```go
|
||||
// 迁移前:传递 *log.Logger
|
||||
func NewServer(addr string, logger *log.Logger) *Server
|
||||
|
||||
// 迁移后:显式传递 *slog.Logger
|
||||
func NewServer(addr string, logger *slog.Logger) *Server
|
||||
|
||||
// 或从 handler 中的 context 派生
|
||||
func (s *Server) handleRequest(ctx context.Context) {
|
||||
logger := loggerFromCtx(ctx)
|
||||
logger.Info("handling request")
|
||||
}
|
||||
```
|
||||
|
||||
### 迁移清单
|
||||
|
||||
| 步骤 | 更改什么 | 验证 |
|
||||
|------|----------|------|
|
||||
| 1 | `log.Printf` → `slog.Info/Warn/Error` | `rg 'log\.Printf'` 返回 0 个匹配 |
|
||||
| 2 | `log.Fatalf` → `slog.Error` + `os.Exit(1)` 在 main 中 | 仅在 `main()` 中 |
|
||||
| 3 | 在 main 中尽早设置 `slog.SetDefault` | 旧版 `log` 调用通过 slog 路由 |
|
||||
| 4 | `*log.Logger` 参数 → `*slog.Logger` | 所有构造函数已更新 |
|
||||
| 5 | 移除已替换处的 `"log"` 导入 | `goimports` 会自动处理 |
|
||||
@@ -0,0 +1,138 @@
|
||||
---
|
||||
name: go-packages
|
||||
description: Use when creating Go packages, organizing imports, managing dependencies, or deciding how to structure Go code into packages. Also use when starting a new Go project or splitting a growing codebase into packages, even if the user doesn't explicitly ask about package organization. Does not cover naming individual identifiers (see go-naming).
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
sources: "Google Style Guide, Uber Style Guide, Go Wiki CodeReviewComments"
|
||||
---
|
||||
|
||||
# Go 包和 Import
|
||||
|
||||
> **本技能不适用的场景**:对于包内单个标识符的命名,参见 [go-naming](../go-naming/SKILL.md)。对于单文件中函数的组织,参见 [go-functions](../go-functions/SKILL.md)。对于强制执行 import 规则的 linter 配置,参见 [go-linting](../go-linting/SKILL.md)。
|
||||
|
||||
## 包组织
|
||||
|
||||
### 避免 Util 包
|
||||
|
||||
包名应描述包提供的内容。避免使用 `util`、`helper`、`common` 等泛化名称——它们会模糊含义并导致 import 冲突。
|
||||
|
||||
```go
|
||||
// 好:有意义的包名
|
||||
db := spannertest.NewDatabaseFromFile(...)
|
||||
_, err := f.Seek(0, io.SeekStart)
|
||||
|
||||
// 不好:模糊的名称遮蔽含义
|
||||
db := test.NewDatabaseFromFile(...)
|
||||
_, err := f.Seek(0, common.SeekStart)
|
||||
```
|
||||
|
||||
泛化名称可以作为名称的*一部分*(例如 `stringutil`),但不应成为整个包名。
|
||||
|
||||
### Package Size
|
||||
|
||||
| 问题 | 操作 |
|
||||
|------|------|
|
||||
| 你能用一句话描述它的用途吗? | 不能 → 按职责拆分 |
|
||||
| 文件中从未共享未导出的符号? | 这些文件可以是独立的包 |
|
||||
| 不同的用户群体使用不同部分? | 按用户边界拆分 |
|
||||
| Godoc 页面过于庞大? | 拆分以提高可发现性 |
|
||||
|
||||
**不要拆分**的原因仅仅是文件很长、创建只有单一类型的包,或会产生循环依赖。
|
||||
|
||||
> 在决定是否拆分或合并包、组织包内文件或构建 CLI 程序时,阅读 [references/PACKAGE-SIZE.md](references/PACKAGE-SIZE.md)。
|
||||
|
||||
---
|
||||
|
||||
## Import
|
||||
|
||||
Import 按组组织,组之间用空行分隔。标准库包始终放在第一组。使用
|
||||
[goimports](https://pkg.go.dev/golang.org/x/tools/cmd/goimports) 自动管理。
|
||||
|
||||
```go
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/foo/bar"
|
||||
"rsc.io/goversion/version"
|
||||
)
|
||||
```
|
||||
|
||||
**快速规则:**
|
||||
|
||||
| 规则 | 指导 |
|
||||
|------|------|
|
||||
| 分组 | 标准库优先,然后是外部包。扩展分组:标准库 → 其他 → proto → 副作用 |
|
||||
| 重命名 | 除非冲突,否则避免重命名。重命名最本地的 import。Proto 包加 `pb` 后缀 |
|
||||
| 空白 import(`import _`) | 仅在 `main` 包或测试中使用 |
|
||||
| 点 import(`import .`) | 永不使用,除非用于循环依赖的测试文件 |
|
||||
|
||||
> 在组织扩展分组的 import、重命名 proto 包或决定使用空白/点 import 时,阅读 [references/IMPORTS.md](references/IMPORTS.md)。
|
||||
|
||||
---
|
||||
|
||||
## 避免 init()
|
||||
|
||||
尽可能避免 `init()`。当不可避免时,它必须是:
|
||||
|
||||
1. 完全确定性的
|
||||
2. 不依赖于其他 `init()` 的执行顺序
|
||||
3. 不依赖环境状态(环境变量、工作目录、参数)
|
||||
4. 不进行 I/O(文件系统、网络、系统调用)
|
||||
|
||||
**可接受的使用场景**:无法用单个赋值完成的复杂表达式、可插拔钩子(例如 `database/sql` 方言)、确定性预计算。
|
||||
|
||||
> 在需要将 init() 重构为显式函数或理解可接受的 init() 使用场景时,阅读 [references/PACKAGE-SIZE.md](references/PACKAGE-SIZE.md)。
|
||||
|
||||
---
|
||||
|
||||
## Main 中的退出
|
||||
|
||||
仅在 `main()` 中调用 `os.Exit` 或 `log.Fatal*`。所有其他函数应返回 error。
|
||||
|
||||
**原因**:不明显的控制流、不可测试、`defer` 语句被跳过。
|
||||
|
||||
**最佳实践**:使用 `run()` 模式——将逻辑提取到
|
||||
`func run() error` 中,在 `main()` 中调用并使用单一退出点:
|
||||
|
||||
```go
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 在实现 run() 模式、构建 CLI 子命令或选择 flag 命名约定时,阅读 [references/PACKAGE-SIZE.md](references/PACKAGE-SIZE.md)。
|
||||
|
||||
---
|
||||
|
||||
## 命令行 Flag
|
||||
|
||||
> **建议**:仅在 `package main` 中定义 flag。
|
||||
|
||||
- Flag 名称使用 `snake_case`:`--output_dir` 而非 `--outputDir`
|
||||
- 库应通过参数接收配置,而非直接读取 flag——
|
||||
这使它们可测试且可复用
|
||||
- 优先使用标准 `flag` 包;仅在需要 POSIX 约定
|
||||
(双破折号、单字符快捷方式)时使用 `pflag`
|
||||
|
||||
```go
|
||||
// 好:Flag 在 main 中定义,作为参数传递给库
|
||||
func main() {
|
||||
outputDir := flag.String("output_dir", ".", "directory for output files")
|
||||
flag.Parse()
|
||||
if err := mylib.Generate(*outputDir); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **包命名**:在选择包名、避免名称重复或命名导出符号时,参见 [go-naming](../go-naming/SKILL.md)
|
||||
- **跨包的错误处理**:在使用 `%w` vs `%v` 在包边界包装错误时,参见 [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- **Import linting**:在配置 goimports local-prefixes 或强制执行 import 分组时,参见 [go-linting](../go-linting/SKILL.md)
|
||||
- **全局状态**:在用显式初始化替换 `init()` 或避免可变全局变量时,参见 [go-defensive](../go-defensive/SKILL.md)
|
||||
@@ -0,0 +1,110 @@
|
||||
# Import 组织
|
||||
|
||||
Go import 组织的详细规则和示例。
|
||||
|
||||
## Import 分组
|
||||
|
||||
Import 按组组织,组之间用空行分隔。标准库包始终放在第一组。
|
||||
|
||||
**最小分组(Uber):** 标准库,然后其他所有。
|
||||
|
||||
**扩展分组(Google):** 标准库 → 其他 → protocol buffers → 副作用。
|
||||
|
||||
```go
|
||||
// 好:标准库与外部包分开
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"go.uber.org/atomic"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
```
|
||||
|
||||
```go
|
||||
// 好:完整分组,包含 proto 和副作用
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/dsnet/compress/flate"
|
||||
"golang.org/x/text/encoding"
|
||||
|
||||
foopb "myproj/foo/proto/proto"
|
||||
|
||||
_ "myproj/rpc/protocols/dial"
|
||||
)
|
||||
```
|
||||
|
||||
## Import 重命名
|
||||
|
||||
避免重命名 import,除非为了避免名称冲突;好的包名不需要重命名。
|
||||
在发生冲突时,**优先重命名最本地的或项目特定的 import**。
|
||||
|
||||
**必须重命名:** 与其他 import 冲突、生成的 protocol buffer 包
|
||||
(删除下划线,添加 `pb` 后缀)。
|
||||
|
||||
**可以重命名:** 无意义的名称(例如 `v1`)、与本地变量冲突。
|
||||
|
||||
```go
|
||||
// 好:Proto 包用 pb 后缀重命名
|
||||
import (
|
||||
foosvcpb "path/to/package/foo_service_go_proto"
|
||||
)
|
||||
|
||||
// 好:当需要 url 变量时使用 urlpkg
|
||||
import (
|
||||
urlpkg "net/url"
|
||||
)
|
||||
|
||||
func parseEndpoint(url string) (*urlpkg.URL, error) {
|
||||
return urlpkg.Parse(url)
|
||||
}
|
||||
```
|
||||
|
||||
## 空白 Import(`import _`)
|
||||
|
||||
仅为副作用而导入的包(使用 `import _ "pkg"`)
|
||||
应仅在程序的主包(main)或需要它们的测试中导入。
|
||||
|
||||
```go
|
||||
// 好:在主包中使用空白 import
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "time/tzdata"
|
||||
_ "image/jpeg"
|
||||
)
|
||||
```
|
||||
|
||||
## 点 Import(`import .`)
|
||||
|
||||
**不要**使用点 import。它们使程序难以阅读,因为不清楚
|
||||
`Quux` 这样的名称是当前包中的顶层标识符还是导入包中的。
|
||||
|
||||
**例外:** `import .` 形式在由于循环依赖而无法成为被测试包的一部分的测试文件中可能有用:
|
||||
|
||||
```go
|
||||
package foo_test
|
||||
|
||||
import (
|
||||
"bar/testutil" // 也导入了 "foo"
|
||||
. "foo"
|
||||
)
|
||||
```
|
||||
|
||||
在这种情况下,测试文件不能是 `foo` 包,因为它使用了
|
||||
`bar/testutil`,而后者导入了 `foo`。因此 `import .` 形式让文件
|
||||
假装是 `foo` 包的一部分,即使实际上不是。
|
||||
|
||||
**除了这一种情况外,不要在程序中使用 `import .`。**
|
||||
|
||||
```go
|
||||
// 不好:点 import 隐藏了来源
|
||||
import . "foo"
|
||||
var myThing = Bar() // Bar 来自哪里?
|
||||
|
||||
// 好:显式限定
|
||||
import "foo"
|
||||
var myThing = foo.Bar()
|
||||
```
|
||||
@@ -0,0 +1,214 @@
|
||||
# 包大小、程序结构和 CLI
|
||||
|
||||
关于包拆分、避免 init()、run() 模式和 CLI 结构的详细指南。
|
||||
|
||||
## 何时拆分包
|
||||
|
||||
```
|
||||
包是否变得太大?
|
||||
├─ 你能用一句话描述它的用途吗?
|
||||
│ ├─ 不能 → 按职责拆分
|
||||
│ └─ 能 → 保留,但检查以下内容
|
||||
├─ 包中的文件是否从未导入彼此的未导出符号?
|
||||
│ └─ 是 → 这些文件可以是独立的包
|
||||
├─ 包是否有不同的用户群体使用不同部分?
|
||||
│ └─ 是 → 按用户边界拆分
|
||||
└─ godoc 页面是否过于庞大?
|
||||
└─ 是 → 拆分以提高可发现性
|
||||
```
|
||||
|
||||
### 何时不应拆分
|
||||
|
||||
- 不要仅因为文件很长就拆分——聚焦的包中的大文件是可以的
|
||||
- 不要创建只包含一个类型或函数的包
|
||||
- 如果会产生循环依赖则不要拆分
|
||||
- 避免将内部辅助工具拆分到 `util` 或 `internal/helpers` 包中
|
||||
|
||||
### 何时合并包
|
||||
|
||||
- 如果客户端代码很可能需要两个类型交互,保持它们在一起
|
||||
- 如果类型有紧密耦合的实现
|
||||
- 如果用户需要同时导入两个包才能有意义地使用其中任何一个
|
||||
|
||||
### 文件组织
|
||||
|
||||
Go 中没有"一个类型一个文件"的惯例。文件应该足够聚焦以便知道哪个文件包含什么内容,且足够小以便轻松查找。
|
||||
|
||||
---
|
||||
|
||||
## 避免 init()
|
||||
|
||||
优先使用显式函数而非 `init()`:
|
||||
|
||||
```go
|
||||
// 不好:init() 带有 I/O 和环境依赖
|
||||
var _config Config
|
||||
|
||||
func init() {
|
||||
cwd, _ := os.Getwd()
|
||||
raw, _ := os.ReadFile(path.Join(cwd, "config.yaml"))
|
||||
yaml.Unmarshal(raw, &_config)
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// 好:用于加载配置的显式函数
|
||||
func loadConfig() (Config, error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path.Join(cwd, "config.yaml"))
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
var config Config
|
||||
if err := yaml.Unmarshal(raw, &config); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
```
|
||||
|
||||
**init() 的可接受使用场景:**
|
||||
- 无法用单个赋值完成的复杂表达式
|
||||
- 可插拔钩子(例如 `database/sql` 方言、编码注册表)
|
||||
- 确定性预计算
|
||||
|
||||
---
|
||||
|
||||
## Main 中的退出
|
||||
|
||||
仅在 `main()` 中调用 `os.Exit` 或 `log.Fatal*`。所有其他函数应
|
||||
返回 error 来表示失败。
|
||||
|
||||
**为什么这很重要:**
|
||||
- 不明显的控制流:任何函数都可以退出程序
|
||||
- 难以测试:退出程序的函数也会退出测试
|
||||
- 跳过的清理:`defer` 语句会被跳过
|
||||
|
||||
```go
|
||||
// 不好:在辅助函数中使用 log.Fatal
|
||||
func readFile(path string) string {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
log.Fatal(err) // 退出程序,跳过 defer
|
||||
}
|
||||
b, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// 好:返回 error,让 main() 决定是否退出
|
||||
func main() {
|
||||
body, err := readFile(path)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(body)
|
||||
}
|
||||
|
||||
func readFile(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
```
|
||||
|
||||
### run() 模式
|
||||
|
||||
优先在 `main()` 中**最多调用一次** `os.Exit` 或 `log.Fatal`。将
|
||||
业务逻辑提取到返回 error 的独立函数中。
|
||||
|
||||
```go
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
args := os.Args[1:]
|
||||
if len(args) != 1 {
|
||||
return errors.New("missing file")
|
||||
}
|
||||
|
||||
f, err := os.Open(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close() // 将始终执行
|
||||
|
||||
b, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 处理 b...
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**`run()` 模式的优势:**
|
||||
- 简短的 `main()` 函数,单一退出点
|
||||
- 所有业务逻辑都可测试
|
||||
- `defer` 语句始终执行
|
||||
|
||||
---
|
||||
|
||||
## 命令行接口
|
||||
|
||||
### Flag 命名
|
||||
|
||||
使用小写、连字符分隔的 flag 名称:
|
||||
|
||||
```go
|
||||
// 好
|
||||
flag.String("output-dir", ".", "directory for output files")
|
||||
flag.Bool("dry-run", false, "print actions without executing")
|
||||
|
||||
// 不好
|
||||
flag.String("outputDir", ".", "") // camelCase
|
||||
flag.String("output_dir", ".", "") // 下划线
|
||||
```
|
||||
|
||||
### 子命令
|
||||
|
||||
对于带有子命令的复杂 CLI,为每个子命令使用 `flag.NewFlagSet`:
|
||||
|
||||
```go
|
||||
func main() {
|
||||
serveCmd := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||
port := serveCmd.Int("port", 8080, "listen port")
|
||||
|
||||
migrateCmd := flag.NewFlagSet("migrate", flag.ExitOnError)
|
||||
dryRun := migrateCmd.Bool("dry-run", false, "preview changes")
|
||||
|
||||
switch os.Args[1] {
|
||||
case "serve":
|
||||
serveCmd.Parse(os.Args[2:])
|
||||
runServe(*port)
|
||||
case "migrate":
|
||||
migrateCmd.Parse(os.Args[2:])
|
||||
runMigrate(*dryRun)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
对于更大的 CLI,考虑使用 `cobra` 或 `urfave/cli` 等库。仅从
|
||||
`main()` 退出。
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
name: go-performance
|
||||
description: Use when optimizing Go code, investigating slow performance, or writing performance-critical sections. Also use when a user mentions slow Go code, string concatenation in loops, or asks about benchmarking, even if the user doesn't explicitly mention performance patterns. Does not cover concurrent performance patterns (see go-concurrency).
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
sources: "Uber Style Guide, Google Style Guide, Go Wiki CodeReviewComments"
|
||||
allowed-tools: Bash(bash:*)
|
||||
---
|
||||
|
||||
# Go 性能模式
|
||||
|
||||
## 可用脚本
|
||||
|
||||
- **`scripts/bench-compare.sh`** — 运行 Go 基准测试 N 次,并可选通过 benchstat 进行基线比较。支持保存结果以供未来比较。运行 `bash scripts/bench-compare.sh --help` 查看选项。
|
||||
|
||||
性能特定的指南仅适用于**热点路径**。不要过早优化——将这些模式集中在最重要的地方。
|
||||
|
||||
---
|
||||
|
||||
## 优先使用 strconv 而非 fmt
|
||||
|
||||
在基本类型和字符串之间转换时,`strconv` 比 `fmt` 更快:
|
||||
|
||||
```go
|
||||
s := strconv.Itoa(rand.Int()) // 比 fmt.Sprint() 快约 2 倍
|
||||
```
|
||||
|
||||
| 方式 | 速度 | 分配次数 |
|
||||
|------|------|---------|
|
||||
| `fmt.Sprint` | 143 ns/op | 2 allocs/op |
|
||||
| `strconv.Itoa` | 64.2 ns/op | 1 allocs/op |
|
||||
|
||||
> 在 strconv 和 fmt 之间选择类型转换方式时,或需要完整的转换对照表时,阅读 [references/STRING-OPTIMIZATION.md](references/STRING-OPTIMIZATION.md)。
|
||||
|
||||
---
|
||||
|
||||
## 避免重复的字符串到字节转换
|
||||
|
||||
将固定字符串在循环外转换为 `[]byte` 一次:
|
||||
|
||||
```go
|
||||
data := []byte("Hello world")
|
||||
for i := 0; i < b.N; i++ {
|
||||
w.Write(data) // 比每次迭代 []byte("...") 快约 7 倍
|
||||
}
|
||||
```
|
||||
|
||||
> 在优化热点循环中的重复字节转换时,阅读 [references/STRING-OPTIMIZATION.md](references/STRING-OPTIMIZATION.md)。
|
||||
|
||||
---
|
||||
|
||||
## 优先指定容器容量
|
||||
|
||||
尽可能指定容器容量,以便预先分配内存。这可以最大程度减少后续添加元素时因复制和调整大小而产生的分配。
|
||||
|
||||
### Map 容量提示
|
||||
|
||||
使用 `make()` 初始化 map 时提供容量提示:
|
||||
|
||||
```go
|
||||
m := make(map[string]os.DirEntry, len(files))
|
||||
```
|
||||
|
||||
**注意**:与 slice 不同,map 的容量提示不保证完整的预分配——它只是近似计算所需的哈希桶数量。
|
||||
|
||||
### Slice 容量
|
||||
|
||||
使用 `make()` 初始化 slice 时提供容量提示,特别是在追加时:
|
||||
|
||||
```go
|
||||
data := make([]int, 0, size)
|
||||
```
|
||||
|
||||
与 map 不同,slice 容量**不是提示**——编译器会精确分配那么多内存。后续的 `append()` 操作在达到容量之前不会产生任何分配。
|
||||
|
||||
| 方式 | 时间(1 亿次迭代) |
|
||||
|------|------------------------|
|
||||
| 无容量 | 2.48s |
|
||||
| 指定容量 | 0.21s |
|
||||
|
||||
指定容量的版本**快约 12 倍**,因为追加期间零重新分配。
|
||||
|
||||
---
|
||||
|
||||
## 传值
|
||||
|
||||
不要仅为了节省几个字节就将指针作为函数参数传递。如果函数在整个函数体中仅通过 `*x` 引用其参数 `x`,则该参数不应该是`指针。
|
||||
|
||||
```go
|
||||
func process(s string) { // 不是 *string —— string 是小的固定大小头部
|
||||
fmt.Println(s)
|
||||
}
|
||||
```
|
||||
|
||||
**常见的按值传递类型**:`string`、`io.Reader`、小结构体。
|
||||
|
||||
**例外**:
|
||||
- 复制代价高的大结构体
|
||||
- 未来可能增长的小结构体
|
||||
|
||||
---
|
||||
|
||||
## 字符串拼接
|
||||
|
||||
根据复杂度选择正确的策略:
|
||||
|
||||
| 方法 | 最佳用途 |
|
||||
|------|---------|
|
||||
| `+` | 少量字符串,简单拼接 |
|
||||
| `fmt.Sprintf` | 混合类型的格式化输出 |
|
||||
| `strings.Builder` | 循环/逐段构建 |
|
||||
| `strings.Join` | 连接 slice |
|
||||
| 反引号字面量 | 常量多行文本 |
|
||||
|
||||
> 在选择字符串拼接策略、在循环中使用 strings.Builder 或在 fmt.Sprintf 和手动拼接之间做决定时,阅读 [references/STRING-OPTIMIZATION.md](references/STRING-OPTIMIZATION.md)。
|
||||
|
||||
---
|
||||
|
||||
## 基准测试和性能分析
|
||||
|
||||
在优化前后始终要进行测量。使用 Go 内置的基准测试框架和性能分析工具。
|
||||
|
||||
```bash
|
||||
go test -bench=. -benchmem -count=10 ./...
|
||||
```
|
||||
|
||||
> 在编写基准测试、使用 benchstat 比较结果、使用 pprof 进行性能分析或解读基准测试输出时,阅读 [references/BENCHMARKS.md](references/BENCHMARKS.md)。
|
||||
|
||||
> **验证**:在应用优化后,运行 `bash scripts/bench-compare.sh` 测量实际影响。只保留有可衡量改进的优化。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 模式 | 不好 | 好 | 改进 |
|
||||
|------|-----|------|-------------|
|
||||
| 整数转字符串 | `fmt.Sprint(n)` | `strconv.Itoa(n)` | 快约 2 倍 |
|
||||
| 重复 `[]byte` | 循环中 `[]byte("str")` | 在循环外转换一次 | 快约 7 倍 |
|
||||
| Map 初始化 | `make(map[K]V)` | `make(map[K]V, size)` | 更少分配 |
|
||||
| Slice 初始化 | `make([]T, 0)` | `make([]T, 0, cap)` | 快约 12 倍 |
|
||||
| 小型固定大小参数 | `*string`、`*io.Reader` | `string`、`io.Reader` | 无间接引用 |
|
||||
| 简单字符串连接 | `s1 + " " + s2` | (已经很好) | 对少量字符串使用 `+` |
|
||||
| 循环构建字符串 | 重复 `+=` | `strings.Builder` | O(n) vs O(n²) |
|
||||
|
||||
---
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **数据结构**:在 slice、map 和数组之间选择或理解分配语义时,参见 [go-data-structures](../go-data-structures/SKILL.md)
|
||||
- **声明模式**:在使用 `make` 配合容量提示或初始化 map 和 slice 时,参见 [go-declarations](../go-declarations/SKILL.md)
|
||||
- **并发**:在跨 goroutine 并行化工作或使用 sync.Pool 复用缓冲区时,参见 [go-concurrency](../go-concurrency/SKILL.md)
|
||||
- **风格原则**:在判断优化是否值得牺牲可读性时,参见 [go-style-core](../go-style-core/SKILL.md)
|
||||
@@ -0,0 +1,281 @@
|
||||
# 基准测试方法
|
||||
|
||||
## 编写基准测试
|
||||
|
||||
Go 基准测试使用 `testing.B` 类型,位于 `_test.go` 文件中。
|
||||
基准测试函数名必须以 `Benchmark` 开头。
|
||||
|
||||
```go
|
||||
func BenchmarkStrconv(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
s := strconv.Itoa(rand.Int())
|
||||
_ = s
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFmtSprint(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
s := fmt.Sprint(rand.Int())
|
||||
_ = s
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
关键规则:
|
||||
- 使用 `b.N` 作为循环边界——框架会调整它以获得稳定的计时
|
||||
- 将结果赋值给变量(或 `_`),防止编译器优化掉调用
|
||||
- 在不需要测量的昂贵设置之后使用 `b.ResetTimer()`
|
||||
- 使用 `b.ReportAllocs()` 或 `-benchmem` 标志跟踪分配情况
|
||||
|
||||
### 子基准测试
|
||||
|
||||
```go
|
||||
func BenchmarkConvert(b *testing.B) {
|
||||
for _, size := range []int{10, 100, 1000} {
|
||||
b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
|
||||
data := make([]byte, size)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = string(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 运行基准测试
|
||||
|
||||
```bash
|
||||
# 运行包中的所有基准测试
|
||||
go test -bench=. ./...
|
||||
|
||||
# 运行特定基准测试并显示内存统计
|
||||
go test -bench=BenchmarkStrconv -benchmem ./...
|
||||
|
||||
# 多次运行以获得统计显著性
|
||||
go test -bench=. -benchmem -count=10 ./...
|
||||
```
|
||||
|
||||
`-benchmem` 标志报告每次操作的分配次数。`-count` 标志将每个基准测试运行 N 次以获得统计显著性。
|
||||
|
||||
---
|
||||
|
||||
## 解读结果
|
||||
|
||||
```
|
||||
BenchmarkStrconv-8 18705042 64.2 ns/op 16 B/op 1 allocs/op
|
||||
BenchmarkFmtSprint-8 8249536 143.0 ns/op 16 B/op 2 allocs/op
|
||||
```
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `-8` | GOMAXPROCS |
|
||||
| `18705042` | 迭代次数 |
|
||||
| `64.2 ns/op` | 每次操作时间 |
|
||||
| `16 B/op` | 每次操作分配的字节数 |
|
||||
| `1 allocs/op` | 每次操作的堆分配次数 |
|
||||
|
||||
---
|
||||
|
||||
## 使用 benchstat 进行比较
|
||||
|
||||
`benchstat` 对基准测试结果进行统计比较。安装它并将基准测试输出保存到文件:
|
||||
|
||||
```bash
|
||||
# 安装 benchstat
|
||||
go install golang.org/x/perf/cmd/benchstat@latest
|
||||
|
||||
# 运行基准测试并保存结果
|
||||
go test -bench=. -benchmem -count=10 ./... > old.txt
|
||||
|
||||
# 进行修改后再次运行
|
||||
go test -bench=. -benchmem -count=10 ./... > new.txt
|
||||
|
||||
# 比较结果
|
||||
benchstat old.txt new.txt
|
||||
```
|
||||
|
||||
### 解读 benchstat 输出
|
||||
|
||||
```
|
||||
name old time/op new time/op delta
|
||||
Strconv-8 64.2ns ± 2% 61.8ns ± 1% -3.74% (p=0.001 n=10+10)
|
||||
```
|
||||
|
||||
- **delta**:变化百分比(负数 = 更快)
|
||||
- **p-value**:统计显著性(p < 0.05 为显著)
|
||||
- **n**:使用的有效样本数量
|
||||
|
||||
提示:
|
||||
- 始终使用 `-count=10` 或更高以获得可靠结果
|
||||
- 小的 p 值确认变化是真实的,而非噪声
|
||||
- 如果 benchstat 显示 `~`(波浪号),则差异不具有统计显著性
|
||||
|
||||
---
|
||||
|
||||
## 来自性能模式的基准测试示例
|
||||
|
||||
### strconv vs fmt
|
||||
|
||||
| 方式 | 速度 | 分配次数 |
|
||||
|------|------|---------|
|
||||
| `fmt.Sprint` | 143 ns/op | 2 allocs/op |
|
||||
| `strconv.Itoa` | 64.2 ns/op | 1 allocs/op |
|
||||
|
||||
### 重复字节转换
|
||||
|
||||
```go
|
||||
func BenchmarkRepeatedConversion(b *testing.B) {
|
||||
var buf bytes.Buffer
|
||||
for i := 0; i < b.N; i++ {
|
||||
buf.Write([]byte("Hello world"))
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSingleConversion(b *testing.B) {
|
||||
var buf bytes.Buffer
|
||||
data := []byte("Hello world")
|
||||
for i := 0; i < b.N; i++ {
|
||||
buf.Write(data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 方式 | 速度 |
|
||||
|------|------|
|
||||
| 重复转换 | 22.2 ns/op |
|
||||
| 单次转换 | 3.25 ns/op |
|
||||
|
||||
### Slice 容量
|
||||
|
||||
```go
|
||||
func BenchmarkNoCapacity(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
data := make([]int, 0)
|
||||
for k := 0; k < 1000; k++ {
|
||||
data = append(data, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkWithCapacity(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
data := make([]int, 0, 1000)
|
||||
for k := 0; k < 1000; k++ {
|
||||
data = append(data, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 方式 | 时间(1 亿次迭代) |
|
||||
|------|------------------------|
|
||||
| 无容量 | 2.48s |
|
||||
| 指定容量 | 0.21s |
|
||||
|
||||
---
|
||||
|
||||
## 使用 pprof 进行性能分析
|
||||
|
||||
使用 `pprof` 在优化前识别瓶颈。基准测试衡量改进效果;pprof 找到需要改进的地方。
|
||||
|
||||
### CPU 性能分析
|
||||
|
||||
```bash
|
||||
# 从基准测试生成 CPU 分析文件
|
||||
go test -bench=BenchmarkHotPath -cpuprofile=cpu.prof ./...
|
||||
|
||||
# 使用 pprof 分析
|
||||
go tool pprof cpu.prof
|
||||
```
|
||||
|
||||
常用 pprof 命令:
|
||||
|
||||
```
|
||||
(pprof) top10 # 按 CPU 时间排列的前 10 个函数
|
||||
(pprof) list funcName # 某个函数的带注释源码
|
||||
(pprof) web # 浏览器中的交互式图表
|
||||
```
|
||||
|
||||
### 内存性能分析
|
||||
|
||||
```bash
|
||||
# 生成内存分析文件
|
||||
go test -bench=BenchmarkHotPath -memprofile=mem.prof ./...
|
||||
|
||||
# 分析分配情况
|
||||
go tool pprof -alloc_space mem.prof
|
||||
```
|
||||
|
||||
### 运行中服务的 HTTP 性能分析
|
||||
|
||||
```go
|
||||
import _ "net/http/pprof"
|
||||
|
||||
func main() {
|
||||
go func() {
|
||||
log.Println(http.ListenAndServe("localhost:6060", nil))
|
||||
}()
|
||||
// ... 应用程序代码 ...
|
||||
}
|
||||
```
|
||||
|
||||
通过 `http://localhost:6060/debug/pprof/` 访问性能分析数据。
|
||||
|
||||
### 性能分析工作流
|
||||
|
||||
1. 对疑似热点路径进行**基准测试**
|
||||
2. 使用 pprof **分析**以确认时间花在了哪里
|
||||
3. 使用本技能中的模式进行**优化**
|
||||
4. **重新基准测试**以用 benchstat 验证改进
|
||||
5. **重新分析**以检查是否出现新的瓶颈
|
||||
|
||||
---
|
||||
|
||||
## 常见错误
|
||||
|
||||
### 忽略 b.N
|
||||
|
||||
测试框架会调整 `b.N` 以获得稳定的计时。使用固定迭代次数会产生无意义的结果:
|
||||
|
||||
```go
|
||||
// 不好:忽略 b.N —— 基准测试框架无法校准
|
||||
func BenchmarkFixed(b *testing.B) {
|
||||
for i := 0; i < 1000; i++ {
|
||||
doWork()
|
||||
}
|
||||
}
|
||||
|
||||
// 好:使用 b.N 作为循环边界
|
||||
func BenchmarkCorrect(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
doWork()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 未防止编译器优化消除
|
||||
|
||||
如果函数调用的结果未被使用,编译器可能会完全优化掉该调用。将结果赋值给包级变量:
|
||||
|
||||
```go
|
||||
// 不好:编译器可能会优化掉调用
|
||||
func BenchmarkElided(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
expensiveFunc()
|
||||
}
|
||||
}
|
||||
|
||||
// 好:赋值给包级变量以防止优化消除
|
||||
var benchResult int
|
||||
|
||||
func BenchmarkKept(b *testing.B) {
|
||||
var r int
|
||||
for i := 0; i < b.N; i++ {
|
||||
r = expensiveFunc()
|
||||
}
|
||||
benchResult = r
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,134 @@
|
||||
# 字符串优化模式
|
||||
|
||||
## strconv vs fmt
|
||||
|
||||
在基本类型和字符串之间转换时,`strconv` 比 `fmt` 更快,因为 `fmt` 使用反射并处理任意类型。
|
||||
|
||||
**不好:**
|
||||
|
||||
```go
|
||||
for i := 0; i < b.N; i++ {
|
||||
s := fmt.Sprint(rand.Int())
|
||||
}
|
||||
```
|
||||
|
||||
**好:**
|
||||
|
||||
```go
|
||||
for i := 0; i < b.N; i++ {
|
||||
s := strconv.Itoa(rand.Int())
|
||||
}
|
||||
```
|
||||
|
||||
**基准测试比较:**
|
||||
|
||||
| 方式 | 速度 | 分配次数 |
|
||||
|------|------|---------|
|
||||
| `fmt.Sprint` | 143 ns/op | 2 allocs/op |
|
||||
| `strconv.Itoa` | 64.2 ns/op | 1 allocs/op |
|
||||
|
||||
常用转换:
|
||||
|
||||
| 任务 | `fmt` | `strconv` |
|
||||
|------|-------|-----------|
|
||||
| Int → string | `fmt.Sprint(n)` | `strconv.Itoa(n)` |
|
||||
| Int64 → string | `fmt.Sprint(n)` | `strconv.FormatInt(n, 10)` |
|
||||
| Float → string | `fmt.Sprint(f)` | `strconv.FormatFloat(f, 'f', -1, 64)` |
|
||||
| String → int | — | `strconv.Atoi(s)` |
|
||||
| Bool → string | `fmt.Sprint(b)` | `strconv.FormatBool(b)` |
|
||||
|
||||
---
|
||||
|
||||
## 重复的字符串到字节转换
|
||||
|
||||
不要重复从固定字符串创建字节切片。应该只转换一次并保存结果。
|
||||
|
||||
**不好:**
|
||||
|
||||
```go
|
||||
for i := 0; i < b.N; i++ {
|
||||
w.Write([]byte("Hello world"))
|
||||
}
|
||||
```
|
||||
|
||||
**好:**
|
||||
|
||||
```go
|
||||
data := []byte("Hello world")
|
||||
for i := 0; i < b.N; i++ {
|
||||
w.Write(data)
|
||||
}
|
||||
```
|
||||
|
||||
**基准测试比较:**
|
||||
|
||||
| 方式 | 速度 |
|
||||
|------|------|
|
||||
| 重复转换 | 22.2 ns/op |
|
||||
| 单次转换 | 3.25 ns/op |
|
||||
|
||||
好的版本**快约 7 倍**,因为它避免了每次迭代都分配新的字节切片。
|
||||
|
||||
---
|
||||
|
||||
## 字符串拼接
|
||||
|
||||
根据复杂度选择正确的字符串构建策略。
|
||||
|
||||
### 简单场景使用 `+`
|
||||
|
||||
```go
|
||||
key := "projectid: " + p
|
||||
```
|
||||
|
||||
`+` 运算符对于少量、固定数量的字符串是高效的。编译器通常可以优化相邻的字符串字面量。
|
||||
|
||||
### 格式化使用 `fmt.Sprintf`
|
||||
|
||||
```go
|
||||
// 好:清晰的格式化
|
||||
str := fmt.Sprintf("%s [%s:%d]-> %s", src, qos, mtu, dst)
|
||||
|
||||
// 不好:使用 + 手动转换
|
||||
str := src.String() + " [" + qos.String() + ":" + strconv.Itoa(mtu) + "]-> " + dst.String()
|
||||
```
|
||||
|
||||
当写入 `io.Writer` 时,直接使用 `fmt.Fprintf` 而不是先用 `fmt.Sprintf` 构建临时字符串。
|
||||
|
||||
### 逐段构建使用 `strings.Builder`
|
||||
|
||||
`strings.Builder` 花费摊销线性时间,而重复使用 `+` 或
|
||||
`fmt.Sprintf` 在构建大字符串时花费二次时间:
|
||||
|
||||
```go
|
||||
b := new(strings.Builder)
|
||||
for i, d := range digitsOfPi {
|
||||
fmt.Fprintf(b, "the %d digit of pi is: %d\n", i, d)
|
||||
}
|
||||
str := b.String()
|
||||
```
|
||||
|
||||
### 常量多行字符串使用反引号
|
||||
|
||||
```go
|
||||
// 好:原始字符串字面量
|
||||
usage := `Usage:
|
||||
|
||||
custom_tool [args]`
|
||||
|
||||
// 不好:使用转义序列拼接
|
||||
usage := "" +
|
||||
"Usage:\n" +
|
||||
"\n" +
|
||||
"custom_tool [args]"
|
||||
```
|
||||
|
||||
### 策略总结
|
||||
|
||||
| 方法 | 最佳用途 | 性能 |
|
||||
|------|---------|------|
|
||||
| `+` | 少量字符串,简单拼接 | 小 n 时 O(n) |
|
||||
| `fmt.Sprintf` | 格式化输出 | 较慢,但更清晰 |
|
||||
| `strings.Builder` | 循环/逐段构建 | 摊销 O(n) |
|
||||
| `strings.Join` | 连接 slice | O(n) |
|
||||
| 反引号字面量 | 常量多行文本 | 零开销 |
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="1.1.0"
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
$SCRIPT_NAME v$VERSION — Run Go benchmarks with optional comparison
|
||||
|
||||
USAGE
|
||||
bash $SCRIPT_NAME [options] [package]
|
||||
|
||||
DESCRIPTION
|
||||
Wrapper around 'go test -bench' that runs benchmarks multiple times and
|
||||
optionally compares results against a saved baseline using benchstat.
|
||||
|
||||
Results can be saved to a file for future comparison. If benchstat is
|
||||
installed and a baseline is provided, a statistical comparison is shown.
|
||||
|
||||
EXIT CODES
|
||||
0 Benchmarks ran successfully
|
||||
1 go test failed (compilation error, test failure, no benchmarks found)
|
||||
2 Usage error (missing arguments, bad flags, file exists without --force)
|
||||
|
||||
OPTIONS
|
||||
-h, --help Show this help message
|
||||
-v, --version Show version
|
||||
-n, --count N Number of benchmark iterations (default: 5)
|
||||
-b, --baseline FILE Compare results against this baseline file
|
||||
-s, --save FILE Save benchmark results to this file
|
||||
-f, --filter REGEX Benchmark filter regex (default: ".")
|
||||
--json Output metadata as JSON (human output goes to stderr)
|
||||
--benchmem Include memory allocation stats (default: on)
|
||||
--no-benchmem Disable memory allocation stats
|
||||
--force Allow --save to overwrite existing files
|
||||
--limit N Max benchmark result lines to include (default: 0 = all)
|
||||
|
||||
ARGUMENTS
|
||||
package Go package to benchmark (default: ./...)
|
||||
|
||||
EXAMPLES
|
||||
bash $SCRIPT_NAME
|
||||
bash $SCRIPT_NAME -n 10 ./pkg/parser
|
||||
bash $SCRIPT_NAME --save baseline.txt ./...
|
||||
bash $SCRIPT_NAME --baseline baseline.txt --save current.txt ./...
|
||||
bash $SCRIPT_NAME --filter BenchmarkSort -n 3
|
||||
bash $SCRIPT_NAME --json --limit 5 ./...
|
||||
bash $SCRIPT_NAME --save results.txt --force ./...
|
||||
EOF
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"
|
||||
s="${s//\"/\\\"}"
|
||||
s="${s//$'\t'/\\t}"
|
||||
s="${s//$'\r'/}"
|
||||
s="${s//$'\n'/\\n}"
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
# Print human-readable output: stdout in text mode, stderr in JSON mode.
|
||||
log() {
|
||||
if $JSON_OUTPUT; then
|
||||
echo "$@" >&2
|
||||
else
|
||||
echo "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
COUNT=5
|
||||
BASELINE=""
|
||||
SAVE=""
|
||||
FILTER="."
|
||||
PACKAGE=""
|
||||
JSON_OUTPUT=false
|
||||
BENCHMEM=true
|
||||
FORCE=false
|
||||
LIMIT=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-v|--version) echo "$SCRIPT_NAME v$VERSION"; exit 0 ;;
|
||||
-n|--count) COUNT="${2:?error: --count requires a number}"; shift 2 ;;
|
||||
-b|--baseline) BASELINE="${2:?error: --baseline requires a file path}"; shift 2 ;;
|
||||
-s|--save) SAVE="${2:?error: --save requires a file path}"; shift 2 ;;
|
||||
-f|--filter) FILTER="${2:?error: --filter requires a regex}"; shift 2 ;;
|
||||
--json) JSON_OUTPUT=true; shift ;;
|
||||
--benchmem) BENCHMEM=true; shift ;;
|
||||
--no-benchmem) BENCHMEM=false; shift ;;
|
||||
--force) FORCE=true; shift ;;
|
||||
--limit) LIMIT="${2:?error: --limit requires a number}"; shift 2 ;;
|
||||
-*) echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
*) PACKAGE="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
PACKAGE="${PACKAGE:-./...}"
|
||||
|
||||
if ! command -v go &>/dev/null; then
|
||||
echo "error: 'go' command not found in PATH" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! [[ "$COUNT" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "error: --count must be a positive integer, got: $COUNT" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! [[ "$LIMIT" =~ ^[0-9]+$ ]]; then
|
||||
echo "error: --limit must be a non-negative integer, got: $LIMIT" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -n "$BASELINE" && ! -f "$BASELINE" ]]; then
|
||||
echo "error: baseline file not found: $BASELINE" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -n "$SAVE" && -f "$SAVE" ]] && ! $FORCE; then
|
||||
echo "error: save target already exists: $SAVE (use --force to overwrite)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
HAS_BENCHSTAT=false
|
||||
if command -v benchstat &>/dev/null; then
|
||||
HAS_BENCHSTAT=true
|
||||
fi
|
||||
|
||||
BENCH_ARGS=(-bench "$FILTER" -count "$COUNT" -run '^$')
|
||||
if $BENCHMEM; then
|
||||
BENCH_ARGS+=(-benchmem)
|
||||
fi
|
||||
|
||||
TMPFILE=$(mktemp "${TMPDIR:-/tmp}/bench-XXXXXX.txt")
|
||||
trap 'rm -f "$TMPFILE"' EXIT
|
||||
|
||||
log "Running benchmarks: go test ${BENCH_ARGS[*]} $PACKAGE"
|
||||
log "Iterations: $COUNT"
|
||||
log ""
|
||||
|
||||
GO_EXIT=0
|
||||
if $JSON_OUTPUT; then
|
||||
go test "${BENCH_ARGS[@]}" "$PACKAGE" 2>&1 | tee "$TMPFILE" >&2 || GO_EXIT=$?
|
||||
else
|
||||
go test "${BENCH_ARGS[@]}" "$PACKAGE" 2>&1 | tee "$TMPFILE" || GO_EXIT=$?
|
||||
fi
|
||||
|
||||
BENCH_COUNT=$(grep -cE '^Benchmark' "$TMPFILE" || true)
|
||||
|
||||
TRUNCATED=false
|
||||
if [[ $LIMIT -gt 0 && $BENCH_COUNT -gt $LIMIT ]]; then
|
||||
TRUNCATED=true
|
||||
fi
|
||||
|
||||
if ! $JSON_OUTPUT && $TRUNCATED; then
|
||||
log ""
|
||||
log "Note: $BENCH_COUNT benchmark results found, showing first $LIMIT (--limit $LIMIT)"
|
||||
fi
|
||||
|
||||
if [[ -n "$SAVE" ]]; then
|
||||
cp "$TMPFILE" "$SAVE"
|
||||
log ""
|
||||
log "Results saved to: $SAVE"
|
||||
fi
|
||||
|
||||
if [[ -n "$BASELINE" ]]; then
|
||||
log ""
|
||||
log "=== Comparison with baseline: $BASELINE ==="
|
||||
log ""
|
||||
if $HAS_BENCHSTAT; then
|
||||
if $JSON_OUTPUT; then
|
||||
benchstat "$BASELINE" "$TMPFILE" >&2 || true
|
||||
else
|
||||
benchstat "$BASELINE" "$TMPFILE" || true
|
||||
fi
|
||||
else
|
||||
log "note: install benchstat for statistical comparison:"
|
||||
log " go install golang.org/x/perf/cmd/benchstat@latest"
|
||||
log ""
|
||||
log "--- Baseline ---"
|
||||
if $JSON_OUTPUT; then
|
||||
grep -E '^Benchmark' "$BASELINE" >&2 || true
|
||||
else
|
||||
grep -E '^Benchmark' "$BASELINE" || true
|
||||
fi
|
||||
log ""
|
||||
log "--- Current ---"
|
||||
if $JSON_OUTPUT; then
|
||||
grep -E '^Benchmark' "$TMPFILE" >&2 || true
|
||||
else
|
||||
grep -E '^Benchmark' "$TMPFILE" || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
FINAL_EXIT=0
|
||||
if [[ $GO_EXIT -ne 0 ]]; then
|
||||
FINAL_EXIT=1
|
||||
if ! $JSON_OUTPUT; then
|
||||
log ""
|
||||
log "error: go test exited with code $GO_EXIT"
|
||||
fi
|
||||
elif [[ $BENCH_COUNT -eq 0 ]]; then
|
||||
FINAL_EXIT=1
|
||||
if ! $JSON_OUTPUT; then
|
||||
log ""
|
||||
log "error: no benchmarks found matching filter: $FILTER"
|
||||
fi
|
||||
fi
|
||||
|
||||
if $JSON_OUTPUT; then
|
||||
BENCH_OUTPUT=$(<"$TMPFILE")
|
||||
if $TRUNCATED; then
|
||||
limited=""
|
||||
bench_seen=0
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^Benchmark ]]; then
|
||||
bench_seen=$((bench_seen + 1))
|
||||
if [[ $bench_seen -le $LIMIT ]]; then
|
||||
limited+="$line"$'\n'
|
||||
fi
|
||||
else
|
||||
limited+="$line"$'\n'
|
||||
fi
|
||||
done < "$TMPFILE"
|
||||
BENCH_OUTPUT="$limited"
|
||||
fi
|
||||
|
||||
escaped_package=$(json_escape "$PACKAGE")
|
||||
escaped_filter=$(json_escape "$FILTER")
|
||||
escaped_baseline=$(json_escape "$BASELINE")
|
||||
escaped_save=$(json_escape "$SAVE")
|
||||
escaped_output=$(json_escape "$BENCH_OUTPUT")
|
||||
|
||||
printf '{"count":%d,' "$COUNT"
|
||||
printf '"package":"%s",' "$escaped_package"
|
||||
printf '"filter":"%s",' "$escaped_filter"
|
||||
printf '"benchmarks_found":%d,' "$BENCH_COUNT"
|
||||
printf '"baseline":"%s",' "$escaped_baseline"
|
||||
printf '"save":"%s",' "$escaped_save"
|
||||
printf '"exit_code":%d,' "$GO_EXIT"
|
||||
printf '"output":"%s"' "$escaped_output"
|
||||
if $TRUNCATED; then
|
||||
printf ',"truncated":true'
|
||||
fi
|
||||
printf '}\n'
|
||||
fi
|
||||
|
||||
exit $FINAL_EXIT
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
name: go-testing
|
||||
description: Use when writing, reviewing, or improving Go test code — including table-driven tests, subtests, parallel tests, test helpers, test doubles, and assertions with cmp.Diff. Also use when a user asks to write a test for a Go function, even if they don't mention specific patterns like table-driven tests or subtests. Does not cover benchmark performance testing (see go-performance).
|
||||
license: Apache-2.0
|
||||
compatibility: Uses github.com/google/go-cmp for cmp.Diff comparisons
|
||||
metadata:
|
||||
sources: "Google Style Guide, Uber Style Guide"
|
||||
allowed-tools: Bash(bash:*)
|
||||
---
|
||||
|
||||
# Go 测试
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 模式 | 使用场景 |
|
||||
|------|----------|
|
||||
| `t.Error` | 默认 — 报告失败,继续运行 |
|
||||
| `t.Fatal` | 设置失败或继续运行没有意义 |
|
||||
| `cmp.Diff` | 比较 struct、slice、map、proto |
|
||||
| 表驱动 | 多个用例共享相同逻辑 |
|
||||
| 子测试 | 需要过滤、并行执行或命名 |
|
||||
| `t.Helper()` | 任何测试辅助函数(作为第一条语句调用) |
|
||||
| `t.Cleanup()` | 在辅助函数中进行清理,替代 defer |
|
||||
|
||||
---
|
||||
|
||||
## 有用的测试失败信息
|
||||
|
||||
> **规范**:测试失败必须在不阅读测试源码的情况下可诊断。
|
||||
|
||||
每条失败信息必须包含:函数名、输入、实际值(got)和期望值(want)。使用格式 `YourFunc(%v) = %v, want %v`。
|
||||
|
||||
```go
|
||||
// 好:
|
||||
t.Errorf("Add(2, 3) = %d, want %d", got, 5)
|
||||
|
||||
// 不好:缺少函数名和输入
|
||||
t.Errorf("got %d, want %d", got, 5)
|
||||
```
|
||||
|
||||
始终先打印 got 再打印 want:`got %v, want %v` — 绝不反转。
|
||||
|
||||
---
|
||||
|
||||
## 不使用断言库
|
||||
|
||||
> **规范**:不要使用断言库。对于复杂比较使用 `cmp.Diff`。
|
||||
|
||||
```go
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("GetPost() mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
```
|
||||
|
||||
对于 protocol buffers,添加 `protocmp.Transform()` 作为 cmp 选项。始终在 diff 信息中包含方向键 `(-want +got)`。避免比较 JSON/序列化输出 — 改为语义比较。
|
||||
|
||||
> 在编写自定义比较辅助函数或领域特定测试工具时,请阅读 [references/TEST-HELPERS.md](references/TEST-HELPERS.md)。
|
||||
|
||||
---
|
||||
|
||||
## t.Error vs t.Fatal
|
||||
|
||||
> **规范**:默认使用 `t.Error` 以在一次运行中报告所有失败。仅在无法继续时使用 `t.Fatal`。
|
||||
|
||||
**选择 `t.Fatal` 的场景:**
|
||||
- 设置失败(数据库连接、文件加载)
|
||||
- 下一个断言依赖于上一个断言成功(例如,编码后的解码)
|
||||
|
||||
**绝不在测试 goroutine 以外的 goroutine 中调用 `t.Fatal`/`t.FailNow`** — 改为使用 `t.Error`。
|
||||
|
||||
> 在编写需要在 t.Error 和 t.Fatal 之间选择的辅助函数时,或需要两者的详细示例时,请阅读 [references/TEST-HELPERS.md](references/TEST-HELPERS.md)。
|
||||
|
||||
---
|
||||
|
||||
## 表驱动测试
|
||||
|
||||
> 在搭建新的表驱动测试并需要标准的 struct、循环和子测试布局时,请参阅 `assets/table-test-template.go`。
|
||||
|
||||
> **建议**:当多个用例共享相同逻辑时使用表驱动测试。
|
||||
|
||||
**使用表测试的场景:** 所有用例运行相同的代码路径,没有条件设置、mock 或断言。单个 `shouldErr` bool 是可以接受的。
|
||||
|
||||
**不使用表测试的场景:** 用例需要复杂设置、条件 mock 或多个分支 — 改为编写单独的测试函数。
|
||||
|
||||
**关键规则:**
|
||||
- 当用例跨越多行或有相同类型的相邻字段时,使用字段名
|
||||
- 在失败信息中包含输入 — 绝不通过索引标识行
|
||||
|
||||
> 在编写表驱动测试、子测试或并行测试时,请阅读 [references/TABLE-DRIVEN-TESTS.md](references/TABLE-DRIVEN-TESTS.md)。
|
||||
|
||||
> **验证**:在生成或修改测试后,运行 `go test -run TestXxx -v` 验证测试能编译并通过。在继续之前修复任何编译错误。
|
||||
|
||||
---
|
||||
|
||||
## 测试辅助函数
|
||||
|
||||
> **规范**:测试辅助函数必须首先调用 `t.Helper()` 并使用 `t.Cleanup()` 进行清理。
|
||||
|
||||
```go
|
||||
func setupTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("Could not open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
```
|
||||
|
||||
> 在编写测试辅助函数、清理函数或自定义比较工具时,请阅读 [references/TEST-HELPERS.md](references/TEST-HELPERS.md)。
|
||||
|
||||
---
|
||||
|
||||
## 测试错误语义
|
||||
|
||||
> **建议**:测试错误语义,而非错误消息字符串。
|
||||
|
||||
```go
|
||||
// 不好:脆弱的字符串比较
|
||||
if err.Error() != "invalid input" { ... }
|
||||
|
||||
// 好:语义检查
|
||||
if !errors.Is(err, ErrInvalidInput) { ... }
|
||||
```
|
||||
|
||||
对于不需要特定语义的简单存在性检查:
|
||||
|
||||
```go
|
||||
if gotErr := err != nil; gotErr != tt.wantErr {
|
||||
t.Errorf("f(%v) error = %v, want error presence = %t", tt.input, err, tt.wantErr)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试组织
|
||||
|
||||
> 在使用测试替身、选择测试包位置或规划测试设置范围时,请阅读 [references/TEST-ORGANIZATION.md](references/TEST-ORGANIZATION.md)。
|
||||
|
||||
> 在设计可重用的测试验证函数时,请阅读 [references/VALIDATION-APIS.md](references/VALIDATION-APIS.md)。
|
||||
|
||||
---
|
||||
|
||||
## 集成测试
|
||||
|
||||
> 在编写 TestMain、验收测试或需要真实 HTTP/RPC 传输层的测试时,请阅读 [references/INTEGRATION.md](references/INTEGRATION.md)。
|
||||
|
||||
---
|
||||
|
||||
## 可用脚本
|
||||
|
||||
- **`scripts/gen-table-test.sh`** — 生成表驱动测试脚手架
|
||||
|
||||
```bash
|
||||
bash scripts/gen-table-test.sh ParseConfig config > config/parse_config_test.go
|
||||
bash scripts/gen-table-test.sh --parallel ParseConfig config # 带 t.Parallel()
|
||||
bash scripts/gen-table-test.sh --output config/parse_config_test.go ParseConfig config
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 相关 Skill
|
||||
|
||||
- **错误测试**:在使用 `errors.Is`/`errors.As` 或哨兵错误测试错误语义时,请参阅 [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- **接口 mock**:在消费端通过实现接口创建测试替身时,请参阅 [go-interfaces](../go-interfaces/SKILL.md)
|
||||
- **测试函数命名**:在命名测试函数、子测试或测试辅助工具时,请参阅 [go-naming](../go-naming/SKILL.md)
|
||||
- **Linter 集成**:在 CI 或 pre-commit hooks 中与测试一起运行 linter 时,请参阅 [go-linting](../go-linting/SKILL.md)
|
||||
@@ -0,0 +1,30 @@
|
||||
package example_test
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExample(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
// TODO: add input fields
|
||||
// TODO: add expected output fields
|
||||
}{
|
||||
{
|
||||
name: "basic case",
|
||||
// TODO: fill in
|
||||
},
|
||||
{
|
||||
name: "edge case",
|
||||
// TODO: fill in
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// TODO: call function under test
|
||||
// TODO: compare got vs want
|
||||
// if diff := cmp.Diff(want, got); diff != "" {
|
||||
// t.Errorf("Example() mismatch (-want +got):\n%s", diff)
|
||||
// }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
# Go 测试:集成和高级模式
|
||||
|
||||
TestMain、验收测试和真实传输层测试的详细参考。
|
||||
来源:Google Go Style Guide(最佳实践)。
|
||||
|
||||
---
|
||||
|
||||
## TestMain
|
||||
|
||||
> **来源**:Google Go Style Guide(最佳实践)
|
||||
|
||||
当 **包中的所有测试** 都需要共同的设置且需要清理时(例如,共享数据库),使用 `func TestMain(m *testing.M)`。这 **不应该是你的首选** — 尽可能优先使用作用域测试辅助函数或 `t.Cleanup`。
|
||||
|
||||
```go
|
||||
var db *sql.DB
|
||||
|
||||
func TestInsert(t *testing.T) { /* 使用 db */ }
|
||||
func TestSelect(t *testing.T) { /* 使用 db */ }
|
||||
|
||||
func runMain(ctx context.Context, m *testing.M) (code int, err error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
d, err := setupDatabase(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer d.Close()
|
||||
db = d
|
||||
|
||||
return m.Run(), nil
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
code, err := runMain(context.Background(), m)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// defer 语句在 os.Exit 之后不会执行
|
||||
os.Exit(code)
|
||||
}
|
||||
```
|
||||
|
||||
关键点:
|
||||
- 将设置提取到辅助函数(`runMain`)中,使 `defer` 能正确工作
|
||||
- 通过 `log.Fatal` 将失败信息写入 stderr
|
||||
- 确保各个测试用例保持独立 — 重置它们修改的任何全局状态
|
||||
|
||||
---
|
||||
|
||||
## 验收测试
|
||||
|
||||
> **来源**:Google Go Style Guide(最佳实践)
|
||||
|
||||
验收测试验证实现是否遵循契约,将其视为黑盒。当用户实现你的接口并且你想提供可重用的验证套件时,这种模式很有用。
|
||||
|
||||
### 结构
|
||||
|
||||
1. 创建测试辅助包(例如,为 `chess` 包创建 `chesstest`)
|
||||
2. 导出一个接受被测实现的验证函数:
|
||||
|
||||
```go
|
||||
// Package chesstest 为 chess.Player 实现提供验收测试。
|
||||
package chesstest
|
||||
|
||||
// ExercisePlayer 在单回合中测试 Player 实现。
|
||||
// 如果玩家走了正确的一步,返回 nil,否则返回描述违规行为的错误。
|
||||
func ExercisePlayer(b *chess.Board, p chess.Player) error {
|
||||
move := p.Move()
|
||||
if putsOwnKingIntoCheck(b, move) {
|
||||
return &IllegalMoveError{Move: move, Reason: "puts own king in check"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
3. 最终用户针对验证函数编写简单测试:
|
||||
|
||||
```go
|
||||
func TestAcceptance(t *testing.T) {
|
||||
player := deepblue.New()
|
||||
if err := chesstest.ExerciseGame(t, chesstest.SimpleGame, player); err != nil {
|
||||
t.Errorf("Deep Blue player failed acceptance test: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
仅在设置失败时使用 `t.Fatal` — 验证错误应该返回,而非 fatal。
|
||||
|
||||
---
|
||||
|
||||
## 使用真实传输层
|
||||
|
||||
> **来源**:Google Go Style Guide(最佳实践)
|
||||
|
||||
在测试基于 HTTP 或 RPC 的组件集成时,优先使用真实传输层往返而非手动实现的客户端 mock:
|
||||
|
||||
```go
|
||||
func TestAPIIntegration(t *testing.T) {
|
||||
// 使用假后端启动测试服务器
|
||||
srv := httptest.NewServer(newFakeHandler())
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
// 对测试服务器使用真实 HTTP 客户端
|
||||
client := api.NewClient(srv.URL)
|
||||
result, err := client.GetUser(context.Background(), "user-123")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser() error: %v", err)
|
||||
}
|
||||
if result.Name != "Test User" {
|
||||
t.Errorf("GetUser().Name = %q, want %q", result.Name, "Test User")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
使用生产客户端配合测试服务器,可以确保测试尽可能多地覆盖真实代码,避免模拟客户端行为的复杂性。
|
||||
|
||||
---
|
||||
|
||||
## 常见错误
|
||||
|
||||
### 在 TestMain 中直接调用 os.Exit
|
||||
|
||||
`os.Exit` 立即终止进程 — defer 的清理函数永远不会执行。将设置/清理提取到辅助函数中,使 `defer` 能正确工作:
|
||||
|
||||
```go
|
||||
// 不好:defer 不会执行
|
||||
func TestMain(m *testing.M) {
|
||||
setup()
|
||||
defer cleanup()
|
||||
os.Exit(m.Run()) // cleanup() 永远不会执行
|
||||
}
|
||||
|
||||
// 好:提取到辅助函数中,使 defer 在 os.Exit 之前执行
|
||||
func runTests(m *testing.M) int {
|
||||
setup()
|
||||
defer cleanup()
|
||||
return m.Run()
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(runTests(m))
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,154 @@
|
||||
# 表驱动测试、子测试和并行测试
|
||||
|
||||
在 Go 中组织表驱动测试和子测试的详细参考。
|
||||
来源:Google Go Style Guide、Uber Go Style Guide。
|
||||
|
||||
---
|
||||
|
||||
## 基本结构
|
||||
|
||||
```go
|
||||
func TestCompare(t *testing.T) {
|
||||
tests := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"", "", 0},
|
||||
{"a", "", 1},
|
||||
{"", "a", -1},
|
||||
{"abc", "abc", 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := Compare(tt.a, tt.b)
|
||||
if got != tt.want {
|
||||
t.Errorf("Compare(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 最佳实践
|
||||
|
||||
当测试用例跨越多行或有相同类型的相邻字段时,**使用字段名**:
|
||||
|
||||
```go
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want int
|
||||
}{
|
||||
{name: "empty", input: "", want: 0},
|
||||
{name: "single", input: "a", want: 1},
|
||||
}
|
||||
```
|
||||
|
||||
**不要通过索引标识行** — 在失败信息中包含输入,而非使用 `Case #%d failed`。
|
||||
|
||||
---
|
||||
|
||||
## 避免表测试中的复杂性
|
||||
|
||||
当测试用例需要复杂设置、条件 mock 或多个分支时,优先使用单独的测试函数而非表测试。
|
||||
|
||||
```go
|
||||
// 不好:太多条件字段使测试难以理解
|
||||
tests := []struct {
|
||||
give string
|
||||
want string
|
||||
wantErr error
|
||||
shouldCallX bool
|
||||
shouldCallY bool
|
||||
giveXResponse string
|
||||
giveXErr error
|
||||
giveYResponse string
|
||||
giveYErr error
|
||||
}{...}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.give, func(t *testing.T) {
|
||||
if tt.shouldCallX {
|
||||
xMock.EXPECT().Call().Return(tt.giveXResponse, tt.giveXErr)
|
||||
}
|
||||
if tt.shouldCallY {
|
||||
yMock.EXPECT().Call().Return(tt.giveYResponse, tt.giveYErr)
|
||||
}
|
||||
// ...
|
||||
})
|
||||
}
|
||||
|
||||
// 好:单独的专注测试更清晰
|
||||
func TestShouldCallX(t *testing.T) {
|
||||
xMock.EXPECT().Call().Return("XResponse", nil)
|
||||
got, err := DoComplexThing("inputX", xMock, yMock)
|
||||
// 断言...
|
||||
}
|
||||
|
||||
func TestShouldCallYAndFail(t *testing.T) {
|
||||
yMock.EXPECT().Call().Return("YResponse", nil)
|
||||
_, err := DoComplexThing("inputY", xMock, yMock)
|
||||
// 断言错误...
|
||||
}
|
||||
```
|
||||
|
||||
**表测试最适合以下场景:**
|
||||
|
||||
- 所有用例运行相同逻辑(无条件断言)
|
||||
- 所有用例的设置相同
|
||||
- 没有基于测试用例字段的条件 mock
|
||||
- 所有表字段在所有测试中都被使用
|
||||
|
||||
如果测试体短且直接,单个 `shouldErr` 字段用于成功/失败检查是可以接受的。
|
||||
|
||||
---
|
||||
|
||||
## 子测试
|
||||
|
||||
使用 `t.Run` 实现更好的组织、过滤和并行执行。
|
||||
|
||||
### 子测试命名
|
||||
|
||||
- 使用清晰、简洁的名称:`t.Run("empty_input", ...)`、`t.Run("hu_to_en", ...)`
|
||||
- 避免冗长的描述或斜杠(斜杠会破坏测试过滤)
|
||||
- 子测试必须独立 — 不共享状态或执行顺序依赖
|
||||
|
||||
### 带子测试的表测试
|
||||
|
||||
```go
|
||||
func TestTranslate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, srcLang, dstLang, input, want string
|
||||
}{
|
||||
{"hu_en_basic", "hu", "en", "köszönöm", "thank you"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := Translate(tt.srcLang, tt.dstLang, tt.input); got != tt.want {
|
||||
t.Errorf("Translate(%q, %q, %q) = %q, want %q",
|
||||
tt.srcLang, tt.dstLang, tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 并行测试
|
||||
|
||||
在表测试中使用 `t.Parallel()` 时,注意循环变量捕获:
|
||||
|
||||
```go
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Go 1.22+:tt 在每次迭代中被正确捕获
|
||||
// Go 1.21-:在此处添加 "tt := tt" 来捕获变量
|
||||
got := Process(tt.give)
|
||||
if got != tt.want {
|
||||
t.Errorf("Process(%q) = %q, want %q", tt.give, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,131 @@
|
||||
# 测试辅助函数、断言和比较
|
||||
|
||||
编写测试辅助函数、避免断言库以及在 t.Error 和 t.Fatal 之间选择的详细参考。
|
||||
来源:Google Go Style Guide、Uber Go Style Guide。
|
||||
|
||||
---
|
||||
|
||||
## 测试辅助函数模式
|
||||
|
||||
测试辅助函数必须首先调用 `t.Helper()`,使失败指向调用者。
|
||||
对设置失败使用 `t.Fatal`,对清理使用 `t.Cleanup`。
|
||||
|
||||
```go
|
||||
func mustLoadTestData(t *testing.T, filename string) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
t.Fatalf("Setup failed: could not read %s: %v", filename, err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func setupTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("Could not open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
```
|
||||
|
||||
**关键规则:**
|
||||
- 将 `t.Helper()` 作为第一条语句调用,将失败归因于调用者
|
||||
- 对设置失败使用 `t.Fatal`(不要从辅助函数返回错误)
|
||||
- 使用 `t.Cleanup()` 进行清理而非 defer — 即使测试调用 `t.FailNow` 它也会执行
|
||||
|
||||
---
|
||||
|
||||
## 避免断言库
|
||||
|
||||
> **规范**:不要创建或使用断言库。
|
||||
|
||||
断言库会碎片化开发者体验,并且经常产生无用的失败信息。
|
||||
|
||||
```go
|
||||
// 不好:
|
||||
assert.IsNotNil(t, "obj", obj)
|
||||
assert.StringEq(t, "obj.Type", obj.Type, "blogPost")
|
||||
assert.IntEq(t, "obj.Comments", obj.Comments, 2)
|
||||
|
||||
// 好:使用 cmp 包和标准比较
|
||||
want := BlogPost{
|
||||
Type: "blogPost",
|
||||
Comments: 2,
|
||||
Body: "Hello, world!",
|
||||
}
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("GetPost() mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
```
|
||||
|
||||
### 领域特定比较
|
||||
|
||||
对于领域特定比较,返回值或错误而非调用 `t.Error`:
|
||||
|
||||
```go
|
||||
func postLength(p BlogPost) int { return len(p.Body) }
|
||||
|
||||
func TestBlogPost(t *testing.T) {
|
||||
post := BlogPost{Body: "Hello"}
|
||||
if got, want := postLength(post), 5; got != want {
|
||||
t.Errorf("postLength(post) = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 比较和 Diff
|
||||
|
||||
对于复杂类型,优先使用 `cmp.Equal` 和 `cmp.Diff`。始终在 diff 信息中包含方向键 `(-want +got)`。
|
||||
|
||||
```go
|
||||
// struct 比较
|
||||
want := &Doc{Type: "blogPost", Authors: []string{"isaac", "albert"}}
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("AddPost() mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
// Protocol buffers
|
||||
if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
|
||||
t.Errorf("Foo() mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
```
|
||||
|
||||
**避免不稳定的比较** — 不要比较可能变化的 JSON/序列化输出。改为语义比较。
|
||||
|
||||
---
|
||||
|
||||
## t.Error vs t.Fatal:详细指南
|
||||
|
||||
使用 `t.Error` 保持测试继续运行,在一次运行中报告所有失败:
|
||||
|
||||
```go
|
||||
// 好:报告所有不匹配
|
||||
if diff := cmp.Diff(wantMean, gotMean); diff != "" {
|
||||
t.Errorf("Mean mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
if diff := cmp.Diff(wantVariance, gotVariance); diff != "" {
|
||||
t.Errorf("Variance mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
```
|
||||
|
||||
当后续检查无意义时使用 `t.Fatal`:
|
||||
|
||||
```go
|
||||
gotEncoded := Encode(input)
|
||||
if gotEncoded != wantEncoded {
|
||||
t.Fatalf("Encode(%q) = %q, want %q", input, gotEncoded, wantEncoded)
|
||||
}
|
||||
gotDecoded, err := Decode(gotEncoded)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode(%q) error: %v", gotEncoded, err)
|
||||
}
|
||||
```
|
||||
|
||||
### 不要从 Goroutine 中调用 t.Fatal
|
||||
|
||||
> **规范**:绝不在测试 goroutine 以外的 goroutine 中调用 `t.Fatal`、`t.Fatalf` 或 `t.FailNow`。改为使用 `t.Error` 并让 goroutine 自然返回。
|
||||
@@ -0,0 +1,167 @@
|
||||
# 测试组织参考
|
||||
|
||||
来源:Google Go Style Guide(最佳实践、决策)。
|
||||
|
||||
---
|
||||
|
||||
## 测试替身类型
|
||||
|
||||
| 替身 | 用途 | 有状态? | 验证调用? |
|
||||
|------|------|----------|-----------|
|
||||
| Stub | 返回预设数据 | 否 | 否 |
|
||||
| Fake | 可工作但简化的实现 | 是 | 否 |
|
||||
| Spy | 记录调用以供后续检查 | 是 | 是 |
|
||||
|
||||
**优先使用 fake 而非 mock。** Fake 更具可读性且不需要 mock 框架。仅在验证副作用(例如,分析事件)时使用 spy。
|
||||
|
||||
```go
|
||||
// Fake:可工作的内存实现
|
||||
type FakeUserStore struct {
|
||||
users map[string]*User
|
||||
}
|
||||
|
||||
func (f *FakeUserStore) GetUser(id string) (*User, error) {
|
||||
u, ok := f.users[id]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Spy:记录调用以供后续断言
|
||||
type SpyEmailSender struct{ Sent []string }
|
||||
|
||||
func (s *SpyEmailSender) Send(to, body string) error {
|
||||
s.Sent = append(s.Sent, to)
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试替身命名约定
|
||||
|
||||
> **建议**:为测试替身(stub、fake、spy)遵循一致的命名。
|
||||
|
||||
**包命名**:在生产代码旁边创建一个 `*test` 包(例如,为 `creditcard` 包创建 `creditcardtest`,为独立的 fake 服务创建 `fakeauthservice`)。
|
||||
|
||||
```go
|
||||
// 好:在 creditcardtest 包中
|
||||
|
||||
// 单个替身 — 使用简单名称
|
||||
type Stub struct{}
|
||||
func (Stub) Charge(*creditcard.Card, money.Money) error { return nil }
|
||||
|
||||
// 多种行为 — 按行为命名
|
||||
type AlwaysCharges struct{}
|
||||
type AlwaysDeclines struct{}
|
||||
|
||||
// 多种类型 — 包含类型名
|
||||
type StubService struct{}
|
||||
type StubStoredValue struct{}
|
||||
```
|
||||
|
||||
**局部变量**:为测试替身变量添加替身类型前缀,使调用处更清晰:
|
||||
|
||||
```go
|
||||
// 好:替身类型立即可见
|
||||
spyCC := &creditcardtest.Spy{}
|
||||
stubDB := &dbtest.Stub{Balance: 100}
|
||||
|
||||
// 不好:模糊 — 这是真实的还是替身?
|
||||
cc := &creditcardtest.Spy{}
|
||||
db := &dbtest.Stub{Balance: 100}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 独立测试辅助包
|
||||
|
||||
当多个包需要相同的替身、辅助函数有足够的逻辑需要自己的测试、或者你想为接口实现者提供验收测试套件时,创建独立的测试辅助包。
|
||||
|
||||
| 模式 | 使用场景 | 示例 |
|
||||
|------|----------|------|
|
||||
| `footest` | `foo` 包的通用测试辅助 | `creditcardtest`、`usertest` |
|
||||
| `fakeX` | 独立的 fake 服务包 | `fakeauthservice`、`fakestorage` |
|
||||
|
||||
```go
|
||||
package usertest
|
||||
|
||||
func NewFakeStore(t *testing.T, users ...*user.User) *FakeUserStore {
|
||||
t.Helper()
|
||||
store := &FakeUserStore{users: make(map[string]*user.User)}
|
||||
for _, u := range users {
|
||||
store.users[u.ID] = u
|
||||
}
|
||||
return store
|
||||
}
|
||||
```
|
||||
|
||||
导出接受 `*testing.T` 的构造函数,以便调用 `t.Helper()` 和 `t.Cleanup()`。
|
||||
|
||||
---
|
||||
|
||||
## 测试包
|
||||
|
||||
| 包声明 | 使用场景 |
|
||||
|--------|----------|
|
||||
| `package foo` | 同包测试,可以访问非导出标识符 |
|
||||
| `package foo_test` | 黑盒测试,避免循环依赖 |
|
||||
|
||||
两者都放在同一目录下的 `foo_test.go` 文件中。
|
||||
|
||||
**使用 `package foo`(白盒)** 当你需要测试非导出函数或内部状态时。
|
||||
|
||||
**使用 `package foo_test`(黑盒)** 当仅测试公共 API、打破导入循环或验证外部可用性时。
|
||||
|
||||
```go
|
||||
package parser_test // 黑盒:仅测试导出的 API
|
||||
|
||||
import "mymodule/parser"
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
got, err := parser.Parse("input")
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
如果黑盒测试需要非导出符号,在 `package foo`(非 `foo_test`)中创建 `export_test.go` 来暴露它。谨慎使用。
|
||||
|
||||
---
|
||||
|
||||
## 设置作用域
|
||||
|
||||
> **建议**:保持设置仅限于需要它的测试。
|
||||
|
||||
每个测试中的显式设置更清晰,避免惩罚不相关的测试:
|
||||
|
||||
```go
|
||||
// 好:在需要它的测试中显式设置
|
||||
func TestParseData(t *testing.T) {
|
||||
data := mustLoadDataset(t)
|
||||
// ...
|
||||
}
|
||||
|
||||
func TestUnrelated(t *testing.T) {
|
||||
// 不需要为数据集加载付出代价
|
||||
}
|
||||
```
|
||||
|
||||
**避免使用全局 `init` 进行测试设置** — 它会对文件中的每个测试运行,即使是不相关的测试。
|
||||
|
||||
**子测试设置**:当一组子测试共享设置时,使用带 `t.Run` 的父测试:
|
||||
|
||||
```go
|
||||
func TestDatabase(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
t.Run("Insert", func(t *testing.T) {
|
||||
// 使用 db
|
||||
})
|
||||
t.Run("Select", func(t *testing.T) {
|
||||
// 使用 db
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
这将数据库的生命周期限定在需要它的子测试范围内。仅在万不得已时使用 `TestMain`(参见 [INTEGRATION.md](INTEGRATION.md))。
|
||||
@@ -0,0 +1,108 @@
|
||||
# 可扩展验证 API
|
||||
|
||||
设计可重用测试验证函数的详细参考,调用者可以将其用于验收测试。来源:Google Go Style Guide(最佳实践)。
|
||||
|
||||
---
|
||||
|
||||
## `*test` 包导出模式
|
||||
|
||||
当你拥有一个由他人实现的接口时,在配套的 `*test` 包中导出一个验证函数。这使实现者无需复制你的测试逻辑即可验证正确性。
|
||||
|
||||
```go
|
||||
// Package storagetest 为 storage.Backend 提供验收测试。
|
||||
package storagetest
|
||||
|
||||
// Verify 对任何 storage.Backend 运行验证套件。
|
||||
// 返回描述第一个违规行为的错误,成功时返回 nil。
|
||||
func Verify(b storage.Backend) error {
|
||||
if err := verifyRoundTrip(b); err != nil {
|
||||
return fmt.Errorf("round-trip: %w", err)
|
||||
}
|
||||
if err := verifyNotFound(b); err != nil {
|
||||
return fmt.Errorf("not-found: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
调用者编写一个薄测试来接入他们的实现:
|
||||
|
||||
```go
|
||||
func TestMyBackend(t *testing.T) {
|
||||
b := mybackend.New(t)
|
||||
if err := storagetest.Verify(b); err != nil {
|
||||
t.Errorf("MyBackend failed acceptance: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 设计可扩展的验证函数
|
||||
|
||||
**返回错误,而非 `*testing.T` 失败。** 这使验证函数可作为普通 Go 函数使用 — 调用者决定违规是 `t.Error` 还是 `t.Fatal`。
|
||||
|
||||
```go
|
||||
// 好:返回错误 — 调用者控制测试流程
|
||||
func ExercisePlayer(b *chess.Board, p chess.Player) error {
|
||||
move := p.Move()
|
||||
if putsOwnKingIntoCheck(b, move) {
|
||||
return &IllegalMoveError{Move: move, Reason: "puts own king in check"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 不好:调用 t.Fatal — 调用者失去控制
|
||||
func ExercisePlayer(t *testing.T, b *chess.Board, p chess.Player) {
|
||||
t.Helper()
|
||||
move := p.Move()
|
||||
if putsOwnKingIntoCheck(b, move) {
|
||||
t.Fatalf("illegal move: %v puts own king in check", move)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**在需要丰富诊断信息时使用自定义错误类型**:
|
||||
|
||||
```go
|
||||
type IllegalMoveError struct {
|
||||
Move chess.Move
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *IllegalMoveError) Error() string {
|
||||
return fmt.Sprintf("illegal move %v: %s", e.Move, e.Reason)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 何时使用验证 API vs 简单辅助函数
|
||||
|
||||
| 场景 | 使用方式 |
|
||||
|------|----------|
|
||||
| 你拥有的接口,由他人实现 | `*test` 包中的验证 API |
|
||||
| 同一包中跨测试共享设置 | 使用 `t.Helper()` 的测试辅助函数 |
|
||||
| 在 2-3 个测试中重用的复杂断言 | 返回 `error` 或 `bool` 的辅助函数 |
|
||||
| 一次性的设置或比较 | 内联测试代码 |
|
||||
|
||||
**验证 API** 在以下场景值得额外的包:
|
||||
- 多个外部包将实现你的接口
|
||||
- 契约有容易被忽略的非显而易见的不变量
|
||||
- 你想为"正确行为"提供单一事实来源
|
||||
|
||||
**简单辅助函数** 在以下场景更好:
|
||||
- 辅助函数是直接的设置或比较函数
|
||||
- 重用是偶然的,不是已发布契约的一部分
|
||||
|
||||
---
|
||||
|
||||
## 命名约定
|
||||
|
||||
使用表示范围的动词命名函数:`Verify`、`Exercise`、`RunConformance`。接受被测接口作为参数 — 绝不在验证包内部构造实现。
|
||||
|
||||
| 包 | 函数 | 用途 |
|
||||
|----|------|------|
|
||||
| `storagetest` | `Verify` | 验证 `storage.Backend` |
|
||||
| `chesstest` | `ExercisePlayer` | 验证 `chess.Player` |
|
||||
| `cachetest` | `RunConformance` | `cache.Cache` 的完整一致性套件 |
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="1.0.0"
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
$SCRIPT_NAME v$VERSION — Generate a table-driven test scaffold for a Go function
|
||||
|
||||
USAGE
|
||||
bash $SCRIPT_NAME [options] <FuncName> <package>
|
||||
|
||||
DESCRIPTION
|
||||
Outputs a table-driven test file for the given function and package.
|
||||
By default writes to stdout; use --output to write to a file.
|
||||
|
||||
Exits 0 on success, 2 on error.
|
||||
|
||||
OPTIONS
|
||||
-h, --help Show this help message
|
||||
-v, --version Show version
|
||||
--output FILE Write to FILE instead of stdout
|
||||
--force Allow --output to overwrite an existing file
|
||||
--parallel Include t.Parallel() in generated test
|
||||
--json Output structured JSON metadata to stdout
|
||||
|
||||
ARGUMENTS
|
||||
FuncName Name of the function to test (must be exported/uppercase)
|
||||
package Go package name for the test file
|
||||
|
||||
EXAMPLES
|
||||
bash $SCRIPT_NAME ParseConfig config
|
||||
bash $SCRIPT_NAME --parallel ParseConfig config
|
||||
bash $SCRIPT_NAME --output config/parse_config_test.go ParseConfig config
|
||||
bash $SCRIPT_NAME --force --output config/parse_config_test.go ParseConfig config
|
||||
bash $SCRIPT_NAME --json --output config/parse_config_test.go ParseConfig config
|
||||
bash $SCRIPT_NAME ParseConfig config > config/parse_config_test.go
|
||||
EOF
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"
|
||||
s="${s//\"/\\\"}"
|
||||
s="${s//$'\t'/\\t}"
|
||||
s="${s//$'\r'/}"
|
||||
s="${s//$'\n'/\\n}"
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
OUTPUT=""
|
||||
PARALLEL=false
|
||||
JSON_OUTPUT=false
|
||||
FORCE=false
|
||||
POSITIONAL=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-v|--version) echo "$SCRIPT_NAME v$VERSION"; exit 0 ;;
|
||||
--output) OUTPUT="${2:?error: --output requires a file path}"; shift 2 ;;
|
||||
--force) FORCE=true; shift ;;
|
||||
--parallel) PARALLEL=true; shift ;;
|
||||
--json) JSON_OUTPUT=true; shift ;;
|
||||
-*) echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
*) POSITIONAL+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ${#POSITIONAL[@]} -lt 2 ]]; then
|
||||
echo "error: FuncName and package are required" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
FUNC="${POSITIONAL[0]}"
|
||||
PKG="${POSITIONAL[1]}"
|
||||
|
||||
if [[ ! "$FUNC" =~ ^[A-Z] ]]; then
|
||||
echo "error: FuncName '$FUNC' must start with an uppercase letter" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
generate_test() {
|
||||
local parallel_top="" parallel_sub=""
|
||||
if $PARALLEL; then
|
||||
parallel_top=$'\tt.Parallel()\n'
|
||||
parallel_sub=$'\t\t\tt.Parallel()\n'
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
package ${PKG}
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test${FUNC}(t *testing.T) {
|
||||
${parallel_top} tests := []struct {
|
||||
name string
|
||||
give string // TODO: replace with actual input type
|
||||
want string // TODO: replace with actual output type
|
||||
}{
|
||||
{
|
||||
name: "basic case",
|
||||
give: "",
|
||||
want: "",
|
||||
},
|
||||
// TODO: add more test cases
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
${parallel_sub} got := ${FUNC}(tt.give)
|
||||
if got != tt.want {
|
||||
t.Errorf("${FUNC}(%q) = %q, want %q", tt.give, got, tt.want)
|
||||
}
|
||||
// For richer diffs, consider:
|
||||
// if diff := cmp.Diff(tt.want, got); diff != "" {
|
||||
// t.Errorf("${FUNC}() mismatch (-want +got):\n%s", diff)
|
||||
// }
|
||||
})
|
||||
}
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ -n "$OUTPUT" ]]; then
|
||||
OUTPUT_DIR="$(dirname "$OUTPUT")"
|
||||
if [[ ! -d "$OUTPUT_DIR" ]]; then
|
||||
echo "error: directory '$OUTPUT_DIR' does not exist" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ -f "$OUTPUT" ]] && ! $FORCE; then
|
||||
echo "error: '$OUTPUT' already exists (use --force to overwrite)" >&2
|
||||
exit 2
|
||||
fi
|
||||
generate_test > "$OUTPUT"
|
||||
if $JSON_OUTPUT; then
|
||||
FUNC_ESC="$(json_escape "$FUNC")"
|
||||
PKG_ESC="$(json_escape "$PKG")"
|
||||
OUTPUT_ESC="$(json_escape "$OUTPUT")"
|
||||
cat <<EOF
|
||||
{"func":"$FUNC_ESC","package":"$PKG_ESC","output_file":"$OUTPUT_ESC","parallel":$PARALLEL,"written":true}
|
||||
EOF
|
||||
else
|
||||
echo "Wrote test scaffold to $OUTPUT"
|
||||
fi
|
||||
else
|
||||
if $JSON_OUTPUT; then
|
||||
generate_test >&2
|
||||
FUNC_ESC="$(json_escape "$FUNC")"
|
||||
PKG_ESC="$(json_escape "$PKG")"
|
||||
cat <<EOF
|
||||
{"func":"$FUNC_ESC","package":"$PKG_ESC","output_file":"","parallel":$PARALLEL,"written":false}
|
||||
EOF
|
||||
else
|
||||
generate_test
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: "logstore"
|
||||
description: "OpenFlare / Wavelet:当新增或修改日志/分析用途表(节点访问日志、用户访问日志、可观测时序)、接入 internal/repository/logstore、切换日志主库、实现 PG/SQLite 回落,或判断一张表该走业务主库还是日志库时必须使用。"
|
||||
---
|
||||
|
||||
# 日志用途表开发
|
||||
|
||||
开始前阅读根目录 `AGENTS.md`。DDL 用 `database-migration`;高频写入队列用 `clickhouse-batchwriter`;切换任务用 `new-async-task`。本技能只回答:**这张表是不是日志表,以及如何接入可切换的日志主库。**
|
||||
|
||||
设计背景见 [日志存储解耦](../../../docs/design/logstore.md)。
|
||||
|
||||
## 先判定
|
||||
|
||||
日志表同时满足:
|
||||
|
||||
- 追加写入、几乎不更新单行
|
||||
- 按时间查询/聚合,允许按保留天数删除
|
||||
- 关闭 ClickHouse 后仍要能写、能查
|
||||
- 不参与网站/节点/证书等事务一致性
|
||||
|
||||
**不要**做成日志表:Zone、节点、配置版本、任务执行、上传元数据。这些走主库 `repository`。
|
||||
|
||||
当前日志域:
|
||||
|
||||
| 域 | 接口 | 表 |
|
||||
| :--- | :--- | :--- |
|
||||
| 节点访问日志 | `AccessLogStore` | `of_node_access_logs` |
|
||||
| 可观测 | `ObservabilityStore` | `of_node_metric_snapshots` / `of_node_edge_health` / `of_node_obs_frps` / `of_node_obs_frpc` |
|
||||
| 用户访问审计 | `UserAccessLogStore` | `w_user_access_logs` |
|
||||
|
||||
## 分层
|
||||
|
||||
| 层级 | 路径 | 职责 |
|
||||
| :--- | :--- | :--- |
|
||||
| 抽象 | `internal/repository/logstore` | 接口 + `Active`/`BuildForMigration`;apps **只**面向这里或 `repository` 门面 |
|
||||
| CH 实现 | `logstore/clickhouse_store.go` 委托 `analytics` | 原生批量 + 现有聚合 SQL |
|
||||
| 主库实现 | `logstore/postgres_store.go` | PG(按月分区)与 SQLite(普通表)共用 GORM |
|
||||
| Model | `internal/model/analytics` | 实体与批量 SQL,无 IO |
|
||||
| 入队 | `chwriter` / `risk_control` + `batchwriter` | flush 调 logstore `BatchInsert*`;CH 入队经 hooks |
|
||||
| 切换 | `of_log_db_switch` | 冻结 → `chwriter.Drain` → 逐表复制 → 翻转 |
|
||||
| 约束 | `logstore/imports_test.go` | apps 禁止 import `repository/analytics` |
|
||||
|
||||
`log_database` 只能是「随主库」或 `clickhouse`。`log_database` / `log_db_migration` 受保护。
|
||||
|
||||
## 新增一张日志表
|
||||
|
||||
1. **Model**(`internal/model/analytics`):`TableName` + `InsertColumns` / `BatchInsertSQL`。
|
||||
2. **三套 DDL**:CH `MergeTree` + `toYYYYMM`;PG `PARTITION BY RANGE(时间列)`(主键含分区键);SQLite 普通表。不要在主库建 CH 物化视图,聚合实时算。
|
||||
3. **挂到已有域或新接口**:能进 `AccessLogStore` / `ObservabilityStore` / `UserAccessLogStore` 就不要再拆包。新域才新增接口并放进 `Store`。
|
||||
4. **方法最少集**:`BatchInsert`(含 `ensureWritable`)、业务查询、`ListForMigration`、`MigrationRange`、`DeleteAll`、`DeleteBefore`、`EnsurePartitions`(仅 PG 预建)。
|
||||
5. **双实现**:CH 委托 `analyticsrepo`;GORM 共用一套,方言 SQL 放 `dialect_*.go`。零值 id 用 `idgen.NextUint64ID()`。
|
||||
6. **`buildStore`**:CH / GORM 两分支都挂上。
|
||||
7. **写入**:独立 `batchwriter`;`FlushFunc` → `logstore.Active`。节点日志/可观测走 `SetAccessLogHooks` / `SetObservabilityHooks`,不要让 apps 碰 `ChConn`。
|
||||
8. **切换任务**:`clearTarget` + `copy*` 增加该表;源数据不删,失败不翻转。
|
||||
9. **清理**:访问类走 `log_retention_days_*`;性能指标走 `metric_retention_days`。不要擅自共用错误的 TTL。
|
||||
10. **import-lint**:apps 新增对 `analytics` 或 `infra/persistence`(`batchwriter`/`idgen` 除外)的 import 必须失败。
|
||||
|
||||
## 禁止
|
||||
|
||||
- apps 直连 `analyticsrepo` / `db.ChConn` / `db.ChDB` 做日志读写
|
||||
- 只建 CH、不建主库回落
|
||||
- Handler 内逐条 `PrepareBatch`
|
||||
- 业务表塞进 logstore
|
||||
- 管理端改 `log_database` / `log_db_migration`
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
go test ./internal/repository/logstore ./internal/repository/analytics
|
||||
go test ./internal/apps/openflare/... ./internal/apps/admin/logs ./internal/apps/admin/status
|
||||
make swagger
|
||||
make code-check
|
||||
```
|
||||
|
||||
对照:`of_node_access_logs` 或 `w_user_access_logs` 的 model、三库 goose、`logstore` 双实现、`chwriter`/`risk_control` flush、`LogDBSwitchHandler`。
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
name: "new-api"
|
||||
description: "Wavelet 项目专用:当新增或修改自定义业务 API、新增业务路由、新增 service 层核心逻辑时必须使用。本技能指导包职责划分、推荐文件结构、路由解耦、Swagger 文档生成与质量门禁验证。"
|
||||
---
|
||||
|
||||
# 新增业务 API 开发与路由注册规范
|
||||
|
||||
本技能是 Wavelet 项目接口开发与路由注册的唯一指导规范。在开发任何新接口前,请严格按照本指南进行架构决策与路由注册。
|
||||
|
||||
---
|
||||
|
||||
## 核心路由准则与防线 (Routing Governance & Guardrails)
|
||||
|
||||
Wavelet 后端路由采用了**严格的框架层与业务层隔离机制**。请牢记以下开发原则:
|
||||
|
||||
1. **禁止修改框架级路由文件**:
|
||||
- 以下文件属于系统框架/平台级接口,**禁止为了添加自定义业务接口而进行任何修改**:
|
||||
- `internal/router/router.go`(核心入口委派)
|
||||
- `internal/router/root/default.go`(公开文件服务、robots.txt、Swagger 及 /api/health 路由)
|
||||
- `internal/router/root/frontend.go`(前端静态服务)
|
||||
- `internal/router/v1/v1.go`(V1 分发层协调器)
|
||||
- `internal/router/v1/admin.go`(框架管理员端管理接口)
|
||||
- `internal/router/v1/user.go`(框架普通用户端基础接口、OAuth及公开接口)
|
||||
2. **仅允许在 `custom.go` 中注册业务接口**:
|
||||
- 所有的自定义/业务相关接口注册,有且仅有以下两个合法的承载点:
|
||||
- [internal/router/root/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/root/custom.go)(用于挂载到根路径的特殊业务接口)
|
||||
- [internal/router/v1/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/v1/custom.go)(用于挂载在 API V1 下的标准自定义业务接口)
|
||||
|
||||
---
|
||||
|
||||
## 路由归属判定表 (Where should I register my new API?)
|
||||
|
||||
根据接口的**访问路径特征**和**访问身份/限制条件**,决定将新开发的 API 挂载至何处:
|
||||
|
||||
| 目标 API 路径特征 | 访问身份/条件限制 | 对应的路由注册入口 | 是否允许修改 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **`/my-custom-path`** (挂载在根路径下的特殊业务接口) | 自定义控制 | `root/custom.go` 中的 `RegisterCustomRootRoutes` | **允许修改 (业务自定义入口)** |
|
||||
| **`/api/v1/custom/...`** (API v1 下的定制业务接口) | 自定义控制 | `v1/custom.go` 中的 `RegisterCustomRoutes` | **允许修改 (业务自定义入口)** |
|
||||
| **`/api/v1/admin/...`** (系统管理员管理端接口) | 需要管理员登录 (`admin.LoginAdminRequired()`) | `v1/admin.go` | **禁止修改 (仅限系统框架路由)** |
|
||||
| **`/api/v1/user/...`** (框架普通用户基础接口) | 需要普通用户登录 (`oauth.LoginRequired()`) | `v1/user.go` | **禁止修改 (仅限系统框架路由)** |
|
||||
| **`/api/v1/public/...`** (Captcha、Config 等系统公开接口) | 所有人 (无条件 / 公开) | `v1/user.go` | **禁止修改 (仅限系统框架路由)** |
|
||||
| **`GET /f/:id`**, **`GET /robots.txt`**, **`GET /api/health`** (系统级默认及公开接口) | 所有人 (无条件 / 公开) | `root/default.go` | **禁止修改 (仅限系统框架路由)** |
|
||||
|
||||
---
|
||||
|
||||
## 两个自定义路由包的用法与区别 (Root Custom vs V1 Custom)
|
||||
|
||||
### 1. 根路径自定义包:`root/custom.go`
|
||||
|
||||
* **适用场景**:适用于需要**直接挂载在主域名根路径下**的特殊自定义业务接口(如第三方 Webhook 回调、特定的短链接重定向、外部数据接口等,不需要 `/api/v1` 前缀)。
|
||||
* **用法示例**:
|
||||
在 [root/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/root/custom.go) 中实现:
|
||||
```go
|
||||
package root
|
||||
|
||||
import (
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/custom"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterCustomRootRoutes registers custom business routes that belong to the root path.
|
||||
func RegisterCustomRootRoutes(r *gin.Engine) {
|
||||
// 挂载到根路径下,如 GET /my-custom-webhook
|
||||
r.GET("/my-custom-webhook", custom.HandleRootWebhook)
|
||||
}
|
||||
```
|
||||
*(注:该函数已由 `root.go` 自动加载,你无需修改任何其他核心文件。)*
|
||||
|
||||
### 2. V1 API 自定义包:`v1/custom.go`
|
||||
|
||||
* **适用场景**:适用于普通的**自定义业务 API**,需要规范挂载在标准 API V1 路径下(即自动带有 `/api/v1/custom/...` 前缀,可选择性配置用户/管理员登录中间件)。
|
||||
* **用法示例**:
|
||||
在 [v1/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/v1/custom.go) 中实现:
|
||||
```go
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/custom"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterCustomRoutes registers standard custom API routes under /api/v1.
|
||||
func RegisterCustomRoutes(apiV1Router *gin.RouterGroup) {
|
||||
customRouter := apiV1Router.Group("/custom")
|
||||
{
|
||||
// 挂载到 /api/v1/custom 下,例如:POST /api/v1/custom/action
|
||||
customRouter.POST("/action", custom.DoActionHandler)
|
||||
}
|
||||
}
|
||||
```
|
||||
*(注:该函数已由 `v1/v1.go` 自动加载,你无需修改任何其他核心文件。)*
|
||||
|
||||
---
|
||||
|
||||
## 建议创建/修改的文件结构 (Recommended Directory Structure)
|
||||
|
||||
当新增一套定制的业务接口(例如名为 `custom` 的业务模块)时,建议采用以下标准文件结构:
|
||||
|
||||
```text
|
||||
internal/
|
||||
├── router/
|
||||
│ ├── root/
|
||||
│ │ └── custom.go # [修改] 若为根路径 API,在此处注册,将路由委派给 apps/custom
|
||||
│ └── v1/
|
||||
│ └── custom.go # [修改] 若为 v1 API,在此处注册,将路由委派给 apps/custom
|
||||
└── apps/
|
||||
└── custom/
|
||||
├── routers.go # [新建] HTTP Handlers (Gin),负责参数绑定、校验与响应
|
||||
├── logics.go # [新建] 业务逻辑层:承载模块内闭环的纯 Go 业务逻辑,不依赖 gin.Context
|
||||
└── errs.go # [新建] 存放模块特有的业务错误常量定义(可选)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心开发步骤 (Step-by-Step Flow)
|
||||
|
||||
### 步骤 1:数据库定义与迁移
|
||||
如果自定义功能涉及新表或字段,请参考 [database-migration](../database-migration/SKILL.md) 技能,在 `internal/infra/persistence/migrator/goose/` 目录下编写迁移文件,在 `internal/model/` 中定义 GORM 实体(无 CRUD / 无 DB 访问),并在 `internal/repository/` 中实现数据访问(**repository 为唯一持久化入口**)。
|
||||
|
||||
### 步骤 2:在模块内实现业务逻辑 (`logics.go` / `service.go`)
|
||||
业务逻辑逻辑应当实现于 `internal/apps/custom/` 目录下:
|
||||
- **优先使用纯函数(`logics.go`)**:定义接收 `context.Context` 且不依赖 `*gin.Context` 的函数,易于单元测试与 Worker 复用。参考 `internal/apps/user/logics.go`。
|
||||
- **有状态服务(`service.go`)**:若需注入依赖(如 DB 连接、外部客户端等),可定义 Service 结构体和构造函数。
|
||||
- **跨模块副作用(推送、任务监听等)**:核心业务代码通过 `internal/listener` 发射域事件,禁止直接 `import` push 模块;装配在 `internal/platform/bootstrap` 完成(参见 `push-notification` skill)。
|
||||
|
||||
### 步骤 3:编写 HTTP Handler (`routers.go`)
|
||||
在 `internal/apps/custom/routers.go` 中编写 Handler:
|
||||
- 负责请求参数绑定与校验(使用 `ShouldBindJSON`/`ShouldBindQuery`)。
|
||||
- 负责提取 Session / 用户身份。
|
||||
- 调用业务逻辑层,并使用 `github.com/Rain-kl/Wavelet/internal/shared/response` 统一返回响应:
|
||||
- 成功时返回:`response.OK(data)` 或 `response.OKNil()`
|
||||
- 失败时返回:`response.Err(msg)`
|
||||
- 编写规范的 Swagger 注释。
|
||||
|
||||
### 步骤 4:在自定义包中注册路由并委派
|
||||
根据 **路由归属判定表**,在 [root/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/root/custom.go) 或 [v1/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/v1/custom.go) 中编写注册代码,将路由路径绑定到步骤 3 中编写的 Handler。
|
||||
|
||||
---
|
||||
|
||||
## 质量验证门禁 (Quality Gates)
|
||||
|
||||
每次新增或修改接口后,必须运行并验证以下各项:
|
||||
1. **自动授权许可**:`make license`(新增 Go 文件时自动添加许可头)
|
||||
2. **重新生成 Swagger 文档**:`make swagger`(若有 Swagger 注释修改)
|
||||
3. **静态代码及风格检查**:`make code-check`(确保通过 golangci-lint 和前端 TS 检查)
|
||||
4. **自动化单元测试**:`go test ./...`(确保所有测试 100% 通过)
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package references
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/service"
|
||||
"github.com/Rain-kl/Wavelet/internal/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// customRequest 客户端请求体 DTO
|
||||
type customRequest struct {
|
||||
Payload string `json:"payload" binding:"required,min=1,max=100"`
|
||||
}
|
||||
|
||||
// customResponse API 响应体 DTO
|
||||
type customResponse struct {
|
||||
Result string `json:"result"`
|
||||
}
|
||||
|
||||
// HandleCustomBusiness 示例 API Handler
|
||||
// @Summary 示例定制业务接口
|
||||
// @Description 接收数据载荷,调用 Service 执行核心逻辑,并返回统一格式的 JSON 结果。
|
||||
// @Tags custom
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body customRequest true "业务请求参数"
|
||||
// @Success 200 {object} util.ResponseAny{data=customResponse} "操作成功"
|
||||
// @Router /api/v1/custom/business [post]
|
||||
func HandleCustomBusiness(c *gin.Context) {
|
||||
// 1. 参数绑定与校验
|
||||
var req customRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err("参数校验失败:载荷不能为空且在 1-100 字符内"))
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 模拟获取当前上下文与已登录用户(例如从 Session 中提取)
|
||||
// 通常结合 oauth.LoginRequired() 等中间件使用
|
||||
userID := int64(9527)
|
||||
|
||||
// 3. 实例化业务 Service 并调用核心逻辑
|
||||
// 注意传入 c.Request.Context() 以正确传递 OpenTelemetry Tracing 等上下文信息
|
||||
svc := service.NewCustomService()
|
||||
resText, err := svc.ProcessBusinessData(c.Request.Context(), userID, req.Payload)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 返回符合外层形状规范 { "error_msg": "", "data": ... } 的统一成功响应
|
||||
c.JSON(http.StatusOK, util.OK(customResponse{
|
||||
Result: resText,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package references
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ProcessLocalBusiness 示例的模块内部闭环业务逻辑
|
||||
// 1. 存放在 apps/custom/logics.go 下,遵循纯 Go 规范,不强依赖 gin.Context,以便逻辑清晰和便于单元测试。
|
||||
// 2. 用于当前应用模块内的简单业务或通用过程。
|
||||
func ProcessLocalBusiness(ctx context.Context, userID int64, param string) (string, error) {
|
||||
if param == "" {
|
||||
return "", errors.New("param cannot be empty")
|
||||
}
|
||||
|
||||
logger.Info(ctx, "processing local business inside apps/custom/logics",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.String("param", param),
|
||||
)
|
||||
|
||||
// 执行轻量级、无需跨模块/多入口复用的本地计算或模型操作
|
||||
result := fmt.Sprintf("Processed local logic for user %d: %s", userID, param)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package references
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CustomService 示例业务 Service 结构体(通常放在 internal/apps/custom/service.go 中)
|
||||
type CustomService struct {
|
||||
// 这里可以注入数据库连接、配置对象或者其他基础服务的客户端
|
||||
// 例如:db *gorm.DB
|
||||
}
|
||||
|
||||
// NewCustomService 创建 CustomService 实例的构造函数
|
||||
func NewCustomService() *CustomService {
|
||||
return &CustomService{}
|
||||
}
|
||||
|
||||
// ProcessBusinessData 演示核心业务处理逻辑的 Service 方法
|
||||
// 1. 首位参数必须是 context.Context,以传播链路追踪 (OTel) 和超时控制。
|
||||
// 2. 方法签名应该只包含纯 Go 的参数与返回值,禁止导入 Gin 或与 HTTP 相关的协议依赖。
|
||||
// 3. 将可能发生的核心异常通过 error 返回给上层,而不是在这一层转换成 HTTP 状态码。
|
||||
func (s *CustomService) ProcessBusinessData(ctx context.Context, userID int64, payload string) (string, error) {
|
||||
if payload == "" {
|
||||
return "", errors.New("payload cannot be empty")
|
||||
}
|
||||
|
||||
// 模拟执行业务逻辑...
|
||||
logger.Info(ctx, "processing custom business data in service",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.String("payload", payload),
|
||||
)
|
||||
|
||||
// 这里可以包含数据库读写、事务控制、或者远程 API 调用等复杂逻辑。
|
||||
result := fmt.Sprintf("Success processed data for user %d: %s", userID, payload)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
name: "new-async-task"
|
||||
description: "Wavelet 项目专用:新增或修改 Asynq 异步任务、后台任务、定时任务、任务元数据、TaskHandler、TaskParam、PayloadValidator、AppendLog、任务重试、任务执行记录或 Admin 任务 API 时必须使用。"
|
||||
---
|
||||
|
||||
# 异步任务开发
|
||||
|
||||
开始前阅读根目录 `AGENTS.md`。只修改任务相关链路,遵守项目路由、日志、数据库迁移和质量门禁要求。
|
||||
|
||||
## 开始前
|
||||
|
||||
按任务范围检查当前实现:
|
||||
|
||||
- `internal/infra/task/handler.go`:`TaskHandler`、`TaskResult`、`PayloadValidator`
|
||||
- `internal/infra/task/meta.go`:`TaskMeta`、`TaskParam`
|
||||
- `internal/infra/task/executor.go`:下发、执行、日志、重试、`OnTaskCompleted` 订阅
|
||||
- `internal/infra/task/handlers/register.go`:Handler 和元数据注册(由 bootstrap 调用)
|
||||
- `internal/platform/bootstrap/bootstrap.go`:任务注册与进程级装配入口
|
||||
- `internal/infra/task/worker/worker.go`:Worker 路由和队列
|
||||
- `internal/infra/task/scheduler/scheduler.go`:定时调度
|
||||
- `internal/apps/admin/task/routers.go`:Admin 任务 API
|
||||
- `internal/model/task_execution.go`:执行记录实体与 DTO
|
||||
- `internal/repository/task_execution.go`:执行记录和日志持久化
|
||||
|
||||
需要模板时阅读 [references/CODE-EXAMPLES.md](references/CODE-EXAMPLES.md)。
|
||||
|
||||
## 实现要求
|
||||
|
||||
### 任务定义
|
||||
|
||||
- 在 `internal/apps/<module>/tasks.go` 定义任务类型、Admin 任务类型和 `TaskMeta`。
|
||||
- Asynq 任务类型使用 `<module>:<action>` 格式。
|
||||
- 完整设置 `Type`、`AsynqTask`、`Name`、`Description`、`MaxRetry`、`Queue`、`Retryable`。
|
||||
- 有参数任务必须定义 payload struct。
|
||||
- `TaskParam.Name` 必须与 payload JSON tag 一致。
|
||||
- `TaskParam` 只描述前端表单,不代替服务端校验。
|
||||
|
||||
### Handler
|
||||
|
||||
- Handler 必须实现 `task.TaskHandler`。
|
||||
- 有参数任务必须实现 `task.PayloadValidator`,负责校验和标准化 Admin 下发参数。
|
||||
- `Execute` 必须再次解析 payload;不要假设入口一定经过 Admin 校验。
|
||||
- 成功返回 `&task.TaskResult{Message: ..., Detail: ...}`。
|
||||
- 失败返回 error,由任务框架处理状态和重试。
|
||||
- 不要吞掉关键错误。
|
||||
- 持久化只通过 `internal/repository/`(唯一入口);业务编排放模块内 `logics.go` / `service.go`。`internal/model` 仅实体/DTO,禁止 CRUD 与 DB 访问。
|
||||
|
||||
### 注册
|
||||
|
||||
- 在 `internal/infra/task/handlers/register.go` 同时注册 Handler 和 `TaskMeta`。
|
||||
- 不要在其他位置单独注册任务。
|
||||
- **禁止**在业务包 `routers.go` 或 `init()` 中调用 `task.RegisterHandler`;统一由 `bootstrap.RegisterTasks()` → `taskhandlers.Register()` 在进程启动时装配。
|
||||
- 任务完成钩子(如 push 通知)通过 `task.OnTaskCompleted` 注册,在 `bootstrap.RegisterTaskListeners()` 中装配(Worker/`all` 进程)。
|
||||
|
||||
### 进程装配分工
|
||||
|
||||
| 进程 | 注册入口 |
|
||||
| :--- | :--- |
|
||||
| `api` | `cmd/api.go` → `bootstrap.RegisterAPI()`(含 `RegisterTasks`) |
|
||||
| `worker` | `worker.StartWorker()` → `bootstrap.RegisterWorker()`(含 `RegisterTasks` + `RegisterTaskListeners`) |
|
||||
| `scheduler` | `scheduler.StartScheduler()` → `bootstrap.RegisterScheduler()` |
|
||||
| `all` | `cmd/all.go` → `bootstrap.RegisterAll()` |
|
||||
|
||||
所有 `Register*` 使用 `sync.Once`,重复调用安全。
|
||||
|
||||
### 测试
|
||||
|
||||
- 依赖已注册任务类型或 Handler 的测试(如 `internal/apps/admin/task/routers_test.go`),必须在 setup 中显式调用 `bootstrap.RegisterTasks()`。
|
||||
- 不得依赖 `init()` 副作用或 import 链触发注册。
|
||||
|
||||
## 日志要求
|
||||
|
||||
- 在 `TaskHandler.Execute` 中使用 `task.AppendLog(ctx, format, args...)`。
|
||||
- 记录任务开始、参数摘要、批次进度、关键状态、可继续错误和完成摘要。
|
||||
- 批量处理按批次记录;禁止为大循环中的每条数据写日志。
|
||||
- 不要直接修改任务日志的 Redis key 或 `w_task_executions.log`。
|
||||
|
||||
日志框架约束:
|
||||
|
||||
- 执行状态实时写入数据库:`pending`、`running`、`succeeded`、`failed`。
|
||||
- 实时日志写入 Redis,每个任务最多保留最近 1000 行。
|
||||
- Redis 日志 TTL 为 24 小时,每次追加时刷新。
|
||||
- 查询时优先返回 Redis 日志,Redis 不存在时读取数据库。
|
||||
- 任务成功或自动重试耗尽后,将日志写入数据库并删除 Redis 缓冲。
|
||||
- 自动重试期间保留同一 taskID 的 Redis 日志。
|
||||
|
||||
## 重试要求
|
||||
|
||||
- Handler 返回 error 以触发 Asynq 自动重试。
|
||||
- 不要在 Handler 内自行实现重复重试循环。
|
||||
- Admin 手动重试只允许:
|
||||
- 原任务状态为 `failed`
|
||||
- `Retryable=true`
|
||||
- `RetryCount < MaxRetry`
|
||||
- 修改重试行为时同时检查:
|
||||
- `internal/infra/task/executor.go`
|
||||
- `internal/model/task_execution.go`
|
||||
- `internal/apps/admin/task/routers.go`
|
||||
- 前端任务执行列表
|
||||
|
||||
## 定时任务
|
||||
|
||||
- 默认定时任务必须通过 Goose SQL 迁移写入 `schedules`。
|
||||
- PostgreSQL 和 SQLite 迁移必须同时提供。
|
||||
- 初始化 SQL 必须幂等。
|
||||
- 涉及迁移时使用 `database-migration` skill。
|
||||
|
||||
## Admin API
|
||||
|
||||
- Handler 放在现有 Admin task 模块或 `internal/apps/admin/<module>/`。
|
||||
- 路由只在 `internal/router/router.go` 注册。
|
||||
- 响应保持 `{ "error_msg": "", "data": ... }`。
|
||||
- 分页数据保持 `{ "total": 0, "results": [] }`。
|
||||
- Swagger 注释必须完整;API 变化后运行 `make swagger`。
|
||||
|
||||
## 前端
|
||||
|
||||
- 仅任务元数据变化时,优先复用现有动态任务表单,不新增页面。
|
||||
- API 调用必须通过 `frontend/lib/services/`。
|
||||
- 修改 shadcn/ui 时使用 `shadcn` skill。
|
||||
- 不使用 `any`。
|
||||
- 页面根容器使用 `w-full`,不添加页面级 `max-w-*`。
|
||||
@@ -0,0 +1,277 @@
|
||||
# Wavelet 异步任务代码示例
|
||||
|
||||
这些示例用于新增或修改 Wavelet Asynq 任务时快速套用。复制前先对照当前代码,因为任务框架可能随项目演进。
|
||||
|
||||
## 任务元数据与常量定义
|
||||
|
||||
在对应的业务包 `internal/apps/<module>/tasks.go` 中定义 Asynq task type、Admin task type 和 `TaskMeta`。
|
||||
|
||||
```go
|
||||
package upload
|
||||
|
||||
import (
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/task"
|
||||
)
|
||||
|
||||
// 异步任务类型标识。格式建议为 "{module}:{action}"。
|
||||
const CleanupUnusedUploadsTask = "upload:cleanup_unused"
|
||||
|
||||
// 管理员可下发的任务类型标识。用于 Admin API 的 task_type。
|
||||
const TaskTypeCleanupUploads = "cleanup_unused_uploads"
|
||||
|
||||
// CleanupUnusedUploadsMeta 任务元数据
|
||||
var CleanupUnusedUploadsMeta = task.TaskMeta{
|
||||
Type: TaskTypeCleanupUploads,
|
||||
AsynqTask: CleanupUnusedUploadsTask,
|
||||
Name: "清理未使用上传",
|
||||
Description: "清理超过1小时未使用的上传文件",
|
||||
SupportsTime: false,
|
||||
MaxRetry: task.DefaultMaxRetry,
|
||||
Queue: task.QueueDefault,
|
||||
Retryable: true,
|
||||
}
|
||||
```
|
||||
|
||||
带参数任务把前端表单元数据放在 `Params`。`Name` 必须和 payload JSON tag 对齐。
|
||||
|
||||
```go
|
||||
{
|
||||
Type: TaskTypeSendEmail,
|
||||
AsynqTask: SendEmailTask,
|
||||
Name: "发送邮件",
|
||||
Description: "异步发送系统邮件",
|
||||
SupportsTime: false,
|
||||
MaxRetry: defaultMaxRetry,
|
||||
Queue: QueueDefault,
|
||||
Retryable: true,
|
||||
Params: []TaskParam{
|
||||
{
|
||||
Name: "to",
|
||||
Label: "接收邮箱 (To)",
|
||||
Type: "string",
|
||||
Required: true,
|
||||
Placeholder: "receiver@example.com",
|
||||
Description: "接收邮件的目标邮箱地址",
|
||||
},
|
||||
{
|
||||
Name: "subject",
|
||||
Label: "邮件主题 (Subject)",
|
||||
Type: "string",
|
||||
Required: true,
|
||||
Placeholder: "请输入邮件主题",
|
||||
Description: "发送邮件的主题标题",
|
||||
},
|
||||
{
|
||||
Name: "body",
|
||||
Label: "邮件内容 (Body)",
|
||||
Type: "text",
|
||||
Required: true,
|
||||
Placeholder: "请输入邮件内容",
|
||||
Description: "发送邮件的内容主体",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## 无参数 Handler
|
||||
|
||||
放在对应业务模块,例如 `internal/apps/upload/tasks.go`。
|
||||
|
||||
```go
|
||||
package upload
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/task"
|
||||
)
|
||||
|
||||
type CleanupUnusedUploadsHandler struct{}
|
||||
|
||||
func (h *CleanupUnusedUploadsHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
|
||||
task.AppendLog(ctx, "开始扫描未使用上传")
|
||||
|
||||
// 调用 model/service 完成业务逻辑。
|
||||
// 批量处理时按批次记录日志,不要每条记录都 AppendLog。
|
||||
|
||||
msg := "清理完成"
|
||||
task.AppendLog(ctx, "%s", msg)
|
||||
return &task.TaskResult{Message: msg}, nil
|
||||
}
|
||||
```
|
||||
|
||||
## 带参数 Handler
|
||||
|
||||
实现 `PayloadValidator` 做 Admin 下发时的服务端校验和标准化。`Execute` 仍然解析 payload,因为 Scheduler 和 Retry 不一定经过 Admin 校验路径。
|
||||
|
||||
```go
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/task"
|
||||
)
|
||||
|
||||
type SendEmailPayload struct {
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
type SendEmailHandler struct{}
|
||||
|
||||
func (h *SendEmailHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
||||
if len(payload) == 0 {
|
||||
return nil, errors.New("任务参数不能为空")
|
||||
}
|
||||
|
||||
var req SendEmailPayload
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
return nil, fmt.Errorf("无效的 JSON 格式: %w", err)
|
||||
}
|
||||
|
||||
req.To = strings.TrimSpace(req.To)
|
||||
req.Subject = strings.TrimSpace(req.Subject)
|
||||
req.Body = strings.TrimSpace(req.Body)
|
||||
if req.To == "" || req.Subject == "" || req.Body == "" {
|
||||
return nil, errors.New("to、subject、body 不能为空")
|
||||
}
|
||||
|
||||
return json.Marshal(req)
|
||||
}
|
||||
|
||||
func (h *SendEmailHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
|
||||
var req SendEmailPayload
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
return nil, fmt.Errorf("解析任务参数: %w", err)
|
||||
}
|
||||
|
||||
task.AppendLog(ctx, "开始发送邮件到: %s", req.To)
|
||||
|
||||
// 调用业务服务发送邮件。
|
||||
|
||||
msg := fmt.Sprintf("邮件成功发送至: %s", req.To)
|
||||
task.AppendLog(ctx, "%s", msg)
|
||||
return &task.TaskResult{Message: msg}, nil
|
||||
}
|
||||
```
|
||||
|
||||
## 统一注册
|
||||
|
||||
在 `internal/infra/task/handlers/register.go` 注册。Admin dispatch 的 `ValidateAndNormalizePayload` 和 Worker 执行都依赖这里。
|
||||
|
||||
```go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/upload"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/user"
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/task"
|
||||
)
|
||||
|
||||
func Register() {
|
||||
task.RegisterHandler(task.CleanupUnusedUploadsTask, &upload.CleanupUnusedUploadsHandler{})
|
||||
task.RegisterHandler(task.SendEmailTask, &user.SendEmailHandler{})
|
||||
}
|
||||
```
|
||||
|
||||
## Cron 调度和配置
|
||||
|
||||
系统默认的定时任务必须通过 Goose SQL 迁移初始化插入到 `schedules` 表。
|
||||
|
||||
在 `internal/infra/persistence/migrator/goose/postgres` 下的示例:
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
INSERT INTO schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at)
|
||||
VALUES (1, '清理未使用上传', 'cleanup_unused_uploads', '0 */2 * * *', '{}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
-- 根据业务需求决定是否需要在此删除
|
||||
```
|
||||
|
||||
对于 `sqlite` 也可以使用类似的 `INSERT INTO ... ON CONFLICT(id) DO NOTHING` 语法。数据库更新后,后端会自动热重载调度器。
|
||||
|
||||
## Handler 测试
|
||||
|
||||
带参数任务至少覆盖合法 payload、空 payload、非法 JSON、缺失必填和标准化。
|
||||
|
||||
```go
|
||||
func TestSendEmailHandlerValidatePayload(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload []byte
|
||||
want SendEmailPayload
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid payload is normalized",
|
||||
payload: []byte(`{"to":" user@example.com ","subject":" hi ","body":" body "}`),
|
||||
want: SendEmailPayload{
|
||||
To: "user@example.com",
|
||||
Subject: "hi",
|
||||
Body: "body",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty payload",
|
||||
payload: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid json",
|
||||
payload: []byte(`{`),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing required field",
|
||||
payload: []byte(`{"to":"user@example.com","subject":"","body":"body"}`),
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
h := &SendEmailHandler{}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotPayload, err := h.ValidatePayload(tt.payload)
|
||||
if gotErr := err != nil; gotErr != tt.wantErr {
|
||||
t.Fatalf("ValidatePayload(%s) error = %v, want error presence = %t", tt.payload, err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
|
||||
var got SendEmailPayload
|
||||
if err := json.Unmarshal(gotPayload, &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal(%s) error = %v", gotPayload, err)
|
||||
}
|
||||
if diff := cmp.Diff(tt.want, got); diff != "" {
|
||||
t.Errorf("ValidatePayload(%s) mismatch (-want +got):\n%s", tt.payload, diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Execute` 测试优先验证业务服务调用、错误返回和结果摘要;日志可只验证关键路径,避免把精确日志文本写成脆弱断言。
|
||||
|
||||
## Admin Dispatch 测试形状
|
||||
|
||||
Admin dispatch 测试关注通用链路是否调用了 `PayloadValidator`,不要为每种任务在 handler 里写 if 分支。
|
||||
|
||||
```go
|
||||
func TestDispatchTaskValidatesPayload(t *testing.T) {
|
||||
// 1. 初始化测试 DB 和 task.AsynqClient。
|
||||
// 2. 注册测试 handler: task.RegisterHandler(task.SendEmailTask, &user.SendEmailHandler{})
|
||||
// 3. POST /api/v1/admin/tasks/dispatch,传入非法 payload。
|
||||
// 4. 断言响应为 400,错误信息清晰,且没有创建可执行任务。
|
||||
}
|
||||
```
|
||||
|
||||
需要 Redis/Asynq 时优先复用项目现有测试模式;没有现成依赖时可用 `miniredis` 初始化 `task.AsynqClient`。不要把 `internal/infra/task` 依赖塞进通用 testhelper 造成 import cycle。
|
||||
@@ -0,0 +1,161 @@
|
||||
---
|
||||
name: "new-setting"
|
||||
description: "Wavelet 项目专用:当新增或修改启动时设置、数据库系统设置、业务设置、公共可见配置、/admin/system 参数配置、/admin/settings 图形化设置界面,或前端公共配置消费逻辑时必须使用。本技能指导设置类型判定、SystemConfig 字段与 visibility、goose SQL 初始化/升级、热更新读取、公共配置暴露、shadcn 图形组件和验证流程。"
|
||||
---
|
||||
|
||||
# 新增设置项
|
||||
|
||||
本技能覆盖 Wavelet 的设置体系。开始前先读仓库根目录 `AGENTS.md`,遵守项目级规则:HTTP 路由只在 `internal/router/router.go` 注册、API 变更后运行 `make swagger`、提交前运行 `make code-check`、不要删除 `frontend/node_modules`、`internal/util/` 不引入框架依赖。
|
||||
|
||||
如果需要在 `/admin/settings` 增加或调整图形化设置组件,同时阅读 [shadcn](../shadcn/SKILL.md)。如果只是新增 Go 读取逻辑、测试或错误处理,再按需阅读对应 `go-*` skill。
|
||||
|
||||
## 先判定设置类型
|
||||
|
||||
Wavelet 当前有两套设置入口:
|
||||
|
||||
- 启动时设置:来自 `config.yaml` 或环境变量,适合进程启动前必须确定、通常不热更新的基础配置。
|
||||
- 系统设置:保存于数据库 `system_configs`,经 `model.SystemConfig` 实体(key 常量在 model)与 `repository` 读取层(含 Redis hash 缓存)访问,支持运行时热更新。管理入口是 `/admin/system` 和 `/admin/settings`。
|
||||
|
||||
系统设置分三种使用语义:
|
||||
|
||||
- 业务设置:`type=business`,由管理员配置,影响业务规则,例如用户额度、业务限制。
|
||||
- 系统设置:`type=system`,由管理员配置,影响平台能力、基础开关、外部服务参数。
|
||||
- 公共可见配置:附加在业务设置或系统设置之上,由 `visibility=1` 控制是否通过公开接口返回给前端使用。它不是第三种数据库 `type`,不要把 `type` 写成 `public`。
|
||||
|
||||
业务设置和系统设置互斥:一个配置项只能选择 `business` 或 `system`。是否公开给前端由 `visibility` 决定:`0` 表示隐藏,`1` 表示 `/api/v1/config/public` 可见。
|
||||
|
||||
特殊设置组件不一定需要新增 `SystemConfig` 参数项。例如认证源设置、模板管理这类有独立模型和 API 的功能,应沿用对应领域模型,不要为了出现在 `/admin/settings` 强行创建参数配置。
|
||||
|
||||
## 先定位真实链路
|
||||
|
||||
修改前快速查看这些文件,确认当前实现没有漂移:
|
||||
|
||||
- `internal/model/system_configs.go`: 配置 key 常量(`ConfigKey*`)、`SystemConfig` 实体与字段语义;**不含**持久化读取 API。
|
||||
- `internal/repository/system_config.go`: 配置读取与缓存(`GetSystemConfigByKey`、`GetBoolByKey`、`GetIntByKey`、`GetDecimalByKey`、`ListVisibleSystemConfigs` 等)。
|
||||
- `internal/infra/persistence/migrator/goose/postgres/*.sql` 和 `internal/infra/persistence/migrator/goose/sqlite/*.sql`: `system_configs` 表结构、初始化 seed、后续升级迁移。
|
||||
- `internal/infra/persistence/migrator/migrator.go`: goose 迁移入口和 PostgreSQL/SQLite 方言选择。
|
||||
- `internal/testhelper/test_helper.go`: Go 测试用默认系统配置 seed。
|
||||
- `internal/apps/admin/system_config/routers.go`: `/api/v1/admin/system-configs` 参数表 API。
|
||||
- `internal/apps/config/routers.go`: `/api/v1/config/public` 公共配置响应。
|
||||
- `frontend/components/common/admin/system.tsx`: `/admin/system` 参数表管理界面,展示所有参数配置项。
|
||||
- `frontend/components/common/settings/system-settings.tsx`: `/admin/settings` 图形化设置页入口。
|
||||
- `frontend/components/common/settings/*-tab.tsx`: `/admin/settings` 各图形化设置分组。
|
||||
- `frontend/lib/services/admin/*`: Admin 系统配置 service 类型和 API 封装。
|
||||
- `frontend/lib/services/config/*`、`frontend/hooks/use-public-config`、`frontend/components/layout/*`: 前端公共配置消费链路。
|
||||
|
||||
## 新增数据库系统设置
|
||||
|
||||
按影响面选择步骤,不要只改 UI 或只改默认值。
|
||||
|
||||
1. 定义配置 key。
|
||||
- 在 `internal/model/system_configs.go` 添加 `ConfigKey...` 常量。
|
||||
- key 使用 lowercase snake case,例如 `search_engine_indexing_enabled`。
|
||||
- 值仍存为字符串;布尔值用 `"true"` / `"false"`,数值用十进制字符串,复杂结构用 JSON 字符串。
|
||||
|
||||
2. 初始化默认配置。
|
||||
- 如果修改初始 schema,必须同步 `internal/infra/persistence/migrator/goose/postgres/` 和 `internal/infra/persistence/migrator/goose/sqlite/` 中的 goose SQL。
|
||||
- 既有库新增配置时,新增一组时间戳递增的双 SQL 迁移文件,分别放在 PostgreSQL 和 SQLite 目录;不要回到 GORM AutoMigrate 或 Go 代码 seed。
|
||||
- 新库初始化也需要包含同一个默认 key:当前初始 seed 在 `202606090001_initial_schema.sql` 的 `INSERT INTO system_configs (...) VALUES ... ON CONFLICT (key) DO NOTHING`。
|
||||
- 设置正确的 `Type`:只能是 `"system"` 或 `"business"`。
|
||||
- 设置正确的 `Visibility`:公共可见填 `1`,内部配置填 `0`。
|
||||
- 默认值要和 Go 读取侧的零值或兜底值一致,避免首次启动和数据库缺失时行为不同。
|
||||
- 如果相关 Go 包测试依赖默认配置,同步 `internal/testhelper/test_helper.go` 的 `seedDefaultConfigs` 和公共 key 列表。
|
||||
|
||||
3. 读取配置。
|
||||
- 后端业务代码通过 `internal/repository` 读取:`repository.GetBoolByKey`、`repository.GetIntByKey`、`repository.GetDecimalByKey` 或 `repository.GetSystemConfigByKey`;key 常量仍用 `model.ConfigKey*`。
|
||||
- 禁止新增或调用 `model.Get*ByKey` / `model.ListVisibleSystemConfigs` 等数据访问 API(model 无 CRUD)。
|
||||
- 运行时可热更新的规则不要放进 `config.Config`;启动时设置才走 `internal/infra/config/model.go` 和 `config.example.yaml`。
|
||||
- 不要在 handler 或业务代码里直接读 `os.Getenv()`。
|
||||
|
||||
4. 如果前端需要未登录或全局消费,暴露为公共可见配置。
|
||||
- 把该配置的 `visibility` 设为 `1`,`GetPublicConfig` 会通过 `repository.ListVisibleSystemConfigs` 返回所有可见 key/value。
|
||||
- `/api/v1/config/public` 的 `data` 是动态对象:后端返回 `map[string]string`,前端类型是 `Record<string, string | undefined>`。
|
||||
- 前端读取时按配置 key 访问,必要时在消费侧把字符串转换为 boolean/number/JSON。
|
||||
- 检查使用方的 query key,更新后需要 invalidate `["public-config"]`。
|
||||
- 只有公共配置 API 形状或注释变化时才需要更新 Swagger;单纯新增 `visibility=1` 的 key 通常不需要改 `PublicConfigResponse` 类型。
|
||||
|
||||
5. 如果管理员需要图形化配置,更新 `/admin/settings`。
|
||||
- 先阅读 shadcn skill。
|
||||
- 根据设置语义选择现有 tab:安全类进 `security-tab.tsx`,运营类进 `operation-tab.tsx`,系统基础参数进 `system-tab.tsx`,其它菜单或杂项进 `other-tab.tsx`。
|
||||
- `SystemSettingsMain` 当前通过 `AdminService.listSystemConfigs("system")` 只加载 `type=system` 的配置;`type=business` 的配置若也需要图形化入口,先确认是否要调整查询范围或放到其它 Admin 页面。
|
||||
- 新的图形组件优先放在 `frontend/components/common/settings/`,使用现有 `AdminService.updateSystemConfig`。
|
||||
- 更新成功后 invalidate `["admin", "system-configs"]`;公共可见配置还要 invalidate `["public-config"]`。
|
||||
- 使用 Sonner toast 反馈成功或失败。
|
||||
- 不使用 `any`,不要硬编码页面级 `max-w-*`,页面根容器保持 `w-full`。
|
||||
|
||||
6. `/admin/system` 参数表通常不需要新代码。
|
||||
- 只要 `SystemConfig` 默认数据存在,参数表会展示配置项。
|
||||
- `/admin/system` 偏向所有参数配置项的键值管理,不替代 `/admin/settings` 的友好图形界面。
|
||||
|
||||
## 新增启动时设置
|
||||
|
||||
只有在配置必须随进程启动确定、不能或不应热更新时,才走启动时设置。
|
||||
|
||||
1. 在 `internal/infra/config/model.go` 添加配置字段。
|
||||
2. 在 `config.example.yaml` 添加示例值和说明。
|
||||
3. 确认 Viper 现有加载逻辑能绑定该字段;需要环境变量时沿用当前命名和绑定方式。
|
||||
4. 运行时代码从 `config.Config.<Section>.<Field>` 读取。
|
||||
5. 不要把启动时设置同步塞进 `SystemConfig`,除非产品明确需要运行时覆盖。
|
||||
|
||||
## 常见模式
|
||||
|
||||
### 布尔公共设置
|
||||
|
||||
- model key:`ConfigKeyFeatureEnabled = "feature_enabled"`(定义在 `internal/model`)
|
||||
- goose SQL 默认值:`value='false'`,`type` 按语义选 `"system"` 或 `"business"`,`visibility=1`。
|
||||
- 后端读取:`repository.GetBoolByKey(ctx, model.ConfigKeyFeatureEnabled)`。
|
||||
- 公共响应:`/api/v1/config/public` 的 `data.feature_enabled` 为字符串 `"true"` 或 `"false"`。
|
||||
- 前端图形控件:`Switch`,保存时写 `"true"` / `"false"`。
|
||||
|
||||
### 数值业务设置
|
||||
|
||||
- model key:`ConfigKeyMaxSomething = "max_something"`。
|
||||
- goose SQL 默认值:例如 `"5"`,`type` 通常为 `"business"`,只有前端公共消费时才设 `visibility=1`。
|
||||
- 后端读取:`repository.GetIntByKey` 或 `repository.GetDecimalByKey`。
|
||||
- 前端图形控件:`Input type="number"` 或合适的 shadcn 数值控件;保存前做最小必要校验,错误用 toast。
|
||||
|
||||
### JSON 设置
|
||||
|
||||
- 默认值使用合法 JSON,例如 `"{}"` 或 `"[]"`。
|
||||
- 在 repository 或业务 logics 中提供解析函数,像 `repository.GetMenuDisplayConfig` 一样把 JSON 解析错误包装成清晰错误;不要在 model 中做 IO。
|
||||
- 前端不要直接拼接 JSON 字符串;用 `JSON.stringify` 写入,用类型化对象在组件中操作。
|
||||
|
||||
## 验证
|
||||
|
||||
根据改动范围运行最小有效验证,最后提交前必须运行项目门禁。
|
||||
|
||||
- 新增或修改系统配置默认值、visibility 或公共配置读取:至少运行相关 Go 包测试,例如:
|
||||
|
||||
```bash
|
||||
go test ./internal/repository ./internal/apps/config ./internal/apps/admin/system_config
|
||||
```
|
||||
|
||||
- 新增 goose 迁移后,至少用当前数据库方言跑一次迁移;如果 SQL 同时改了 PostgreSQL 和 SQLite,尽量覆盖两种方言。涉及 schema/seed 的任务还应遵循 database-migration skill。
|
||||
|
||||
- 公共配置 API 注释或 handler 签名改动后:
|
||||
|
||||
```bash
|
||||
make swagger
|
||||
```
|
||||
|
||||
- 前端图形设置改动后:
|
||||
|
||||
```bash
|
||||
cd frontend && pnpm typecheck && pnpm lint
|
||||
```
|
||||
|
||||
- 提交前:
|
||||
|
||||
```bash
|
||||
make code-check
|
||||
```
|
||||
|
||||
如涉及前端页面体验,启动本地服务并用浏览器验证 `/admin/settings` 和 `/admin/system`:配置能显示、保存、toast 反馈正常、刷新后值保持、公共配置消费方能即时或刷新后生效。
|
||||
|
||||
## 相关 Skills
|
||||
|
||||
- shadcn:新增或调整 `/admin/settings` 图形化设置组件时使用。
|
||||
- database-migration:新增或修改 `system_configs` schema、默认 seed 或 goose SQL 迁移时使用。
|
||||
- go-error-handling:配置解析、缺失配置、非法值错误需要跨包返回时使用。
|
||||
- go-testing:为配置读取、公共配置 API 或 Admin 配置 API 添加测试时使用。
|
||||
- go-context:配置读取在请求链路或后台链路中传递取消和超时时使用。
|
||||
@@ -0,0 +1,171 @@
|
||||
---
|
||||
name: "push-notification"
|
||||
description: "Wavelet 项目专用:当需要开发或接入新的系统通知推送事件、修改消息推送底层设计、调用统一触发器投递消息、或开发带消息推送功能的业务功能时必须使用。本技能指导元数据声明、触发流程、解耦防线和动态同步机制。"
|
||||
---
|
||||
|
||||
# 新增消息推送与通知事件开发规范
|
||||
|
||||
本技能涵盖 Wavelet 的系统通知推送开发规范。开始开发前先阅读仓库根目录 [AGENTS.md](file:///Users/ryan/DEV/Go/Wavelet/AGENTS.md),遵守项目级核心规则。
|
||||
|
||||
---
|
||||
|
||||
## 消息推送架构设计 (Architecture)
|
||||
|
||||
Wavelet 的消息推送机制采用了**元数据驱动 + 统一触发器 + 异步任务派发**的解耦设计,其分层及职责划分如下:
|
||||
|
||||
| 目录/包名 | 职责定位 | 包含内容与设计细节 |
|
||||
| :--- | :--- | :--- |
|
||||
| **`pkg/push/`** | 推送基础设施层 | 静态定义、不依赖系统数据库和任何框架。定义了统一接口 `Pusher`、单例 `PusherPool` 和多实现(Lark, Webhook, Email 等),提供配置验证及发送功能。 |
|
||||
| **`internal/apps/admin/push/`** | 通知服务与后台任务层 | 包含以下核心文件:<br>1. [events.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/events.go):定义通知事件的结构模型(`NotificationMessage`, `EventMetadata`)、内置事件的动态注册中心(`BuiltInEvents` 及 `RegisterBuiltInEvent` 函数)以及统一触发器类 `EventTrigger`(包括其底层的派发引擎逻辑)。<br>2. [tasks.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/tasks.go):定义 Asynq 后台异步发送任务、处理器 `PushHandler` 及其校验逻辑,并记录推送历史审计。<br>3. [routers.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/routers.go):管理端接口,负责获取事件配置列表和更新配置。 |
|
||||
| **`internal/apps/admin/push/custom_events/`** | 自定义通知事件包 | 事件元数据定义与 push 侧处理逻辑;**一个 Go 文件代表一个事件**。在 [register.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/custom_events/register.go) 统一装配,禁止 `init()` 副作用。 |
|
||||
| **`internal/listener/`** | 域事件分发层 | 核心域发射事件(如 `EmitAdminLoggedIn`),push 在 bootstrap 阶段通过 `OnAdminLoggedIn` 订阅,避免 auth/user 直接依赖 push。 |
|
||||
| **`internal/platform/bootstrap/`** | 应用装配根 | `RegisterPushDomainEvents()` 调用 `custom_events.Register()`;`Init` 中执行 `SyncEvents` 将内置事件元数据同步到数据库。 |
|
||||
| **数据库审计表** | 状态与历史审计 | `w_push_events` 存放每个通知事件的启用状态、启用渠道、发送目标和自定义渲染模板。<br>`w_push_histories` 存放消息发送记录用于审计。 |
|
||||
|
||||
---
|
||||
|
||||
## 核心开发步骤 (Step-by-Step Flow)
|
||||
|
||||
如果某个新业务(如“新用户注册”或“订单创建”)需要带有消息推送功能,请严格按照以下步骤开发:
|
||||
|
||||
### 步骤 1:在 `custom_events/` 中声明事件元数据与处理函数
|
||||
在 `internal/apps/admin/push/custom_events/` 下新建一个 Go 文件(如 `user_registered.go`),声明 `EventMetadata` 和 push 侧处理函数(组装 body 并调用 `DefaultTrigger.Trigger`)。
|
||||
|
||||
```go
|
||||
package custom_events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/admin/push"
|
||||
"github.com/Rain-kl/Wavelet/internal/listener"
|
||||
)
|
||||
|
||||
var NewUserRegistered = push.EventMetadata{
|
||||
Key: "user_registered",
|
||||
Name: "新用户注册提醒",
|
||||
DefaultTemplate: push.NotificationMessage{
|
||||
Title: "新用户注册通知",
|
||||
Content: "新用户 {{user.username}} (邮箱: {{user.email}}) 于 {{time}} 成功注册。",
|
||||
Level: "INFO",
|
||||
},
|
||||
Description: "当系统有新用户注册成功时,向管理员或指定目标发送通知",
|
||||
}
|
||||
|
||||
func handleUserRegistered(ctx context.Context, event listener.UserRegistered) {
|
||||
if event.User == nil {
|
||||
return
|
||||
}
|
||||
body := map[string]any{
|
||||
"user": event.User,
|
||||
"time": time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
push.DefaultTrigger.Trigger(ctx, NewUserRegistered, body)
|
||||
}
|
||||
```
|
||||
|
||||
> `EventTrigger.Trigger` 已内置异步 Goroutine 与 `context.WithoutCancel`;处理函数内直接调用即可,无需外层 `go func()`。
|
||||
|
||||
### 步骤 2:在 `listener/` 定义域事件并在 `register.go` 装配
|
||||
1. 在 `internal/listener/` 新增域事件类型、`Emit*` 与 `On*` 注册函数(参考 [admin_login.go](file:///Users/ryan/DEV/Go/Wavelet/internal/listener/admin_login.go))。
|
||||
2. 在 [register.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/custom_events/register.go) 中注册元数据并订阅域事件:
|
||||
|
||||
```go
|
||||
func Register() {
|
||||
push.RegisterBuiltInEvent(NewUserRegistered)
|
||||
listener.OnUserRegistered(handleUserRegistered)
|
||||
}
|
||||
```
|
||||
|
||||
**禁止**在 `custom_events` 或 `router` 中使用 `init()` 注册;**禁止**在 `router.go` 空白导入 `custom_events`。
|
||||
|
||||
### 步骤 3:在业务代码中发射域事件(不 import push)
|
||||
在业务逻辑完成处(如 `internal/apps/user/routers.go`)仅 import `internal/listener` 并发射事件:
|
||||
|
||||
```go
|
||||
import "github.com/Rain-kl/Wavelet/internal/listener"
|
||||
|
||||
func Register(c *gin.Context) {
|
||||
// ... 注册成功逻辑 ...
|
||||
listener.EmitUserRegistered(ctx, user)
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤 4:在 bootstrap / cmd 入口显式装配
|
||||
新增事件后,确保 `custom_events.Register()` 已被 `bootstrap.RegisterPushDomainEvents()` 调用,且 API/`all` 进程在 `bootstrap.Init` 之前完成注册:
|
||||
|
||||
| 进程 | cmd 入口调用 |
|
||||
| :--- | :--- |
|
||||
| `api` | `bootstrap.RegisterAPI()` → `bootstrap.Init(ctx, Options{API: true})` |
|
||||
| `all` | `bootstrap.RegisterAll()` → `bootstrap.Init(ctx, Options{API: true})` |
|
||||
| `worker` / `scheduler` | 不注册 push 域事件;仅 `bootstrap.Init` + 各自 `RegisterWorker`/`RegisterScheduler` |
|
||||
|
||||
`Init` 中的 `SyncEvents` 会将 `user_registered` 元数据同步到 `w_push_events`,管理员即可在前端配置推送渠道。
|
||||
|
||||
### 步骤 5:编写集成测试
|
||||
在 `custom_events/` 或 `listener/` 包内添加测试,验证 `Emit*` → handler → `DefaultTrigger.Trigger` 全链路。测试 setup 须显式调用 `custom_events.Register()`(或 `bootstrap.RegisterPushDomainEvents()`)和 `push.SyncEvents`,参考 [admin_login_test.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/custom_events/admin_login_test.go)。
|
||||
|
||||
---
|
||||
|
||||
## 模板渲染与支持的系统变量 (Template Rendering & Variables)
|
||||
|
||||
消息的 `title`、`content` 以及 `ext` 字段中的字符串值都支持变量占位符替换,采用双花括号形式 `{{variable}}`。
|
||||
|
||||
### 1. 通用事件参数 (Common Variables)
|
||||
在 Wavelet 系统中,`user` 是一个通用的、必传的事件参数。如果在触发通知事件时未提供 `user`(或为 `nil`),底层 `EventTrigger` 会自动注入一个系统的虚拟用户(ID 为 999,昵称为“系统”)。因此,以下变量是所有通知事件均支持的通用渲染参数:
|
||||
|
||||
- `{{time}}`:事件发生/触发的具体时间(格式:`2006-01-02 15:04:05`)
|
||||
- `{{user.id}}`:触发用户/系统用户的 ID
|
||||
- `{{user.username}}`:触发用户/系统用户的用户名
|
||||
- `{{user.nickname}}`:触发用户/系统用户的昵称
|
||||
- `{{user.email}}`:触发用户/系统用户的电子邮箱
|
||||
- `{{user.phone}}`:触发用户/系统用户的手机号
|
||||
- `{{user.bio}}`:触发用户/系统用户的个人简介
|
||||
- `{{user.gender}}`:触发用户/系统用户的性别
|
||||
- `{{user.location}}`:触发用户/系统用户的所在地
|
||||
- `{{user.website}}`:触发用户/系统用户的个人网站
|
||||
|
||||
*(注:系统中的任何自定义事件,若传入了对应的复杂结构体,其结构体 JSON 字段均可通过扁平化点路径方式直接在模板中进行引用。)*
|
||||
|
||||
### 2. 特定事件携带的业务变量 (Event Specific Variables)
|
||||
除了通用的 `user` 和 `time` 外,特定事件在触发时还可以携带额外的上下文参数:
|
||||
|
||||
- **管理员登录提醒 (`admin_login`)**
|
||||
- `{{ip}}`:管理员登录来源的客户端 IP
|
||||
- `{{time}}`:管理员登录成功时间
|
||||
|
||||
### 3. 自定义消息通道的请求体变量说明 (Custom Channel JSON Variables)
|
||||
在配置“自定义消息通道”时,其请求体 (JSON Schema) 支持以 `$` 开头的变量替换。支持的替换变量如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "$title",
|
||||
"description": "$description",
|
||||
"content": "$content",
|
||||
"url": "$url",
|
||||
"to": "$to"
|
||||
}
|
||||
```
|
||||
|
||||
- `$title`:通知的标题(如:“管理员登录提醒”)
|
||||
- `$description`:当前通知事件的描述
|
||||
- `$content`:通知的具体渲染后正文内容
|
||||
- `$url`:附加的操作或详情链接(若有)
|
||||
- `$to`:当前派发的推送目标(如邮箱、ID 或 Chat ID,即 resolved target)
|
||||
|
||||
---
|
||||
|
||||
## 严格遵循事项与防线 (Guardrails)
|
||||
|
||||
### 1. 禁止绕过统一触发器 (Always Use EventTrigger)
|
||||
- 所有推送请求必须经过 `EventTrigger.Trigger`,以确保进行“事件是否启用”、“目标渠道过滤”、“全局推送配置读取”及“发送日志审计”等流程。
|
||||
|
||||
### 2. 禁止业务模块直接依赖 push (Decouple via listener)
|
||||
- `oauth`、`user` 等核心域 **不得** `import` `internal/apps/admin/push` 或 `custom_events`。
|
||||
- 跨模块通知必须通过 `internal/listener` 发射域事件;push 在 `custom_events.Register()` 中订阅。
|
||||
|
||||
### 3. 禁止 init() 与 router 副作用注册 (Explicit Bootstrap)
|
||||
- 不得在 `init()` 中调用 `RegisterBuiltInEvent` 或订阅 listener。
|
||||
- 不得在 `router.go` 空白导入 `custom_events` 触发注册。
|
||||
- 统一在 `internal/platform/bootstrap` + `internal/cmd` 入口显式装配。
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
name: "release-guide"
|
||||
description: "Wavelet 项目专用:根据自上一个正式版本 Tag 以来的提交记录,整理生成规范的 Version Bump Commit Message,用于触发自动双语 Release。"
|
||||
---
|
||||
|
||||
# Release Commit Message Guide
|
||||
|
||||
## 目标
|
||||
|
||||
当用户准备发布 Wavelet 新版本时,本 Skill 负责:
|
||||
|
||||
1. 根据上一正式版本 Tag 以来的提交,整理面向用户的发版说明;
|
||||
2. 新建 **独立的** `chore(release): vX.Y.Z` 提交(可附带将 `docs/changelog` 从 `[unreleased]` 落版)。
|
||||
|
||||
## 硬性约束(禁止改写历史)
|
||||
|
||||
- **禁止** `git commit --amend` 修改任何**已经 push 到远端**的提交。
|
||||
- **禁止** 为了发版去改写已有功能/修复提交的 message 或内容。
|
||||
- **禁止** 发版流程中的 force-push(除非用户明确要求且知晓后果)。
|
||||
- 发版提交必须是 **新增 commit**:在当前 `HEAD` 之上 `git commit` 一次。
|
||||
- 默认 **不要 push、不要打 tag**;生成并完成本地 release commit 后,把后续 `push` / `git tag` 命令交给用户确认执行。
|
||||
|
||||
## 生成提交信息
|
||||
|
||||
将原始 commit log 整理为面向 Release 的更新说明。
|
||||
|
||||
要求:
|
||||
|
||||
1. 合并重复或相近提交。
|
||||
2. 删除无意义提交,例如格式化、临时调试、无关重构。
|
||||
3. 将内部实现描述改写为用户可理解的变更, 说明“修复/优化了什么”以及“带来的效果”。
|
||||
4. 不要写技术细节:只描述用户可感知的行为与效果,禁止内部实现描述,例如字段名/表名/SQL(`node_id = ''`)、框架或库名称(shadcn、GORM、OpenResty)、配置或协议细节(RFC3339、ClickHouse/PostgreSQL 差异)、代码机制(`proxy_intercept_errors`、Lua 过滤器、雪花 ID)。数据库名称仅在说明受影响用户范围时使用(如「PostgreSQL 日志库下无数据」)。
|
||||
5. 如果某个分类没有内容,则省略。
|
||||
|
||||
固定使用以下分类:
|
||||
|
||||
```text
|
||||
### ✨ 新功能
|
||||
### 🛠 修复
|
||||
### ⚡️ 优化与改进
|
||||
### 💄 其他/体验
|
||||
```
|
||||
|
||||
分类规则:
|
||||
|
||||
- 新功能、新能力、新配置、新任务:放入 ### ✨ 新功能
|
||||
- Bug、异常行为、错误逻辑:放入 ### 🛠 修复
|
||||
- 性能、稳定性、接口、架构、兼容性:放入 ### ⚡️ 优化与改进
|
||||
- 日志、文案、UI、文档、开发体验:放入 ### 💄 其他/体验
|
||||
|
||||
「修复/优化」与「新增」的判定(关键):
|
||||
|
||||
- **判定标准是“该功能在上一正式版本中是否已存在”**:
|
||||
- 已存在 → 本次对其 bug 的修正可计入「🛠 修复」,对其行为/性能的改进可计入「⚡️ 优化与改进」;
|
||||
- 不存在(本版本新增)→ 该功能的一切内容——包括开发过程中修的 bug、做的性能优化、补的索引——都只属于新功能开发的一部分,不应该在发布说明中提及。
|
||||
- 禁止把新功能的开发期修复/优化写进「修复」或「优化」:新功能此前版本没有,谈不上“修复/优化了旧行为”。
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
chore(release): v3.3.0
|
||||
|
||||
### ✨ 新功能
|
||||
- 新增笔记库快照备份功能,支持定时备份与手动一键恢复(仅说明新增的功能, 禁止提及新功能开发时期的优化修复等内容)。
|
||||
|
||||
### 🛠 修复
|
||||
- 修复首页「来源分布」卡片在 PostgreSQL/SQLite 日志库下无数据的问题。
|
||||
- 修复源站错误页「仅针对 GET 请求」未真正透传非 GET 响应的问题:POST/PUT 等非 GET 请求现可完整看到源站原始报错内容。
|
||||
|
||||
### ⚡️ 优化与改进
|
||||
- 优化了 WebGUI 登录机制,引入设备令牌自动轮转,减少因 IP 变化产生的冗余令牌。
|
||||
|
||||
### 💄 其他/体验
|
||||
- 优化了 WebSocket 错误日志,增加请求路径信息,方便问题排查。
|
||||
```
|
||||
|
||||
## 提交步骤
|
||||
|
||||
1. 确认工作区干净,且 `HEAD` 与将要发布的代码一致(通常已与 `origin/main` 对齐或仅含未 push 的合法新提交)。
|
||||
2. 将 `docs/changelog/index.md` 中 `[unreleased]` 落版为 `[vX.Y.Z] - YYYY-MM-DD`(按需整理条目)。
|
||||
3. **新建** release 提交(不要 amend):
|
||||
|
||||
```bash
|
||||
git add docs/changelog/index.md # 及其他发版所需文件
|
||||
git commit -m "$(cat <<'EOF'
|
||||
chore(release): vX.Y.Z
|
||||
|
||||
### 🛠 修复
|
||||
- ...
|
||||
|
||||
### ⚡️ 优化与改进
|
||||
- ...
|
||||
|
||||
### 💄 其他/体验
|
||||
- ...
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
4. 向用户展示完整 commit message,并说明后续可由用户执行:
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
git tag vX.Y.Z
|
||||
git push origin vX.Y.Z
|
||||
```
|
||||
|
||||
(打 tag 后由 CI 创建双语 Release。)
|
||||
|
||||
## 任务结束条件
|
||||
|
||||
本地已存在 **新的** `chore(release): vX.Y.Z` 提交,且**未**改写任何已 push 提交、**未**擅自 push/tag。
|
||||
@@ -0,0 +1,267 @@
|
||||
---
|
||||
name: shadcn
|
||||
description: Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI. Provides project context, component docs, and usage examples. Applies when working with shadcn/ui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for "shadcn init", "create an app with --preset", or "switch to --preset".
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
# shadcn/ui
|
||||
|
||||
A framework for building ui, components and design systems. Components are added as source code to the user's project via the CLI.
|
||||
|
||||
> **IMPORTANT:** Run all CLI commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest` — based on the project's `packageManager`. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
|
||||
|
||||
## Current Project Context
|
||||
|
||||
```json
|
||||
!`npx shadcn@latest info --json`
|
||||
```
|
||||
|
||||
The JSON above contains the project config and installed components. Use `npx shadcn@latest docs <component>` to get documentation and example URLs for any component.
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Use existing components first.** Use `npx shadcn@latest search` to check registries before writing custom UI. Check community registries too.
|
||||
2. **Compose, don't reinvent.** Settings page = Tabs + Card + form controls. Dashboard = Sidebar + Card + Chart + Table.
|
||||
3. **Use built-in variants before custom styles.** `variant="outline"`, `size="sm"`, etc.
|
||||
4. **Use semantic colors.** `bg-primary`, `text-muted-foreground` — never raw values like `bg-blue-500`.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
These rules are **always enforced**. Each links to a file with Incorrect/Correct code pairs.
|
||||
|
||||
### Styling & Tailwind → [styling.md](./rules/styling.md)
|
||||
|
||||
- **`className` for layout, not styling.** Never override component colors or typography.
|
||||
- **No `space-x-*` or `space-y-*`.** Use `flex` with `gap-*`. For vertical stacks, `flex flex-col gap-*`.
|
||||
- **Use `size-*` when width and height are equal.** `size-10` not `w-10 h-10`.
|
||||
- **Use `truncate` shorthand.** Not `overflow-hidden text-ellipsis whitespace-nowrap`.
|
||||
- **No manual `dark:` color overrides.** Use semantic tokens (`bg-background`, `text-muted-foreground`).
|
||||
- **Use `cn()` for conditional classes.** Don't write manual template literal ternaries.
|
||||
- **No manual `z-index` on overlay components.** Dialog, Sheet, Popover, etc. handle their own stacking.
|
||||
|
||||
### Forms & Inputs → [forms.md](./rules/forms.md)
|
||||
|
||||
- **Forms use `FieldGroup` + `Field`.** Never use raw `div` with `space-y-*` or `grid gap-*` for form layout.
|
||||
- **`InputGroup` uses `InputGroupInput`/`InputGroupTextarea`.** Never raw `Input`/`Textarea` inside `InputGroup`.
|
||||
- **Buttons inside inputs use `InputGroup` + `InputGroupAddon`.**
|
||||
- **Option sets (2–7 choices) use `ToggleGroup`.** Don't loop `Button` with manual active state.
|
||||
- **`FieldSet` + `FieldLegend` for grouping related checkboxes/radios.** Don't use a `div` with a heading.
|
||||
- **Field validation uses `data-invalid` + `aria-invalid`.** `data-invalid` on `Field`, `aria-invalid` on the control. For disabled: `data-disabled` on `Field`, `disabled` on the control.
|
||||
|
||||
### Component Structure → [composition.md](./rules/composition.md)
|
||||
|
||||
- **Items always inside their Group.** `SelectItem` → `SelectGroup`. `DropdownMenuItem` → `DropdownMenuGroup`. `CommandItem` → `CommandGroup`.
|
||||
- **Use `asChild` (radix) or `render` (base) for custom triggers.** Check `base` field from `npx shadcn@latest info`. → [base-vs-radix.md](./rules/base-vs-radix.md)
|
||||
- **Dialog, Sheet, and Drawer always need a Title.** `DialogTitle`, `SheetTitle`, `DrawerTitle` required for accessibility. Use `className="sr-only"` if visually hidden.
|
||||
- **Use full Card composition.** `CardHeader`/`CardTitle`/`CardDescription`/`CardContent`/`CardFooter`. Don't dump everything in `CardContent`.
|
||||
- **Button has no `isPending`/`isLoading`.** Compose with `Spinner` + `data-icon` + `disabled`.
|
||||
- **`TabsTrigger` must be inside `TabsList`.** Never render triggers directly in `Tabs`.
|
||||
- **`Avatar` always needs `AvatarFallback`.** For when the image fails to load.
|
||||
|
||||
### Use Components, Not Custom Markup → [composition.md](./rules/composition.md)
|
||||
|
||||
- **Use existing components before custom markup.** Check if a component exists before writing a styled `div`.
|
||||
- **Callouts use `Alert`.** Don't build custom styled divs.
|
||||
- **Empty states use `Empty`.** Don't build custom empty state markup.
|
||||
- **Toast via `sonner`.** Use `toast()` from `sonner`.
|
||||
- **Use `Separator`** instead of `<hr>` or `<div className="border-t">`.
|
||||
- **Use `Skeleton`** for loading placeholders. No custom `animate-pulse` divs.
|
||||
- **Use `Badge`** instead of custom styled spans.
|
||||
|
||||
### Icons → [icons.md](./rules/icons.md)
|
||||
|
||||
- **Icons in `Button` use `data-icon`.** `data-icon="inline-start"` or `data-icon="inline-end"` on the icon.
|
||||
- **No sizing classes on icons inside components.** Components handle icon sizing via CSS. No `size-4` or `w-4 h-4`.
|
||||
- **Pass icons as objects, not string keys.** `icon={CheckIcon}`, not a string lookup.
|
||||
|
||||
### CLI
|
||||
|
||||
- **Never decode preset codes or build preset URLs manually.** Use `npx shadcn@latest preset decode <code>`, `preset url <code>`, or `preset open <code>`. For project-aware preset detection, use `npx shadcn@latest preset resolve`.
|
||||
- **Apply preset codes directly with the CLI.** Use `npx shadcn@latest apply <code>` for existing projects, or `npx shadcn@latest init --preset <code>` when initializing.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
These are the most common patterns that differentiate correct shadcn/ui code. For edge cases, see the linked rule files above.
|
||||
|
||||
```tsx
|
||||
// Form layout: FieldGroup + Field, not div + Label.
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input id="email" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
// Validation: data-invalid on Field, aria-invalid on the control.
|
||||
<Field data-invalid>
|
||||
<FieldLabel>Email</FieldLabel>
|
||||
<Input aria-invalid />
|
||||
<FieldDescription>Invalid email.</FieldDescription>
|
||||
</Field>
|
||||
|
||||
// Icons in buttons: data-icon, no sizing classes.
|
||||
<Button>
|
||||
<SearchIcon data-icon="inline-start" />
|
||||
Search
|
||||
</Button>
|
||||
|
||||
// Spacing: gap-*, not space-y-*.
|
||||
<div className="flex flex-col gap-4"> // correct
|
||||
<div className="space-y-4"> // wrong
|
||||
|
||||
// Equal dimensions: size-*, not w-* h-*.
|
||||
<Avatar className="size-10"> // correct
|
||||
<Avatar className="w-10 h-10"> // wrong
|
||||
|
||||
// Status colors: Badge variants or semantic tokens, not raw colors.
|
||||
<Badge variant="secondary">+20.1%</Badge> // correct
|
||||
<span className="text-emerald-600">+20.1%</span> // wrong
|
||||
```
|
||||
|
||||
## Component Selection
|
||||
|
||||
| Need | Use |
|
||||
| -------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Button/action | `Button` with appropriate variant |
|
||||
| Form inputs | `Input`, `Select`, `Combobox`, `Switch`, `Checkbox`, `RadioGroup`, `Textarea`, `InputOTP`, `Slider` |
|
||||
| Toggle between 2–5 options | `ToggleGroup` + `ToggleGroupItem` |
|
||||
| Data display | `Table`, `Card`, `Badge`, `Avatar` |
|
||||
| Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` |
|
||||
| Overlays | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation) |
|
||||
| Feedback | `sonner` (toast), `Alert`, `Progress`, `Skeleton`, `Spinner` |
|
||||
| Command palette | `Command` inside `Dialog` |
|
||||
| Charts | `Chart` (wraps Recharts) |
|
||||
| Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` |
|
||||
| Empty states | `Empty` |
|
||||
| Menus | `DropdownMenu`, `ContextMenu`, `Menubar` |
|
||||
| Tooltips/info | `Tooltip`, `HoverCard`, `Popover` |
|
||||
|
||||
## Key Fields
|
||||
|
||||
The injected project context contains these key fields:
|
||||
|
||||
- **`aliases`** → use the actual alias prefix for imports (e.g. `@/`, `~/`), never hardcode.
|
||||
- **`isRSC`** → when `true`, components using `useState`, `useEffect`, event handlers, or browser APIs need `"use client"` at the top of the file. Always reference this field when advising on the directive.
|
||||
- **`tailwindVersion`** → `"v4"` uses `@theme inline` blocks; `"v3"` uses `tailwind.config.js`.
|
||||
- **`tailwindCssFile`** → the global CSS file where custom CSS variables are defined. Always edit this file, never create a new one.
|
||||
- **`style`** → component visual treatment (e.g. `nova`, `vega`).
|
||||
- **`base`** → primitive library (`radix` or `base`). Affects component APIs and available props.
|
||||
- **`iconLibrary`** → determines icon imports. Use `lucide-react` for `lucide`, `@tabler/icons-react` for `tabler`, etc. Never assume `lucide-react`.
|
||||
- **`resolvedPaths`** → exact file-system destinations for components, utils, hooks, etc.
|
||||
- **`framework`** → routing and file conventions (e.g. Next.js App Router vs Vite SPA).
|
||||
- **`packageManager`** → use this for any non-shadcn dependency installs (e.g. `pnpm add date-fns` vs `npm install date-fns`).
|
||||
- **`preset`** → resolved preset code and values for the current project. Use `npx shadcn@latest preset resolve --json` when you only need preset information.
|
||||
|
||||
See [cli.md — `info` command](./cli.md) for the full field reference.
|
||||
|
||||
## Component Docs, Examples, and Usage
|
||||
|
||||
Run `npx shadcn@latest docs <component>` to get the URLs for a component's documentation, examples, and API reference. Fetch these URLs to get the actual content.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest docs button dialog select
|
||||
```
|
||||
|
||||
**When creating, fixing, debugging, or using a component, always run `npx shadcn@latest docs` and fetch the URLs first.** This ensures you're working with the correct API and usage patterns rather than guessing.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Get project context** — already injected above. Run `npx shadcn@latest info` again if you need to refresh.
|
||||
2. **Check installed components first** — before running `add`, always check the `components` list from project context or list the `resolvedPaths.ui` directory. Don't import components that haven't been added, and don't re-add ones already installed.
|
||||
3. **Find components** — `npx shadcn@latest search`.
|
||||
4. **Get docs and examples** — run `npx shadcn@latest docs <component>` to get URLs, then fetch them. Use `npx shadcn@latest view` to browse registry items you haven't installed. To preview changes to installed components, use `npx shadcn@latest add --diff`.
|
||||
5. **Install or update** — `npx shadcn@latest add`. When updating existing components, use `--dry-run` and `--diff` to preview changes first (see [Updating Components](#updating-components) below).
|
||||
6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project.
|
||||
7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
|
||||
8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, `owner/repo`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
|
||||
9. **Switching presets** — Ask the user first: **overwrite**, **partial**, **merge**, or **skip**?
|
||||
- **Inspect current preset**: `npx shadcn@latest preset resolve`. Use `--json` when you need structured values.
|
||||
- **Inspect incoming preset**: `npx shadcn@latest preset decode <code>`. Use `preset url <code>` or `preset open <code>` to share or open the preset builder.
|
||||
- **Overwrite**: `npx shadcn@latest apply <code>`. Overwrites detected components, fonts, and CSS variables.
|
||||
- **Partial**: `npx shadcn@latest apply <code> --only theme,font`. Updates only the selected preset parts without reinstalling UI components. Supported values are `theme` and `font`; comma-separated combinations are allowed. `icon` is intentionally not supported, because icon changes may require full component reinstall and transforms.
|
||||
- **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually.
|
||||
- **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is.
|
||||
- **Important**: Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
|
||||
|
||||
## Updating Components
|
||||
|
||||
When the user asks to update a component from upstream while keeping their local changes, use `--dry-run` and `--diff` to intelligently merge. **NEVER fetch raw files from GitHub manually — always use the CLI.**
|
||||
|
||||
1. Run `npx shadcn@latest add <component> --dry-run` to see all files that would be affected.
|
||||
2. For each file, run `npx shadcn@latest add <component> --diff <file>` to see what changed upstream vs local.
|
||||
3. Decide per file based on the diff:
|
||||
- No local changes → safe to overwrite.
|
||||
- Has local changes → read the local file, analyze the diff, and apply upstream updates while preserving local modifications.
|
||||
- User says "just update everything" → use `--overwrite`, but confirm first.
|
||||
4. **Never use `--overwrite` without the user's explicit approval.**
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Create a new project.
|
||||
npx shadcn@latest init --name my-app --preset base-nova
|
||||
npx shadcn@latest init --name my-app --preset a2r6bw --template vite
|
||||
|
||||
# Create a monorepo project.
|
||||
npx shadcn@latest init --name my-app --preset base-nova --monorepo
|
||||
npx shadcn@latest init --name my-app --preset base-nova --template next --monorepo
|
||||
|
||||
# Initialize existing project.
|
||||
npx shadcn@latest init --preset base-nova
|
||||
npx shadcn@latest init --defaults # shortcut: --template=next --preset=nova (base style implied)
|
||||
|
||||
# Apply a preset to an existing project.
|
||||
npx shadcn@latest apply a2r6bw
|
||||
npx shadcn@latest apply a2r6bw --only theme
|
||||
npx shadcn@latest apply a2r6bw --only font
|
||||
npx shadcn@latest apply a2r6bw --only theme,font
|
||||
|
||||
# Inspect preset codes and project preset state.
|
||||
npx shadcn@latest preset decode a2r6bw
|
||||
npx shadcn@latest preset url a2r6bw
|
||||
npx shadcn@latest preset open a2r6bw
|
||||
npx shadcn@latest preset resolve
|
||||
npx shadcn@latest preset resolve --json
|
||||
|
||||
# Add components.
|
||||
npx shadcn@latest add button card dialog
|
||||
npx shadcn@latest add @magicui/shimmer-button
|
||||
npx shadcn@latest add owner/repo/item
|
||||
npx shadcn@latest add --all
|
||||
|
||||
# Preview changes before adding/updating.
|
||||
npx shadcn@latest add button --dry-run
|
||||
npx shadcn@latest add button --diff button.tsx
|
||||
npx shadcn@latest add @acme/form --view button.tsx
|
||||
npx shadcn@latest add owner/repo/item --dry-run
|
||||
|
||||
# Search registries.
|
||||
npx shadcn@latest search @shadcn -q "sidebar"
|
||||
npx shadcn@latest search @tailark -q "stats"
|
||||
npx shadcn@latest search owner/repo -q "login"
|
||||
npx shadcn@latest search # all configured registries
|
||||
npx shadcn@latest search @shadcn -q "menu" -t ui # filter by item type
|
||||
|
||||
# Get component docs and example URLs.
|
||||
npx shadcn@latest docs button dialog select
|
||||
|
||||
# View registry item details (for items not yet installed).
|
||||
npx shadcn@latest view @shadcn/button
|
||||
npx shadcn@latest view owner/repo/item
|
||||
```
|
||||
|
||||
**Named presets:** `nova`, `vega`, `maia`, `lyra`, `mira`, `luma`
|
||||
**Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo)
|
||||
**Preset codes:** Version-prefixed base62 strings (e.g. `a2r6bw` or `b0`), from [ui.shadcn.com](https://ui.shadcn.com).
|
||||
|
||||
## Detailed References
|
||||
|
||||
- [rules/forms.md](./rules/forms.md) — FieldGroup, Field, InputGroup, ToggleGroup, FieldSet, validation states
|
||||
- [rules/composition.md](./rules/composition.md) — Groups, overlays, Card, Tabs, Avatar, Alert, Empty, Toast, Separator, Skeleton, Badge, Button loading
|
||||
- [rules/icons.md](./rules/icons.md) — data-icon, icon sizing, passing icons as objects
|
||||
- [rules/styling.md](./rules/styling.md) — Semantic colors, variants, className, spacing, size, truncate, dark mode, cn(), z-index
|
||||
- [rules/base-vs-radix.md](./rules/base-vs-radix.md) — asChild vs render, Select, ToggleGroup, Slider, Accordion
|
||||
- [cli.md](./cli.md) — Commands, flags, presets, templates
|
||||
- [registry.md](./registry.md) — Authoring source registries, `include`, item definitions, dependencies, GitHub registry rules
|
||||
- [customization.md](./customization.md) — Theming, CSS variables, extending components
|
||||
@@ -0,0 +1,5 @@
|
||||
interface:
|
||||
display_name: "shadcn/ui"
|
||||
short_description: "Manages shadcn/ui components — adding, searching, fixing, debugging, styling, and composing UI."
|
||||
icon_small: "./assets/shadcn-small.png"
|
||||
icon_large: "./assets/shadcn.png"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,290 @@
|
||||
# shadcn CLI Reference
|
||||
|
||||
Configuration is read from `components.json`.
|
||||
|
||||
> **IMPORTANT:** Always run commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest`. Check `packageManager` from project context to choose the right one. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
|
||||
|
||||
> **IMPORTANT:** Only use the flags documented below. Do not invent or guess flags — if a flag isn't listed here, it doesn't exist. The CLI auto-detects the package manager from the project's lockfile; there is no `--package-manager` flag.
|
||||
|
||||
## Contents
|
||||
|
||||
- Commands: init, apply, add (dry-run, smart merge), search, view, docs, info, build
|
||||
- Templates: next, vite, start, react-router, astro
|
||||
- Presets: named, code, URL formats and fields
|
||||
- Switching presets
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
### `init` — Initialize or create a project
|
||||
|
||||
```bash
|
||||
npx shadcn@latest init [components...] [options]
|
||||
```
|
||||
|
||||
Initializes shadcn/ui in an existing project or creates a new project (when `--name` is provided). Optionally installs components in the same step.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ----------------------- | ----- | --------------------------------------------------------- | ------- |
|
||||
| `--template <template>` | `-t` | Template (next, start, vite, next-monorepo, react-router) | — |
|
||||
| `--preset [name]` | `-p` | Preset configuration (named, code, or URL) | — |
|
||||
| `--yes` | `-y` | Skip confirmation prompt | `true` |
|
||||
| `--defaults` | `-d` | Use defaults (`--template=next --preset=base-nova`) | `false` |
|
||||
| `--force` | `-f` | Force overwrite existing configuration | `false` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
| `--name <name>` | `-n` | Name for new project | — |
|
||||
| `--silent` | `-s` | Mute output | `false` |
|
||||
| `--rtl` | | Enable RTL support | — |
|
||||
| `--reinstall` | | Re-install existing UI components | `false` |
|
||||
| `--monorepo` | | Scaffold a monorepo project | — |
|
||||
| `--no-monorepo` | | Skip the monorepo prompt | — |
|
||||
|
||||
`npx shadcn@latest create` is an alias for `npx shadcn@latest init`.
|
||||
|
||||
### `apply` — Apply a preset to an existing project
|
||||
|
||||
```bash
|
||||
npx shadcn@latest apply [preset] [options]
|
||||
```
|
||||
|
||||
Applies a preset to an existing project, overwriting preset-driven config, fonts, CSS variables, and detected UI components.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ------------------- | ----- | ------------------------------------------ | ------- |
|
||||
| `--preset <preset>` | — | Preset configuration (named, code, or URL) | — |
|
||||
| `--yes` | `-y` | Skip confirmation prompt | `false` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
| `--silent` | `-s` | Mute output | `false` |
|
||||
|
||||
`[preset]` is a shorthand for `--preset <preset>`. If both are provided, they must match.
|
||||
If no preset is provided, the CLI offers to open the custom preset builder on `ui.shadcn.com/create`.
|
||||
|
||||
### `add` — Add components
|
||||
|
||||
> **IMPORTANT:** To compare local components against upstream or to preview changes, ALWAYS use `npx shadcn@latest add <component> --dry-run`, `--diff`, or `--view`. NEVER fetch raw files from GitHub or other sources manually. The CLI handles registry resolution, file paths, and CSS diffing automatically.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add [components...] [options]
|
||||
```
|
||||
|
||||
Accepts component names, registry-prefixed names (`@magicui/shimmer-button`),
|
||||
GitHub item addresses (`owner/repo/item`), URLs, or local paths.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| --------------- | ----- | -------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| `--yes` | `-y` | Skip confirmation prompt | `false` |
|
||||
| `--overwrite` | `-o` | Overwrite existing files | `false` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
| `--all` | `-a` | Add all available components | `false` |
|
||||
| `--path <path>` | `-p` | Target path for the component | — |
|
||||
| `--silent` | `-s` | Mute output | `false` |
|
||||
| `--dry-run` | | Preview all changes without writing files | `false` |
|
||||
| `--diff [path]` | | Show diffs. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
|
||||
| `--view [path]` | | Show file contents. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
|
||||
|
||||
#### Dry-Run Mode
|
||||
|
||||
Use `--dry-run` to preview what `add` would do without writing any files. `--diff` and `--view` both imply `--dry-run`.
|
||||
|
||||
```bash
|
||||
# Preview all changes.
|
||||
npx shadcn@latest add button --dry-run
|
||||
|
||||
# Show diffs for all files (top 5).
|
||||
npx shadcn@latest add button --diff
|
||||
|
||||
# Show the diff for a specific file.
|
||||
npx shadcn@latest add button --diff button.tsx
|
||||
|
||||
# Show contents for all files (top 5).
|
||||
npx shadcn@latest add button --view
|
||||
|
||||
# Show the full content of a specific file.
|
||||
npx shadcn@latest add button --view button.tsx
|
||||
|
||||
# Works with URLs too.
|
||||
npx shadcn@latest add https://api.npoint.io/abc123 --dry-run
|
||||
|
||||
# Works with public GitHub registries too.
|
||||
npx shadcn@latest add owner/repo/item --dry-run
|
||||
|
||||
# CSS diffs.
|
||||
npx shadcn@latest add button --diff globals.css
|
||||
```
|
||||
|
||||
**When to use dry-run:**
|
||||
|
||||
- When the user asks "what files will this add?" or "what will this change?" — use `--dry-run`.
|
||||
- Before overwriting existing components — use `--diff` to preview the changes first.
|
||||
- When the user wants to inspect component source code without installing — use `--view`.
|
||||
- When checking what CSS changes would be made to `globals.css` — use `--diff globals.css`.
|
||||
- When the user asks to review or audit third-party registry code before installing — use `--view` to inspect the source.
|
||||
|
||||
> **`npx shadcn@latest add --dry-run` vs `npx shadcn@latest view`:** Prefer `npx shadcn@latest add --dry-run/--diff/--view` over `npx shadcn@latest view` when the user wants to preview changes to their project. `npx shadcn@latest view` only shows raw registry metadata. `npx shadcn@latest add --dry-run` shows exactly what would happen in the user's project: resolved file paths, diffs against existing files, and CSS updates. Use `npx shadcn@latest view` only when the user wants to browse registry info without a project context.
|
||||
|
||||
#### Smart Merge from Upstream
|
||||
|
||||
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full workflow.
|
||||
|
||||
### `search` — Search registries
|
||||
|
||||
```bash
|
||||
npx shadcn@latest search [registries...] [options]
|
||||
```
|
||||
|
||||
Fuzzy search across registries. Also aliased as `npx shadcn@latest list`.
|
||||
Supports namespaces (`@acme`), public GitHub registry sources (`owner/repo`),
|
||||
and registry catalog URLs. Without `-q`, lists all items. When no registries are
|
||||
passed, searches every registry configured in `components.json`.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ------------------- | ----- | ------------------------------------------------- | ------- |
|
||||
| `--query <query>` | `-q` | Search query | — |
|
||||
| `--type <type>` | `-t` | Filter by item type (e.g. `ui`, `block`, `hook`); comma-separated | — |
|
||||
| `--limit <number>` | `-l` | Max items to display | `100` |
|
||||
| `--offset <number>` | `-o` | Items to skip | `0` |
|
||||
| `--json` | | Output as JSON | `false` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
|
||||
### `view` — View item details
|
||||
|
||||
```bash
|
||||
npx shadcn@latest view <items...> [options]
|
||||
```
|
||||
|
||||
Displays item info including file contents. Examples:
|
||||
`npx shadcn@latest view @shadcn/button`,
|
||||
`npx shadcn@latest view owner/repo/item`.
|
||||
|
||||
### `docs` — Get component documentation URLs
|
||||
|
||||
```bash
|
||||
npx shadcn@latest docs <components...> [options]
|
||||
```
|
||||
|
||||
Outputs resolved URLs for component documentation, examples, and API references. Accepts one or more component names. Fetch the URLs to get the actual content.
|
||||
|
||||
Example output for `npx shadcn@latest docs input button`:
|
||||
|
||||
```
|
||||
base radix
|
||||
|
||||
input
|
||||
docs https://ui.shadcn.com/docs/components/radix/input
|
||||
examples https://raw.githubusercontent.com/.../examples/input-example.tsx
|
||||
|
||||
button
|
||||
docs https://ui.shadcn.com/docs/components/radix/button
|
||||
examples https://raw.githubusercontent.com/.../examples/button-example.tsx
|
||||
```
|
||||
|
||||
Some components include an `api` link to the underlying library (e.g. `cmdk` for the command component).
|
||||
|
||||
### `diff` — Check for updates
|
||||
|
||||
Do not use this command. Use `npx shadcn@latest add --diff` instead.
|
||||
|
||||
### `info` — Project information
|
||||
|
||||
```bash
|
||||
npx shadcn@latest info [options]
|
||||
```
|
||||
|
||||
Displays project info and `components.json` configuration. Run this first to discover the project's framework, aliases, Tailwind version, and resolved paths.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ------------- | ----- | ----------------- | ------- |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
|
||||
**Project Info fields:**
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| -------------------- | --------- | ------------------------------------------------------------------ |
|
||||
| `framework` | `string` | Detected framework (`next`, `vite`, `react-router`, `start`, etc.) |
|
||||
| `frameworkVersion` | `string` | Framework version (e.g. `15.2.4`) |
|
||||
| `isSrcDir` | `boolean` | Whether the project uses a `src/` directory |
|
||||
| `isRSC` | `boolean` | Whether React Server Components are enabled |
|
||||
| `isTsx` | `boolean` | Whether the project uses TypeScript |
|
||||
| `tailwindVersion` | `string` | `"v3"` or `"v4"` |
|
||||
| `tailwindConfigFile` | `string` | Path to the Tailwind config file |
|
||||
| `tailwindCssFile` | `string` | Path to the global CSS file |
|
||||
| `aliasPrefix` | `string` | Import alias prefix (e.g. `@`, `~`, `@/`) |
|
||||
| `packageManager` | `string` | Detected package manager (`npm`, `pnpm`, `yarn`, `bun`) |
|
||||
|
||||
**Components.json fields:**
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| -------------------- | --------- | ------------------------------------------------------------------------------------------ |
|
||||
| `base` | `string` | Primitive library (`radix` or `base`) — determines component APIs and available props |
|
||||
| `style` | `string` | Visual style (e.g. `nova`, `vega`) |
|
||||
| `rsc` | `boolean` | RSC flag from config |
|
||||
| `tsx` | `boolean` | TypeScript flag |
|
||||
| `tailwind.config` | `string` | Tailwind config path |
|
||||
| `tailwind.css` | `string` | Global CSS path — this is where custom CSS variables go |
|
||||
| `iconLibrary` | `string` | Icon library — determines icon import package (e.g. `lucide-react`, `@tabler/icons-react`) |
|
||||
| `aliases.components` | `string` | Component import alias (e.g. `@/components`) |
|
||||
| `aliases.utils` | `string` | Utils import alias (e.g. `@/lib/utils`) |
|
||||
| `aliases.ui` | `string` | UI component alias (e.g. `@/components/ui`) |
|
||||
| `aliases.lib` | `string` | Lib alias (e.g. `@/lib`) |
|
||||
| `aliases.hooks` | `string` | Hooks alias (e.g. `@/hooks`) |
|
||||
| `resolvedPaths` | `object` | Absolute file-system paths for each alias |
|
||||
| `registries` | `object` | Configured custom registries |
|
||||
|
||||
**Links fields:**
|
||||
|
||||
The `info` output includes a **Links** section with templated URLs for component docs, source, and examples. For resolved URLs, use `npx shadcn@latest docs <component>` instead.
|
||||
|
||||
### `build` — Build a custom registry
|
||||
|
||||
```bash
|
||||
npx shadcn@latest build [registry] [options]
|
||||
```
|
||||
|
||||
Builds `registry.json` into individual JSON files for distribution. Default input: `./registry.json`, default output: `./public/r`.
|
||||
|
||||
For authoring rules, `include`, item definitions, `registryDependencies`, and
|
||||
GitHub registry behavior, see [registry.md](./registry.md).
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ----------------- | ----- | ----------------- | ------------ |
|
||||
| `--output <path>` | `-o` | Output directory | `./public/r` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
|
||||
---
|
||||
|
||||
## Templates
|
||||
|
||||
| Value | Framework | Monorepo support |
|
||||
| -------------- | -------------- | ---------------- |
|
||||
| `next` | Next.js | Yes |
|
||||
| `vite` | Vite | Yes |
|
||||
| `start` | TanStack Start | Yes |
|
||||
| `react-router` | React Router | Yes |
|
||||
| `astro` | Astro | Yes |
|
||||
| `laravel` | Laravel | No |
|
||||
|
||||
All templates support monorepo scaffolding via the `--monorepo` flag. When passed, the CLI uses a monorepo-specific template directory (e.g. `next-monorepo`, `vite-monorepo`). When neither `--monorepo` nor `--no-monorepo` is passed, the CLI prompts interactively. Laravel does not support monorepo scaffolding.
|
||||
|
||||
---
|
||||
|
||||
## Presets
|
||||
|
||||
Three ways to specify a preset via `--preset`:
|
||||
|
||||
1. **Named:** `--preset nova` or `--preset lyra`
|
||||
2. **Code:** `--preset a2r6bw` (version-prefixed base62 string, e.g. `a2r6bw` or `b0`)
|
||||
3. **URL:** `--preset "https://ui.shadcn.com/init?base=radix&style=nova&..."`
|
||||
|
||||
> **IMPORTANT:** Never try to decode, fetch, or resolve preset codes manually. Preset codes are opaque — pass them directly to `npx shadcn@latest init --preset <code>` and let the CLI handle resolution.
|
||||
> Use `npx shadcn@latest apply --preset <code>` when overwriting an existing project's preset.
|
||||
|
||||
## Switching Presets
|
||||
|
||||
Ask the user first: **overwrite**, **merge**, or **skip** existing components?
|
||||
|
||||
- **Overwrite / Re-install** → `npx shadcn@latest apply --preset <code>`. Overwrites all detected component files with the new preset styles. Use when the user hasn't customized components.
|
||||
- **Merge** → `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to get the list of installed components and use the [smart merge workflow](./SKILL.md#updating-components) to update them one by one, preserving local changes. Use when the user has customized components.
|
||||
- **Skip** → `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS variables, leaves existing components as-is.
|
||||
|
||||
Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
|
||||
@@ -0,0 +1,209 @@
|
||||
# Customization & Theming
|
||||
|
||||
Components reference semantic CSS variable tokens. Change the variables to change every component.
|
||||
|
||||
## Contents
|
||||
|
||||
- How it works (CSS variables → Tailwind utilities → components)
|
||||
- Color variables and OKLCH format
|
||||
- Dark mode setup
|
||||
- Changing the theme (presets, CSS variables)
|
||||
- Adding custom colors (Tailwind v3 and v4)
|
||||
- Border radius
|
||||
- Customizing components (variants, className, wrappers)
|
||||
- Checking for updates
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
1. CSS variables defined in `:root` (light) and `.dark` (dark mode).
|
||||
2. Tailwind maps them to utilities: `bg-primary`, `text-muted-foreground`, etc.
|
||||
3. Components use these utilities — changing a variable changes all components that reference it.
|
||||
|
||||
---
|
||||
|
||||
## Color Variables
|
||||
|
||||
Every color follows the `name` / `name-foreground` convention. The base variable is for backgrounds, `-foreground` is for text/icons on that background.
|
||||
|
||||
| Variable | Purpose |
|
||||
| -------------------------------------------- | -------------------------------- |
|
||||
| `--background` / `--foreground` | Page background and default text |
|
||||
| `--card` / `--card-foreground` | Card surfaces |
|
||||
| `--primary` / `--primary-foreground` | Primary buttons and actions |
|
||||
| `--secondary` / `--secondary-foreground` | Secondary actions |
|
||||
| `--muted` / `--muted-foreground` | Muted/disabled states |
|
||||
| `--accent` / `--accent-foreground` | Hover and accent states |
|
||||
| `--destructive` / `--destructive-foreground` | Error and destructive actions |
|
||||
| `--border` | Default border color |
|
||||
| `--input` | Form input borders |
|
||||
| `--ring` | Focus ring color |
|
||||
| `--chart-1` through `--chart-5` | Chart/data visualization |
|
||||
| `--sidebar-*` | Sidebar-specific colors |
|
||||
| `--surface` / `--surface-foreground` | Secondary surface |
|
||||
|
||||
Colors use OKLCH: `--primary: oklch(0.205 0 0)` where values are lightness (0–1), chroma (0 = gray), and hue (0–360).
|
||||
|
||||
---
|
||||
|
||||
## Dark Mode
|
||||
|
||||
Class-based toggle via `.dark` on the root element. In Next.js, use `next-themes`:
|
||||
|
||||
```tsx
|
||||
import { ThemeProvider } from "next-themes"
|
||||
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Changing the Theme
|
||||
|
||||
```bash
|
||||
# Apply a preset code from ui.shadcn.com.
|
||||
npx shadcn@latest apply --preset a2r6bw
|
||||
|
||||
# Positional shorthand also works.
|
||||
npx shadcn@latest apply a2r6bw
|
||||
|
||||
# Switch to a named preset and overwrite existing components.
|
||||
npx shadcn@latest apply --preset nova
|
||||
|
||||
# Preserve existing components instead.
|
||||
npx shadcn@latest init --preset nova --force --no-reinstall
|
||||
|
||||
# Use a custom theme URL.
|
||||
npx shadcn@latest apply --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..."
|
||||
```
|
||||
|
||||
Or edit CSS variables directly in `globals.css`.
|
||||
|
||||
---
|
||||
|
||||
## Adding Custom Colors
|
||||
|
||||
Add variables to the file at `tailwindCssFile` from `npx shadcn@latest info` (typically `globals.css`). Never create a new CSS file for this.
|
||||
|
||||
```css
|
||||
/* 1. Define in the global CSS file. */
|
||||
:root {
|
||||
--warning: oklch(0.84 0.16 84);
|
||||
--warning-foreground: oklch(0.28 0.07 46);
|
||||
}
|
||||
.dark {
|
||||
--warning: oklch(0.41 0.11 46);
|
||||
--warning-foreground: oklch(0.99 0.02 95);
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
/* 2a. Register with Tailwind v4 (@theme inline). */
|
||||
@theme inline {
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
}
|
||||
```
|
||||
|
||||
When `tailwindVersion` is `"v3"` (check via `npx shadcn@latest info`), register in `tailwind.config.js` instead:
|
||||
|
||||
```js
|
||||
// 2b. Register with Tailwind v3 (tailwind.config.js).
|
||||
module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
warning: "oklch(var(--warning) / <alpha-value>)",
|
||||
"warning-foreground":
|
||||
"oklch(var(--warning-foreground) / <alpha-value>)",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// 3. Use in components.
|
||||
<div className="bg-warning text-warning-foreground">Warning</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Border Radius
|
||||
|
||||
`--radius` controls border radius globally. Components derive values from it (`rounded-lg` = `var(--radius)`, `rounded-md` = `calc(var(--radius) - 2px)`).
|
||||
|
||||
---
|
||||
|
||||
## Customizing Components
|
||||
|
||||
See also: [rules/styling.md](./rules/styling.md) for Incorrect/Correct examples.
|
||||
|
||||
Prefer these approaches in order:
|
||||
|
||||
### 1. Built-in variants
|
||||
|
||||
```tsx
|
||||
<Button variant="outline" size="sm">
|
||||
Click
|
||||
</Button>
|
||||
```
|
||||
|
||||
### 2. Tailwind classes via `className`
|
||||
|
||||
```tsx
|
||||
<Card className="mx-auto max-w-md">...</Card>
|
||||
```
|
||||
|
||||
### 3. Add a new variant
|
||||
|
||||
Edit the component source to add a variant via `cva`:
|
||||
|
||||
```tsx
|
||||
// components/ui/button.tsx
|
||||
warning: "bg-warning text-warning-foreground hover:bg-warning/90",
|
||||
```
|
||||
|
||||
### 4. Wrapper components
|
||||
|
||||
Compose shadcn/ui primitives into higher-level components:
|
||||
|
||||
```tsx
|
||||
export function ConfirmDialog({ title, description, onConfirm, children }) {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm}>Confirm</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checking for Updates
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add button --diff
|
||||
```
|
||||
|
||||
To preview exactly what would change before updating, use `--dry-run` and `--diff`:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add button --dry-run # see all affected files
|
||||
npx shadcn@latest add button --diff button.tsx # see the diff for a specific file
|
||||
```
|
||||
|
||||
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full smart merge workflow.
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"skill_name": "shadcn",
|
||||
"evals": [
|
||||
{
|
||||
"id": 1,
|
||||
"prompt": "I'm building a Next.js app with shadcn/ui (base-nova preset, lucide icons). Create a settings form component with fields for: full name, email address, and notification preferences (email, SMS, push notifications as toggle options). Add validation states for required fields.",
|
||||
"expected_output": "A React component using FieldGroup, Field, ToggleGroup, data-invalid/aria-invalid validation, gap-* spacing, and semantic colors.",
|
||||
"files": [],
|
||||
"expectations": [
|
||||
"Uses FieldGroup and Field components for form layout instead of raw div with space-y",
|
||||
"Uses Switch for independent on/off notification toggles (not looping Button with manual active state)",
|
||||
"Uses data-invalid on Field and aria-invalid on the input control for validation states",
|
||||
"Uses gap-* (e.g. gap-4, gap-6) instead of space-y-* or space-x-* for spacing",
|
||||
"Uses semantic color tokens (e.g. bg-background, text-muted-foreground, text-destructive) instead of raw colors like bg-red-500",
|
||||
"No manual dark: color overrides"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"prompt": "Create a dialog component for editing a user profile. It should have the user's avatar at the top, input fields for name and bio, and Save/Cancel buttons with appropriate icons. Using shadcn/ui with radix-nova preset and tabler icons.",
|
||||
"expected_output": "A React component with DialogTitle, Avatar+AvatarFallback, data-icon on icon buttons, no icon sizing classes, tabler icon imports.",
|
||||
"files": [],
|
||||
"expectations": [
|
||||
"Includes DialogTitle for accessibility (visible or with sr-only class)",
|
||||
"Avatar component includes AvatarFallback",
|
||||
"Icons on buttons use the data-icon attribute (data-icon=\"inline-start\" or data-icon=\"inline-end\")",
|
||||
"No sizing classes on icons inside components (no size-4, w-4, h-4, etc.)",
|
||||
"Uses tabler icons (@tabler/icons-react) instead of lucide-react",
|
||||
"Uses asChild for custom triggers (radix preset)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"prompt": "Create a dashboard component that shows 4 stat cards in a grid. Each card has a title, large number, percentage change badge, and a loading skeleton state. Using shadcn/ui with base-nova preset and lucide icons.",
|
||||
"expected_output": "A React component with full Card composition, Skeleton for loading, Badge for changes, semantic colors, gap-* spacing.",
|
||||
"files": [],
|
||||
"expectations": [
|
||||
"Uses full Card composition with CardHeader, CardTitle, CardContent (not dumping everything into CardContent)",
|
||||
"Uses Skeleton component for loading placeholders instead of custom animate-pulse divs",
|
||||
"Uses Badge component for percentage change instead of custom styled spans",
|
||||
"Uses semantic color tokens instead of raw color values like bg-green-500 or text-red-600",
|
||||
"Uses gap-* instead of space-y-* or space-x-* for spacing",
|
||||
"Uses size-* when width and height are equal instead of separate w-* h-*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
# shadcn MCP Server
|
||||
|
||||
The CLI includes an MCP server that lets AI assistants search, browse, view, and install items from registries.
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
shadcn mcp # start the MCP server (stdio)
|
||||
shadcn mcp init # write config for your editor
|
||||
```
|
||||
|
||||
Editor config files:
|
||||
|
||||
| Editor | Config file |
|
||||
| ----------- | ------------------------------- |
|
||||
| Claude Code | `.mcp.json` |
|
||||
| Cursor | `.cursor/mcp.json` |
|
||||
| VS Code | `.vscode/mcp.json` |
|
||||
| OpenCode | `opencode.json` |
|
||||
| Codex | `~/.codex/config.toml` (manual) |
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
> **Tip:** MCP tools handle registry operations (search, view, install). For project configuration (aliases, framework, Tailwind version), use `npx shadcn@latest info` — there is no MCP equivalent.
|
||||
|
||||
### `shadcn:get_project_registries`
|
||||
|
||||
Returns registry names from `components.json`. Errors if no `components.json` exists.
|
||||
|
||||
**Input:** none
|
||||
|
||||
### `shadcn:list_items_in_registries`
|
||||
|
||||
Lists all items from one or more registries. Registries can be configured
|
||||
namespaces such as `@acme`, public GitHub sources such as `owner/repo`, or
|
||||
registry catalog URLs. Omit `registries` to list from every registry configured
|
||||
in `components.json`.
|
||||
|
||||
**Input:** `registries` (string[], optional — omit for all configured), `types` (string[], optional — e.g. `["ui", "block"]`), `limit` (number, optional, defaults to 100), `offset` (number, optional)
|
||||
|
||||
### `shadcn:search_items_in_registries`
|
||||
|
||||
Fuzzy search across registries. Registries can be configured namespaces, public
|
||||
GitHub sources, or registry catalog URLs. Omit `registries` to search every
|
||||
registry configured in `components.json` — e.g. "find me a hero" across all
|
||||
configured registries.
|
||||
|
||||
**Input:** `registries` (string[], optional — omit for all configured), `query` (string), `types` (string[], optional — e.g. `["ui", "block"]`), `limit` (number, optional, defaults to 100), `offset` (number, optional)
|
||||
|
||||
### `shadcn:view_items_in_registries`
|
||||
|
||||
View item details including full file contents.
|
||||
|
||||
**Input:** `items` (string[]) — e.g.
|
||||
`["@shadcn/button", "@shadcn/card", "owner/repo/item"]`
|
||||
|
||||
### `shadcn:get_item_examples_from_registries`
|
||||
|
||||
Find usage examples and demos with source code. Omit `registries` to search
|
||||
every registry configured in `components.json`.
|
||||
|
||||
**Input:** `registries` (string[], optional — omit for all configured), `query` (string) — e.g. `"accordion-demo"`, `"button example"`
|
||||
|
||||
### `shadcn:get_add_command_for_items`
|
||||
|
||||
Returns the CLI install command.
|
||||
|
||||
**Input:** `items` (string[]) — e.g. `["@shadcn/button"]`
|
||||
|
||||
### `shadcn:get_audit_checklist`
|
||||
|
||||
Returns a checklist for verifying components (imports, deps, lint, TypeScript).
|
||||
|
||||
**Input:** none
|
||||
|
||||
---
|
||||
|
||||
## Configuring Registries
|
||||
|
||||
Namespaced and authenticated registries are set in `components.json`. The
|
||||
`@shadcn` registry is always built-in. Public GitHub registries can also be used
|
||||
directly as `owner/repo` registry sources when the repository has a root
|
||||
`registry.json`; they do not need `components.json` configuration.
|
||||
|
||||
```json
|
||||
{
|
||||
"registries": {
|
||||
"@acme": "https://acme.com/r/{name}.json",
|
||||
"@private": {
|
||||
"url": "https://private.com/r/{name}.json",
|
||||
"headers": { "Authorization": "Bearer ${MY_TOKEN}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Names must start with `@`.
|
||||
- URLs must contain `{name}`.
|
||||
- `${VAR}` references are resolved from environment variables.
|
||||
|
||||
Community registry index: `https://ui.shadcn.com/r/registries.json`
|
||||
@@ -0,0 +1,277 @@
|
||||
# Registry Authoring and Addresses
|
||||
|
||||
Use this reference when the user wants to create, fix, publish, or reason about
|
||||
a shadcn registry.
|
||||
|
||||
## Mental Model
|
||||
|
||||
A registry has two forms:
|
||||
|
||||
- **Source registry**: an authored `registry.json` in a project or repository.
|
||||
It may use `include` and file paths that point at source files.
|
||||
- **Built registry**: generated JSON files served to CLI consumers, usually
|
||||
from `public/r`. Use `npx shadcn@latest build` to create this form.
|
||||
|
||||
The CLI installer consumes registry item payloads. A source registry is a way to
|
||||
author those payloads from real files.
|
||||
|
||||
Registry items are not limited to React components. They can distribute
|
||||
components, hooks, utilities, design tokens, pages, config files, docs, rules,
|
||||
workflows, templates, MCP files, and other project files.
|
||||
|
||||
## Root `registry.json`
|
||||
|
||||
The root registry file should define registry metadata and either `items` or
|
||||
`include`.
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||
"name": "acme",
|
||||
"homepage": "https://acme.com",
|
||||
"items": [
|
||||
{
|
||||
"name": "absolute-url",
|
||||
"type": "registry:lib",
|
||||
"title": "Absolute URL",
|
||||
"description": "A utility to turn any path into an absolute URL.",
|
||||
"files": [
|
||||
{
|
||||
"path": "lib/absolute-url.ts",
|
||||
"type": "registry:lib"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Root registry rules:
|
||||
|
||||
- Root `registry.json` must include `name` and `homepage`.
|
||||
- `items` is an array of registry item definitions.
|
||||
- `include` may be used to split the source registry into multiple files.
|
||||
- Included registry files may omit `name` and `homepage`.
|
||||
|
||||
## Include
|
||||
|
||||
Use `include` to keep large registries modular.
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry.json",
|
||||
"name": "acme",
|
||||
"homepage": "https://acme.com",
|
||||
"include": ["registry/ui/registry.json", "registry/blocks/registry.json"]
|
||||
}
|
||||
```
|
||||
|
||||
Include rules:
|
||||
|
||||
- Include paths are relative to the `registry.json` that declares them.
|
||||
- Include paths must explicitly point to a `registry.json` file.
|
||||
- Do not use remote URLs, absolute paths, or parent traversal (`..`).
|
||||
- Item file paths are relative to the registry file that declares the item.
|
||||
- Duplicate item names fail across the resolved registry.
|
||||
|
||||
Example included file:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"name": "button",
|
||||
"type": "registry:ui",
|
||||
"files": [
|
||||
{
|
||||
"path": "button.tsx",
|
||||
"type": "registry:ui"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
If this file is at `registry/ui/registry.json`, then `button.tsx` is read from
|
||||
`registry/ui/button.tsx`, and the built item path is emitted relative to the
|
||||
root registry.
|
||||
|
||||
## Item Definitions
|
||||
|
||||
Common item fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "login-form",
|
||||
"type": "registry:block",
|
||||
"title": "Login Form",
|
||||
"description": "A login form with email and password fields.",
|
||||
"dependencies": ["zod"],
|
||||
"registryDependencies": ["button", "input", "label"],
|
||||
"files": [
|
||||
{
|
||||
"path": "blocks/login-form.tsx",
|
||||
"type": "registry:block"
|
||||
}
|
||||
],
|
||||
"cssVars": {
|
||||
"light": {
|
||||
"brand": "oklch(0.62 0.18 250)"
|
||||
},
|
||||
"dark": {
|
||||
"brand": "oklch(0.72 0.16 250)"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Important fields:
|
||||
|
||||
- `name`: the installable item name. It is not necessarily a file path.
|
||||
- `type`: one of the registry item types, such as `registry:ui`,
|
||||
`registry:block`, `registry:lib`, `registry:hook`, `registry:file`,
|
||||
`registry:page`, `registry:theme`, `registry:style`, `registry:font`, or
|
||||
`registry:item`.
|
||||
- `files`: source files copied or generated by the item.
|
||||
- `dependencies`: npm runtime dependencies.
|
||||
- `devDependencies`: npm development dependencies.
|
||||
- `registryDependencies`: other registry items required by this item.
|
||||
- `cssVars`, `css`, `tailwind`, `envVars`, and `docs`: optional install-time
|
||||
additions.
|
||||
|
||||
File rules:
|
||||
|
||||
- File paths are relative to the declaring `registry.json`.
|
||||
- `registry:file` and `registry:page` files require a `target`.
|
||||
- Do not use remote file URLs in source registry file paths.
|
||||
- Keep source files copy-pasteable: no hidden app-only imports.
|
||||
|
||||
## Registry Dependencies
|
||||
|
||||
`registryDependencies` entries are item addresses, not file paths.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "login-form",
|
||||
"type": "registry:block",
|
||||
"registryDependencies": ["button", "@acme/input", "acme/ui/card#v1.2.0"],
|
||||
"files": [
|
||||
{
|
||||
"path": "blocks/login-form.tsx",
|
||||
"type": "registry:block"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Dependency rules:
|
||||
|
||||
- Bare names such as `"button"` mean official shadcn items.
|
||||
- Bare names never mean same-registry or same-repository items.
|
||||
- Namespaced dependencies use `@namespace/item-name`.
|
||||
- GitHub dependencies use `owner/repo/item-name`.
|
||||
- Pin GitHub dependencies with `owner/repo/item-name#ref` when needed.
|
||||
- Refs are not inherited. If `owner/repo/foo#v2` depends on `bar` from the same
|
||||
repo at `v2`, write `owner/repo/bar#v2`.
|
||||
- Do not use relative dependencies such as `"./bar"`.
|
||||
|
||||
## Address Schemes
|
||||
|
||||
When reasoning about a registry item string, classify it first.
|
||||
|
||||
| Address | Scheme | Meaning |
|
||||
| ----------------------------------- | --------- | ------------------------------------------------------------ |
|
||||
| `button` | shadcn | Official shadcn item named `button`. |
|
||||
| `@acme/button` | namespace | Item `button` from configured registry `@acme`. |
|
||||
| `@acme/ui/button` | namespace | Item `ui/button` from configured registry `@acme`. |
|
||||
| `https://example.com/r/button.json` | url | Built registry item JSON at that URL. |
|
||||
| `./button.json` | file | Built registry item JSON on disk. |
|
||||
| `acme/ui/button` | github | Item `button` from GitHub repo `acme/ui`. |
|
||||
| `acme/ui/forms/login#main` | github | Item `forms/login` from GitHub repo `acme/ui` at ref `main`. |
|
||||
|
||||
For namespace and GitHub addresses, slashful item names are allowed and are item
|
||||
names, not file paths. Addresses ending in `.json` keep file-address
|
||||
precedence, so `acme/ui/data/schema.json` is treated as a file path, not a
|
||||
GitHub item address.
|
||||
|
||||
## GitHub Registries
|
||||
|
||||
A public GitHub repository can act as a source registry when it has a root
|
||||
`registry.json`.
|
||||
|
||||
```txt
|
||||
owner/repo/item-name[#ref]
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- The first two path segments are GitHub owner and repo.
|
||||
- All remaining path segments are the registry item name.
|
||||
- The source entrypoint is always root `registry.json`.
|
||||
- GitHub registries are source registries consumed directly by the CLI. They do
|
||||
not require `shadcn build` or generated item JSON files.
|
||||
- `include` follows the same source-registry rules as local registries.
|
||||
- Currently, GitHub addresses support public `github.com` repositories only.
|
||||
- Private repos and GitHub Enterprise require explicit product decisions.
|
||||
|
||||
When implementing GitHub registry fetching, resolve refs to a commit SHA before
|
||||
reading source files. Do not read moving refs directly from
|
||||
`raw.githubusercontent.com`, because branch-like refs can be cached for several
|
||||
minutes.
|
||||
|
||||
Preferred flow:
|
||||
|
||||
```txt
|
||||
owner/repo[#ref]
|
||||
-> resolve ref with git ls-remote
|
||||
-> commit SHA
|
||||
-> read https://raw.githubusercontent.com/{owner}/{repo}/{sha}/registry.json
|
||||
-> read includes and item files from the same SHA
|
||||
```
|
||||
|
||||
This keeps a command on one consistent repository snapshot.
|
||||
|
||||
Full 40-character commit SHAs are already stable and can be used directly.
|
||||
Branches, tags, and short refs require Git so the CLI can resolve them to a
|
||||
commit SHA first.
|
||||
|
||||
## Build and Verify
|
||||
|
||||
Use the CLI to build source registries:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest build
|
||||
npx shadcn@latest build registry.json --output public/r
|
||||
```
|
||||
|
||||
Use CLI commands to inspect the result:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest list @acme
|
||||
npx shadcn@latest search @acme -q "login"
|
||||
npx shadcn@latest view @acme/login-form
|
||||
npx shadcn@latest add @acme/login-form --dry-run
|
||||
npx shadcn@latest registry validate ./registry.json
|
||||
```
|
||||
|
||||
Use GitHub addresses directly for public GitHub registries:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest list owner/repo
|
||||
npx shadcn@latest search owner/repo -q "login"
|
||||
npx shadcn@latest view owner/repo/item
|
||||
npx shadcn@latest add owner/repo/item --dry-run
|
||||
npx shadcn@latest registry validate owner/repo
|
||||
```
|
||||
|
||||
When working on registry implementation in the shadcn/ui codebase:
|
||||
|
||||
- Keep address parsing pure and testable.
|
||||
- Do not add side effects to validators.
|
||||
- Preserve existing behavior for official shadcn, namespace, URL, and file
|
||||
schemes.
|
||||
- Add tests for address parsing, source loading, dependency resolution, list,
|
||||
search, view, and add paths.
|
||||
- Prefer small source-reader abstractions over a plugin system until there are
|
||||
multiple real providers.
|
||||
@@ -0,0 +1,306 @@
|
||||
# Base vs Radix
|
||||
|
||||
API differences between `base` and `radix`. Check the `base` field from `npx shadcn@latest info`.
|
||||
|
||||
## Contents
|
||||
|
||||
- Composition: asChild vs render
|
||||
- Button / trigger as non-button element
|
||||
- Select (items prop, placeholder, positioning, multiple, object values)
|
||||
- ToggleGroup (type vs multiple)
|
||||
- Slider (scalar vs array)
|
||||
- Accordion (type and defaultValue)
|
||||
|
||||
---
|
||||
|
||||
## Composition: asChild (radix) vs render (base)
|
||||
|
||||
Radix uses `asChild` to replace the default element. Base uses `render`. Don't wrap triggers in extra elements.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<DialogTrigger>
|
||||
<div>
|
||||
<Button>Open</Button>
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
```
|
||||
|
||||
**Correct (radix):**
|
||||
|
||||
```tsx
|
||||
<DialogTrigger asChild>
|
||||
<Button>Open</Button>
|
||||
</DialogTrigger>
|
||||
```
|
||||
|
||||
**Correct (base):**
|
||||
|
||||
```tsx
|
||||
<DialogTrigger render={<Button />}>Open</DialogTrigger>
|
||||
```
|
||||
|
||||
This applies to all trigger and close components: `DialogTrigger`, `SheetTrigger`, `AlertDialogTrigger`, `DropdownMenuTrigger`, `PopoverTrigger`, `TooltipTrigger`, `CollapsibleTrigger`, `DialogClose`, `SheetClose`, `NavigationMenuLink`, `BreadcrumbLink`, `SidebarMenuButton`, `Badge`, `Item`.
|
||||
|
||||
---
|
||||
|
||||
## Button / trigger as non-button element (base only)
|
||||
|
||||
When `render` changes an element to a non-button (`<a>`, `<span>`), add `nativeButton={false}`.
|
||||
|
||||
**Incorrect (base):** missing `nativeButton={false}`.
|
||||
|
||||
```tsx
|
||||
<Button render={<a href="/docs" />}>Read the docs</Button>
|
||||
```
|
||||
|
||||
**Correct (base):**
|
||||
|
||||
```tsx
|
||||
<Button render={<a href="/docs" />} nativeButton={false}>
|
||||
Read the docs
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Correct (radix):**
|
||||
|
||||
```tsx
|
||||
<Button asChild>
|
||||
<a href="/docs">Read the docs</a>
|
||||
</Button>
|
||||
```
|
||||
|
||||
Same for triggers whose `render` is not a `Button`:
|
||||
|
||||
```tsx
|
||||
// base.
|
||||
<PopoverTrigger render={<InputGroupAddon />} nativeButton={false}>
|
||||
Pick date
|
||||
</PopoverTrigger>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Select
|
||||
|
||||
**items prop (base only).** Base requires an `items` prop on the root. Radix uses inline JSX only.
|
||||
|
||||
**Incorrect (base):**
|
||||
|
||||
```tsx
|
||||
<Select>
|
||||
<SelectTrigger><SelectValue placeholder="Select a fruit" /></SelectTrigger>
|
||||
</Select>
|
||||
```
|
||||
|
||||
**Correct (base):**
|
||||
|
||||
```tsx
|
||||
const items = [
|
||||
{ label: "Select a fruit", value: null },
|
||||
{ label: "Apple", value: "apple" },
|
||||
{ label: "Banana", value: "banana" },
|
||||
]
|
||||
|
||||
<Select items={items}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
```
|
||||
|
||||
**Correct (radix):**
|
||||
|
||||
```tsx
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a fruit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
```
|
||||
|
||||
**Placeholder.** Base uses a `{ value: null }` item in the items array. Radix uses `<SelectValue placeholder="...">`.
|
||||
|
||||
**Content positioning.** Base uses `alignItemWithTrigger`. Radix uses `position`.
|
||||
|
||||
```tsx
|
||||
// base.
|
||||
<SelectContent alignItemWithTrigger={false} side="bottom">
|
||||
|
||||
// radix.
|
||||
<SelectContent position="popper">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Select — multiple selection and object values (base only)
|
||||
|
||||
Base supports `multiple`, render-function children on `SelectValue`, and object values with `itemToStringValue`. Radix is single-select with string values only.
|
||||
|
||||
**Correct (base — multiple selection):**
|
||||
|
||||
```tsx
|
||||
<Select items={items} multiple defaultValue={[]}>
|
||||
<SelectTrigger>
|
||||
<SelectValue>
|
||||
{(value: string[]) => value.length === 0 ? "Select fruits" : `${value.length} selected`}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
...
|
||||
</Select>
|
||||
```
|
||||
|
||||
**Correct (base — object values):**
|
||||
|
||||
```tsx
|
||||
<Select defaultValue={plans[0]} itemToStringValue={(plan) => plan.name}>
|
||||
<SelectTrigger>
|
||||
<SelectValue>{(value) => value.name}</SelectValue>
|
||||
</SelectTrigger>
|
||||
...
|
||||
</Select>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ToggleGroup
|
||||
|
||||
Base uses a `multiple` boolean prop. Radix uses `type="single"` or `type="multiple"`.
|
||||
|
||||
**Incorrect (base):**
|
||||
|
||||
```tsx
|
||||
<ToggleGroup type="single" defaultValue="daily">
|
||||
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
```
|
||||
|
||||
**Correct (base):**
|
||||
|
||||
```tsx
|
||||
// Single (no prop needed), defaultValue is always an array.
|
||||
<ToggleGroup defaultValue={["daily"]} spacing={2}>
|
||||
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
// Multi-selection.
|
||||
<ToggleGroup multiple>
|
||||
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
|
||||
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
```
|
||||
|
||||
**Correct (radix):**
|
||||
|
||||
```tsx
|
||||
// Single, defaultValue is a string.
|
||||
<ToggleGroup type="single" defaultValue="daily" spacing={2}>
|
||||
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
// Multi-selection.
|
||||
<ToggleGroup type="multiple">
|
||||
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
|
||||
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
```
|
||||
|
||||
**Controlled single value:**
|
||||
|
||||
```tsx
|
||||
// base — wrap/unwrap arrays.
|
||||
const [value, setValue] = React.useState("normal")
|
||||
<ToggleGroup value={[value]} onValueChange={(v) => setValue(v[0])}>
|
||||
|
||||
// radix — plain string.
|
||||
const [value, setValue] = React.useState("normal")
|
||||
<ToggleGroup type="single" value={value} onValueChange={setValue}>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Slider
|
||||
|
||||
Base accepts a plain number for a single thumb. Radix always requires an array.
|
||||
|
||||
**Incorrect (base):**
|
||||
|
||||
```tsx
|
||||
<Slider defaultValue={[50]} max={100} step={1} />
|
||||
```
|
||||
|
||||
**Correct (base):**
|
||||
|
||||
```tsx
|
||||
<Slider defaultValue={50} max={100} step={1} />
|
||||
```
|
||||
|
||||
**Correct (radix):**
|
||||
|
||||
```tsx
|
||||
<Slider defaultValue={[50]} max={100} step={1} />
|
||||
```
|
||||
|
||||
Both use arrays for range sliders. Controlled `onValueChange` in base may need a cast:
|
||||
|
||||
```tsx
|
||||
// base.
|
||||
const [value, setValue] = React.useState([0.3, 0.7])
|
||||
<Slider value={value} onValueChange={(v) => setValue(v as number[])} />
|
||||
|
||||
// radix.
|
||||
const [value, setValue] = React.useState([0.3, 0.7])
|
||||
<Slider value={value} onValueChange={setValue} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Accordion
|
||||
|
||||
Radix requires `type="single"` or `type="multiple"` and supports `collapsible`. `defaultValue` is a string. Base uses no `type` prop, uses `multiple` boolean, and `defaultValue` is always an array.
|
||||
|
||||
**Incorrect (base):**
|
||||
|
||||
```tsx
|
||||
<Accordion type="single" collapsible defaultValue="item-1">
|
||||
<AccordionItem value="item-1">...</AccordionItem>
|
||||
</Accordion>
|
||||
```
|
||||
|
||||
**Correct (base):**
|
||||
|
||||
```tsx
|
||||
<Accordion defaultValue={["item-1"]}>
|
||||
<AccordionItem value="item-1">...</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
// Multi-select.
|
||||
<Accordion multiple defaultValue={["item-1", "item-2"]}>
|
||||
<AccordionItem value="item-1">...</AccordionItem>
|
||||
<AccordionItem value="item-2">...</AccordionItem>
|
||||
</Accordion>
|
||||
```
|
||||
|
||||
**Correct (radix):**
|
||||
|
||||
```tsx
|
||||
<Accordion type="single" collapsible defaultValue="item-1">
|
||||
<AccordionItem value="item-1">...</AccordionItem>
|
||||
</Accordion>
|
||||
```
|
||||
@@ -0,0 +1,195 @@
|
||||
# Component Composition
|
||||
|
||||
## Contents
|
||||
|
||||
- Items always inside their Group component
|
||||
- Callouts use Alert
|
||||
- Empty states use Empty component
|
||||
- Toast notifications use sonner
|
||||
- Choosing between overlay components
|
||||
- Dialog, Sheet, and Drawer always need a Title
|
||||
- Card structure
|
||||
- Button has no isPending or isLoading prop
|
||||
- TabsTrigger must be inside TabsList
|
||||
- Avatar always needs AvatarFallback
|
||||
- Use Separator instead of raw hr or border divs
|
||||
- Use Skeleton for loading placeholders
|
||||
- Use Badge instead of custom styled spans
|
||||
|
||||
---
|
||||
|
||||
## Items always inside their Group component
|
||||
|
||||
Never render items directly inside the content container.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<SelectContent>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
</SelectContent>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
```
|
||||
|
||||
This applies to all group-based components:
|
||||
|
||||
| Item | Group |
|
||||
|------|-------|
|
||||
| `SelectItem`, `SelectLabel` | `SelectGroup` |
|
||||
| `DropdownMenuItem`, `DropdownMenuLabel`, `DropdownMenuSub` | `DropdownMenuGroup` |
|
||||
| `MenubarItem` | `MenubarGroup` |
|
||||
| `ContextMenuItem` | `ContextMenuGroup` |
|
||||
| `CommandItem` | `CommandGroup` |
|
||||
|
||||
---
|
||||
|
||||
## Callouts use Alert
|
||||
|
||||
```tsx
|
||||
<Alert>
|
||||
<AlertTitle>Warning</AlertTitle>
|
||||
<AlertDescription>Something needs attention.</AlertDescription>
|
||||
</Alert>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Empty states use Empty component
|
||||
|
||||
```tsx
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon"><FolderIcon /></EmptyMedia>
|
||||
<EmptyTitle>No projects yet</EmptyTitle>
|
||||
<EmptyDescription>Get started by creating a new project.</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button>Create Project</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Toast notifications use sonner
|
||||
|
||||
```tsx
|
||||
import { toast } from "sonner"
|
||||
|
||||
toast.success("Changes saved.")
|
||||
toast.error("Something went wrong.")
|
||||
toast("File deleted.", {
|
||||
action: { label: "Undo", onClick: () => undoDelete() },
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Choosing between overlay components
|
||||
|
||||
| Use case | Component |
|
||||
|----------|-----------|
|
||||
| Focused task that requires input | `Dialog` |
|
||||
| Destructive action confirmation | `AlertDialog` |
|
||||
| Side panel with details or filters | `Sheet` |
|
||||
| Mobile-first bottom panel | `Drawer` |
|
||||
| Quick info on hover | `HoverCard` |
|
||||
| Small contextual content on click | `Popover` |
|
||||
|
||||
---
|
||||
|
||||
## Dialog, Sheet, and Drawer always need a Title
|
||||
|
||||
`DialogTitle`, `SheetTitle`, `DrawerTitle` are required for accessibility. Use `className="sr-only"` if visually hidden.
|
||||
|
||||
```tsx
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Profile</DialogTitle>
|
||||
<DialogDescription>Update your profile.</DialogDescription>
|
||||
</DialogHeader>
|
||||
...
|
||||
</DialogContent>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Card structure
|
||||
|
||||
Use full composition — don't dump everything into `CardContent`:
|
||||
|
||||
```tsx
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Team Members</CardTitle>
|
||||
<CardDescription>Manage your team.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>...</CardContent>
|
||||
<CardFooter>
|
||||
<Button>Invite</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Button has no isPending or isLoading prop
|
||||
|
||||
Compose with `Spinner` + `data-icon` + `disabled`:
|
||||
|
||||
```tsx
|
||||
<Button disabled>
|
||||
<Spinner data-icon="inline-start" />
|
||||
Saving...
|
||||
</Button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TabsTrigger must be inside TabsList
|
||||
|
||||
Never render `TabsTrigger` directly inside `Tabs` — always wrap in `TabsList`:
|
||||
|
||||
```tsx
|
||||
<Tabs defaultValue="account">
|
||||
<TabsList>
|
||||
<TabsTrigger value="account">Account</TabsTrigger>
|
||||
<TabsTrigger value="password">Password</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="account">...</TabsContent>
|
||||
</Tabs>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Avatar always needs AvatarFallback
|
||||
|
||||
Always include `AvatarFallback` for when the image fails to load:
|
||||
|
||||
```tsx
|
||||
<Avatar>
|
||||
<AvatarImage src="/avatar.png" alt="User" />
|
||||
<AvatarFallback>JD</AvatarFallback>
|
||||
</Avatar>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Use existing components instead of custom markup
|
||||
|
||||
| Instead of | Use |
|
||||
|---|---|
|
||||
| `<hr>` or `<div className="border-t">` | `<Separator />` |
|
||||
| `<div className="animate-pulse">` with styled divs | `<Skeleton className="h-4 w-3/4" />` |
|
||||
| `<span className="rounded-full bg-green-100 ...">` | `<Badge variant="secondary">` |
|
||||
@@ -0,0 +1,192 @@
|
||||
# Forms & Inputs
|
||||
|
||||
## Contents
|
||||
|
||||
- Forms use FieldGroup + Field
|
||||
- InputGroup requires InputGroupInput/InputGroupTextarea
|
||||
- Buttons inside inputs use InputGroup + InputGroupAddon
|
||||
- Option sets (2–7 choices) use ToggleGroup
|
||||
- FieldSet + FieldLegend for grouping related fields
|
||||
- Field validation and disabled states
|
||||
|
||||
---
|
||||
|
||||
## Forms use FieldGroup + Field
|
||||
|
||||
Always use `FieldGroup` + `Field` — never raw `div` with `space-y-*`:
|
||||
|
||||
```tsx
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input id="email" type="email" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||
<Input id="password" type="password" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
```
|
||||
|
||||
Use `Field orientation="horizontal"` for settings pages. Use `FieldLabel className="sr-only"` for visually hidden labels.
|
||||
|
||||
**Choosing form controls:**
|
||||
|
||||
- Simple text input → `Input`
|
||||
- Dropdown with predefined options → `Select`
|
||||
- Searchable dropdown → `Combobox`
|
||||
- Native HTML select (no JS) → `native-select`
|
||||
- Boolean toggle → `Switch` (for settings) or `Checkbox` (for forms)
|
||||
- Single choice from few options → `RadioGroup`
|
||||
- Toggle between 2–5 options → `ToggleGroup` + `ToggleGroupItem`
|
||||
- OTP/verification code → `InputOTP`
|
||||
- Multi-line text → `Textarea`
|
||||
|
||||
---
|
||||
|
||||
## InputGroup requires InputGroupInput/InputGroupTextarea
|
||||
|
||||
Never use raw `Input` or `Textarea` inside an `InputGroup`.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<InputGroup>
|
||||
<Input placeholder="Search..." />
|
||||
</InputGroup>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
import { InputGroup, InputGroupInput } from "@/components/ui/input-group"
|
||||
|
||||
<InputGroup>
|
||||
<InputGroupInput placeholder="Search..." />
|
||||
</InputGroup>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Buttons inside inputs use InputGroup + InputGroupAddon
|
||||
|
||||
Never place a `Button` directly inside or adjacent to an `Input` with custom positioning.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<div className="relative">
|
||||
<Input placeholder="Search..." className="pr-10" />
|
||||
<Button className="absolute right-0 top-0" size="icon">
|
||||
<SearchIcon />
|
||||
</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
import { InputGroup, InputGroupInput, InputGroupAddon } from "@/components/ui/input-group"
|
||||
|
||||
<InputGroup>
|
||||
<InputGroupInput placeholder="Search..." />
|
||||
<InputGroupAddon>
|
||||
<Button size="icon">
|
||||
<SearchIcon data-icon="inline-start" />
|
||||
</Button>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Option sets (2–7 choices) use ToggleGroup
|
||||
|
||||
Don't manually loop `Button` components with active state.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
const [selected, setSelected] = useState("daily")
|
||||
|
||||
<div className="flex gap-2">
|
||||
{["daily", "weekly", "monthly"].map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
variant={selected === option ? "default" : "outline"}
|
||||
onClick={() => setSelected(option)}
|
||||
>
|
||||
{option}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||
|
||||
<ToggleGroup spacing={2}>
|
||||
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
|
||||
<ToggleGroupItem value="monthly">Monthly</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
```
|
||||
|
||||
Combine with `Field` for labelled toggle groups:
|
||||
|
||||
```tsx
|
||||
<Field orientation="horizontal">
|
||||
<FieldTitle id="theme-label">Theme</FieldTitle>
|
||||
<ToggleGroup aria-labelledby="theme-label" spacing={2}>
|
||||
<ToggleGroupItem value="light">Light</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
|
||||
<ToggleGroupItem value="system">System</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</Field>
|
||||
```
|
||||
|
||||
> **Note:** `defaultValue` and `type`/`multiple` props differ between base and radix. See [base-vs-radix.md](./base-vs-radix.md#togglegroup).
|
||||
|
||||
---
|
||||
|
||||
## FieldSet + FieldLegend for grouping related fields
|
||||
|
||||
Use `FieldSet` + `FieldLegend` for related checkboxes, radios, or switches — not `div` with a heading:
|
||||
|
||||
```tsx
|
||||
<FieldSet>
|
||||
<FieldLegend variant="label">Preferences</FieldLegend>
|
||||
<FieldDescription>Select all that apply.</FieldDescription>
|
||||
<FieldGroup className="gap-3">
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox id="dark" />
|
||||
<FieldLabel htmlFor="dark" className="font-normal">Dark mode</FieldLabel>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Field validation and disabled states
|
||||
|
||||
Both attributes are needed — `data-invalid`/`data-disabled` styles the field (label, description), while `aria-invalid`/`disabled` styles the control.
|
||||
|
||||
```tsx
|
||||
// Invalid.
|
||||
<Field data-invalid>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input id="email" aria-invalid />
|
||||
<FieldDescription>Invalid email address.</FieldDescription>
|
||||
</Field>
|
||||
|
||||
// Disabled.
|
||||
<Field data-disabled>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input id="email" disabled />
|
||||
</Field>
|
||||
```
|
||||
|
||||
Works for all controls: `Input`, `Textarea`, `Select`, `Checkbox`, `RadioGroupItem`, `Switch`, `Slider`, `NativeSelect`, `InputOTP`.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Icons
|
||||
|
||||
**Always use the project's configured `iconLibrary` for imports.** Check the `iconLibrary` field from project context: `lucide` → `lucide-react`, `tabler` → `@tabler/icons-react`, etc. Never assume `lucide-react`.
|
||||
|
||||
---
|
||||
|
||||
## Icons in Button use data-icon attribute
|
||||
|
||||
Add `data-icon="inline-start"` (prefix) or `data-icon="inline-end"` (suffix) to the icon. No sizing classes on the icon.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<Button>
|
||||
<SearchIcon className="mr-2 size-4" />
|
||||
Search
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<Button>
|
||||
<SearchIcon data-icon="inline-start"/>
|
||||
Search
|
||||
</Button>
|
||||
|
||||
<Button>
|
||||
Next
|
||||
<ArrowRightIcon data-icon="inline-end"/>
|
||||
</Button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## No sizing classes on icons inside components
|
||||
|
||||
Components handle icon sizing via CSS. Don't add `size-4`, `w-4 h-4`, or other sizing classes to icons inside `Button`, `DropdownMenuItem`, `Alert`, `Sidebar*`, or other shadcn components. Unless the user explicitly asks for custom icon sizes.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<Button>
|
||||
<SearchIcon className="size-4" data-icon="inline-start" />
|
||||
Search
|
||||
</Button>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<SettingsIcon className="mr-2 size-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<Button>
|
||||
<SearchIcon data-icon="inline-start" />
|
||||
Search
|
||||
</Button>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<SettingsIcon />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pass icons as component objects, not string keys
|
||||
|
||||
Use `icon={CheckIcon}`, not a string key to a lookup map.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
const iconMap = {
|
||||
check: CheckIcon,
|
||||
alert: AlertIcon,
|
||||
}
|
||||
|
||||
function StatusBadge({ icon }: { icon: string }) {
|
||||
const Icon = iconMap[icon]
|
||||
return <Icon />
|
||||
}
|
||||
|
||||
<StatusBadge icon="check" />
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
// Import from the project's configured iconLibrary (e.g. lucide-react, @tabler/icons-react).
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
function StatusBadge({ icon: Icon }: { icon: React.ComponentType }) {
|
||||
return <Icon />
|
||||
}
|
||||
|
||||
<StatusBadge icon={CheckIcon} />
|
||||
```
|
||||
@@ -0,0 +1,162 @@
|
||||
# Styling & Customization
|
||||
|
||||
See [customization.md](../customization.md) for theming, CSS variables, and adding custom colors.
|
||||
|
||||
## Contents
|
||||
|
||||
- Semantic colors
|
||||
- Built-in variants first
|
||||
- className for layout only
|
||||
- No space-x-* / space-y-*
|
||||
- Prefer size-* over w-* h-* when equal
|
||||
- Prefer truncate shorthand
|
||||
- No manual dark: color overrides
|
||||
- Use cn() for conditional classes
|
||||
- No manual z-index on overlay components
|
||||
|
||||
---
|
||||
|
||||
## Semantic colors
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<div className="bg-blue-500 text-white">
|
||||
<p className="text-gray-600">Secondary text</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<div className="bg-primary text-primary-foreground">
|
||||
<p className="text-muted-foreground">Secondary text</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## No raw color values for status/state indicators
|
||||
|
||||
For positive, negative, or status indicators, use Badge variants, semantic tokens like `text-destructive`, or define custom CSS variables — don't reach for raw Tailwind colors.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<span className="text-emerald-600">+20.1%</span>
|
||||
<span className="text-green-500">Active</span>
|
||||
<span className="text-red-600">-3.2%</span>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<Badge variant="secondary">+20.1%</Badge>
|
||||
<Badge>Active</Badge>
|
||||
<span className="text-destructive">-3.2%</span>
|
||||
```
|
||||
|
||||
If you need a success/positive color that doesn't exist as a semantic token, use a Badge variant or ask the user about adding a custom CSS variable to the theme (see [customization.md](../customization.md)).
|
||||
|
||||
---
|
||||
|
||||
## Built-in variants first
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<Button className="border border-input bg-transparent hover:bg-accent">
|
||||
Click me
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<Button variant="outline">Click me</Button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## className for layout only
|
||||
|
||||
Use `className` for layout (e.g. `max-w-md`, `mx-auto`, `mt-4`), **not** for overriding component colors or typography. To change colors, use semantic tokens, built-in variants, or CSS variables.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<Card className="bg-blue-100 text-blue-900 font-bold">
|
||||
<CardContent>Dashboard</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<Card className="max-w-md mx-auto">
|
||||
<CardContent>Dashboard</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
To customize a component's appearance, prefer these approaches in order:
|
||||
1. **Built-in variants** — `variant="outline"`, `variant="destructive"`, etc.
|
||||
2. **Semantic color tokens** — `bg-primary`, `text-muted-foreground`.
|
||||
3. **CSS variables** — define custom colors in the global CSS file (see [customization.md](../customization.md)).
|
||||
|
||||
---
|
||||
|
||||
## No space-x-* / space-y-*
|
||||
|
||||
Use `gap-*` instead. `space-y-4` → `flex flex-col gap-4`. `space-x-2` → `flex gap-2`.
|
||||
|
||||
```tsx
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input />
|
||||
<Input />
|
||||
<Button>Submit</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prefer size-* over w-* h-* when equal
|
||||
|
||||
`size-10` not `w-10 h-10`. Applies to icons, avatars, skeletons, etc.
|
||||
|
||||
---
|
||||
|
||||
## Prefer truncate shorthand
|
||||
|
||||
`truncate` not `overflow-hidden text-ellipsis whitespace-nowrap`.
|
||||
|
||||
---
|
||||
|
||||
## No manual dark: color overrides
|
||||
|
||||
Use semantic tokens — they handle light/dark via CSS variables. `bg-background text-foreground` not `bg-white dark:bg-gray-950`.
|
||||
|
||||
---
|
||||
|
||||
## Use cn() for conditional classes
|
||||
|
||||
Use the `cn()` utility from the project for conditional or merged class names. Don't write manual ternaries in className strings.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<div className={`flex items-center ${isActive ? "bg-primary text-primary-foreground" : "bg-muted"}`}>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
<div className={cn("flex items-center", isActive ? "bg-primary text-primary-foreground" : "bg-muted")}>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## No manual z-index on overlay components
|
||||
|
||||
`Dialog`, `Sheet`, `Drawer`, `AlertDialog`, `DropdownMenu`, `Popover`, `Tooltip`, `HoverCard` handle their own stacking. Never add `z-50` or `z-[999]`.
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
# Correctness gate: must pass after every edit. Fails fast on real breakage.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "==> go vet ./..."
|
||||
go vet ./... 2>&1 | tail -20
|
||||
|
||||
echo "==> go build ./..."
|
||||
go build ./... 2>&1 | tail -20
|
||||
|
||||
echo "==> golangci-lint run (repo config)"
|
||||
golangci-lint run 2>&1 | tail -20
|
||||
|
||||
# 全量单测(sqlite + miniredis,纯本地无需外部服务;2026-08-16 起全绿)
|
||||
echo "==> go test ./internal/... ./pkg/..."
|
||||
go test ./internal/... ./pkg/... 2>&1 | grep -E "^--- FAIL|^FAIL" | head -20 || true
|
||||
if go test ./internal/... ./pkg/... > /tmp/auto_gotest.log 2>&1; then
|
||||
:
|
||||
else
|
||||
tail -30 /tmp/auto_gotest.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 前端测试(vitest;2026-08-16 起全绿)
|
||||
echo "==> pnpm exec vitest run (frontend)"
|
||||
(cd frontend && node scripts/merge-i18n-fragments.mjs && pnpm exec vitest run --reporter=dot > /tmp/auto_vitest.log 2>&1) || {
|
||||
tail -30 /tmp/auto_vitest.log
|
||||
exit 1
|
||||
}
|
||||
|
||||
# SPDX license 头门禁(repo 自带约定)
|
||||
echo "==> make license-check"
|
||||
make license-check 2>&1 | grep "needs license" | head -10 || true
|
||||
if make license-check > /tmp/auto_license.log 2>&1; then
|
||||
:
|
||||
else
|
||||
tail -15 /tmp/auto_license.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 并发密集包 -race 门禁(2026-08-16 全仓 -race 清零后纳入,防回归;
|
||||
# frpc/frps 慢套件不含在此,另做全量周期验证)
|
||||
echo "==> go test -race (concurrency packages)"
|
||||
RACE_PKGS="./internal/apps/oauth/ ./internal/apps/openflare/tls/ ./internal/apps/openflare/uptimekuma/ ./internal/apps/upload/cache/ ./internal/repository/ ./pkg/cache/disk/ ./pkg/logger/ ./internal/infra/persistence/batchwriter/"
|
||||
if go test -race -count=1 $RACE_PKGS > /tmp/auto_race.log 2>&1; then
|
||||
:
|
||||
else
|
||||
grep -E "WARNING: DATA RACE|^--- FAIL|^FAIL" /tmp/auto_race.log | head -20
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: checks passed"
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
# Ideas backlog (代码质量)
|
||||
|
||||
## 已尝试并收尾(2026-08-16 会话,14 个实验,108→8)
|
||||
|
||||
- 生产代码 golangci 扩展集 13 类 linter 全量清理(modernize/perfsprint/
|
||||
errorlint/canonicalheader/usestdlibvars/intrange/wastedassign/errname/
|
||||
forcetypeassert/prealloc/gosec/recvcheck/exhaustive),剩余 8 处全部为
|
||||
有据可查的刻意保留项(telegram %v、3 处嵌套 struct omitempty、
|
||||
3 处 not-found 惯例、1 处 encoding/json 接收者混合)。
|
||||
- 测试代码质量维度(testifylint/usetesting/thelper)25→0。
|
||||
- 前端 eslint/tsc 0。
|
||||
- 修复中积累的工具经验:golangci-lint v2 `--fix` 的 import 管理不可靠,
|
||||
跑完必须 `goimports -w`;`--max-issues-per-linter=0` 才能拿到全量清单
|
||||
(默认 50 + max-same-issues=3 会掩盖重复模式);cyclop 与 exhaustive
|
||||
有张力(显式 case 计入复杂度)。
|
||||
|
||||
## 未来可深化方向(均经评估)
|
||||
|
||||
- 测试可运行性修复:`go test ./internal/...` 目前在 main 上就有失败
|
||||
(无本地 redis、frpc 进程测试 flaky)。修复这些环境问题后,可以把
|
||||
`go test` 加入 checks.sh,解锁 paralleltest/tparallel 维度
|
||||
(t.Parallel 提速 + 正确性,目前因共享状态+不可运行而放弃)。
|
||||
- frontend biome 格式漂移(76 文件):一次性 `make format` 提交,
|
||||
与质量修复分开做,不进基准。
|
||||
- fieldalignment:结构体内存布局优化,但会改变 JSON key 顺序且有
|
||||
位置字面量风险 —— 若做,需按文件人工核对,不进自动基准。
|
||||
- Go 1.26 新特性扫描:`go vet` 新分析器、golangci-lint 新 linter
|
||||
(如 recvcheck 之后的 new receivers 检查)随版本跟进。
|
||||
- 文档/示例代码(docs/、scripts/)质量:目前不在 golangci 范围(tests:false
|
||||
之外还有 scripts 目录),可用同一扩展集扫 scripts/ 下的 main.go。
|
||||
|
||||
## 会话收尾(2026-08-16,run #23 后)
|
||||
|
||||
- 已确认收敛:基准 5 维全下限、-race 全仓清零、双端测试全绿、发布构建可复现、
|
||||
config.example.yaml ↔ model.go 同步无漂移、无 flaky 测试。
|
||||
- 明确评估为不值得做的方向:paralleltest/tparallel(共享全局状态风险)、
|
||||
fieldalignment(JSON key 顺序变化)、biome 格式漂移(纯噪声)、
|
||||
frpc/frps 慢测试注入 backoff(为省 ~40s 改生产时序逻辑,不值)。
|
||||
- 未来如继续:可周期跑 `go test -race ./...` 全量(frpc/frps 慢套件);
|
||||
或前端 a11y 用 axe 做浏览器级审计(超出 eslint 静态规则)。
|
||||
|
||||
## 本会话新增(runs #39-#43)
|
||||
|
||||
已修复:
|
||||
- agent auth_cache negative 缓存无上限 → 10k 上限+过期清理(DoS 防护)
|
||||
- relay/flared 与 agent 三份重复 authenticateAccessToken → 共享 agent 版(负缓存共享,DB 压力下降)
|
||||
- websocket 三 hub:runWritePump 抽取、wsClientCore 嵌入(close/enqueue 单份)、broadcastAgent 合并
|
||||
- frps/frpc TOML 注入 → pkg/protocol/toml.go TOMLQuote 转义全部插值
|
||||
|
||||
评估后不修/暂缓:
|
||||
- cloudflare listMemberItems、config_version snapshot 证书循环的 N+1:管理端小 N 低频,
|
||||
加批量 repo API 属投机优化;若未来组员数量变大再做 ListZoneDomainsByIDs。
|
||||
- fatcontext ×3(oauth/upload/auth_source cache listener):别名赋值误报,非嵌套包装。
|
||||
- objectstore newOSSBackend/newWebDAVBackend 恒 nil error:跨后端工厂签名统一,刻意设计。
|
||||
- edge/updater assetNameForGOOSGOARCH 恒 "linux":跨平台预留参数,刻意泛化。
|
||||
- agent ResolverDirective explicitResolvers 原样插入 nginx conf:管理员配置属可信输入;
|
||||
若未来开放给低权限角色需加格式校验(IP 解析)。
|
||||
- pkg/render/openresty 管理端旋钮(ClientMaxBodySize 等)原样插值:管理员权限范围内。
|
||||
- frontend/settings/profile.tsx(858 行)超 AGENTS.md ~600 行指引:存量组件,拆分属
|
||||
纯重构无质量增益,暂缓;若后续要改该页面功能时顺手拆 components/。
|
||||
|
||||
## Run #44(全仓 -race 扫描)
|
||||
|
||||
- 发现并修复 upload/cache 监听器 DATA RACE:goroutine 读可变全局 db.Redis vs
|
||||
testhelper 清理置 nil。根因修复=启动时捕获 redisClient(oauth×2/repository×2
|
||||
同型监听器一并加固),StopUploadMetaCacheListener 补 done 等待。
|
||||
- 教训:testhelper 不能 import upload/cache(循环依赖);"捕获替代全局读"是
|
||||
无环的根因修法。
|
||||
- 全仓 -race 现为 0 竞争(internal/... + pkg/...);建议周期性重跑。
|
||||
|
||||
## LIKE 转义(本轮已修日志搜索 4 站点;同类遗留)
|
||||
|
||||
- 已修:analytics/node_access_log_filter.go、analytics/access_log_filter.go、
|
||||
logstore/postgres_store.go×2(PG/SQLite 加 ESCAPE '\',CH 用默认反斜杠转义)。
|
||||
新助手 pkg/util/like.go EscapeLike + 单测。
|
||||
- Run #47 已收尾全部 GORM 站点:upload.go keyword、user.go:73/76/188/229
|
||||
(含 OAuth uniqueUsername base 转义——外部输入含 _ 曾误报用户名冲突)、
|
||||
task_execution.go task_type 前缀。均加显式 ESCAPE '\'。
|
||||
- 刻意保留:upload.go:199 `image/%`(系统常量)、config_version.go:65(系统生成)。
|
||||
|
||||
## Run #48(后台 goroutine panic 防护,55db1c01)
|
||||
|
||||
- 全仓 20 处裸 go func() 零 recover → 新增 pkg/util/goroutine.go `Go(fn)`(recover +
|
||||
slog + debug.Stack,runtime.Caller 自动记录调用点无需手写名字),22 个站点全部收口
|
||||
(oauth/upload/system_config/auth_source 的嵌套 ctx-done watcher 也含)。
|
||||
- 教训:脚本括号深度匹配首轮会跳过嵌套内层 goroutine,需跑两轮;新 Go 文件必须先跑
|
||||
scripts/update_go_license.sh(license-check 会拦)。
|
||||
- 已过期记录:go test ./internal/... ./pkg/... 现全过(94 ok)——"main 上测试失败"
|
||||
不再成立。scripts/、docs/ 下 Go 文件用扩展 linter 扫过:0 issues。
|
||||
|
||||
## Run #50(发现型 linter 扫描,全证伪——勿重跑这些维度)
|
||||
|
||||
- errchkjson 12 处:全部为不可能失败的 json.Marshal(纯 string/int/[]string
|
||||
结构体;admin/logs/routers.go:131 与 waf/ip_group_sync.go:255 的 "unsafe type"
|
||||
是传递性保守标记,RawMessage/time.Time 内容来自必然成功的 marshal)。
|
||||
- spancheck 1 处(pkg/trace/trace.go:61):误报,helper 正常返回 span,
|
||||
唯一调用方 internal/infra/task/executor.go:242 有 defer span.End()。
|
||||
- unparam ×2(objectstore oss/webdav 恒 nil error):已在 #43 前评估为跨后端工厂签名统一。
|
||||
- 性能排查:正则全部包级编译(无函数内 MustCompile);包级 map 全为有界静态注册表;
|
||||
task AppendLog 走 DB 非内存累积;push escapeJSONString 用法正确。
|
||||
- 结论:Go 静态可发现的低垂果实已穷尽。剩余方向:frontend axe a11y 浏览器级审计、
|
||||
周期性 -race 重跑(上次 #49 干净)、运维类增长审查。
|
||||
|
||||
## Run #54(认证页 axe a11y 审计+修复,451ce525)
|
||||
|
||||
已修(复扫验证生效):
|
||||
- 布局级全局:sidebar 折叠按钮 aria-label、Sidebar role=navigation(region 18 节点/页清零)、
|
||||
header Kbd 对比度 text-foreground/70、空态/错误/加载 h3→p(heading-order 清零)。
|
||||
- 页面级:dashboard 4 个 Progress aria-label、users 分页 prev/next aria-label、
|
||||
admin/system 无内容 Tabs→aria-pressed 按钮组(aria-valid-attr-value critical 清零)。
|
||||
- / 与 /admin/system 现 axe 0 违规。
|
||||
|
||||
后续可做(页面级批量,工作量大):
|
||||
- admin 数据表格行内操作图标按钮(编辑/删除)与 Switch 开关无 aria-label —— 每张管理表逐个补;
|
||||
- muted 文本对比度(card description、radix tabs trigger、primary 按钮文字)—— shadcn 默认色在浅色主题下 axe 判 fail,改主题变量影响面大需设计确认。
|
||||
- 审计环境复用:后端 :3100 + CONFIG_PATH=/tmp/of-audit/config.yaml(sqlite)、docker redis --network host、
|
||||
pnpm dev --port 3002 WAVELET_BACKEND_URL=:3100;admin 密码 reset-passwd 重置。注意 :3000 是生产实例勿动。
|
||||
|
||||
## Run #54-#55(认证页 a11y 审计,两轮 keep)
|
||||
|
||||
已修复(浏览器 axe 复扫验证):
|
||||
- 全局布局:sidebar 折叠按钮 aria-label、Sidebar role=navigation、header Kbd 对比度、
|
||||
dashboard Progress aria-label、分页 prev/next、空态/加载 h3→p、admin/system Tabs→aria-pressed。
|
||||
- 主题级根因:--primary indigo-500(#6366f1) 白字对比度仅 4.27(AA 需 4.5) → indigo-600
|
||||
oklch(51.1% 0.262 276.966) ≈6.8,一处修复全站 contrast 清零。
|
||||
- 控件名:access-analytics 刷新、events-tab Switch/编辑/删除、openflare-ops Switch/Select/
|
||||
Input(htmlFor)/Textarea、table-browser/sql-console SelectTrigger;heading-order:眉题
|
||||
h4→p(cache-manager/user-detail-sheet)、卡片题 h3→p(task-manager/file-manager)。
|
||||
- 结果:dashboard、admin/system、admin/settings、admin/logs、admin/push、admin/tasks、
|
||||
admin/database、files 共 8 页 axe 0 违规。
|
||||
|
||||
审计方法(可复用):后端 :3100(CONFIG_PATH=/tmp/of-audit/config.yaml,sqlite,
|
||||
api_prefix 必须显式 /api)+ docker redis --network host(本机 bridge NAT 坏)+
|
||||
pnpm dev --port 3002 WAVELET_BACKEND_URL=:3100 + admin 密码经 reset-passwd 重置。
|
||||
axe 注入:eval 建 CDN script → Promise 轮询 window.axe → axe.run。
|
||||
教训:表单页异步渲染,须 wait≥5s 再扫否则漏报 label 规则;Radix SelectValue
|
||||
value='' 时 placeholder 不显示,combobox 无名需 aria-label 兜底。
|
||||
|
||||
## 剩余可做
|
||||
|
||||
- 抽查其余页面(websites/[zoneId]、origins/detail、responses 编辑器等富交互页)
|
||||
——contrast 已由主题修复覆盖,预期只剩个别控件名。
|
||||
- 周期性 go test -race ./... 全量重跑(上次干净为 run #49 后)。
|
||||
|
||||
## Run #56(富交互页抽查,keep,63e3b852)
|
||||
|
||||
- 扫描 11 页:websites/origins/proxy-routes/certificates/dns-accounts 直接 0 违规
|
||||
(indigo-600 主题修复已覆盖全站 contrast)。
|
||||
- 修复 3 处并复扫归零:
|
||||
1. cloudflare/components/sync-tasks-panel.tsx 状态筛选 SelectTrigger 加 aria-label
|
||||
(Radix SelectValue value='' 时 placeholder 不渲染,combobox 无名)。
|
||||
2. components/common/settings/access-token.tsx 安全提示 text-amber-600→amber-700
|
||||
(12px 小字对比度不足)。
|
||||
3. settings/notifications 面包屑页缺 h1 → sr-only h1。教训:h1 不能作为
|
||||
BreadcrumbList 子元素(axe list 规则报 list 语义破坏),须放 <Breadcrumb> 外;
|
||||
BreadcrumbPage 无 asChild 支持。
|
||||
- a11y 维度至此穷尽:累计 14 页 axe 全部 0 违规。
|
||||
|
||||
## Run #59(-shuffle=on 测试顺序随机化扫描,keep,b56f2763)
|
||||
|
||||
- 新维度:`go test -shuffle=on` 抓到 config_version 包测试顺序依赖——
|
||||
TestBuildOpenRestyConfigSnapshotOriginErrorPageDefaults 在 shuffle 下命中
|
||||
Custom 用例留在进程级 RAM 配置缓存的值(GetSystemConfigByGroup 未命中时
|
||||
ram.Set 回填,TTL 跨测试存活;:memory: DB + SetDB 换库不使缓存失效)。
|
||||
- 修复:setupOriginErrorPageSnapshotDB / setupConfigVersionTestDB 换 DB 前后
|
||||
接入既有 ram.ResetForTest()。包内 shuffle×8 + 全仓 shuffle 复扫全过。
|
||||
- 教训:默认源码顺序掩盖顺序依赖;-shuffle=on 是低成本周期扫描手段。
|
||||
全仓 -race(#58 后)同样干净。其余用 SetDB 的测试包如后续 shuffle 复发,
|
||||
同法接入 ResetForTest 即可。
|
||||
@@ -0,0 +1,60 @@
|
||||
{"type":"config","name":"前后端代码质量优化(符合最佳实践)","metricName":"total_issues","metricUnit":"","bestDirection":"lower"}
|
||||
{"run":1,"commit":"305d609","metric":108,"metrics":{"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":2,"golint_intrange":3,"golint_modernize":37,"golint_nilnil":3,"golint_perfsprint":18,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":107,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":36},"status":"checks_failed","description":"基线:总问题 108(golangci 107 + eslint 1)。checks 失败的唯一原因:repo 自带 golangci gate 有 2 个既有 gosec G115 问题(预期内,首次修复后即绿)。","timestamp":1786871594292,"segment":0,"confidence":null,"asi":{"hypothesis":"baseline","next_action_hint":"修复 internal/apps/edge/observability/linux.go 的 2 个 G115 gosec 问题后 checks.sh 才能通过;之后每次迭代即可正常 keep/discard"}}
|
||||
{"run":2,"commit":"f1f6bb8","metric":106,"metrics":{"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":37,"golint_nilnil":3,"golint_perfsprint":18,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":105,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":38},"status":"keep","description":"修复 internal/apps/edge/observability/linux.go 的 2 个 gosec G115 整数溢出转换:helper 改为接收 int64 b,用 gosec 认可的饱和乘法模式(uint64 域乘积 + 上界比较),去掉原 //nolint:gosec,语义不变(Bsize 恒为正)。repo 自带 gate 首次全绿。","timestamp":1786872064145,"segment":0,"confidence":null,"asi":{"hypothesis":"修复 gosec G115:multiplyUint64ToInt64 改为 accept int64 b 并采用 gosec 认可的饱和乘法模式","insight":"gosec G115 不接受分支上界证明(a > MaxInt64/b),但接受先算 uint64 乘积再 if v > MaxInt64 饱和的模式,无需 nolint","next_action_hint":"下一步批量清理 modernize(37)/perfsprint(18) 等自动可修复类别,用 golangci-lint --fix 后人工核对 diff"}}
|
||||
{"run":3,"commit":"b76f707","metric":74,"metrics":{"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":18,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":73,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":38},"status":"keep","description":"modernize 37→5(-32):interface{}→any、内置 max/min、slices/maps 辅助、strings.Cut/SplitSeq、strings.Builder(修复 mail.go O(n²) 拼接)。逐 hunk 核对语义等价;omitzero 冲突修复被自动跳过(wire 格式不变);手动清 4 处遗留 sort import + 2 处 QF1012。","timestamp":1786872502383,"segment":0,"confidence":17,"asi":{"hypothesis":"批量应用 modernize 自动修复(interface{}→any、max/min、slices.Sort/Contains、strings.Cut/SplitSeq、maps.Copy、strings.Builder)","insight":"golangci-lint --fix 会把 omitempty→omitzero 的冲突修复跳过(2个文件保留原 tag,wire 格式不变,好);但 fixer 会遗留未使用的 sort import,需手动清 4 处;mail.go 的 Builder 迁移附带暴露 2 个 QF1012,顺手用 fmt.Fprintf 修复。全部修复语义等价,已逐项核对 diff","next_action_hint":"剩余 modernize=5 应为 omitzero 冲突文件;下轮先处理 perfsprint(18):先看 --fix 是否安全再决定"}}
|
||||
{"run":4,"commit":"699e95f","metric":56,"metrics":{"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":55,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":47},"status":"keep","description":"perfsprint 18→0:strconv.Itoa/FormatInt/FormatUint/FormatBool 替代 fmt.Sprintf、无动词 fmt.Errorf→errors.New、纯字符串拼接。全部语义等价(已核对 diff)。修正 fixer 遗留的 import 问题(引入 goimports 统一整理)。","timestamp":1786872884713,"segment":0,"confidence":3.0588235294117645,"asi":{"hypothesis":"perfsprint --fix:%d→strconv.Itoa/FormatInt、%t→FormatBool、%s+const→拼接、无动词 Errorf→errors.New","insight":"重要:golangci-lint v2 fixer 的 import 管理不可靠(删除/添加 import 会出错,53 个文件中 5 处报 undefined)+ 遗留未用 import。已安装 goimports(repo make format 本来就需要它),对改动文件统一 goimports -w 修复。后续只要用 --fix 就要记得跑 goimports -w","next_action_hint":"剩余大头:errorlint(12)、canonicalheader(8)(usestdlibvars 同类)、recvcheck(7)、wastedassign(7)。errorlint 需手工逐处判断;先做 canonicalheader+usestdlibvars(自动可修复但要核对)"}}
|
||||
{"run":5,"commit":"d0414b4","metric":45,"metrics":{"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":7,"golint_total":44,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":38},"status":"keep","description":"canonicalheader 8→0 + usestdlibvars 3→0:header key 改为 Go 规范大小写(wire 格式本就如此,纯代码修正)、HTTP 方法常量替代字符串字面量。","timestamp":1786873098921,"segment":0,"confidence":2.1724137931034484,"asi":{"hypothesis":"canonicalheader+usestdlibvars --fix:Header key 统一规范大小写、GET/OPTIONS 等方法常量","insight":"GitHub header 修正前后的 wire 格式完全一致(Go 在 Set 时本来就会规范化),纯代码层面修正,零行为风险;下次遇到同类 100% 安全","next_action_hint":"剩余:errorlint(12) 需逐处人工判断(其中 3 处 err != context.Canceled、2 处 %v wrap、若干 ==/类型断言);recvcheck(7) 是模型接收者一致性;wastedassign(7) 删 TODO 赋值;intrange(3)/modernize(5)/nilnil(3)/prealloc(3)/forcetypeassert(3)/errname(1)/eslint(1)"}}
|
||||
{"run":6,"commit":"ce28f63","metric":38,"metrics":{"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":37,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":47},"status":"keep","description":"wastedassign 7→0:删除 7 处死初始化(snapshot.go 三连、push 三件套 content、format.go numStr),改 var 声明,零行为变化。","timestamp":1786873485497,"segment":0,"confidence":2.978723404255319,"asi":{"hypothesis":"wastedassign 7→0:删除 7 处死初始化(x := \"\" 后所有分支都赋值)改为 var 声明","insight":"replace 工具会归一化 replacement_text 的前导空白;对需要缩进的编辑直接用 sed/gofmt -w 处理更稳","next_action_hint":"剩余:errorlint(12)、recvcheck(7)、modernize(5)、intrange(3)、nilnil(3)、prealloc(3)、forcetypeassert(3)、errname(1)、eslint(1)"}}
|
||||
{"run":7,"commit":"288b74d","metric":33,"metrics":{"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":32,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":45},"status":"keep","description":"intrange 3→0 + modernize 5→3:for i:=0;i<len/N;i++ → range len/N(8 处);time.Time 字段 omitempty→omitzero(wire 输出一致);SplitSeq;min() 简化。刻意保留 lark.go omitzero(会改变 wire 行为)。","timestamp":1786873629461,"segment":0,"confidence":4.166666666666667,"asi":{"hypothesis":"intrange(3) + modernize 剩余(2 个 time.Time omitempty→omitzero + SplitSeq + min)","insight":"lark.go larkTextContent omitempty→omitzero 会改变 wire(普通 struct 无 IsZero,当前恒序列化,改后零值省略)—— 判定为行为变化,故意保留;time.Time 字段 omitempty/omitzero 输出一致,可安全替换","next_action_hint":"剩余:errorlint(12) 大头(3 处 != context.Canceled 需确认 runner 是否 wrap;%v→%w 2 处;若干 ==err / 类型断言);recvcheck(7);forcetypeassert(3);nilnil(3);prealloc(3);errname(1);eslint(1)"}}
|
||||
{"run":8,"commit":"86fad02","metric":22,"metrics":{"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":1,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":21,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":46},"status":"keep","description":"errorlint 12→1:3 处 cmd 入口 err!=context.Canceled→errors.Is(防御性,当前 runner 不 wrap 语义不变);2 处 strconv.NumError 断言、1 处 viper 断言、2 处 ==io.EOF、2 处 ==redis.Nil、1 处 ==gorm.ErrRecordNotFound→errors.As/Is;8 处 %v→%w 保留错误链。刻意保留 telegram.go 单处 %v(原始错误仅作上下文文本,wrap 会改变 errors.Is 匹配语义)。","timestamp":1786873923775,"segment":0,"confidence":4.195121951219512,"asi":{"hypothesis":"errorlint 12→1:errors.Is/As 替代 ==/类型断言(防御 wrap),%v→%w 保留错误链","insight":"errorlint 结果在并行分析时一度不稳定(可能文件缓存竞争),多跑一次确认;telegram.go 的 %v 是刻意保留原始 HTML 错误为文本(只 wrap fallbackErr),判定为合理例外,不计为负债。错误链保留(%w)对多错误组合消息(manager.go、restart_unix.go、service.go)是净收益,调用方无 Is 匹配这些次要错误","next_action_hint":"剩余:recvcheck(7)、forcetypeassert(3)、nilnil(3)、prealloc(3)、modernize(3=lark omitzero 刻意保留)、errname(1)、eslint(1)"}}
|
||||
{"run":9,"commit":"4ecec2c","metric":15,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":14,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":43},"status":"keep","description":"forcetypeassert 6→0(缓存 list 断言、relay/flared 中间件契约断言、图片压缩 flight 断言,全部带检查+安全失败路径);errname 1→0;prealloc 2 处(另 1 处与 repo mnd 冲突,用命名常量解决)。nilnil 保留(not-found/可选结果惯例,含接口契约注释)。","timestamp":1786874283774,"segment":0,"confidence":4.043478260869565,"asi":{"hypothesis":"forcetypeassert(6处) → 带检查断言(middleware 契约破坏时 Abort 401/返回错误);errname runtimeInitErr→errRuntimeInit;prealloc 2 处(uptimekuma、postgres replicas)","insight":"prealloc 与 repo mnd 门禁冲突(magic number 3):用命名常量 baseTracingOptionCount 同时满足两者;nilnil 5 处判定为合法 not-found/可选结果惯例(含接口注释契约 + 测试断言),全部保留;用 --max-issues-per-linter=0 拿全量清单避免被默认 50 截断误导","next_action_hint":"剩余:recvcheck(7) 接收者一致性(需逐模型判断)、eslint(1) exhaustive-deps、modernize(3=lark omitzero 刻意保留+2 处待查)、nilnil(3 刻意保留)、errorlint(1 刻意保留)"}}
|
||||
{"run":10,"commit":"73d8173","metric":9,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":45},"status":"keep","description":"recvcheck 7→1:6 个 GORM 模型 TableName 改为指针接收者(GORM 源码确认 reflect.New 判定 Tabler,兼容;模型单测通过)。MillisecondDuration 刻意保留(encoding/json 要求 Marshal 值/Unmarshal 指针的混合)。","timestamp":1786874445733,"segment":0,"confidence":4.304347826086956,"asi":{"hypothesis":"recvcheck 7→1:GORM 模型 TableName 值接收者→指针接收者,与其它方法一致","insight":"GORM schema.Parse 用 reflect.New(modelType) 判定 Tabler,指针接收者 TableName 完全兼容(已读 gorm 源码确认 + 模型单测通过);仓库中 (Model{}).TableName() 字面量调用都在未改的类型上,无破坏。MillisecondDuration 保留:MarshalJSON 值接收者是 json 对不可寻址值的行为保障,UnmarshalJSON 必须指针 —— 混合是 encoding/json 硬性要求","next_action_hint":"剩余:modernize(3,含 lark omitzero 刻意保留 + 2 处待查)、nilnil(3 刻意保留)、eslint(1 exhaustive-deps)、errorlint(1 刻意保留)。下一步查 modernize 剩余 2 处并修 eslint 的 hook 依赖"}}
|
||||
{"run":11,"commit":"111d290","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":38},"status":"keep","description":"eslint 1→0:pages-source-card useEffect 补 t 依赖(next-intl 稳定引用)。modernize 补 1 处 time.Time omitzero。剩余 8 全部为刻意保留项。","timestamp":1786874578893,"segment":0,"confidence":4.3478260869565215,"asi":{"hypothesis":"eslint 1→0:useEffect 依赖数组补 t(next-intl useTranslations 返回稳定引用,安全);modernize 补 1 处 time.Time omitempty→omitzero(输出一致)","insight":"modernize 剩余 3 处全部是嵌套 struct omitempty(client.go Release/Asset、lark.go Content)→ omitzero 会改变 wire,全部刻意保留。至此所有可安全修复的类别清零,剩余 8 个全部是有据可查的刻意保留项","next_action_hint":"剩余 8 全部刻意保留(errorlint 1 telegram、modernize 3 嵌套struct、nilnil 3 not-found、recvcheck 1 json)。下一轮做深化方向:测试代码质量(tests:false 之外)、或 golangci 附加 linter(gocritic 更多检查)作为新基准段"}}
|
||||
{"run":12,"commit":"e5f6b0a","metric":33,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":20,"golint_test_thelper":3,"golint_test_usetesting":2,"golint_test_total":25,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":37},"status":"keep","description":"基准扩展(文档化):新增测试代码质量维度 25 处(testifylint 20 + thelper 3 + usetesting 2),生产代码 8 处刻意保留不变。新基线 total=33。","timestamp":1786874744438,"segment":0,"confidence":4.878048780487805,"asi":{"hypothesis":"扩展基准到测试代码质量维度(testifylint 20 + thelper 3 + usetesting 2 = 25)","insight":"刻意排除 paralleltest/tparallel(共享 DB/redis 状态 + 本环境无法跑测试,t.Parallel 有风险)—— 这是范围扩展(抬高门槛),不是 gaming;基准定义已写入 prompt.md","next_action_hint":"修 25 处测试问题:float-compare 3(InDelta)、require-error 3、encoded-compare 1(JSONEq)、empty 3、contains 1、error-is-as 3、len 3、go-require-in-handler 2、t.Helper 3、os.MkdirTemp→t.TempDir 2"}}
|
||||
{"run":13,"commit":"63a24da","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":39},"status":"keep","description":"测试代码质量 25→0:assert↔require 一致性(fail-fast)、float 精确比较→InDelta、Equal(\"\",x)→Empty、Equal(len)→Len、errors.Is/As→ErrorIs/ErrorAs、JSON 字符串→JSONEq、handler goroutine 内 require→assert(真健壮性修复)、t.Helper()、os.MkdirTemp→t.TempDir()(符合 repo AGENTS 约束)。","timestamp":1786875177918,"segment":0,"confidence":4.3478260869565215,"asi":{"hypothesis":"修完测试代码质量维度 25 处(testifylint 20 + thelper 3 + usetesting 2)","insight":"批量修复过程揭示 testifylint 默认 max-same-issues=3 会掩盖重复模式(len(entries) 出现 4+ 次、float64(3) 4 次),需 --max-issues-per-linter=0 反复收敛;全部修复语义中性(assert↔require 仅 fail-fast 差异,InDelta/JSONEq/Empty/Len/ErrorIs 等价断言,t.Helper/t.TempDir 纯改善)。go-require 类(handler 内 require→assert)是真正的健壮性修复","next_action_hint":"测试维度清零。生产代码剩余 8 全部刻意保留。可选深化:gocritic 更多子检查/staticcheck 更多(SA 系列)扫描、或 biome check 格式一次性提交、或前端 a11y(eslint jsx-a11y 已含于 next core-web-vitals 默认关闭项)"}}
|
||||
{"run":14,"commit":"65c02ef","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":36},"status":"keep","description":"基准扩展 exhaustive(文档化)+ 12→0:枚举 switch 补显式 case(全部与现有 default 行为等价,fail-explicit 防未来枚举静默落入 default);source_tasks.go 为控制复杂度合并两个等价校验条件。","timestamp":1786875548060,"segment":0,"confidence":4.25531914893617,"asi":{"hypothesis":"基准扩展 exhaustive(12 处枚举 switch 显式化)+ 全量修复","insight":"12 处全部是 default 已正确处理、缺显式 case 的类型;补显式 case 仅为 fail-explicit(未来枚举新增不会静默落入 default)。source_tasks 补 case 后 Execute 复杂度 20→21 触发 cyclop,合并两个 ActionInvalid 条件(逻辑等价)降回 19。cyclop 与 exhaustive 的张力:显式 case 也计入复杂度","next_action_hint":"剩余 8 全为刻意保留。可再深化:sloglint 全量、govet 附加分析器、或前端 jsx-a11y/next 规则已有覆盖。也可将剩余 8 处文档化后收尾总结"}}
|
||||
{"run":15,"commit":"d7b8f44","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":37},"status":"keep","description":"修复 geoip/runtime.go 真死代码:ensureServerMMDB 的 os.Stat 错误被 if-init 遮蔽,`err != nil && !os.IsNotExist(err)` 恒为 false(外层 err 恒 nil),防御检查从未生效;改为显式捕获 statErr,stat 非 not-exist 错误现在正确返回。基准新增第 4 维度 govet nilness+unusedwrite(文档化扩展),当前 0。","timestamp":1786875949461,"segment":0,"confidence":4.166666666666667,"asi":{"hypothesis":"govet nilness 真实死代码 bug:ensureServerMMDB 的 stat 错误被 if-init 遮蔽,!os.IsNotExist(err) 恒为死条件(外层 err 恒 nil)","insight":"修复:显式捕获 statErr,使防御检查生效(stat 权限错误现在立即返回,不再静默吞掉后走 WriteFile 失败)。顺带基准扩展第 4 维度 govet nilness+unusedwrite(文档化,survey 过 fatcontext/containedctx/unparam/gocritic+29 检查:unparam 有 6+ 处真实死结果但需签名改动,留待下轮)","next_action_hint":"下轮候选:unparam(6+ 处 always-nil/never-used 结果,含 getSQLiteOverview/getPostgresOverview/getStatus 等,需改签名+调用方,churn 中等但都是真实死代码);或 fatcontext/containedctx(3+3 处,需逐处判断是否真反模式)"}}
|
||||
{"run":16,"commit":"c85373f","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":43},"status":"keep","description":"unparam 死代码清理 12→2(保留 2 处 objectstore 构造函数统一签名):移除 10 处恒 nil error / 从未使用的结果(getPoWConfigForRoute 的恒 nil *PoWConfig、getSQLiteOverview/getPostgresOverview/getStatus/loadKumaConfig/filterExpectedRoutes 的恒 nil error、rawJSONString/parsePositiveInt 的弃用 bool、buildProxyRoute 的弃用 []ZoneDomain、getLocked 的恒 nil error),同步简化 12+ 处调用方与死错误检查。9 个受影响包测试通过。metric 持平 8(改进在基准之外)。","timestamp":1786876191447,"segment":0,"confidence":5.128205128205129,"asi":{"hypothesis":"unparam 死代码清理:10 处 always-nil error / never-used 结果从签名移除","insight":"移除后调用方同步简化(db_manage 的 err 检查、option routers 的 AbortBadRequestOnError 成为死代码一并删)。getPoWConfigForRoute 的 *PoWConfig 结果恒 nil 且从未被用 —— 真死代码。保留 2 处 objectstore 构造函数 (X, error):factory switch 统一签名(newS3Backend/newLocalBackend 等可能真实报错),unparam 在此为接口一致性误报。全部 9 个受影响包测试通过。metric 持平 8(改进在基准之外,诚实记录)","next_action_hint":"下一候选:fatcontext(3 处嵌套 context 闭包,多为 slog/otel ctx 传递,需逐处判断是否真反模式) 或 containedctx(3 处 struct 含 ctx 字段,含 webdav/uptimekuma client —— 重构风险中等);或收尾把 unparam 加入基准(2 处已知保留)"}}
|
||||
{"run":17,"commit":"a16be01","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":40},"status":"keep","description":"修复 frpc 进程生命周期真 bug(agent 生产代码):exec.CommandContext 默认只杀直接子进程,被杀 shell 的孤儿 sleep 继续持有 stderr 管道,cmd.Wait() 阻塞到其自然退出(Stop/重启可挂起秒级)。改 Setpgid 进程组 + Kill(-pid) 整组击杀。连带修复两个测试 bug(Manager 拥有 Cmd 的并发 Wait 竞态 → Signal(0) 探测;ssl_renew 用 miniredis 替代 init() 创建的真实 redis 客户端)。go test ./internal/... ./pkg/... 全绿,checks.sh 升级为真实测试门禁。","timestamp":1786877266517,"segment":0,"confidence":7.142857142857143,"asi":{"hypothesis":"frpc 进程生命周期真 bug:exec.CommandContext 只杀直接子进程,孤儿孙进程持有 stderr 管道导致 cmd.Wait 阻塞到其自然退出(实测脚本 sleep 5 时 Stop 挂起 5s)","insight":"修复:Setpgid 独立进程组 + cmd.Cancel 覆盖为 Kill(-pid,SIGKILL) 整组击杀(经隔离复现 + 临时插桩定位,4 次假设检验收敛)。连带修复两个测试 bug:TestStopCancelsRunningProcesses 对 Manager 拥有的 Cmd 并发 Wait(与 os/exec ctxResult 通道竞争永久挂起)改为 Signal(0) 探测;ssl_renew 测试改用 miniredis(task 包 init() 创建真实 redis 客户端,违反 repo 无 init 装配约束)。成果:go test ./internal/... ./pkg/... 从 3 个失败→全绿(81+13 包),checks.sh 升级为真实测试门禁。metric 持平 8(改进在基准之外,但价值最高的一轮)","next_action_hint":"测试全绿后可解锁:paralleltest/tparallel 维度(t.Parallel 提速)——需先评估共享状态(miniredis/sqlite 每测试独立,风险低);或探索 relay/frps 同构代码是否有同样的 group-kill 问题(frps/manager 结构相同,值得检查)"}}
|
||||
{"run":18,"commit":"f5c9da0","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":39},"status":"keep","description":"前端测试套件 44 失败→全绿:10 个测试文件补 NextIntlClientProvider 包装(含 React19 createElement 类型修复、.ts→.tsx 重命名);修复真实 i18n ICU bug(githubUrlInvalid 的 {owner}/{repo} 未转义导致生产渲染成 key,zh/en + fragment 4 文件同步转义);更新 2 处过期测试期望。vitest 116/116 + tsc + eslint 全绿,checks.sh 增加前端测试门禁。","timestamp":1786878501539,"segment":0,"confidence":9.523809523809524,"asi":{"hypothesis":"前端测试可运行性:next-intl 迁移后 44/116 测试失败(缺 NextIntlClientProvider + 3 处真实断言问题)","insight":"修复三类:(1) 10 个测试文件的 render 助手缺 NextIntlClientProvider(createElement 与 JSX 混用踩 React19 类型坑,.ts 文件不能写 JSX → 重命名为 .tsx);(2) 真实 i18n bug:githubUrlInvalid 消息的 {owner}/{repo} 被 ICU 当占位符,t() 无参调用渲染成 key —— 需 '{' 单引号转义('{}' 内层转义不够,必须整体引号包裹 '{owner}'),4 个消息文件(zh/en + fragment 源)同步修复,check:i18n 通过;(3) 2 处测试期望过期(唯一访问者→查询窗口独立访客、检查间隔→检查间隔(分钟),以消息文件为准)。成果:116/116 vitest + tsc/eslint 全绿,checks.sh 增加前端测试门禁","next_action_hint":"前端测试全绿后可把 vitest 失败数纳入基准(当前不在基准内);或检查 app/(main) 目录下 3 个自带 .test.tsx(waf editor 系列)是否也符合新约定"}}
|
||||
{"run":19,"commit":"c455be3","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":62},"status":"keep","description":"基准扩展第 5 维度(文档化):前端 vitest 失败数纳入 total_issues(vitest_failed=0, total=116)。5 维全部处于下限,total=8 不变。","timestamp":1786878719509,"segment":0,"confidence":14.285714285714286,"asi":{"hypothesis":"基准扩展第 5 维度:前端 vitest 失败数(全绿后纳入防回归,文档化范围扩展非作弊)","insight":"measure_s 从 39s 升到 62s(vitest ~20s + eslint 冷启动),可接受。5 个维度全部在其下限:生产 8(全刻意保留)+ 测试 0 + govet 0 + eslint/tsc 0 + vitest 0","next_action_hint":"基准已 5 维全下限。后续可深化:paralleltest(现在测试可跑,但共享全局状态风险仍在,低优先);或 frontend biome 格式一次性提交(不进基准);或前端组件更深规则(jsx-a11y 已在 next core-web-vitals 覆盖)。也可认为会话到达稳定收尾点,更新 prompt/ideas 后总结"}}
|
||||
{"run":20,"commit":"4962bf9","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":86},"status":"keep","description":"两处真实质量修复:(1) 过期 swagger 文档重新生成(status_2xx/4xx/5xx_count 字段随 a4dd5ca9 加入后未同步 docs,违反 repo 约定,swag init 后差异仅真实新增字段);(2) generate-themes.js 输出补尾换行,themes.json 构建可复现(此前每次 build 弄脏工作树)。验证 next build 成功、musttag/tagalign 调查无真实问题。","timestamp":1786879144888,"segment":0,"confidence":25,"asi":{"hypothesis":"验证生产构建 + 修两处真实质量问题:swagger 文档过期(status_2xx/4xx/5xx_count 新增字段未重新生成)与 themes.json 构建不可复现(generate-themes.js 缺尾换行,每次 build 弄脏工作树)","insight":"next build 成功(无构建问题);musttag 3 处与 tagalign 均判定为非问题(持久化 round-trip 自洽/调试日志/纯格式)。swagger 差异仅 27 行且全部真实(a4dd5ca9 状态码拆分字段)。generate-themes.js 补 '\\n' 后 themes.json 再生与提交版完全一致,构建可复现。metric 持平 8(改进在基准之外)","next_action_hint":"会话已 5 维全下限 + 构建可复现 + 双端测试全绿。收尾候选:更新 prompt/ideas 记录本轮成果后总结;或继续验证 swag 生成的 docs.go 在 CI 中的可复现性"}}
|
||||
{"run":21,"commit":"e1b439d","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":74},"status":"keep","description":"全仓 go test -race 扫描(93 包)→ 全绿。修复 6 类数据竞争:frpc/frps 测试的锁外读与并发 Wait;oauth/repository 4 个 Pub/Sub 监听器 goroutine 读可变包变量(局部捕获 + done 通道等待);oauth 测试换 db.Redis 前停监听器;【真实生产 bug】tls 响应快照与异步续签 goroutine 并发写 cert 竞争(先快照再起 goroutine);upload/cache 监听器 goroutine 内读 db.Redis(调用方捕获)。","timestamp":1786881379399,"segment":0,"confidence":100,"asi":{"hypothesis":"全仓 -race 扫描:发现并修复 6 类数据竞争,含 1 个真实生产竞争","insight":"修复清单:(1) frpc 测试助手锁外读 proc.Status;(2) frps 测试对 Manager 拥有的 Cmd 并发 Wait(与 frpc 同类);(3) oauth/repository 4 个 Pub/Sub 监听器 goroutine 内读可变包变量 userListenerCtx 等 → 局部捕获 + stop 增加 done 通道等待;(4) oauth 测试 setupTestRouter 换 db.Redis 前先停各层监听器;(5)【真实生产 bug】tls logics 的 sanitizeCertificateForResponse 整体拷贝 cert 与异步续签 goroutine 字段写入并发 → 先快照再起 goroutine;(6) upload/cache 监听器 goroutine 内读 db.Redis → 调用方捕获。成果:93 包 go test -race 全绿。基准 5 维全下限不变(改进在基准外,但 tls 竞争是生产级真实问题)","next_action_hint":"可考虑把 -race 纳入周期性验证(不进每次 checks,全仓 ~3 分钟);或在 prompt/ideas 记录本轮成果"}}
|
||||
{"run":22,"commit":"ab70633","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":68},"status":"keep","description":"checks.sh 新增并发密集包 -race 门禁(8 个快速包,全仓 -race 清零后纳入防回归;frpc/frps 慢套件留作周期全量验证)。核查 7 处 t.Skip 均为合法环境门控。","timestamp":1786881700477,"segment":0,"confidence":200,"asi":{"hypothesis":"把 -race 门禁纳入 checks.sh(并发密集包子集,全仓 -race 清零后防回归)","insight":"选 8 个快速并发包(oauth/tls/uptimekuma/upload-cache/repository/disk-cache/logger/batchwriter,约 20s),排除 frpc/frps 慢套件(15s backoff + 25s supervisor,另做周期全量验证)。checks 总耗时 ~60s 可接受。顺带核查 7 处 t.Skip 均为合法环境门控(PG/CH 集成、symlink 平台差异),无隐藏损坏。metric 持平 8","next_action_hint":"会话已达全面收敛:5 维基准全下限 + 全仓 -race 清零 + 双端测试全绿 + 构建可复现 + -race 门禁入 checks。可收尾:更新 prompt/ideas 后总结"}}
|
||||
{"run":23,"commit":"aa4fadd","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":81},"status":"keep","description":"补齐 131 个 .go 文件的 SPDX license 头(repo 自带 make license 约定,早于约定新增的文件含 2 个生产文件;纯注释插入零行为影响),make license-check 转绿。go mod tidy -diff 确认干净。","timestamp":1786882164158,"segment":0,"confidence":null,"asi":{"hypothesis":"跑 repo 自带门禁 make license-check:发现 131 个 .go 文件缺 SPDX 头(早于 license 约定,含 2 个生产文件与多份本会话早期改过的文件)","insight":"用 repo 自带 scripts/update_go_license.sh(make license)自动补头(391 行纯注释插入),license-check 转绿;go mod tidy -diff 干净。gofmt/build/全测试/-race 均通过。metric 持平 8","next_action_hint":"会话全维度收敛。收尾:把 license-check 加入 checks.sh 防再犯;更新 prompt/ideas 最终状态"}}
|
||||
{"run":24,"commit":"4d78bc1","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":83},"status":"keep","description":"收敛验证轮:3× 时序敏感包测试无 flake;config.example.yaml ↔ model.go 同步确认无漂移(scheduler 空结构、replicas 注释化、queues[].name 嵌套均为误报);make build-embedded 发布路径构建成功且工作树干净(bin/、frontend/out 已 gitignore)。更新 prompt/ideas 记录会话终态。metric 持平 8。","timestamp":1786882694298,"segment":0,"confidence":null,"asi":{"hypothesis":"会话收敛验证:跑 3× 时序敏感包(无 flake)、config.example.yaml↔model.go 同步(3 个疑似缺失均为误报:scheduler 空结构/replicas 注释化/queues[].name 嵌套)、make build-embedded 发布路径(成功且树干净)","insight":"全部为确认性结果,无新问题。诚实记录:本轮零代码改动(上一轮已修 license),metric 持平 8。会话正式收敛:基准 5 维全下限 + 全仓 -race 清零 + 双端测试全绿 + 发布可复现 + config/文档同步。prompt/ideas 已更新终态","next_action_hint":"会话已收敛。若继续:周期全量 -race、前端 axe 浏览器级 a11y 审计(ideas.md 已记录)"}}
|
||||
{"run":25,"commit":"7f8e257","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":118,"measure_s":71},"status":"keep","description":"前端真实 a11y 审计:新增 axe-core(devDep)+ tests/a11y.test.tsx,对登录页与注册页渲染完整表单后运行 axe 结构性规则(label/button-name/heading-order/landmark/aria),两页均零违规。摸清并处理了渲染依赖(UserProvider 会话检查、publicConfigQuery 门控、configBool 字符串语义)。vitest 116→118 全绿。","timestamp":1786884595141,"segment":0,"confidence":null,"asi":{"hypothesis":"前端真实 a11y 审计:axe-core(jsdom 结构性规则)覆盖登录/注册页,超出 eslint 静态 jsx-a11y 的动态可访问性验证","insight":"新增 tests/a11y.test.tsx(2 测试)+ axe-core devDependency。调试中摸清登录/注册页渲染依赖链(UserProvider 挂载跳查 getUserInfo、LoginForm/RegisterForm 门控 publicConfigQuery、configBool 期望字符串 'true' 而非布尔 —— mock 需给字符串)。两页均零 axe 违规(color-contrast 因 jsdom 无布局引擎禁用,文档化)。vitest 116→118,checks 全绿。metric 持平 8","next_action_hint":"可扩展 axe 到更多页面(如登录 OTP 态、设置页),或收尾。axe 依赖仅 devDependency,不进基准计数(vitest_failed 已含新测试)"}}
|
||||
{"run":26,"commit":"7d03154","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":120,"measure_s":71},"status":"keep","description":"axe a11y 审计扩展到登录 OTP 验证表单(input-otp 分段输入,FieldLabel htmlFor 正确关联,零违规)与人机验证小部件手动模式(零违规)。环境修复:tests/setup.ts 加 ResizeObserver mock(input-otp 依赖,jsdom 未内置)。vitest 118→120 全绿。","timestamp":1786885103995,"segment":0,"confidence":null,"asi":{"hypothesis":"把 axe a11y 审计扩展到登录/注册外的关键表单状态:登录 OTP 验证(input-otp 分段输入是经典 a11y 雷区)与人机验证小部件(CapWidget 手动模式)","insight":"新增 2 个 axe 测试(vitest 118→120)。OTPForm 验证通过 —— FieldLabel htmlFor='otp-verification' 与 InputOTP id 正确关联,分段输入零违规;CapWidget 手动模式零违规。环境修复:input-otp 依赖 ResizeObserver,jsdom 未内置 → tests/setup.ts 加 ResizeObserver mock(全局,其它测试无害)。渲染上下文修正:裸组件需 <main> landmark 包裹(region 规则,真实页面由 AuthShell 提供)。scripts/ Go 代码用扩展 linter 集扫描为 0 问题(死路,未纳入基准)。metric 持平 8","next_action_hint":"a11y 覆盖已达:登录/注册页/OTP/CapWidget 全零违规。剩余页面(设置页、admin)需较重 mock。会话可收尾,或周期跑全量 -race 验证无 flake"}}
|
||||
{"run":27,"commit":"6c128e0","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":121,"measure_s":71},"status":"keep","description":"axe a11y 审计扩展到最复杂认证路径:注册页开启人机验证(CapWidget 自动求解→已通过状态 + 完整表单),mock getCapToken 避免 jsdom 无 Worker 环境限制。零违规。vitest 120→121 全绿。","timestamp":1786885536618,"segment":0,"confidence":null,"asi":{"hypothesis":"axe 审计覆盖最复杂的认证渲染路径:注册页开启人机验证(CapWidget 自动求解→已通过状态 + 完整表单)","insight":"新增第 5 个 axe 测试(vitest 120→121):mock @/lib/cap-solver 的 getCapToken 直接 resolve token(避免 jsdom 无 Web Worker),config 开 cap_login_enabled/cap_auto_solve,注册页渲染出 CAPTCHA 已通过态 + 表单全字段 → 零违规。vi.mock('@/lib/cap-solver') 对其它测试无害(仅 capEnabled 时渲染 CapWidget)。metric 持平 8","next_action_hint":"axe 覆盖已达 5 个认证表单态(登录/注册/OTP/验证小部件手动/注册+验证)。剩余:设置页与 admin 页需较重 mock。可收尾,或周期跑全量 -race 验证无 flake"}}
|
||||
{"run":28,"commit":"40eee77","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":121,"measure_s":0},"status":"keep","description":"前端显式 any 类型清理 2→0:Slot children?: any → ReactNode | MotionValue 联合(motion 真实类型),顺带修复潜在崩溃(原代码在 isValidElement 前访问 children.type,缺失时 TypeError,现无效 children 返回 null,hooks 无条件合规);useControlledState Rest extends any[] → unknown[]。两处 eslint-disable 注释删除。tsc/eslint/vitest 121 全绿。","timestamp":1786886086713,"segment":0,"confidence":null,"asi":{"hypothesis":"前端显式 any 类型清理:全仓 grep 仅 2 处 any —— Slot children?: any 与 useControlledState 的 Rest extends any[],均为真实类型缺陷","insight":"全前端 any 计数 2→0。slot.tsx:children?: any → React.ReactNode | MotionValue<string> | MotionValue<number>(motion HTMLMotionProps 的真实 children 类型);顺带修复潜在崩溃 —— 原代码在 isValidElement 检查前就访问 children.type,children 缺失时 TypeError,改为 isValidChild/childrenType 先计算(hooks 无条件,rules-of-hooks 合规),无效 children 返回 null。use-controlled-state.tsx:Rest extends any[] → unknown[]。两处 eslint-disable no-explicit-any 注释随之删除(无抑制注释)。tsc/eslint/vitest 121/checks.sh 全绿。benchmark 无关(metric 持平 8)。注意:run #28 的 run_experiment 被用户中断(aborted),但代码修复已通过全部门禁验证","next_action_hint":"用户要求合并到 main 并推送"}}
|
||||
{"run":29,"commit":"511bed8","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":63,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":124},"status":"keep","description":"修复 2 个新增 unconvert 问题(linux.go 中 int64(stat.Bsize) 恒等转换,Statfs_t.Bsize 在 Linux 上本就是 int64),删除多余转换零行为变化;total 10→8 回到 5 维全下限。","timestamp":1786894372432,"segment":0,"confidence":null,"asi":{"category":"unconvert","hypothesis":"会话恢复后 measure 显示 total=10,出现 2 个新的 unconvert 问题(internal/apps/edge/observability/linux.go:261-262 的 int64(stat.Bsize) 恒等转换,Linux Statfs_t.Bsize 本就是 int64)。删除多余转换,零行为变化","finding":"unconvert 是 repo 自带配置启用的 linter,此前 baseline 无此问题,最近用户提交/Go 版本变化后新增;修复后 5 维回到全下限 8","next_action_hint":"会话恢复点确认:total=8(5 维全下限,8 项均为有据可查的刻意保留)。下一轮候选:静态检查新维度(staticcheck SA 系列在 repo 配置中已启用且为 0)、或把 docs/ 下 vitepress 站点的构建纳入 measure 防回归(docs build 不属质量计数,不进基准)"}}
|
||||
{"run":30,"commit":"d49c7e1","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":183,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"checks_failed","description":"Agent 发现 Token 比较改为 SHA-256 后恒定时间 Compare,堵住未授权节点注册口的计时侧信道。checks 在 -race 阶段超时(包本身已单独跑绿)。","timestamp":1787667218065,"segment":0,"confidence":null,"asi":{"hypothesis":"discovery token 用 != 比较,未授权 /agent/nodes/register 可被计时;改 SHA-256 + ConstantTimeCompare","rollback_reason":"checks.sh 在 go test -race 阶段 300s 超时(包单独跑全绿,预算不够)","next_action_hint":"同一修复用 checks_timeout_seconds=600 重跑"}}
|
||||
{"run":31,"commit":"69055a9","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":71,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"未授权 Agent 注册口的 discovery token 改为 SHA-256 后恒定时间比较,堵住计时侧信道;空 token / 末字节翻转用例同步补上。metric 持平 8。","timestamp":1787667401636,"segment":0,"confidence":null,"asi":{"hypothesis":"discovery token 用 != 比较,未授权 /agent/nodes/register 可被计时;改 SHA-256 + ConstantTimeCompare","finding":"公开面注册口 ValidateDiscoveryToken 是入侵入口;管理员已登录操作不在范围内。checks 全绿。","next_action_hint":"下一轮可查边缘 Token 比较(agent/relay/flared 走 DB 查找,计时面更弱)或登录口限流"}}
|
||||
{"run":32,"commit":"fb62802","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":81,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"公开登录/注册邮箱验证码比较改为 SHA-256 后恒定时间 Compare,堵住未授权口的计时侧信道。metric 持平 8。","timestamp":1787667660993,"segment":0,"confidence":null,"asi":{"hypothesis":"verifyEmailCode 用 != 比较 6 位码,公开登录/注册口可被计时","finding":"公开面验证码比较已改恒定时间;冷却仍在,不改限流策略。","next_action_hint":"下一轮可查边缘节点 access_token 比较(DB 查找,计时面更弱)或登录失败锁定"}}
|
||||
{"run":33,"commit":"b8bf82b","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":99,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"未授权登录口补哑 bcrypt 比较,用户不存在与密码错误耗时对齐;禁用账号不再返回不同文案,堵住用户枚举。metric 持平 8。","timestamp":1787668237322,"segment":0,"confidence":null,"asi":{"hypothesis":"未授权 /user/login 在用户不存在时跳过 bcrypt,且禁用账号返回不同文案,可枚举用户","finding":"DummyCheckPassword 启动时生成哑哈希,gosec 不报警;禁用账号改统一错误文案。管理员已登录不在范围内。","next_action_hint":"下一轮可查边缘节点 access_token 明文比较,或公开 CAP challenge 滥用"}}
|
||||
{"run":34,"commit":"380a42a","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":90,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"登录/注册/OAuth 回调统一走 SetLoginSession,保存前清空 Redis 会话 ID,堵住未授权会话固定。metric 持平 8。","timestamp":1787669059138,"segment":0,"confidence":null,"asi":{"hypothesis":"生产 Redis 会话在登录时复用同一 ID,未授权方可固定会话 cookie","finding":"SetLoginSession 先 Clear 再把 gorilla session.ID 置空,Save 时 redistore 生成新 ID;明文改密标记经 extras 写回。","next_action_hint":"下一轮可查边缘节点 access_token 明文比较,或公开 CAP challenge 滥用"}}
|
||||
{"run":35,"commit":"dfda2d3","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":75,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"去掉公开 CAP 口硬编码默认密钥;SessionSecret 为空时拒绝签发/核销,防止未授权伪造 PoW。metric 持平 8。","timestamp":1787669542055,"segment":0,"confidence":null,"asi":{"hypothesis":"公开 /api/cap/challenge 在 SessionSecret 为空时用硬编码默认密钥,未授权方可伪造 PoW","finding":"GetDefaultManager 无密钥时返回 nil;Challenge/Redeem 拒绝,VerifyMiddleware 在 CAP 开启时同样拒绝。测试自行设置密钥。","next_action_hint":"下一轮可查公开 OAuth state 洪水或边缘节点 access_token 明文比较"}}
|
||||
{"run":36,"commit":"7fa9e46","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":86,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"注册开关读取失败时改为关闭,堵住配置缺失时未授权开注册;OAuth 自动注册同样 fail-closed。metric 持平 8。","timestamp":1787669960693,"segment":0,"confidence":null,"asi":{"hypothesis":"registration_enabled/password_register_enabled 读取失败默认 true,和种子 false 相反,配置缺失时未授权开注册","finding":"密码注册与 OAuth 自动注册均 fail-closed;测试改为显式开启注册并正确失效缓存。","next_action_hint":"下一轮可查 OIDC 开关 fail-open(种子默认 true,风险较低)或公开 OAuth state 洪水"}}
|
||||
{"run":37,"commit":"0290c93","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":95,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"公开 OAuth 登录/授权入口按会话限制 10 分钟内最多 20 个 state,堵住未授权 Redis 洪水。metric 持平 8。","timestamp":1787670327304,"segment":0,"confidence":null,"asi":{"hypothesis":"公开 /oauth/login 与 /oauth/{source}/authorize 每次请求都往 Redis 写 10 分钟 state,无上限","finding":"按 sessionHash 计数,10 分钟内最多 20 个;超出返回业务错误。mock Redis 补 Incr/Expire。","next_action_hint":"下一轮可查边缘节点 access_token 明文比较,或公开 CAP challenge 洪水"}}
|
||||
{"run":38,"commit":"c0a82f8","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":85,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"公开密码登录口按 IP 限制 10 分钟内最多 20 次失败,堵住未授权爆破。metric 持平 8。","timestamp":1787670665553,"segment":0,"confidence":null,"asi":{"hypothesis":"公开 /user/login 失败无 IP 限流,未授权方可无限爆破","finding":"按 ClientIP 计数,10 分钟 20 次失败后拒绝;成功清零。管理员已登录不在范围内。","next_action_hint":"下一轮可查公开 CAP challenge 洪水或边缘节点 access_token 明文比较"}}
|
||||
{"run":39,"commit":"be5d067","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":76,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"auth_cache negative 缓存加上限防 DoS + relay/flared 删除重复 authenticateAccessToken 改用 agent 共享缓存版","timestamp":1787708052241,"segment":0,"confidence":null,"asi":{"hypothesis":"negative cache 无上限可被伪造 token 撑爆内存;relay/flared 与 agent 三份重复的 authenticateAccessToken","next_action_hint":"继续扫其他无界缓存/限流缺口","result":"metric 持平 8(8 个均为 deliberate keeper),安全修复不计入 metric","security":"negative cache 加 10k 上限+过期清理;relay/flared 复用 agent.AuthenticateAccessToken(共享 2min 正/10min 负缓存,DB 压力下降)"}}
|
||||
{"run":40,"commit":"0dd2cf9","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"websocket 三 hub 去重:抽 runWritePump 共享写泵 + 合并 agent 广播函数为 broadcastAgent","timestamp":1787708370650,"segment":0,"confidence":null,"asi":{"hypothesis":"三份 hub 的 writePump 完全重复(仅日志前缀不同),readPump 已有 runReadPump 抽取先例;BroadcastWAFIPGroups/BroadcastActiveConfig 复制粘贴","next_action_hint":"close() 3 份小重复可再合并但收益低;继续找其他模块的重复/无界增长","result":"metric 持平 8,全测试绿","refactor":"新增 websocket/write_pump.go runWritePump(对齐 runReadPump 模式),agent/relay/flared writePump 改委托;agent_hub 抽 broadcastAgent 合并两个广播函数"}}
|
||||
{"run":41,"commit":"efd8268","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":75,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"websocket 三 client 结构体去重:嵌入共享 wsClientCore(close/enqueue 单份实现)","timestamp":1787708975609,"segment":0,"confidence":null,"asi":{"hypothesis":"agentClient/relayClient/flaredClient 字段与 close/enqueue 完全相同,用组合(嵌入 wsClientCore)消除三份重复","next_action_hint":"代码库经 40 轮已高度收敛;后续可周期性跑 go test -race 全量","result":"metric 持平 8,全测试绿;净减 ~60 行重复代码","refactor":"新增 websocket/client_core.go:wsClientCore(nodeID/conn/send/done/once) + 共享 close/enqueue;三个 client 结构体改为嵌入"}}
|
||||
{"run":42,"commit":"ed1efd3","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"补 wsClientCore 并发测试 + close() 防 nil conn 守卫","timestamp":1787709222794,"segment":0,"confidence":null,"asi":{"hypothesis":"wsClientCore 并发语义(close 幂等、enqueue 不阻塞/关后拒绝)无测试覆盖","next_action_hint":"websocket 包已有基础并发测试;继续其他模块扫描","result":"metric 持平 8;测试还暴露 close 未防 nil conn 的防御缺口,已补守卫","refactor":"新增 websocket/client_core_test.go 3 个 -race 测试;client_core.go close() 增加 nil conn 守卫"}}
|
||||
{"run":43,"commit":"4f8e7e6","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"修复 frps/frpc TOML 配置注入:新增 protocol.TOMLQuote 并在两处配置渲染全部使用","timestamp":1787709693698,"segment":0,"confidence":null,"asi":{"hypothesis":"frps/frpc TOML 配置用裸 Fprintf 拼接,token/password/域名含引号、反斜杠、换行时会破坏配置或注入键","next_action_hint":"检查其他配置生成点是否有同类注入面(nginx/openresty 配置)","result":"metric 回到 8;frpc 慢套件 16.8s 全绿;mnd 曾短暂+1(Grow 魔法数),删除微优化后消除","security":"新增 pkg/protocol/toml.go TOMLQuote 转义助手 + toml_test.go;relay/frps renderConfig 与 flared/frpc buildFrpcToml 全部插值改为转义输出"}}
|
||||
{"run":44,"commit":"63007fc","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":92,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"全仓 race 扫描发现 upload/cache 监听器 DATA RACE:捕获 redis 客户端消除全局读竞争 + Stop 等待 done + 同型监听器(oauth×2/repository×2)加固","timestamp":1787711092906,"segment":0,"confidence":null,"asi":{"hypothesis":"全仓 go test -race 可能暴露并发 bug(此前仅局部验证)","next_action_hint":"继续扫其他模块;可考虑把 -race 纳入周期性检查","result":"发现并修复 1 个真实 DATA RACE;修复后全仓 -race 0 竞争,metric 持平 8","root_cause":"upload/cache 监听器 goroutine 读可变全局 db.Redis,与 testhelper 清理置 nil 竞争;testhelper 导入 upload/cache 有循环依赖,故用启动时捕获客户端的根因修复(oauth/repository 同型监听器一并加固),并补 StopUploadMetaCacheListener 同步等待 done"}}
|
||||
{"run":45,"commit":"63007fc","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":70,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"探索轮:索引对齐/前端请求瀑布/BasicAuth 注入面三假设均证伪,无代码变更","timestamp":1787711474404,"segment":0,"confidence":null,"asi":{"hypothesis":"SQLite 迁移缺 PG 同款索引;前端存在串行请求瀑布;nginx BasicAuth 密码有注入面","next_action_hint":"代码库已高度收敛;下轮可考虑 observability 查询构造器审计或周期性重跑 -race","rollback_reason":"纯探索无代码变更,无需回滚","result":"三个假设均无产出:①索引对比(修正提取正则后)PG/SQLite 完全对齐,SQLite 仅多 legacy w_* 冗余索引;②前端 await Service 均在事件处理器非渲染期;③BasicAuth 密码经 base64 编码(字母表无元字符)无注入面","lessons":"grep 提取 SQL 时注意 IF NOT EXISTS 变体,否则产生假缺口"}}
|
||||
{"run":46,"commit":"2cb3392","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":106,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"LIKE 过滤器转义修复:日志搜索含 %/_ 的输入不再被当通配符;pkg/util 新增 EscapeLike 共享助手 + 单测","timestamp":1787712116152,"segment":0,"confidence":null,"asi":{"hypothesis":"日志搜索 LIKE 过滤器不转义 %/_/\\,含下划线的路径/主机名搜索结果错误","next_action_hint":"同类遗留站点(upload/user/task_execution GORM 搜索)已记 ideas.md,可作后续轮次","result":"修复 4 个站点:analytics 两处 CH 过滤器 + logstore postgres_store 两处(PG/SQLite 加 ESCAPE '\\')。新增 pkg/util/like.go EscapeLike + 单测。metric 持平 8,全部测试通过","scope_decision":"GORM 实体搜索站(upload keyword、user username/email)同 bug 类但低风险且可能依赖现有通配语义,本轮不动"}}
|
||||
{"run":47,"commit":"3528323","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":102,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"GORM 实体搜索 LIKE 转义收尾:6 站点复用 EscapeLike + 显式 ESCAPE 子句,含 OAuth 用户名冲突误报修复","timestamp":1787712555794,"segment":0,"confidence":null,"asi":{"hypothesis":"GORM 实体搜索站与 #46 日志搜索同 bug 类:LIKE 模式不转义通配符","next_action_hint":"LIKE 类已全部收尾;下轮可考虑 ideas.md 的测试可运行性方向或周期性全仓 -race 重跑","result":"6 站点修复(upload keyword、user username/email 前缀+contains、OAuth uniqueUsername base、task_type 前缀),PG/SQLite 加显式 ESCAPE。系统常量模式刻意保留(upload.go:199 image/%)。metric 持平 8,测试全绿","scope_decision":"uniqueUsername 的 base 来自 OAuth 用户信息属外部输入,含 _ 会误报用户名冲突——虽是系统生成后缀模式也需转义 base 本身"}}
|
||||
{"run":48,"commit":"55db1c0","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":112,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"后台 goroutine panic 防护:新增 pkg/util.Go 共享助手(recover+调用点日志),全仓 22 个裸 go func() 站点统一收口","timestamp":1787713583118,"segment":0,"confidence":null,"asi":{"hypothesis":"全仓 20 处后台 goroutine 裸跑零 recover,任一 panic 击穿 gin handler 级恢复直接崩溃进程","next_action_hint":"goroutine 收口完成;下轮可周期性 go test -race ./... 全量重跑(上次 #44)","result":"pkg/util.Go(fn) 共享助手(runtime.Caller 自动记录调用点 + slog + debug.Stack),22 个站点全部收口(含嵌套 watcher)。脚本转换两轮(首轮漏嵌套内层)。首次 checks_failed 因新文件缺 SPDX 头,update_go_license.sh 修复后全绿。metric 持平 8"}}
|
||||
{"run":49,"commit":"40232d8","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":75,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"修复 frpc restartProcess 发布未初始化 exec.Cmd 的数据竞争:proc.Cmd/Status 改为 Start 成功后加锁发布","timestamp":1787714358791,"segment":0,"confidence":null,"asi":{"hypothesis":"周期性全仓 go test -race ./... 重跑(上次 #44 后又改了 repository/logstore/goroutine 站点)能抓出新数据竞争","next_action_hint":"-race 全仓清零;下轮候选:frontend axe a11y 审计,或 Go 1.26 新 linter 扫描","result":"全仓 -race 抓到 1 个真实 race:frpc/manager.go restartProcess 在 cmd.Start() 前就发布 proc.Cmd+Status=running(Start 中 cmd.Process 未赋值),测试读句柄与之竞争。修复=Start 成功后再加锁发布(manager.go:219-220 移入 err==nil 分支)。frpc 包 -race 连续 3 次通过。其余全仓 -race 干净"}}
|
||||
{"run":50,"commit":"40232d8","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":70,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"扩展 linter 发现扫描 + 热路径性能排查:errchkjson/unparam/spancheck 等 9 个新维度,全部核实为不可失败/刻意设计/误报","timestamp":1787714798689,"segment":0,"confidence":null,"asi":{"hypothesis":"基准外发现型 linter(errchkjson/unparam/spancheck/exptostd/durationcheck/makezero/reassign/asasalint/bidichk)+ 热路径性能 grep 能找到真实缺陷","next_action_hint":"发现型 linter 已穷尽;下轮候选:frontend axe a11y 浏览器级审计,或任务执行日志/DB 增长类运维审查","result":"全部证伪:errchkjson 12 处均核实为不可能失败的 marshal(纯 string/int/[]string 结构体;2 处 unsafe 标记是传递性保守);spancheck 1 处误报(唯一调用方 executor.go:242 有 defer span.End());unparam×2 为已评估的工厂签名设计;正则全在包级编译无热路径重编译;包级 map 全为有界静态注册表;AppendLog 走 DB 无内存累积。escapeJSONString 用法正确。无代码变更"}}
|
||||
{"run":51,"commit":"bbf7919","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":72,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"运行时资源审计:HTTP 客户端超时覆盖 + 查询热路径索引覆盖,两项全部干净无缺陷","timestamp":1787715135724,"segment":0,"confidence":null,"asi":{"hypothesis":"运行时资源审计:出站 HTTP 客户端超时覆盖 + LIKE/精确匹配热路径的 DB 索引支撑","next_action_hint":"两项审计干净。剩余:frontend axe a11y(需起前端+浏览器)、周期性 -race 重跑、uploads LOWER(file_name) contains 若成为性能痛点需改前缀语义+表达式索引","result":"全部干净:15 个 http.Client 中 14 个显式 Timeout,唯一无 Timeout 的 agent/nginx checkStubStatus 走 NewRequestWithContext+WithTimeout 边界;users.username 全部精确匹配热路径由 UNIQUE 内联索引覆盖(PG+SQLite 均确认),email/task_type/logstore 过滤列均已有索引;uploads LOWER(file_name) contains 不可用 b-tree 但属管理端低频,改语义才有收益故不动"}}
|
||||
{"run":52,"commit":"bbf7919","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":71,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"SQL 注入面 + Go 运行时陷阱模式 + react-hooks 依赖三重审计,全部干净无缺陷","timestamp":1787715503278,"segment":0,"confidence":null,"asi":{"hypothesis":"原始 SQL 拼接注入面 + 经典 Go 运行时陷阱(time.After 循环泄漏/defer-in-loop/context.Background 丢失取消)+ 前端 react-hooks 依赖正确性","next_action_hint":"静态+运行时审计维度已穷尽。剩余唯一大项:frontend axe a11y 浏览器级审计(需起前端 dev server + agent_browser)","result":"全部干净:db_manage SQL 控制台为管理端允许例外且表名双引号转义正确、analytics Sprintf 均内部常量表名+参数化占位符;time.After 仅 3 处且均为 select 单次等待/有界重试;defer 均在函数级非循环内;19 处 context.Background() 全部为后台监听器(WithCancel)/重启路径/自带超时的清理任务,无请求 ctx 丢弃;react-hooks/exhaustive-deps 全仓零违规(CLI 临时规则,未改配置)"}}
|
||||
{"run":53,"commit":"bbf7919","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":72,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"前端 axe a11y 浏览器审计:唯一违规为无后端环境产物,无代码缺陷","timestamp":1787716027952,"segment":0,"confidence":null,"asi":{"hypothesis":"前端 axe-core 浏览器级 a11y 审计(最后一个未探索大维度)","next_action_hint":"a11y 维度已探索但受登录墙限制:完整审计需起后端+种子账号登录。若未来重跑:起 Go 后端 + admin 登录后逐页 axe.run","result":"agent-browser 0.34.0 已装好可复用。axe 审计覆盖所有无认证可达页面(/login、/register、/docs/* 全被登录墙拦截):唯一违规 page-has-heading-one 是环境产物——后端未启动时页面卡在 session-check/publicConfig-pending 态只渲染 Spinner,真实表单的 AuthHeading h1 未渲染;瞬态态用 h3 属可接受的瞬态层级。无代码缺陷。已认证页面需后端才能审计"}}
|
||||
{"run":54,"commit":"451ce52","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":93,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"认证页 axe a11y 审计+修复:7 处布局级真实违规全修,复扫验证 dashboard/admin/system 归零;基准 total_issues 保持 8 不变(纯质量收益)","timestamp":1787718397798,"segment":0,"confidence":null,"asi":{"hypothesis":"认证页 axe a11y 审计(起后端+登录突破登录墙):修复布局级真实违规","next_action_hint":"已验证 / 与 /admin/system 归零。剩余页面级:admin 表格行内操作按钮/Switch 无 aria-label、muted 文本对比度——需逐表补标签,工作量大已归档 ideas.md","result":"修复 7 处全局问题并复扫验证:sidebar 折叠按钮 aria-label、Sidebar role=navigation(region 违规 18 节点/页清零)、header Kbd 对比度 text-foreground/70(每页 1 处)、dashboard 4 个 Progress aria-label、分页按钮 aria-label、空态/错误/加载 h3→p(heading-order 清零)、admin/system 无内容 Tabs 改 aria-pressed 按钮组(aria-valid-attr-value critical 清零)。dashboard 与 admin/system 现 0 违规","setup":"审计环境:后端 go run . api @:3100(CONFIG_PATH=/tmp/of-audit/config.yaml,sqlite+redis host 网络 docker)、前端 pnpm dev --port 3002(WAVELET_BACKEND_URL=:3100)、admin 密码经 reset-passwd 重置"}}
|
||||
{"run":55,"commit":"e66dea9","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":85,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"a11y 收尾:主题级对比度根因修复(indigo-500→600)+12 处控件 accessible name+4 处 heading-order,7 页复扫全 0 违规;基准 total_issues 保持 8","timestamp":1787719908229,"segment":0,"confidence":null,"asi":{"hypothesis":"页面级 a11y 批量收尾:主题级 color-contrast 根因 + 表格/表单控件 accessible name","next_action_hint":"7 页复扫全 0 违规。剩余:其余页面(websites/origins/cloudflare 等仅扫过 contrast 已由主题修复覆盖)可抽查;-race 周期重跑","result":"根因1:--primary indigo-500(#6366f1) 对 #fafafa 仅 4.27 → 改 indigo-600 oklch(51.1% 0.262 276.966)(~6.8 AA),全站 contrast 清零(一处主题修复覆盖所有页面)。修复 12 处控件名:access-analytics 刷新按钮、events-tab Switch/edit/delete、openflare-ops ToggleRow Switch+geoip/kuma Select+FieldInput Input htmlFor+discovery Textarea、table-browser/sql-console SelectTrigger;heading-order:cache-manager/user-detail-sheet h4→p、task-manager h3→p、file-manager noFiles h3→p;新增 admin.logs.analytics.refresh i18n 键(en/zh)+merge-i18n-fragments。教训:settings 表单异步渲染,早前扫描漏报 label 违规需 wait 5s 后再 axe.run;Radix SelectValue value='' 时 placeholder 不显示致 combobox 无名,须 aria-label 兜底","setup":"审计环境同 run#54:后端:3100(sqlite) + docker redis host 网络 + pnpm dev --port 3002"}}
|
||||
{"run":56,"commit":"63e3b85","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":85,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"富交互页 a11y 抽查收尾:8+3 页扫描,修复 cloudflare 筛选器无名/access-token amber 对比度/notifications 缺 h1 共 3 处,全部复扫归零;基准 total_issues 保持 8","timestamp":1787720716912,"segment":0,"confidence":null,"asi":{"hypothesis":"富交互页抽查(websites/origins/proxy-routes/certificates/cloudflare/dns-accounts/settings 子页)","next_action_hint":"11 页扫描全部归零,a11y 维度已穷尽。剩余:周期性 -race 重跑;审计环境复用法在 ideas.md","result":"websites/origins/proxy-routes/certificates/dns-accounts 5 页直接 0 违规(主题修复覆盖);3 处新发现全修复并复扫验证:cloudflare 同步面板状态筛选 SelectTrigger 加 aria-label(statusPlaceholder);access-token 安全提示 amber-600→amber-700(12px 小字对比度 4.5 不达标);notifications 面包屑页加 sr-only h1——教训:h1 不能放 BreadcrumbList 内(破坏 list 语义 axe list 规则),BreadcrumbPage 无 asChild 需放 Breadcrumb 外","setup":"审计环境同前:后端:3100 + docker redis host 网络 + pnpm dev --port 3002"}}
|
||||
{"run":57,"commit":"453f7e5","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":95,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"周期性 -race 重跑抓到真实 bug:wsClientCore.enqueue close 后 select 随机选择致契约违反;确定性先查 done 修复+测试循环加固+gofmt 存量漂移清理","timestamp":1787721299485,"segment":0,"confidence":null,"asi":{"hypothesis":"周期性全仓 -race 重跑(上次干净为 run #49)","next_action_hint":"websocket 包 -race 10×count=1 全过。教训已记录:select 多 case 同时就绪时随机选择,closed 检查须独立 select 先行;replace 工具锚点选错会级联破坏文件,小文件直接 write 重写更安全","root_cause":"enqueue 把 closed 检查与发送合并在同一个 select,两 case 同时就绪时 Go 随机选择,close 后约 50% 概率仍投递成功——违反 fail-fast 契约且测试 flaky。修复=独立 select 确定性先查 done;测试加固为循环 50 次","result":"抓到真实 bug:wsClientCore.enqueue close 后非确定返回 true(TestWSClientCoreEnqueueFailsAfterClose 必失败)。调用方 agent_hub×3 语义无影响(false=丢弃本就正确)。顺带修 3 个 hub 文件存量 gofmt 漂移","scope_note":"-race 重跑仅 websocket 包 1 个 FAIL,其余 internal/... pkg/... 全部通过"}}
|
||||
{"run":58,"commit":"fc733d0","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"#57 enqueue 修复的同型残留收口:SendFlaredPong/SendRelayPong 合并 select 随机选择 bug,委托 client.enqueue 去重修复","timestamp":1787721635717,"segment":0,"confidence":null,"asi":{"hypothesis":"#57 修复 enqueue 后,grep 全 hub 同型合并 select——发现 SendFlaredPong/SendRelayPong 残留相同 bug","lesson":"修一个 bug 后应 grep 所有同型调用点(本会话 run #44/#46/#57 三次都是同型残留收口模式);委托共享 enqueue 是去重+根因一步到位","next_action_hint":"websocket 并发面已全清。下轮可做:周期性全仓 -race 或 go test -count=10 稳定性抽查","root_cause":"SendFlaredPong (flared_hub.go) 与 SendRelayPong (relay_hub.go) 把 case <-client.done 与 case client.send <- 合并同一 select,两 case 同时就绪时 Go 随机选择,close 后仍可能投递成功。修复=委托 client.enqueue(内含确定性先查 done),同时消除重复代码"}}
|
||||
{"run":59,"commit":"b56f276","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":66,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"#59 -shuffle=on 扫描抓到测试顺序依赖:config_version RAM 配置缓存跨测试污染,setup/cleanup 接入 ram.ResetForTest() 修复","timestamp":1787722520315,"segment":0,"confidence":null,"asi":{"hypothesis":"-shuffle=on 测试顺序随机化扫描(未查过的维度),暴露测试间共享状态依赖","lesson":"repository 读配置会写进程级 RAM 缓存(ram.Set,TTL 跨测试存活);测试用 :memory: DB + SetDB 换库时缓存不随之失效。默认源码顺序下 Defaults 先跑掩盖了问题。-shuffle=on 是暴露此类顺序依赖的低成本手段,可周期重跑","next_action_hint":"全仓 shuffle 已干净。下轮候选:-count 多轮稳定性、或从 ideas.md 剩余条目挑;明确不做清单见 ideas.md","root_cause":"TestBuildOpenRestyConfigSnapshotOriginErrorPageDefaults 在 shuffle 下命中 Custom 用例留在进程级 RAM 配置缓存的 enabled=false/[\"522\",\"500-502\"](GetSystemConfigByGroup 未命中时 ram.Set 回填)。修复=两个测试 setup(setupOriginErrorPageSnapshotDB/setupConfigVersionTestDB)接入既有 ram.ResetForTest():换 DB 前后各清一次"}}
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/bin/bash
|
||||
# Benchmark: total code-quality issues across backend + frontend (lower is better).
|
||||
# Fixed linter set — see .auto/prompt.md. Never tune this file to game counts.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
start=$(date +%s)
|
||||
|
||||
# ---------- Backend: golangci-lint, repo config + fixed best-practice extras ----------
|
||||
EXTRA_LINTERS="errorlint,errname,nilnil,forcetypeassert,copyloopvar,intrange,mirror,perfsprint,prealloc,usestdlibvars,modernize,sloglint,canonicalheader,nosprintfhostport,recvcheck,wastedassign,exhaustive"
|
||||
golang_out=$(golangci-lint run --enable="$EXTRA_LINTERS" 2>&1 || true)
|
||||
|
||||
golang_total=0
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^\*\ ([a-zA-Z0-9_]+):\ ([0-9]+)$ ]]; then
|
||||
name="${BASH_REMATCH[1]}"
|
||||
n="${BASH_REMATCH[2]}"
|
||||
golang_total=$((golang_total + n))
|
||||
echo "METRIC golint_${name}=$n"
|
||||
fi
|
||||
done <<< "$golang_out"
|
||||
echo "METRIC golint_total=$golang_total"
|
||||
|
||||
# ---------- Backend: test-code quality (tests excluded from repo config; safe linters only) ----------
|
||||
test_out=$(golangci-lint run --tests=true --enable=testifylint,usetesting,thelper --enable-only=testifylint,usetesting,thelper 2>&1 || true)
|
||||
golang_test_total=0
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^\*\ ([a-zA-Z0-9_]+):\ ([0-9]+)$ ]]; then
|
||||
name="${BASH_REMATCH[1]}"
|
||||
n="${BASH_REMATCH[2]}"
|
||||
golang_test_total=$((golang_test_total + n))
|
||||
echo "METRIC golint_test_${name}=$n"
|
||||
fi
|
||||
done <<< "$test_out"
|
||||
echo "METRIC golint_test_total=$golang_test_total"
|
||||
|
||||
# ---------- Backend: govet extra analyzers (dead code / nil deref — real-bug finders) ----------
|
||||
cat > /tmp/govetx.yml <<'EOF'
|
||||
version: "2"
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- govet
|
||||
settings:
|
||||
govet:
|
||||
enable:
|
||||
- nilness
|
||||
- unusedwrite
|
||||
EOF
|
||||
vetx_out=$(golangci-lint run --config /tmp/govetx.yml --max-issues-per-linter=0 2>&1 || true)
|
||||
rm -f /tmp/govetx.yml
|
||||
golang_vetx_total=0
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^\*\ ([a-zA-Z0-9_]+):\ ([0-9]+)$ ]]; then
|
||||
name="${BASH_REMATCH[1]}"
|
||||
n="${BASH_REMATCH[2]}"
|
||||
golang_vetx_total=$((golang_vetx_total + n))
|
||||
echo "METRIC golint_vetx_${name}=$n"
|
||||
fi
|
||||
done <<< "$vetx_out"
|
||||
echo "METRIC golint_vetx_total=$golang_vetx_total"
|
||||
|
||||
# ---------- Frontend: eslint (repo gate) ----------
|
||||
cd frontend
|
||||
eslint_out=$(pnpm exec eslint . --max-warnings 0 2>&1 || true)
|
||||
eslint_problems=0; eslint_errors=0; eslint_warnings=0
|
||||
if [[ "$eslint_out" =~ ([0-9]+)\ problems? ]]; then eslint_problems="${BASH_REMATCH[1]}"; fi
|
||||
if [[ "$eslint_out" =~ \(([0-9]+)\ errors?, ]]; then eslint_errors="${BASH_REMATCH[1]}"; fi
|
||||
if [[ "$eslint_out" =~ ,\ ([0-9]+)\ warnings? ]]; then eslint_warnings="${BASH_REMATCH[1]}"; fi
|
||||
echo "METRIC eslint_problems=$eslint_problems"
|
||||
echo "METRIC eslint_errors=$eslint_errors"
|
||||
echo "METRIC eslint_warnings=$eslint_warnings"
|
||||
|
||||
# ---------- Frontend: tsc (repo gate) ----------
|
||||
tsc_out=$(pnpm exec tsc --noEmit --jsx preserve 2>&1 || true)
|
||||
tsc_errors=$(grep -cE "error TS" <<< "$tsc_out" || true)
|
||||
echo "METRIC tsc_errors=$tsc_errors"
|
||||
|
||||
# ---------- Frontend: vitest (2026-08-16 起全绿,纳入基准防回归) ----------
|
||||
vitest_out=$(pnpm exec vitest run --reporter=dot 2>&1 || true)
|
||||
vitest_failed=0; vitest_total=0
|
||||
if [[ "$vitest_out" =~ ([0-9]+)\ failed ]]; then vitest_failed="${BASH_REMATCH[1]}"; fi
|
||||
if [[ "$vitest_out" =~ Tests[[:space:]]+([0-9]+)\ passed ]]; then vitest_total="${BASH_REMATCH[1]}"; fi
|
||||
if [[ "$vitest_out" =~ Tests[[:space:]]+([0-9]+) ]]; then vitest_total="${BASH_REMATCH[1]}"; fi
|
||||
echo "METRIC vitest_failed=$vitest_failed"
|
||||
echo "METRIC vitest_total=$vitest_total"
|
||||
|
||||
end=$(date +%s)
|
||||
total=$((golang_total + golang_test_total + golang_vetx_total + eslint_problems + tsc_errors + vitest_failed))
|
||||
echo "METRIC total_issues=$total"
|
||||
echo "METRIC measure_s=$((end - start))"
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
# Autoresearch: 前后端代码质量符合最佳代码实践
|
||||
|
||||
## Objective
|
||||
|
||||
Improve backend (Go) and frontend (Next.js/TS) code quality so the codebase
|
||||
conforms to best practices. NOT a performance task. Each experiment is a code
|
||||
change that removes real, lint-diagnosed code-quality issues (dead assignments,
|
||||
error-wrapping bugs, non-idiomatic loops, mixed receivers, unsafe error
|
||||
comparisons, unnecessary string fmt, etc.) without changing behavior.
|
||||
|
||||
Genuine quality work only: fix code, never weaken the checks. Do NOT edit
|
||||
`.golangci.yml`, eslint/biome config, or add `nolint`/`eslint-disable`
|
||||
comments to reduce counts. Do NOT reformat code that isn't part of a fix
|
||||
(no formatted-only churn).
|
||||
|
||||
## Metrics
|
||||
|
||||
- **Primary**: `total_issues` (unitless, lower is better) = backend golangci
|
||||
issues (extended linter set below) + frontend eslint problems + tsc errors.
|
||||
- **Secondary**: per-linter counts (`golint_modernize`, `golint_perfsprint`,
|
||||
`golint_errorlint`, `golint_gosec`, `golint_canonicalheader`,
|
||||
`golint_recvcheck`, `golint_wastedassign`, `golint_usestdlibvars`,
|
||||
`golint_intrange`, `golint_forcetypeassert`, `golint_nilnil`,
|
||||
`golint_prealloc`, `golint_errname`, `golint_sloglint`,
|
||||
`golint_copyloopvar`, `golint_mirror`, `golint_nosprintfhostport`),
|
||||
`eslint_problems`, `eslint_errors`, `eslint_warnings`, `tsc_errors`,
|
||||
`measure_s` (benchmark wall time).
|
||||
|
||||
## How to Run
|
||||
|
||||
`./.auto/measure.sh` — outputs `METRIC name=value` lines. Parsed by
|
||||
run_experiment automatically.
|
||||
|
||||
Correctness gate: `./.auto/checks.sh` runs `go vet ./...`, `go build ./...`,
|
||||
and the repo's own `golangci-lint run` (repo config, tests excluded) — all
|
||||
must pass. Note: `go test ./...` is NOT in checks.sh — several tests fail on
|
||||
main today for environmental reasons (no local redis; flaky frpc process
|
||||
tests). Don't "fix" those unless cheap and clearly unrelated to redis/flaky.
|
||||
|
||||
## Benchmark Definition (fixed — never change mid-session)
|
||||
|
||||
Backend: `golangci-lint run --enable=errorlint,errname,nilnil,forcetypeassert,
|
||||
copyloopvar,intrange,mirror,perfsprint,prealloc,usestdlibvars,modernize,
|
||||
sloglint,canonicalheader,nosprintfhostport,recvcheck,wastedassign`
|
||||
(repo `.golangci.yml` linters stay active too; `tests: false` as configured).
|
||||
|
||||
Frontend: `pnpm exec eslint . --max-warnings 0` (repo gate) +
|
||||
`pnpm exec tsc --noEmit --jsx preserve` (repo gate).
|
||||
|
||||
Test-code dimension (added 2026-08-16, run #12+, documented scope extension —
|
||||
raising the bar, not gaming): `golangci-lint run --tests=true
|
||||
--enable=testifylint,usetesting,thelper --enable-only=testifylint,usetesting,thelper`
|
||||
counts test-file quality. DELIBERATELY excludes paralleltest/tparallel
|
||||
(t.Parallel advice is unsafe here: many suites share DB/redis state and tests
|
||||
cannot be run in this env) and gocritic extras (noise). Fix test issues only
|
||||
when compile-safe (go vet compiles tests) and semantically neutral.
|
||||
|
||||
Frontend vitest dimension (added run #19, after suite went green in run #18):
|
||||
`pnpm exec vitest run --reporter=dot` — `vitest_failed` counts into total.
|
||||
The suite is fully runnable locally (jsdom + mocks; no external services).
|
||||
Do not add/remove linters or change settings to make the number go down.
|
||||
|
||||
## Files in Scope
|
||||
|
||||
Backend (Go): `cmd/`, `internal/`, `pkg/`. Anything lint-flagged in the
|
||||
extended set above. Note: module name in go.mod is `github.com/Rain-kl/Wavelet`.
|
||||
|
||||
Frontend (TS/React): `frontend/app/`, `frontend/components/`, `frontend/lib/`,
|
||||
`frontend/contexts/`, `frontend/hooks/`, `frontend/types/`, frontend scripts.
|
||||
|
||||
Infra: `frontend/pnpm-workspace.yaml` — approved @parcel/watcher + @swc/core
|
||||
builds (fixes `make code-check` under pnpm 11; ERR_PNPM_IGNORED_BUILDS
|
||||
otherwise). Already committed in setup.
|
||||
|
||||
## Off Limits
|
||||
|
||||
- `.golangci.yml`, `eslint.config.mjs`, `biome.json` — never touch to reduce counts.
|
||||
- No `//nolint` / `eslint-disable` comments to silence checks.
|
||||
- No reformat-only commits (biome/gofmt churn without a fix).
|
||||
- No behavior changes: refactors must compile (checks.sh gate) and keep tests
|
||||
semantics identical. Re-run checks.sh after every edit.
|
||||
- `frontend/node_modules`, `frontend/bun.lock` (untracked, not ours).
|
||||
- Do not run `go test` suites that need redis/network to declare success.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Backend conventions (AGENTS.md): apps → repository → model layering;
|
||||
`pkg/util/` must not import Gin/GORM/sessions; no `db.DB` in model;
|
||||
response.Abort* for API errors; Chinese docs for content changes
|
||||
(code-quality fixes are not content changes — no doc sync needed unless
|
||||
behavior/UX changes; changelog only for user-visible changes, typically
|
||||
none here).
|
||||
- Frontend: run `pnpm exec biome format --write` only on files you edit
|
||||
(repo `make format` uses biome); keep component placement rules.
|
||||
- `golangci-lint --fix` is allowed and preferred for safe fixes
|
||||
(modernize/intrange/perfsprint/usestdlibvars/canonicalheader/mirror/
|
||||
copyloopvar/sloglint/errname) — review the resulting diff before keeping.
|
||||
For no-fix linters (errorlint wrapping, wastedassign, recvcheck, nilnil,
|
||||
prealloc, forcetypeassert) edit by hand.
|
||||
|
||||
## Workflow per iteration
|
||||
|
||||
1. Read current measure output: which categories remain, where.
|
||||
2. Pick ONE category (or a coherent set of similar fixes), locate files, fix
|
||||
by hand or with golangci-lint --fix scoped to that category.
|
||||
3. `./.auto/measure.sh` → if total dropped → `./.auto/checks.sh` → log keep.
|
||||
If flat/worse → discard or adjust.
|
||||
|
||||
## What's Been Tried
|
||||
|
||||
- Setup commit `ee6974d` (autoresearch/code-quality-2026-08-16): branch,
|
||||
.auto/ session files, frontend/pnpm-workspace.yaml build approvals.
|
||||
- Baseline (before any code fix): total_issues = 108
|
||||
(golangci 107 = modernize 37, perfsprint 18, errorlint 12, canonicalheader 8,
|
||||
recvcheck 7, wastedassign 7, usestdlibvars 3, intrange 3, forcetypeassert 3,
|
||||
nilnil 3, prealloc 3, errname 1, gosec 2; eslint 1 warning
|
||||
[react-hooks/exhaustive-deps in
|
||||
app/(main)/pages/detail/components/pages-source-card.tsx:275]; tsc 0).
|
||||
- Environment notes: golangci-lint 2.12.2 warm cache ~3s; eslint cold ~27s
|
||||
(ignore stderr pnpm noise); go vet+go build ~15-30s after edits.
|
||||
|
||||
### 最终状态(run #23,提交 aa4fadda,本会话收敛点)
|
||||
|
||||
基准 5 维全下限 total=8(全为刻意保留);后端 94 包 + 前端 vitest 116 全绿;
|
||||
`go test -race ./internal/... ./pkg/...` 93 包零警告;`make build-embedded`
|
||||
(发布路径)成功且工作树干净;`make license-check` / `go mod tidy -diff` /
|
||||
`go test -count=3`(时序敏感包)全部通过。checks.sh 门禁:vet + build +
|
||||
golangci + 单测 + vitest + 并发包 -race + license-check。
|
||||
|
||||
### Session result (14 experiments, commits f1f6bb85→65c02ef7)
|
||||
|
||||
108 → **8** (-92.6%) across 3 benchmark dimensions, all remaining 8 are
|
||||
deliberate, documented keepers (see below). Never weakened a check; never
|
||||
added nolint/eslint-disable; benchmark extensions were transparently
|
||||
documented (test-code dimension run #12, exhaustive run #14).
|
||||
|
||||
Fixed (zero behavior change, each reviewed):
|
||||
- gosec 2→0 (saturating multiply pattern gosec accepts without nolint)
|
||||
- modernize 37→5→3 (any, max/min, slices/maps, strings.Cut/SplitSeq,
|
||||
strings.Builder; omitted omitted-lark: nested struct omitzero = wire change)
|
||||
- perfsprint 18→0, canonicalheader 8→0, usestdlibvars 3→0, intrange 3→0,
|
||||
wastedassign 7→0, errname 1→0, forcetypeassert 6→0, prealloc 2→0
|
||||
- errorlint 12→1 (errors.Is/As, %v→%w chains)
|
||||
- recvcheck 7→1 (GORM TableName → pointer receiver; verified gorm source uses
|
||||
reflect.New, tests pass)
|
||||
- eslint 1→0 (exhaustive-deps: add stable `t` to dep array)
|
||||
- test dimension 25→0 (testifylint 20, thelper 3, usetesting 2)
|
||||
- exhaustive 12→0 (explicit enum cases = fail-explicit)
|
||||
|
||||
Deliberate keepers (8) — do NOT "fix" without new evidence:
|
||||
- errorlint 1: pkg/push/telegram.go %v — wrapping the original error would
|
||||
change errors.Is matching semantics; it's intentionally textual context.
|
||||
- modernize 3: nested-struct omitempty (client.go Release/Asset,
|
||||
lark.go Content) — omitzero would CHANGE wire output (plain structs
|
||||
serialize always today).
|
||||
- nilnil 3: not-found/optional-result conventions — postgres_store.go
|
||||
ClickHouseOperationalStats (interface contract, documented in comment),
|
||||
openflare_apply_log.go GetLatestOpenFlareApplyLogByNodeID (tested),
|
||||
github_source_action.go guarded outcome (callers check != nil).
|
||||
- recvcheck 1: MillisecondDuration — encoding/json requires Marshal value
|
||||
receiver + Unmarshal pointer receiver.
|
||||
|
||||
Surveyed and rejected (noise/risk, do not add):
|
||||
- fieldalignment (~100+): JSON key order change + positional literal risk.
|
||||
- sloglint full / gocritic extras: 0 findings.
|
||||
- paralleltest/tparallel: t.Parallel advice unsafe (shared DB/redis state;
|
||||
tests not runnable in this env).
|
||||
- biome format drift (76 files): pure formatting noise; repo's make format
|
||||
covers it.
|
||||
@@ -1,5 +1,7 @@
|
||||
.git
|
||||
.idea
|
||||
.vscode
|
||||
.github
|
||||
anubis-source
|
||||
**/node_modules
|
||||
**/.next
|
||||
@@ -9,3 +11,25 @@ anubis-source
|
||||
**/coverage
|
||||
**/*.db
|
||||
**/*.log
|
||||
tmp
|
||||
logs
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
docker-compose*.yml
|
||||
config.yaml
|
||||
bin/
|
||||
data/
|
||||
uploads/
|
||||
s3_cache/
|
||||
frontend/node_modules/
|
||||
frontend/.next/
|
||||
frontend/out/
|
||||
frontend/build/
|
||||
frontend/disk/
|
||||
frontend/.env
|
||||
frontend/next-env.d.ts
|
||||
frontend/*.tsbuildinfo
|
||||
frontend/package-lock.json
|
||||
internal/router/dist/
|
||||
internal/router/root/dist/
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# openflare — 环境变量配置模板
|
||||
# 复制此文件为 .env 并填入实际值: cp .env.example .env
|
||||
# 环境变量优先级高于 config.yaml
|
||||
# docker compose 会读取本文件(env_file: .env)并替换 compose 中的 ${VAR}
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ─── 时区 ─────────────────────────────────────────────────────────────────────
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
# ─── 应用配置 ──────────────────────────────────────────────────────────────────
|
||||
APP_NAME=openflare
|
||||
APP_ENV=production
|
||||
APP_ADDR=:3000
|
||||
APP_NODE_ID=1
|
||||
APP_API_PREFIX=/api
|
||||
# APP_GRACEFUL_SHUTDOWN_TIMEOUT=30
|
||||
APP_SESSION_COOKIE_NAME=openflare_session_id
|
||||
APP_SESSION_SECRET=change-me-to-a-random-string-in-production
|
||||
# APP_SESSION_DOMAIN=
|
||||
APP_SESSION_AGE=86400
|
||||
APP_SESSION_HTTP_ONLY=true
|
||||
# HTTPS 部署时设为 true,HTTP 环境必须为 false
|
||||
APP_SESSION_SECURE=true
|
||||
|
||||
# ─── 数据库(PostgreSQL)──────────────────────────────────────────────────────
|
||||
# 设置 DB_HOST 后自动启用 PostgreSQL,也可通过 DB_ENABLED 显式控制
|
||||
# DB_ENABLED=false 时使用 SQLite 作为后备数据库
|
||||
DB_ENABLED=true
|
||||
# SQLITE_PATH=./data/openflare.db
|
||||
# compose 内应用连服务名;本机直连 Docker 映射端口时用 127.0.0.1
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_USERNAME=openflare
|
||||
DB_PASSWORD=replace-with-strong-password
|
||||
DB_NAME=openflare
|
||||
DB_SSL_MODE=disable
|
||||
DB_TIMEZONE=Asia/Shanghai
|
||||
# DB_LOG_LEVEL=info
|
||||
# DB_MAX_IDLE_CONN=16
|
||||
# DB_MAX_OPEN_CONN=128
|
||||
|
||||
# ─── Redis / Valkey ────────────────────────────────────────────────────────────
|
||||
# 设置 REDIS_ADDR 后自动启用,也可通过 REDIS_ENABLED 显式控制
|
||||
REDIS_ENABLED=true
|
||||
REDIS_ADDR=redis:6379
|
||||
# REDIS_USERNAME=
|
||||
# REDIS_PASSWORD=
|
||||
# REDIS_DB=0
|
||||
REDIS_KEY_PREFIX=openflare:
|
||||
# REDIS_POOL_SIZE=100
|
||||
# 启动时开关;修改后需重启服务
|
||||
REDIS_MAINT_NOTIFICATIONS=false
|
||||
# compose 宿主机映射端口(仅 docker-compose 使用)
|
||||
# REDIS_PORT=6379
|
||||
|
||||
# ─── ClickHouse(必需)────────────────────────────────────────────────────────
|
||||
# CLICKHOUSE_HOST 设置后会自动启用;测试环境可显式 CLICKHOUSE_ENABLED=true 做 live 联调
|
||||
CLICKHOUSE_ENABLED=false
|
||||
# compose 内:clickhouse:9000;本机连映射端口:127.0.0.1:9000
|
||||
CLICKHOUSE_HOST=clickhouse:9000
|
||||
CLICKHOUSE_USERNAME=default
|
||||
# 须与 compose clickhouse 服务密码一致(首次初始化后改密码需清 data/clickhouse_data)
|
||||
CLICKHOUSE_PASSWORD=replace-with-clickhouse-password
|
||||
CLICKHOUSE_NAME=openflare
|
||||
|
||||
|
||||
# ─── 日志 ──────────────────────────────────────────────────────────────────────
|
||||
LOG_LEVEL=info
|
||||
LOG_FORMAT=console
|
||||
LOG_OUTPUT=stdout
|
||||
|
||||
# ─── OpenTelemetry ─────────────────────────────────────────────────────────────
|
||||
# docker-compose 默认将 Trace 发往 Jaeger All-in-One: http://jaeger:4317
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317
|
||||
OTEL_EXPORTER_OTLP_INSECURE=true
|
||||
# 设为 0 关闭 tracing;本地 Jaeger 调试建议设为 1.0
|
||||
OTEL_SAMPLING_RATE=0.0
|
||||
# 全局 Tracer 命名空间,默认为 github.com/Rain-kl/OpenFlare
|
||||
# OTEL_TRACER_NAME=github.com/Rain-kl/OpenFlare
|
||||
# compose 可选端口覆盖
|
||||
# JAEGER_VERSION=2.19.0
|
||||
# JAEGER_UI_PORT=16686
|
||||
# JAEGER_OTLP_GRPC_PORT=4317
|
||||
# JAEGER_OTLP_HTTP_PORT=4318
|
||||
|
||||
# ─── Worker ────────────────────────────────────────────────────────────────────
|
||||
# WORKER_CONCURRENCY=20
|
||||
# WORKER_STRICT_PRIORITY=false
|
||||
@@ -1,122 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve version metadata
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
SHOULD_RUN=true
|
||||
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
|
||||
|
||||
if [[ "${GITHUB_REF}" == refs/heads/main ]] && [[ -n "$POINTED_TAG" ]]; then
|
||||
SHOULD_RUN=false
|
||||
VERSION="$POINTED_TAG"
|
||||
elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
else
|
||||
VERSION="$(git describe --tags)"
|
||||
fi
|
||||
|
||||
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$VERSION" =~ ^v[0-9]+(\.[0-9]+)*$ ]]; then
|
||||
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Set up Node.js
|
||||
if: steps.version.outputs.should_run == 'true'
|
||||
uses: https://github.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Build Frontend
|
||||
if: steps.version.outputs.should_run == 'true'
|
||||
env:
|
||||
CI: ""
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
cd openflare_server/web
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build
|
||||
|
||||
- name: Set up Go
|
||||
if: steps.version.outputs.should_run == 'true'
|
||||
uses: https://github.com/actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: openflare_server/go.mod
|
||||
|
||||
- name: Build Server Binaries
|
||||
if: steps.version.outputs.should_run == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p dist
|
||||
|
||||
cd openflare_server
|
||||
go mod download
|
||||
|
||||
while read -r GOOS GOARCH ASSET_NAME; do
|
||||
GOOS="$GOOS" GOARCH="$GOARCH" \
|
||||
go build -trimpath -ldflags "-s -w -X 'openflare/common.Version=$VERSION'" -o "../dist/$ASSET_NAME" .
|
||||
done <<'EOF'
|
||||
linux amd64 openflare-server-linux-amd64
|
||||
linux arm64 openflare-server-linux-arm64
|
||||
darwin amd64 openflare-server-darwin-amd64
|
||||
darwin arm64 openflare-server-darwin-arm64
|
||||
windows amd64 openflare-server-windows-amd64.exe
|
||||
EOF
|
||||
|
||||
- name: Build Agent Binaries
|
||||
if: steps.version.outputs.should_run == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cd openflare_agent
|
||||
go mod download
|
||||
|
||||
while read -r GOOS GOARCH ASSET_NAME; do
|
||||
GOOS="$GOOS" GOARCH="$GOARCH" \
|
||||
go build -trimpath -ldflags "-s -w -X 'openflare-agent/internal/config.Version=$VERSION'" -o "../dist/$ASSET_NAME" ./cmd/agent
|
||||
done <<'EOF'
|
||||
linux amd64 openflare-agent-linux-amd64
|
||||
linux arm64 openflare-agent-linux-arm64
|
||||
darwin amd64 openflare-agent-darwin-amd64
|
||||
darwin arm64 openflare-agent-darwin-arm64
|
||||
windows amd64 openflare-agent-windows-amd64.exe
|
||||
EOF
|
||||
|
||||
- name: Publish Release
|
||||
if: steps.version.outputs.should_run == 'true'
|
||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.version }}
|
||||
name: ${{ steps.version.outputs.version }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
files: |
|
||||
dist/*
|
||||
draft: false
|
||||
prerelease: ${{ steps.version.outputs.is_prerelease == 'true' }}
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
name: 报告问题
|
||||
about: 使用简练详细的语言描述你遇到的问题
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**例行检查**
|
||||
+ [ ] 我已确认目前没有类似 issue
|
||||
+ [ ] 我已确认我已升级到最新版本
|
||||
+ [ ] 我理解并愿意跟进此 issue,协助测试和提供反馈
|
||||
+ [ ] 我理解并认可上述内容,并理解项目维护者精力有限,不遵循规则的 issue 可能会被无视或直接关闭
|
||||
|
||||
**问题描述**
|
||||
|
||||
**复现步骤**
|
||||
|
||||
**预期结果**
|
||||
|
||||
**相关截图**
|
||||
如果没有的话,请删除此节。
|
||||
@@ -0,0 +1,90 @@
|
||||
name: 使用时的错误报告
|
||||
description: 某些事情不按照预期工作。
|
||||
title: "bug: "
|
||||
labels: ["bug"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您花时间填写此 Bug 报告!
|
||||
|
||||
- **提交错误报告前**:请检查 [已有 Issues](https://github.com/Rain-kl/Wavelet/issues) 列表,了解是否有类似问题被报告。如果不确定,请进行搜索,这有助于我们高效地专注于改进项目。
|
||||
- type: checkboxes
|
||||
id: issue-check
|
||||
attributes:
|
||||
label: 检查现有问题
|
||||
description: 确认您在提交新报告之前已经检查了现有报告。
|
||||
options:
|
||||
- label: 我已经搜索了现有问题和讨论。
|
||||
required: true
|
||||
- label: 我正在使用 wavelet 的最新版本或当前部署实例。
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: 发生了什么?
|
||||
description: 请详细描述您正在进行的操作,您期待看到什么,实际发生了什么。
|
||||
placeholder: 请告诉我们您看到了什么!
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: 如何重现此 Bug?
|
||||
description: 请提供详细的步骤来重现此 Bug。
|
||||
placeholder: |
|
||||
1. 在此环境中...
|
||||
2. 使用此配置...
|
||||
3. 运行 '...'
|
||||
4. 看到错误...
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: browsers
|
||||
attributes:
|
||||
label: 在哪些浏览器中出现问题?
|
||||
multiple: true
|
||||
options:
|
||||
- Firefox
|
||||
- Chrome
|
||||
- Safari
|
||||
- Microsoft Edge
|
||||
- Other (请在“其他信息”中说明)
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: other-info
|
||||
attributes:
|
||||
label: 任何其他信息
|
||||
description: 您有任何其他关于此报告的信息吗?
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
id: confirmation
|
||||
attributes:
|
||||
label: 确认
|
||||
description: 确保已满足以下先决条件。
|
||||
options:
|
||||
- label: 我已阅读并遵循了 `README.md` 中的所有说明。
|
||||
required: true
|
||||
- label: 我正在使用 Rain-kl/Wavelet 的最新版本。
|
||||
required: true
|
||||
- label: 我已提供我能够提供的尽可能多的相关日志,屏幕截图等。
|
||||
required: true
|
||||
- label: |
|
||||
我已详细记录了精确、按顺序且无歧义的逐步重现说明。我的步骤:
|
||||
- 从正在执行的操作开始,
|
||||
- 指定进入了什么页面,
|
||||
- 列出访问的 URL、用户输入(包括所需的示例值/电子邮件/密码),
|
||||
- 描述所有已启用或更改的选项和开关,
|
||||
- 包含任何可能的浏览器控制台日志,
|
||||
- 识别每个阶段的预期和实际结果,
|
||||
- 确保任何有合理技能的用户都可以遵循并遇到相同的问题。
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## 注意
|
||||
如果 Bug 报告不完整或不遵循说明,则可能不会得到处理。请确保您已遵循所有 **README.md** 指南,并提供所有必要信息以便我们重现该问题。
|
||||
感谢您为 wavelet 做出贡献!
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
name: 功能请求
|
||||
about: 使用简练详细的语言描述希望加入的新功能
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**例行检查**
|
||||
+ [ ] 我已确认目前没有类似 issue
|
||||
+ [ ] 我已确认我已升级到最新版本
|
||||
+ [ ] 我理解并愿意跟进此 issue,协助测试和提供反馈
|
||||
+ [ ] 我理解并认可上述内容,并理解项目维护者精力有限,不遵循规则的 issue 可能会被无视或直接关闭
|
||||
|
||||
**功能描述**
|
||||
|
||||
**应用场景**
|
||||
@@ -0,0 +1,79 @@
|
||||
name: 新功能建议
|
||||
description: 请求新的功能或对现有功能进行改进。
|
||||
title: "feature: "
|
||||
labels: ["enhancement"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您花时间填写此功能请求!
|
||||
|
||||
- **提交功能请求前**:请检查 [已有 Issues](https://github.com/Rain-kl/Wavelet/issues) 列表和讨论区,了解是否有类似功能已被讨论或请求。这有助于我们避免重复工作,并高效地专注于改进项目。
|
||||
- type: checkboxes
|
||||
id: check-existing
|
||||
attributes:
|
||||
label: 检查现有问题和讨论
|
||||
description: 确认您在提交新请求之前已经检查了现有报告和讨论。
|
||||
options:
|
||||
- label: 我已经搜索了现有问题和讨论。
|
||||
required: true
|
||||
- label: 我正在使用 wavelet 的最新版本或当前部署实例。
|
||||
required: true
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
label: 你希望添加什么功能或改进什么?
|
||||
description: 请详细描述您希望添加的功能或进行的改进。
|
||||
placeholder: 我希望可以...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: why-needed
|
||||
attributes:
|
||||
label: 为什么需要此功能?
|
||||
description: 请说明此功能解决了什么问题,或提供了什么价值。请提供具体的用例和场景,帮助我们理解其重要性。
|
||||
placeholder: |
|
||||
目前我遇到...
|
||||
如果有了此功能,我可以...
|
||||
这将为用户带来...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposed-solution
|
||||
attributes:
|
||||
label: 建议的解决方案(可选)
|
||||
description: 如果您对如何实现此功能有任何想法,请在此处描述。这可以包括用户界面草图、API 设想、技术方案等。
|
||||
placeholder: |
|
||||
我设想此功能可以通过以下方式实现:
|
||||
1. ...
|
||||
2. ...
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: other-info
|
||||
attributes:
|
||||
label: 任何其他信息
|
||||
description: 您有任何其他关于此报告的信息吗?例如,您目前如何解决这个问题,或者其他类似项目的实现方式等。
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
id: confirmation
|
||||
attributes:
|
||||
label: 确认
|
||||
description: 确保已满足以下先决条件。
|
||||
options:
|
||||
- label: 我已阅读并遵循了 `README.md` 中的所有说明。
|
||||
required: true
|
||||
- label: 我正在使用 Rain-kl/Wavelet 的最新版本。
|
||||
required: true
|
||||
- label: 我已提供我能够提供的尽可能多的相关信息,包括用例和场景。
|
||||
required: true
|
||||
- label: 我理解功能请求的实现取决于项目优先级和资源。
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## 注意
|
||||
如果功能请求不完整或不遵循说明,则可能不会得到处理。请确保您已提供所有必要信息以便我们理解您的建议。
|
||||
感谢您为 wavelet 做出贡献!
|
||||
@@ -0,0 +1,31 @@
|
||||
## 基础规范
|
||||
|
||||
- 在任何情况都使用简体中文
|
||||
- 你是一个专业的代码助手,专门为 wavelet 项目提供代码编写和优化服务
|
||||
- 严格遵循项目的代码规范和最佳实践,确保代码质量和一致性
|
||||
- 保持代码简洁、可读、高效
|
||||
- 优先考虑项目的可维护性、性能和安全性,避免引入不必要的复杂性
|
||||
- 注释和上一行代码之间保留一行空格
|
||||
- 编写代码前仔细分析需求,确保改动有实际价值和意义
|
||||
- 避免仅修改格式、注释或无影响力的拼写错误
|
||||
- 重构代码时必须带来可维护性或功能上的实质提升
|
||||
- 新增功能时考虑向后兼容性和 API 稳定性
|
||||
- 遵循项目的 Apache2.0 许可证要求
|
||||
- 遵循语义化版本控制规范
|
||||
- 新增异步任务时使用项目技能 `.agent/new-async-task/SKILL.md`
|
||||
|
||||
## 后端规范
|
||||
|
||||
- 后端开发使用 Go 语言,所有接口需要符合 Restful 风格
|
||||
- 数据库使用 PostgreSQL 作为主存储,Redis 作为缓存和会话存储
|
||||
- Go 代码遵循 gofmt 标准格式,使用 snake_case 命名数据库字段
|
||||
- 所有 API 接口必须编写完整的 Swagger 文档
|
||||
- API 响应格式统一为 {"error_msg": "", "data": {}} 结构
|
||||
- 分页数据返回 {"error_msg": "", "data": {"total": 0, "results": []}} 格式
|
||||
- 数据库设计禁止使用外键,但必须保留对应字段的索引
|
||||
|
||||
## 前端规范
|
||||
|
||||
- TypeScript 代码严禁使用 any 类型,优先使用 unknown 进行类型安全处理
|
||||
- 组件按功能分类:公共组件放在 components/common,UI 组件放在 components/ui
|
||||
- 自定义图标统一放置在 components/icons 目录,常规图标使用 Lucide 库
|
||||
@@ -0,0 +1,3 @@
|
||||
- 如果有其他代码文件,忽略 Swagger 变更和版本号变更,只需要关注其他代码文件的变更
|
||||
- 需要符合 Github 的提交规范,使用 <type>(<scope>): <subject> 格式
|
||||
- Commit Message 必须有 Scope 信息
|
||||
@@ -0,0 +1,25 @@
|
||||
**例行检查**
|
||||
|
||||
<!-- 请在下面的 [ ] 中删除空格并打 x ,表示已完成相关检查 -->
|
||||
|
||||
- [ ] 我已阅读并理解 [贡献者公约](https://github.com/Rain-kl/Wavelet/blob/main/CODE_OF_CONDUCT.md)
|
||||
- [ ] 我已阅读并同意 [贡献者许可协议 (CLA)](https://github.com/Rain-kl/Wavelet/blob/main/CLA.md),确认我的贡献将根据项目的 Apache2.0 许可证进行许可
|
||||
- [ ] 我知晓如果此 PR 并不做出实质性更改,或可被认为是*为了PR被合并而提交PR*的,则可能不会被合并
|
||||
|
||||
**关联信息**
|
||||
|
||||
<!--
|
||||
如此 PR 解决了一个 Issue, 请在下方填写
|
||||
resolves #<issue_number>,例如:
|
||||
resolves #1234
|
||||
-->
|
||||
|
||||
<!-- 若以上均没有,请删除此节 -->
|
||||
|
||||
**变更内容**
|
||||
|
||||
<!-- 请在下方简要描述此 PR 的变更内容 -->
|
||||
|
||||
**变更原因**
|
||||
|
||||
<!-- 请在下方简要描述此 PR 的变更原因 -->
|
||||
+25
-15
@@ -1,4 +1,4 @@
|
||||
name: Docker image build (Relay)
|
||||
name: Build Image (openflare-agent)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -16,6 +16,10 @@ permissions:
|
||||
attestations: write
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
IMAGE_NAME: openflare-agent
|
||||
DOCKERFILE: docker/Dockerfile.agent
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build (${{ matrix.arch }})
|
||||
@@ -46,7 +50,8 @@ jobs:
|
||||
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
|
||||
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
|
||||
|
||||
echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}-relay" >> "$GITHUB_ENV"
|
||||
OWNER="${GITHUB_REPOSITORY_OWNER,,}"
|
||||
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
elif [[ -n "$INPUT_VERSION" ]]; then
|
||||
@@ -58,6 +63,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "IMAGE=ghcr.io/${OWNER}/openflare-agent" >> "$GITHUB_ENV"
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
@@ -75,27 +81,27 @@ jobs:
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./openflare_relay/Dockerfile
|
||||
file: ${{ env.DOCKERFILE }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
cache-from: type=gha,scope=docker-relay-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,ignore-error=true,timeout=20m,scope=docker-relay-${{ matrix.arch }}
|
||||
cache-from: type=gha,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,ignore-error=true,timeout=20m,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }}
|
||||
|
||||
- name: Export digest
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p /tmp/relay-digests
|
||||
touch "/tmp/relay-digests/${DIGEST#sha256:}"
|
||||
mkdir -p "/tmp/${{ env.IMAGE_NAME }}-digests"
|
||||
touch "/tmp/${{ env.IMAGE_NAME }}-digests/${DIGEST#sha256:}"
|
||||
env:
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: relay-digests-${{ matrix.arch }}
|
||||
path: /tmp/relay-digests/*
|
||||
name: ${{ env.IMAGE_NAME }}-digests-${{ matrix.arch }}
|
||||
path: /tmp/${{ env.IMAGE_NAME }}-digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
@@ -126,7 +132,8 @@ jobs:
|
||||
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
|
||||
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
|
||||
|
||||
echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}-relay" >> "$GITHUB_ENV"
|
||||
OWNER="${GITHUB_REPOSITORY_OWNER,,}"
|
||||
|
||||
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
elif [[ -n "$INPUT_VERSION" ]]; then
|
||||
@@ -138,13 +145,14 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "IMAGE=ghcr.io/${OWNER}/openflare-agent" >> "$GITHUB_ENV"
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/relay-digests
|
||||
pattern: relay-digests-*
|
||||
path: /tmp/${{ env.IMAGE_NAME }}-digests
|
||||
pattern: ${{ env.IMAGE_NAME }}-digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
@@ -158,7 +166,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create and push manifest list
|
||||
working-directory: /tmp/relay-digests
|
||||
working-directory: /tmp/${{ env.IMAGE_NAME }}-digests
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
@@ -168,7 +176,7 @@ jobs:
|
||||
done
|
||||
|
||||
if [ ${#references[@]} -eq 0 ]; then
|
||||
echo "No digests found in /tmp/relay-digests" >&2
|
||||
echo "No digests found in /tmp/${{ env.IMAGE_NAME }}-digests" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -182,6 +190,8 @@ jobs:
|
||||
-t "${IMAGE}:${VERSION}" \
|
||||
-t "${IMAGE}:${FLOATING_TAG}" \
|
||||
"${references[@]}"
|
||||
env:
|
||||
IMAGE: ${{ env.IMAGE }}
|
||||
|
||||
- name: Inspect image
|
||||
run: docker buildx imagetools inspect "${IMAGE}:${VERSION}"
|
||||
run: docker buildx imagetools inspect "${{ env.IMAGE }}:${{ env.VERSION }}"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user