Compare commits

..

287 Commits

Author SHA1 Message Date
ryan ae3b792e16 feat(core): sync framework security hardening and accessibility improvements
- add util.Go with panic recovery for background goroutines
- add util.EscapeLike and explicit ESCAPE clause for SQL LIKE queries
- add DummyCheckPassword and subtle.ConstantTimeCompare against timing attacks
- enforce session ID rotation upon login/oauth callback to prevent session fixation
- add sliding window login failure rate limiting and oauth state rate limiting
- fix redis client capture race in pubsub listeners and wait on stop channel
- adjust global --primary to oklch(51.1% 0.262 276.966) for WCAG AA contrast
- fix semantic heading levels and missing aria-labels across UI components
- document security, concurrency, and a11y standards in AGENTS.md
2026-08-27 23:01:28 +08:00
ryan b66cf3ae9c feat(log): PG 分区清理与 logstore import-lint
CleanupExpired 先按月 DROP 过期分区,再删边界行并清理空分区;apps 禁止直连 analytics。
2026-08-16 16:48:15 +08:00
ryan a8fcf6087a chore(message-gateway): swagger and format 2026-08-16 12:27:03 +08:00
ryan 30acdb91e8 feat(message-gateway): add profile bot pairing card 2026-08-16 12:23:44 +08:00
ryan 124ce9bebb feat(message-gateway): add admin channel cards and per-type forms 2026-08-16 12:22:28 +08:00
ryan 635c1760ad feat(message-gateway): add user bind and unbind APIs 2026-08-16 12:19:32 +08:00
ryan 69d39d906f feat(message-gateway): add admin channel CRUD APIs 2026-08-16 12:17:05 +08:00
ryan 09ec9d0af3 feat(message-gateway): run adapters on worker and handle pairing inbound 2026-08-16 12:14:17 +08:00
ryan 7ca6dbe272 feat(message-gateway): add QQ official C2C botgo adapter
Pin github.com/tencent-connect/botgo v0.2.1. Connect uses C2C intent
only via the official WebSocket session manager.
2026-08-16 12:10:15 +08:00
ryan ef97ca5c7e feat(message-gateway): add Telegram private-chat telebot adapter 2026-08-16 12:06:03 +08:00
ryan cc86370e50 feat(message-gateway): emit message_gateway.inbound domain events 2026-08-16 12:04:55 +08:00
ryan 60afdf7c7b feat(message-gateway): add channel, binding, and pairing repositories 2026-08-16 12:04:14 +08:00
ryan 9b1ef1cf7f feat(message-gateway): add w_message_* models and goose migrations 2026-08-16 12:03:23 +08:00
ryan 8ea4c7e13e feat(message-gateway): add channel types, registry, and pairing codes 2026-08-16 12:01:52 +08:00
ryan a4db79e9bd chore: ignore local git worktrees directory 2026-08-16 12:00:42 +08:00
ryan 8b1eb9ca0d docs(message-gateway): add Wavelet message gateway implementation plan 2026-08-16 11:59:59 +08:00
ryan e36db8a56c docs(message-gateway): add Wavelet inbound channel gateway design spec 2026-08-16 11:55:57 +08:00
ryan 37d5a87c9b chore: docs 2026-08-16 11:32:46 +08:00
ryan e52592b16d feat(log): 解耦用户访问日志存储,支持切换日志主库
用户访问日志可在 ClickHouse、PostgreSQL、SQLite 之间切换。
关闭 ClickHouse 时由主库承接写入与查询;切换任务会冻结写入、复制数据后翻转主库。
启动时校验日志主库与运行配置一致,定期清理按各库保留天数删除过期记录。
2026-08-16 11:17:55 +08:00
ryan 6a53619dd2 feat(framework): 回灌 OpenFlare 分层、安全与运行时改进
将平台域持久化收敛为 repository 唯一入口,model 去掉 IO。
邮件头写入前清除 CR/LF,防止 header 注入。
httppool 支持可配置 Transport;batchwriter 增加 MinBatchSize/Stats,flush 失败交回批次;任务 PermanentError 作为 SkipRetry 终态。
设置与推送页的确认改为 AlertDialog;axios 去尾斜杠并按 Gin 数组序列化查询参数。
升级共享 Go 依赖(Gin、Asynq、OTel、GORM、Redis 等)。
2026-08-16 11:07:20 +08:00
ryan b9b42e3174 ci: make canary 2026-08-16 10:13:39 +08:00
ryan 20830d31bd chore: guideline 2026-08-16 10:01:56 +08:00
ryan 284eec54f7 chore: guideline 2026-08-12 12:40:06 +08:00
ryan a3ad0d2c97 chore: rename .agent to .agents and update skill path references 2026-08-12 12:39:15 +08:00
ryan 92322c7a22 feat(push): log upstream webhook response in task history
Pusher.Send now returns the upstream response body alongside the error,
so the push task handler can print what the webhook actually replied
(custom channel e.g. {"errcode":0,"errmsg":"ok"} or a rejection
like {"errcode":93000,...}) into the task log on both success and
failure. Other pushers (lark/telegram/email) return an empty string,
keeping their behavior unchanged.

fix(push): surface webhook business errors in custom channel audit

CustomPusher.Send only checked the HTTP status code. WeChat Work /
DingTalk webhooks return HTTP 200 with a non-zero errcode in the body
even when the message is rejected (e.g. template_card requires
card_action.url when type=1), so rejected pushes were recorded as
'success' in the notification history. Parse the response body and
return an error when errcode is non-zero, matching the Lark pusher.
2026-08-12 12:32:23 +08:00
ryan f499645cdc chore: guideline 2026-08-12 12:02:14 +08:00
ryan 776c6b397c ci: canary 2026-08-03 21:54:08 +08:00
ryan db17965b1a chore: docker-compose 2026-08-03 21:48:34 +08:00
ryan ae35b4e733 ci: docker hub 2026-08-03 21:45:50 +08:00
ryan c6d22549ce ci: canary 2026-08-03 21:32:42 +08:00
ryan 9f15f42b47 fix(frontend): keep browser API on same-origin rewrites in dev
Document that NEXT_PUBLIC_WAVELET_BACKEND_URL must stay unset for local
next dev so axios hits /api/* and Next rewrites proxy to the backend.
2026-08-03 21:25:22 +08:00
ryan a4588b05e0 fix(frontend): suppress body hydration warning from extensions
Browser extensions can inject classes like vc-init onto body before
React hydrates; ignore attribute mismatches on body.
2026-07-24 16:30:49 +08:00
ryan 1625cfb2fb feat(frontend): add next-intl bilingual i18n for core paths
Wire next-intl without locale routes, add zh-CN/en catalogs, language
switcher, and migrate layout/auth/settings UI copy. Document i18n rules
in AGENTS.md and keep static export builds working.
2026-07-24 16:25:41 +08:00
ryan 9c1369186f docs(i18n): add frontend bilingual i18n design spec
Capture the approved next-intl non-routing approach for zh-CN/en,
locale resolution, static-export constraints, and phase-1 core-path scope.
2026-07-24 16:08:45 +08:00
ryan fbbb75095f refactor(structure): group platform, infra, and shared packages
Move process wiring, technical adapters, and cross-cutting contracts out of flat internal/ packages so new code has a clear home without changing business layout.
2026-07-24 15:28:39 +08:00
ryan 290427d5fa chore: remove 2026-07-24 14:29:07 +08:00
ryan 72dbfac3fc chore: update pg version 2026-07-24 11:18:01 +08:00
ryan 018f237384 perf: AGENTS.md 2026-07-22 23:05:23 +08:00
ryan ef28cdc3ad perf: makefile 2026-07-22 22:52:31 +08:00
ryan b1f2241d0a perf: skill 2026-07-22 22:31:18 +08:00
ryan fae83ab0fa perf: biome 2026-07-22 22:26:02 +08:00
ryan b498117f32 perf(skill): clean 2026-07-22 22:19:24 +08:00
ryan ef4e9c9edc feat(frontend): biome 2026-07-22 22:19:07 +08:00
ryan 5fd056f99f chore(release): bump version to v1.4.1
### 🛠 修复
- 修复了 Redis 维护通知(maintenance notifications)在启动阶段默认开启协商可能影响启动流程的问题,新增 `maint_notifications` 启动开关并将其默认值调整为禁用,同时统一应用到平台 Redis 与 Asynq 任务客户端。
- 修复了 PostgreSQL SQL 日志级别配置未按预期生效的问题,将常规 SQL 语句输出统一收敛至 debug 级别,避免在生产日志中产生冗余输出。
- 修复了 CI 镜像构建流水线中缺少 IMAGE 环境变量、导致后续步骤无法正确引用镜像地址的问题。
- 修复了前端 prettier 配置缺失的问题。

### ⚡️ 优化与改进
- 在 API、Worker、Scheduler 等进程启动并完成监听器绑定后,统一打印服务 Banner 信息,便于快速识别各进程的运行状态、监听端口与数据库迁移进度。
- 管理员设置页面在浏览器刷新后自动保持当前选中的标签页,通过 URL query 参数持久化并校验非法取值,提升后台操作的连续性。

### 💄 其他/体验
- 新增前端 prettier 配置文件与忽略规则,统一代码格式化规范。
2026-07-13 16:05:55 +08:00
ryan 08fac67f2a feat(startup): print service banner after listener ready 2026-07-13 15:59:29 +08:00
ryan 9cac25696a fix(redis): add maintenance notification startup switch
Default Redis maintenance notification negotiation to disabled and apply the startup-only setting to both platform and Asynq clients.
2026-07-13 15:48:31 +08:00
ryan a938a5e67f fix(db): log SQL statements at debug level 2026-07-13 15:41:04 +08:00
ryan e0eb4e5975 prettier 2026-07-13 15:17:45 +08:00
ryan 08e61ce833 fix(frontend): prettier config 2026-07-13 15:14:47 +08:00
ryan c293a255d0 feat(frontend): persist selected tab on admin settings page refresh
Store the selected tab of the admin settings page in the URL query string under the tab parameter. Defend against invalid values by validating it against the list of known tabs.
2026-06-30 20:52:24 +08:00
ryan 1855b48028 fix ci 2026-06-28 17:31:57 +08:00
ryan 2310e1d12a chore(release): bump version to v1.4.0
### 🛠 修复
- 修复了前端人机验证(Captcha)中请求和响应信封格式不匹配的问题。
- 修复了由于自定义 CSS 变量命名冲突导致用户删除确认按钮在特定主题模式下变黑的问题。
- 修复了由于注册 `reset-passwd` 命令行后触发 Cobra 严格子命令校验、导致原有以参数形式启动的 `all`、`api`、`worker`、`scheduler` 等模式命令失效的 Bug。

### ⚡️ 优化与改进
- 重构了 OAuth 缓存,将其替换为系统标准 RAM 内存缓存并引入了多节点之间的 Pub/Sub 订阅发布缓存同步机制,增强高频鉴权的稳定性。
- 后端新增 `reset-passwd` 命令行工具,支持管理员在后台快速重置指定用户的密码并使该用户的 Token 缓存立即失效。
- 后台用户管理支持在详情中直接编辑个人资料和重置密码,并在列表页新增“邮箱”字段与支持邮箱前缀 LIKE 模糊条件搜索。
- 前端引入本地自托管的 Inter 字体,搭配系统原生“苹方”(PingFang SC)与微软雅黑,极大提升了西文、数字及中文字符在不同操作系统下的显示与排版效果。
- 优化了 OpenTelemetry 链路追踪配置,使用 Schemaless 规范避免 semconv 产生 Schema 版本冲突。

### 💄 其他/体验
- 清理了系统设置及个人中心等页面中所有硬编码的靛蓝(indigo)样式色彩,统一改用标准的 Primary 主题配色,使其完美支持多主题与亮暗色模式切换。
- 移除了用户编辑弹窗和徽章(Badge)上不合规的硬编码前背景色,确保所有基础组件的配色由主题系统统一驱动。
- 优化了前端 UI,微调了侧边栏激活项的字体颜色,并修复了危险动作(Destructive)按钮在特定模式下的色彩对比度。
- 优化了 CI 流程,提高构建流水线的执行效率。
2026-06-28 11:40:59 +08:00
ryan f5ee19405f fix(cmd): register all app modes as subcommands in cobra
Resolve unknown command error when launching all/api/worker/scheduler modes due to Cobra strict subcommand validation triggered by reset-passwd. Subcommands now run database migrations via dynamic PreRun hooks.
2026-06-28 11:37:26 +08:00
ryan de605834b7 style(frontend): remove hardcoded indigo colors in settings components
Replace all manual bg-indigo and text-indigo overrides with standard CSS variables such as bg-primary/10 and text-primary across common settings modules to support theme integration.
2026-06-28 11:30:52 +08:00
ryan 5a6ffd600a feat(frontend): integrate self-hosted Inter Google font
Use next/font/google to download and host Inter locally, injecting it into html root via Tailwind CSS variables and fallback to PingFang SC for Chinese characters.
2026-06-28 11:20:02 +08:00
ryan bdbc42c22d style(frontend): remove hardcoded text and bg color overrides on components
Remove redundant text/bg style overrides on Button and Badge components in UserDetailSheet and UserFilterBar to allow proper default and destructive theme variants.
2026-06-28 11:08:11 +08:00
ryan 84626f9613 fix(frontend): remove custom classes to fix black delete confirmation button
Remove custom className from AlertDialogAction to prevent CSS variables overlap, restoring the default red background with white text styling.
2026-06-28 11:06:51 +08:00
ryan 932f0c65b9 feat(admin): support user profile editing, password resetting, and email column with search
- Add UpdateUser API and logics supporting nickname, email, admin flag modification, and password reset.
- Relocate user delete button and confirmation Alert into the EditUserModal.
- Optimize admin Switch change to trigger instant API request with rollback support.
- Fix missing email field in edit form initialization by fetching full profile metadata.
- Render email column in users list and support email-based filtering in UserFilterBar.
- Remove hardcoded styles and sizes from Switch components to follow global theme.
2026-06-28 11:05:57 +08:00
ryan 206f8b59c9 feat(cmd): add reset-passwd command to reset user password
- Added ./wavelet reset-passwd subcommand to reset user passwords via CLI
- Supported --user flag; if not specified, prompts for username interactively
- Supported --password flag; if not specified, generates a secure random password
- Handled access token deletion and cache invalidation
- Added comprehensive unit tests
2026-06-28 10:50:15 +08:00
ryan a25a570973 fix(frontend): adjust sidebar active text color and fix destructive button contrast
- Update sidebar active menu button text color to use theme dynamic `sidebar-primary` variable instead of hardcoded hex value.

- Add missing `destructive-foreground` variables to default theme config and styles, resolving the black-on-black text contrast issue on confirmation dialog delete buttons.

- Update changelog to track these fixes.
2026-06-28 10:50:15 +08:00
ryan 681de3b8cc refactor(oauth): replace legacy oauth cache with standard ram cache and add pubsub synchronization
- Replaced custom map-based cache in apps/oauth/cache.go with standard pkg/cache/ram framework.
- Implemented Redis Pub/Sub invalidation channels for distributed token and user cache synchronization.
- Created apps/oauth/cache_test.go to verify local cache operations and pub/sub broadcasts.

refactor(cache): generic RAM cache with CoW and unified preheating

Replaced L2 Redis cache and old cache package with process-local generic pkg/cache/ram. Implemented Copy-on-Write for reads, fine-grained locks per type for writes, and unified preheating in bootstrap. Changed cache invalidation to lazy-loading to resolve SQLite deadlocks during transactions.
2026-06-27 14:17:26 +08:00
ryan a4f6c2ae34 fix(frontend): cap envelope mismatch 2026-06-21 11:20:36 +08:00
ryan 6e6bce6a03 Optimize CI 2026-06-20 13:53:54 +08:00
ryan ef5d5b46af chore(release): bump version to v1.3.1
### 🛠 修复
- 修复了风控中间件测试在 ClickHouse 批写架构迁移后无法正确初始化的问题。
- 修复了 OpenTelemetry trace provider 初始化时 semconv Schema URL 版本冲突导致进程无法启动的问题。

### ⚡️ 优化与改进
- 新增 ClickHouse 独立 OLAP 管线,以 goose 迁移作为唯一 schema 来源,并抽取 analytics repository 统一访问日志读写。
- 新增通用 ClickHouse batchwriter 批量写入框架,支持各业务域独立缓冲 flush 管道与默认批处理调优。
- 风控访问日志与管理端日志查询改为经 repository 层批写与查询,移除内联 SQL 与手动 DDL 维护路径。
- OAuth Access Token 与会话校验引入 RAM+Redis 缓存,降低高频鉴权路径的数据库读取压力。
- 上传元数据、Auth Source 与系统配置批量读取接入三层缓存(RAM→Redis→DB),并通过 pub/sub 支持多节点失效同步。
- 上传统计增量更新收敛为单事务写入,减少 ingest/remove 路径的锁竞争与统计偏差风险。
- ClickHouse 迁移与连接初始化增加进程隔离,避免与主库迁移互相阻塞。
- 引入 lifecycle 优雅停机钩子,确保进程退出前 flush 批写缓冲与释放资源。

### 💄 其他/体验
- 新增 cache-framework 与 clickhouse-batchwriter 开发技能,并更新 AGENTS.md Skill 关联索引。
- 登录与 Token 校验路径增加缓存预热,缩短冷启动后首次鉴权延迟。
2026-06-20 11:20:11 +08:00
ryan a1f6459c09 fix(trace): 使用 NewSchemaless 避免 semconv schema 版本冲突
业务 Resource 改为 NewSchemaless 合并,继承 resource.Default() 的 SDK 内置
schema URL,不再硬编码 semconv 版本路径。
2026-06-20 11:20:01 +08:00
ryan 280bb63cbd chore(release): bump version to v1.3.1
### 🛠 修复
- 修复了风控中间件测试在 ClickHouse 批写架构迁移后无法正确初始化的问题。

### ⚡️ 优化与改进
- 新增 ClickHouse 独立 OLAP 管线,以 goose 迁移作为唯一 schema 来源,并抽取 analytics repository 统一访问日志读写。
- 新增通用 ClickHouse batchwriter 批量写入框架,支持各业务域独立缓冲 flush 管道与默认批处理调优。
- 风控访问日志与管理端日志查询改为经 repository 层批写与查询,移除内联 SQL 与手动 DDL 维护路径。
- OAuth Access Token 与会话校验引入 RAM+Redis 缓存,降低高频鉴权路径的数据库读取压力。
- 上传元数据、Auth Source 与系统配置批量读取接入三层缓存(RAM→Redis→DB),并通过 pub/sub 支持多节点失效同步。
- 上传统计增量更新收敛为单事务写入,减少 ingest/remove 路径的锁竞争与统计偏差风险。
- ClickHouse 迁移与连接初始化增加进程隔离,避免与主库迁移互相阻塞。
- 引入 lifecycle 优雅停机钩子,确保进程退出前 flush 批写缓冲与释放资源。

### 💄 其他/体验
- 新增 cache-framework 与 clickhouse-batchwriter 开发技能,并更新 AGENTS.md Skill 关联索引。
- 登录与 Token 校验路径增加缓存预热,缩短冷启动后首次鉴权延迟。
2026-06-20 10:51:27 +08:00
ryan f52c8db21a perf(cache): 三层缓存框架补强
- 新增 cache-framework skill,规范 RAM→Redis→DB 读路径、失效与 pub/sub
- 上传元数据 Otter+Redis 缓存与多节点失效;Auth Source 缓存与 pub/sub
- ListSystemConfigsByKeys 补 Redis 层;上传统计单事务;登录/Token 缓存预热
- cleanup 任务补 upload meta 失效钩子
2026-06-20 10:20:35 +08:00
ryan d490030b75 fix: test 2026-06-20 09:58:37 +08:00
ryan 200525a1ab perf: refactor 2026-06-20 09:54:48 +08:00
ryan cac6e88bc0 perf: refactor 2026-06-20 09:46:29 +08:00
ryan 080be1e03a perf: access token cache 2026-06-20 09:46:29 +08:00
ryan d0a9958711 perf: clickhouse isolation 2026-06-19 21:31:03 +08:00
ryan a98d266278 feat(db): add ClickHouse batchwriter framework and skill
Introduce internal/db/batchwriter as a reusable generic buffered writer
for per-domain ClickHouse flush pipelines, with unit tests and default
batch tuning aligned with audit log ingestion.

Add clickhouse-batchwriter agent skill and cross-references in AGENTS.md
and database-migration. Business layers are not wired yet.
2026-06-19 20:59:07 +08:00
ryan 00593742c3 feat(clickhouse): integrate goose migrations and analytics repository
Add a separate ClickHouse OLAP pipeline with goose/clickhouse DDL as the
sole schema source, model/analytics for ORM mapping, and repository/analytics
for reads (ChDB/GORM) and batch writes (ChConn). Refactor risk_control and
admin/logs to use the repository layer instead of inline SQL. Remove the
manual support-files DDL and document the workflow in database-migration skill.
2026-06-19 12:04:22 +08:00
ryan 13e9fead35 chore(release): bump version to v1.3.0
### 🛠 修复
- 修复了存储驱动切换保存后上传记录未同步指向新后端的问题。
- 修复了 S3 不可达时无法暂存本地存储配置的问题。
- 修复了文件软删除后增量统计未正确扣减的问题。
- 修复了 Snowflake ID 生成异常或出现负值时的不可靠行为,增加重试与集中错误处理。
- 修复了自更新流程中解压文件名不匹配导致更新失败的问题。
- 修复了用户列表按 ID 排序方向不正确的问题。
- 修复了前端服务层循环依赖导致构建失败的问题。

### ⚡️ 优化与改进
- 新增 upload.Ingest 程序化上传域服务,统一对象写入、上传记录与增量统计,支持秒传、Worker 摄取与镜像去重策略。
- 新增文件上传统计全量重建异步任务,可在管理端触发以修复历史统计偏差。
- 存储配置收敛为单一 storage_config 来源,移除逐条上传记录的 storage_driver 冗余字段。
- 抽取 repository 层并瘦身 HTTP Handler,统一 Abort 系列错误响应与链路追踪集成。
- 将进程级初始化从 router 迁至 bootstrap/cmd,任务与推送改为显式装配,消除 init 副作用。
- 认证与用户模块通过 listener 域事件解耦推送,避免核心业务直接依赖通知模块。
- 分离 user 模块 Handler 与 Logic 边界,便于 Worker 与单元测试复用业务逻辑。
- 引入系统配置内存缓存与验证码运行时配置快照,降低高频配置读取的数据库压力。
- 优化上传热路径与管理端增量统计表,文件统计查询由全表扫描降为常数级读取。
- 前端拆分管理端 bundle、并行化鉴权与公共配置加载,并虚拟化日志列表以提升首屏与滚动性能。

### 💄 其他/体验
- 新增 file-upload 开发技能,并将 AGENTS.md Skill 索引整理为分类表格。
- 优化文件管理统计页在 Tab 切换与再次进入时的自动刷新,避免展示过期缓存数据。
- 优化异步任务日志的展示与清理体验。
- 前端管理组件就近归位并拆分服务层目录,提升代码可维护性。
2026-06-18 15:14:38 +08:00
ryan 83b5475d69 fix(frontend): refresh file storage stats on tab and page revisit
Set stats query staleTime to 0 with refetchOnMount always, and unmount
inactive file admin tabs so returning to the stats view triggers a new fetch
instead of serving the 30s React Query cache.
2026-06-18 15:13:09 +08:00
ryan 990e3a6f51 feat(upload): add rebuild stats async task 2026-06-18 15:05:52 +08:00
ryan 1cad0c5c55 feat(upload): add programmatic Ingest service and file-upload skill
Introduce upload/ingest as the single domain entry for storing files,
writing w_uploads records, and maintaining incremental stats. Refactor
HTTP UploadFile and delete handlers to delegate to ingest, fix stats
decrement ordering on Remove, and document usage in the file-upload skill.
2026-06-18 14:57:47 +08:00
ryan 9c6c697d95 fix(user): user id asc 2026-06-18 14:00:21 +08:00
ryan 410ff14795 refactor(storage): drop per-upload storage_driver, use storage_config as single source
Remove w_uploads.storage_driver and route all read/write/delete paths through
storage.Active() backed by storage_config.driver. Block direct driver switches
when uploads exist; require migration task instead. Simplify migration to
cursor-based file_path iteration without per-row driver updates.
2026-06-18 13:56:31 +08:00
ryan 1f391e9ec2 fix(storage): apply driver switch on save and repoint upload records
Saving storage settings now activates the selected driver immediately
instead of staging it until migration. When the driver changes, existing
upload rows are repointed to the new storage_driver so /f/{id} reads use
the correct backend. ForDriver can also open non-active drivers from the
saved multi-backend config.
2026-06-18 13:40:29 +08:00
ryan 6fa7034172 update AGENTS.md 2026-06-18 13:32:44 +08:00
ryan e766066a75 fix(storage): allow staging local config when S3 is unreachable
When switching storage drivers in admin settings, save now validates
connectivity against the selected target backend instead of retesting
the still-active driver. The active driver remains unchanged until
migration completes, so unreachable MinIO no longer blocks saving local
storage settings.
2026-06-18 13:28:56 +08:00
ryan 2648f3f3b7 merge main into repository-context-enabled-background 2026-06-18 12:13:45 +08:00
ryan 1b2e083aec refactor(api): extract repository layer and thin HTTP handlers
Introduce internal/repository for data access and cache-backed system
config reads. Move business logic into logics.go across admin push,
user, template, cache, system_config, and upload/handler packages.

Remove Gin from internal/util by relocating request-scoped helpers to
oauth/gin_context.go. Propagate request context for config lookups in
user flows. Slim model entities and delete model-level DB/cache helpers.

Wire handlers to logics/repository so targeted packages no longer call
db.DB directly. Update admin router tests to use ErrorHandlerMiddleware.
2026-06-18 12:12:49 +08:00
ryan dd991909af test(response): fix AbortWithError router tests and oauth/bootstrap reliability
- Add middleware_test.go covering ErrorHandlerMiddleware and Abort helpers
- Switch router test setups to testhelper.NewTestGinEngine for error JSON
- Fix OAuth provider cache to use mock HTTP client and normalize issuer URLs
- Add ResetInitRuntimeOnceForTest to make bootstrap tests hermetic under -count
- Update admin/task test imports for upload/task package move
2026-06-18 12:05:56 +08:00
ryan e5b3a60f73 merge main into handler-model-logics-user 2026-06-18 10:55:18 +08:00
ryan beae8d2dd9 refactor(frontend): colocate admin components and split service layer
Move route-specific admin components from components/common/admin into
app/(main)/admin/<feature>/components/. Split AdminService god object into
domain services, fix UploadService to use BaseService, add AdminUploadService,
consolidate DB export into DbManageService, and migrate consumers to the
unified services entry.
2026-06-18 10:55:14 +08:00
ryan 3d25a377cb merge: integrate http-handler-abort-error architecture refactor
Resolve user package conflicts by keeping main's service-layer logics and
applying Abort* error handling in routers. Align oauth callback with
listener.EmitAdminLoggedIn from main.
2026-06-18 10:53:03 +08:00
ryan 9af84c8ed6 refactor(api): unify error handling and split upload/oauth god modules
Replace c.JSON(200, response.Err) and middleware gin.H bypasses with
response.Abort* helpers so errors flow through Gin Error chain and
ErrorHandlerMiddleware for OTel trace correlation.

Split oauth/sources.go into domain-focused files and decompose upload
into handler/filesrv/stats/task/cache/storage/util subpackages with a
root facade preserving existing import paths.
2026-06-18 10:51:25 +08:00
ryan 13f823e5e3 test(bootstrap): fix review findings and sync architecture docs
- Register tasks in admin/task test setup after init() removal
- Strengthen bootstrap test: RegisterPushDomainEvents before Init
- Add admin_login auth→push listener integration test
- Update AGENTS.md and push/new-async-task/new-api skills for
  bootstrap composition root, listener domain events, and explicit
  test wiring conventions introduced since 50c45db5
2026-06-18 10:50:45 +08:00
ryan dac1c979d1 chore: remove accidental .grok hook files from merge 2026-06-18 10:39:37 +08:00
ryan de8a21a49f merge main: resolve bootstrap and router init conflicts 2026-06-18 10:38:55 +08:00
ryan 03fa5d948d refactor(bootstrap): move runtime init from router to cmd layer
Extract SyncEvents and InitLogWriter from router.Serve into bootstrap.Init
called from cmd entry points with trace-aware context. Preserve existing
Register* wiring for task and push domain integrations.
2026-06-18 10:38:08 +08:00
ryan fcf17db2e7 merge: replace init registration with bootstrap wiring 2026-06-18 10:34:57 +08:00
ryan 22c2ad5c73 refactor(task): replace init registration with bootstrap wiring
Introduce internal/bootstrap as the composition root with sync.Once
guards for task handler registration and push listener wiring. Replace
the single OnTaskCompleted global hook with multi-subscriber handlers
and remove init()-driven side effects from worker, admin task, and push.
2026-06-18 10:34:49 +08:00
ryan 1135347a96 merge: decouple auth from push via domain events 2026-06-18 10:31:14 +08:00
ryan b659f47b62 merge: refactor(user) separate Handler and Logic layer boundaries 2026-06-18 10:31:12 +08:00
ryan 6066eb114b refactor(auth): decouple auth from push via domain events
Introduce internal/listener as a domain event bus so oauth and user
modules emit AdminLoggedIn without depending on admin/push. Register
push handlers explicitly at the router composition root, replacing
init() side-effect registration and blank imports.
2026-06-18 10:31:11 +08:00
ryan 50c45db561 refactor(user): separate Handler and Logic layer boundaries
Move HTTP handlers out of logics.go and replace gin.Context-coupled
login email verification with context-only processLoginEmailVerification.
Add logics_test.go for pure business logic unit tests.
2026-06-18 10:31:09 +08:00
ryan eb99628cd6 dmux 2026-06-18 10:29:48 +08:00
ryan f826807cf1 feat(task): clean task log 2026-06-17 14:29:38 +08:00
ryan 333e45572a fix(frontend): optimize 2026-06-17 13:58:32 +08:00
ryan 3a05e7f09c refactor(idgen): centralize negative ID handling via panic
Restore NextUint64ID() to uint64-only API so callers need no error
checks. Retry logic stays in idgen; after 3 negative values it panics
instead of returning 0, preventing silent NULL primary keys.
2026-06-17 13:14:17 +08:00
ryan 92dc6a59c0 fix(idgen): retry snowflake generation and error on negative ID
NextUint64ID now retries up to 3 times when Int64() is negative, then
returns an error instead of 0 or Fatalf. All call sites propagate the
error to avoid GORM omitting zero-value primary keys.
2026-06-17 13:11:16 +08:00
ryan a27113feb2 fix(frontend): break service layer circular imports for build
Point service implementations at @/lib/services/core for BaseService
instead of the barrel index, fixing static export prerender failure on
ConfigService initialization.
2026-06-17 12:17:05 +08:00
ryan a44043262e perf(frontend): split admin bundles and tighten data fetching
Add next/dynamic lazy loading for database, logs, and settings heavy
modules; migrate access-logs and task-executions to React Query; parallelize
login auth-sources with public config; consolidate users table tooltips;
enable lazy image decoding; and replace barrel @/lib/services imports with
direct module paths.
2026-06-17 12:12:47 +08:00
ryan 9550fa6ff3 perf(cap): add runtime settings snapshot for dynamic config
Replace per-request GetByKey calls with a singleflight-backed
RuntimeSettings snapshot loaded via ListSystemConfigsByKeys.
Invalidate snapshot on admin cap_* writes and via Redis pub/sub.
Simplify VerifyMiddleware and update PERFORMANCE.md status.
2026-06-17 12:07:02 +08:00
ryan d3ba767087 perf(config): add Otter RAM cache layer for system configs
Introduce pkg/cache/ram on top of existing Redis/DB config reads.
GetByKey now checks local RAM before Redis. Unified invalidation clears
RAM and Redis hash fields on admin writes, with pub/sub for multi-node
RAM eviction. Add tests and wire create/update/migrator write paths.
2026-06-17 11:57:04 +08:00
ryan ca21e0eca7 perf(frontend,config): parallelize auth, virtualize logs, cache public configs
Frontend layout renders immediately; pages gate via RequireAuth and
useAuthRedirect. Login/register skip getUserInfo. Admin log panel uses
useVirtualizer to avoid 2000-row DOM. ListVisibleSystemConfigs caches
visibility=1 configs in Redis with invalidation on create/update.
PERFORMANCE.md marks completed optimizations.
2026-06-17 11:41:24 +08:00
ryan 6a8f9a7aea perf(upload): optimize file hot paths and incremental admin stats
- Use RWMutex for disk cache reads and singleflight for WebP cache misses
- Cache migration read-only state and file access whitelist with pub/sub invalidation
- Add w_upload_stats incremental counters updated on upload/delete
- Add w_uploads composite indexes and goose backfill migrations
- Document performance analysis in docs/PERFORMANCE.md
- Fix RegisterCustomRoutes to accept apiV1Router parameter
2026-06-17 11:33:48 +08:00
ryan e057cd5de5 fix(update): extracted filename mismatch 2026-06-16 10:51:58 +08:00
ryan 82155b8599 chore(release): bump version to v1.2.0
### 🛠 修复
- 修复了异步任务列表在成功状态下允许重试、失败状态下无重试按钮的问题。
- 修复了启用推送事件时,在无可用推送渠道配置下仍尝试触发并阻塞的问题。
- 修复了 WebDAV 存储驱动在 Put/Get/Delete 等操作中由于丢弃 context.Context 导致 HTTP 链路追踪断裂(生成无源 Root Span)的问题。
- 修复了结构化日志在没有 active span 时仍强制打印全零 traceID/spanID 产生的日志冗余噪音。

### ⚡️ 优化与改进
- 实现了全新的系统通知推送机制,支持 Telegram Bot、Lark 机器人及自定义 Webhook 等多种推送渠道。
- 优化了路由结构设计,按照 V1 分类与业务模块实现扁平化的路由解耦。
- 引入了全局 OpenTelemetry 链路追踪(Tracer)框架,集成 Gin, GORM 与 Redis 自动化耗时度量,并补充了统一的全局错误处理中间件。
- 优化了采样器命名,将 ParentBasedErrorAwareSampler 重命名为更契合其真实机制的 ParentBasedRatioSampler。
- 重构并统一了项目架构为基于 Feature 的功能模块化结构,将 internal/util/ 拆分得更加纯净,优化了验证码等公共库提取(pkg/cap)。
- 实现了推送事件与自定义通道的 Redis 缓存机制,极大降低了推送触发时的高频 DB 查询压力。

### 💄 其他/体验
- 优化了前端界面布局,同步系统菜单与侧边栏配置显示。
- 优化了前端自定义通道表格的布局与样式,使其与事件管理 Tab 页面保持一致。
- 更新了项目开发技能手册(Skills),包括新增接口路由规范(new-api)和异步任务开发指南(new-async-task)。
2026-06-16 10:39:22 +08:00
ryan f129a9cfca feat(api): implement global error handler middleware and trace integration
- Introduce APIError type and AbortWithError helper in response package
- Implement errorHandlerMiddleware to record Go errors to Otel Spans and format JSON response
- Register errorHandlerMiddleware globally in router
- Refactor logs analytics handler to use the new unified error pattern
2026-06-16 10:24:14 +08:00
ryan 9fa38dcfdc feat(push): implement Redis caching for push events and custom channels
- Implement cached queries in GetActivePushEventByKey and GetActivePushChannelByName.
- Set TTL for cached items to 24 hours via activePushEventCacheTTL and activePushChannelCacheTTL constants.
- Implement GORM hooks (AfterSave and AfterDelete) on PushEvent and PushChannel to auto-evict Redis caches, guaranteeing cache consistency.
- Evict Redis caches manually inside API handlers for Create/Update/Delete/Toggle event/channel endpoints.
- Update events.go to query models through the new caching methods.
2026-06-16 10:19:06 +08:00
ryan 9515f0811b chore: fix release ci 2026-06-16 10:08:21 +08:00
ryan c6df73e522 tracer 2026-06-16 09:58:15 +08:00
ryan bfa00ae6ac feat(push): validate push channels presence before enabling push event
- Reject toggling an event to enabled in ToggleEvent handler if channels list is empty.
- Add model-level validation in PushEvent.Validate() to prevent enabling events without channels during creation/updates.
- Update TestPushRouters/toggle_event_status unit test to cover this verification.
2026-06-16 08:59:03 +08:00
ryan 6a913701b9 refactor(push): remove legacy push_global_token setting
- Clean up redundant push_global_token INSERT and DELETE statements from 202606140004 migration.
- Add DELETE for push_global_token in 202606160001 migration to wipe out legacy settings on update.
- Adjust expected w_system_configs count in migrator_test.go to 30.
2026-06-16 08:57:35 +08:00
ryan ff60f1c699 refactor(push): clean up legacy push_config and w_push_events hardcoded insertions
- Remove ConfigKeyPushConfig and delete legacy push_config query logic from EventTrigger.Trigger.
- Simplify the push event notification dispatching engine to rely purely on database custom channels.
- Remove w_push_events hardcoded INSERT statement from migration files, letting SyncEvents handle default event registration.
- Rewrite unit tests to use model.PushChannel instead of push_config.
2026-06-16 08:54:32 +08:00
ryan e50e600bc8 docs(skill): update router packaging and categorization guidelines in documentation
- Updated `new-api` skill `SKILL.md` to document the centralized v1.go route registration and domain-driven sub-routing patterns
- Updated `AGENTS.md` instructions with the new router subpackages (`root`, `v1`) and route file layout guidelines
2026-06-16 00:00:53 +08:00
ryan 0adc5e8189 refactor(router): organize root routes and centralize v1 route registration
- Created `internal/router/root` package to register root-level paths
- Moved files serving, robots.txt, and Swagger docs to `root/default.go`
- Registered custom root routes under `root/custom.go`
- Centralized all v1 routes registration in `internal/router/v1/v1.go`
- Simplified `internal/router/router.go` by delegating route registration
- Updated `new-api` skill `SKILL.md` to document the new router structure
2026-06-15 23:42:48 +08:00
ryan dfcbdfe47c docs(skill): update new-api guidelines to reflect router v1 structure
- Updated package structure diagram and routing instructions to point to the new `internal/router/v1` package
- Changed custom routes registration guide to `internal/router/v1/custom.go`
2026-06-15 23:30:45 +08:00
ryan 5016103774 refactor(router): split route registration into v1 package categorizations
- Created `internal/router/v1` subpackage
- Split routes into `admin.go`, `user.go`, `public.go`, and `custom.go`
- Cleaned up imports and helper registration functions from `router.go`
- Removed obsolete `internal/router/custom.go`
2026-06-15 23:30:16 +08:00
ryan 363d2cb4cc refactor(router): merge frontend.go and frontend_embedded.go into one file
- Declared package-level `registerFrontend` variable in `router.go` as a no-op fallback
- Configured `frontend.go` under `embed_frontend` build tag to override `registerFrontend` on package initialization
- Removed separate `frontend_embedded.go`
- Resolved related revive linter warnings
2026-06-15 23:24:09 +08:00
ryan 34de1639f5 feat(frontend): sync menu display config items with sidebar layout
- Added missing routes `/files` and `/admin/push` to menu display management
- Standardized icons, labels and descriptions to match the sidebar layout
- Declared MenuItem/MenuGroup interfaces for type safety
2026-06-15 23:18:04 +08:00
ryan 7f00ed6a65 fix(task): fix manual retry button visibility and validation
- Only show the manual retry button for failed tasks (status === "failed") on the frontend.
- Remove the maximum retry limit validation for manual retries in the task executor.
- Remove obsolete test cases verifying maximum retry limit for manual retries.
2026-06-15 23:10:36 +08:00
ryan 3e8e879661 docs(architecture): update architectural guidelines to reflect feature-based service design
- Updated AGENTS.md, new-api SKILL.md, and new-async-task SKILL.md to remove references to the deleted global internal/service/ package.
- Documented feature-based local logics/services design within internal/apps/<module>/.
- Updated package comments in internal/diskcache to point to pkg/cache/disk.
2026-06-15 17:24:44 +08:00
ryan 239711cea7 refactor(service): adopt feature-based architecture and rename pkg/diskcache
- Moved GORM/Redis CAPTCHA manager from internal/service/cap directly into the cohesive CAPTCHA app folder at internal/apps/cap/.
- Moved background system cleanup handler from internal/service/cleanup.go into internal/apps/upload/cleanup.go.
- Completely removed the global internal/service directory to keep module logic self-contained.
- Renamed the core utility engine pkg/diskcache to pkg/cache/disk to separate underlying utility code from db/config integrations.
- Renamed DiskCache struct in pkg/cache/disk to Cache to resolve revive package-name stuttering warning.
- Regenerated Swagger API documentation and confirmed all tests compile and pass with 0 linter issues.
2026-06-15 16:45:26 +08:00
ryan 953af7d8db refactor(util): move response helper to common/response and session logic to oauth
- Relocated generic HTTP response helpers (Response, OK, Err, etc.) from internal/util/ to a dedicated internal/common/response/ package.
- Renamed ResponseAny to Any to resolve revive stuttering warnings.
- Moved session building options and cookie headers logic from internal/util/ to internal/apps/oauth/.
- Removed all direct imports of Gin/Sessions/HTTP frameworks from internal/util/ to keep general utilities 100% pure.
- Regenerated Swagger API documentation via make swagger.
- All tests and make code-check compile and pass with 0 issues.
2026-06-15 16:39:55 +08:00
ryan b3ed94342c refactor(backend): extract to pkg/cap 2026-06-15 16:39:55 +08:00
ryan 84ae4ec27e refactor(frontend): optimization 2026-06-15 16:39:55 +08:00
ryan 1425fd1dbb feat(frontend): align custom channels table layout and styling with events tab 2026-06-15 16:39:55 +08:00
ryan 2a3b9f8a5f feat(push-task): connect notification module with task module
- Add task_type to w_push_events table and GORM models.
- Implement OnTaskCompleted callback hook in task executor to avoid circular dependencies.
- Implement task listener in push package to trigger notifications on task completion.
- Automatically resolve User objects from payload and results.
- Enhance UI to select task completed events and preview default templates.
- Update Swagger documentation.
2026-06-15 16:39:55 +08:00
ryan de58b118b4 feat(push): add telegram bot push notification channel
Implement TelegramPusher in pkg/push, register config schema in channels_definition.go, add validation in model/push_channel.go, update task routing, and update settings-tab.tsx UI validation.
2026-06-15 15:00:49 +08:00
ryan cb018b3b60 feat(push): implement system notification and push framework 2026-06-14 23:07:08 +08:00
ryan aee457093d chore(release): v1.1.0
### 🛠 修复
- 修复了 CAPTCHA 验证的逻辑和安全性。

### ⚡️ 优化与改进
- 新增了动态存储配置与多后端迁移机制。
- 新增了手动触发存储迁移的 Web GUI 操作界面。
- 实现了存储迁移的并发处理(使用 Group)与上传后 SHA-256 完整性自动校验机制。
- 实现了基于 Redis 的分布式锁与集群多节点缓存失效广播机制,防止迁移任务冲突。
- 实现了 Redis 锁续期守护协程 (watchdog) 防止超长迁移任务锁过期。
- 优化了更新存储配置时的连通性自动校验机制,防止配置错误。
- 优化了存储配置的内存缓存机制,并引入了全局共享连接池以提高 TCP 复用率。
- 优化了上传前已有同名文件的比对逻辑,跳过下载阶段,仅比对 Content-Length 以实现零网络流量跳过。
- 新增了普通用户的独立文件管理 Dashboard 与控制接口。
- 优化了文件管理接口命名空间,将全局管理迁移至管理员级 API Namespace 隔离控制。
- 优化了管理员新建用户接口,增加了邮箱(Email)字段的必填要求。

### 💄 其他/体验
- 重构并优化了文件管理页面,引入了多标签页多维度统计数据大屏。
- 基于 shadcn 原生 UI 组件重构并优化了文件详情和仪表盘组件。
- 抽取后端核心路由注册流程,降低主路由文件圈复杂度,使路由配置更易维护。
2026-06-13 17:13:36 +08:00
ryan 5698169f20 refactor(router): extract route registration into helper methods 2026-06-13 16:29:27 +08:00
ryan 922f764241 feat(user): require email when admin creates a user
- Add `email` as a required field in `createUserRequest`
- Enforce email format verification and database uniqueness checks in the admin user creation handler
- Update the admin user creation frontend modal with validation and form field
- Update the corresponding backend unit tests and regenerate Swagger docs
2026-06-13 16:23:07 +08:00
ryan 5b13d5b464 feat(storage): implement user-group level file management dashboard and APIs
- Expose user-scoped CRUD APIs under `/api/v1/upload` (my files query, stats, rename, delete)
- Update backend handlers and routers with ownership validation checks
- Create a dedicated frontend personal file manager card-list and upload button under `/files`
- Add comprehensive backend test coverage and update API docs
2026-06-13 16:16:43 +08:00
ryan 8b19ffed90 refactor(storage): move file management routes from user to admin namespace
- Remove file list, stats, download, and deletion routes from '/api/v1/upload'
- Move these endpoints under '/api/v1/admin/uploads'
- Remove user-specific filtering from files query and statistics to aggregate system-wide uploads by default
- Allow admins to bypass ownership check when downloading private files
- Update backend unit tests, Swagger documentation, and frontend service client and components
2026-06-13 16:04:21 +08:00
ryan dbabe8b8d7 feat(system_config): validate storage configuration connectivity on update
- Add a live test connectivity check in UpdateSystemConfig before saving the storage configuration.
- Merge masked placeholder secrets from current configuration prior to testing and database storage.
- Extract validation and test logic to validateAndMergeStorageConfig helper to satisfy cyclomatic complexity.
- Add TestUpdateStorageConfigValidation covering successful updates and failed checks.
2026-06-13 15:57:41 +08:00
ryan 1d37242a8b refactor(storage): update Backend.Put to return PutResult and encapsulate bucket mapping
- Update Backend.Put method signature in storage.go to return (PutResult, error).
- Adjust all backend implementations (local, oss, s3, webdav) to return a PutResult enclosing Key and Bucket.
- Refactor storeUploadFile in upload routers.go to extract key/bucket from PutResult, eliminating manual config bucket lookups.
- Remove the unused cfgBucket helper from storage_ops.go.
- Adjust storage_migration_task.go and tests to accommodate the updated method signature.
2026-06-13 15:48:41 +08:00
ryan ce9423f877 feat(upload): implement Redis lock renewal watchdog for storage migration
- Add a watchdog goroutine that periodically extends the Redis lock TTL every 10 minutes to prevent premature lease expiration for long-running migrations.
- Use context.Background with timeout contexts for lock renewal and final deletion to prevent parent context cancellation from aborting lock cleanup.
- Add nolint directives for gosec and contextcheck and define constants for durations to satisfy strict code quality gates.
2026-06-13 15:31:39 +08:00
ryan 66e0a69666 feat(frontend): support manual storage migration triggering
- Modify storage-config-tab.tsx to separate storage configuration saving from task dispatching.
- Save operations now always preserve the currently active storage driver to prevent premature storage engine switching.
- Add a manual '开始迁移' button to trigger storage migration explicitly.
- Display a warning notice to the operator when the selected storage type differs from the active one.
2026-06-13 15:31:39 +08:00
ryan 26a1e71a27 feat(storage): implement Redis distributed lock and cache invalidation broadcasting
- Add Redis distributed lock in Execute of MigrationHandler to prevent concurrent storage migrations.
- Define storage:config_invalidation Redis pub/sub channel to broadcast cache invalidation events.
- Implement background pub/sub listener on all nodes to evict config memory cache concurrently.
- Add integration tests for distributed locking and invalidation propagation using miniredis.
2026-06-13 15:31:39 +08:00
ryan acd430836f feat(upload): implement parallel storage migration and integrity check
- Parallelize storage migration using `errgroup` with a concurrency limit of 10.
- Perform post-copy SHA-256 data integrity validation to prevent silent data corruption.
- Add test case verifying migration with both incorrect and correct hashes.
2026-06-13 15:31:39 +08:00
ryan bc18800b58 perf(storage): add config caching and shared HTTP connection pool
- Implement thread-safe local config caching with a 5s TTL check.
- Reuse backend client singletons in storage.Active and storage.ForDriver.
- Reset in-memory config cache on configuration saves and updates.
- Create internal/httppool package to manage shared HTTP transports.
- Configure WebDAV client and CDN retrieval to reuse the shared pool.
- Add unit tests for httppool and storage caching behaviors.
2026-06-13 15:31:39 +08:00
ryan 9a18bea324 feat(storage): add dynamic storage config and migration
Move storage backend configuration from startup YAML to system_config-backed runtime configuration. Add local, S3-compatible, R2, MinIO, OSS, and WebDAV backend support.

Add a storage migration async task using the existing task dispatch framework. Migration target config is carried in task payload, and maintenance mode is derived from task execution state.

Split upload file management and storage operations, add the admin storage configuration tab, and update migrations and Swagger docs.
2026-06-13 15:31:38 +08:00
ryan 4bf8a4806e refactor(frontend): split files coordinator into separate tab components 2026-06-13 12:53:15 +08:00
ryan 9453af2aa0 refactor(frontend): style files stats tab with native shadcn
refactor(frontend): style files stats tab with native shadcn charts
2026-06-13 12:49:29 +08:00
ryan b262880189 refactor(auth): improve session security, CAPTCHA validation and code hygiene
- Integrate CapWidget with dual-scope capability on the frontend and protect registration/send-email-code endpoints on the backend.
- Set session cookie SameSite mode to Lax.
- Propagate request context through auth source database operations and optimize username uniqueness validation.
- Standardize local error naming to camelCase and resolve references.
- Fix linter rules, missing SheetContent closing tag, and unit tests.
2026-06-13 12:33:01 +08:00
ryan 04280a7b11 feat(upload): refactor file management with statistics and multi-tab layout 2026-06-13 12:32:42 +08:00
ryan 40e83a832f add(skill): code review 2026-06-13 11:33:47 +08:00
ryan e5c661f957 add(skill): code review 2026-06-13 11:28:17 +08:00
ryan 49bd850c8b fix(frontend): repair login session flow
Resolve login-page 401 hangs and redirect races by relying on the shared user state. Keep protected-route redirects intact, clean pending requests without unhandled rejections, and allow the dynamic icon route through the page proxy.
2026-06-13 11:27:30 +08:00
ryan 9a6bb04bf5 fix(frontend): distinguish initial authentication state on login page mount
- Add wasUserPresentRef to detect if the user was already authenticated on initial page load.
- Guard the useEffect redirect block so that it only redirects automatically if the user was already authenticated when mounting.
- Prevent duplicate concurrent router.replace calls from canceling each other when logging in via the form.
2026-06-13 11:09:55 +08:00
ryan 0df4712831 fix(frontend): resolve login redirect loop and clean up info tab
- Prevent infinite session probe requests on the login page by guarding the check with the authenticated user state and using the Latest Ref pattern.
- Decouple useEffect from resolveRedirectTarget by using resolveRedirectTargetRef to avoid searchParams dependency loops.
- Remove the unused '服务连接' Card from the settings info tab and clean up unused imports, queries, and properties.
2026-06-13 11:07:27 +08:00
ryan 30aa89f686 chore(release): v1.0.2
### 🛠 修复
- 修复了修改密码时不会吊销现有会话和访问 Token 的问题,确保密码更改后,所有其它客户端会话和 Access Token 立即失效,防范被盗凭据的持续利用。
- 修复了 WebSocket 连接未校验 Origin 的问题,引入严格的 Origin 允许列表校验,防范跨源 WebSocket 劫持攻击 (CSWSH)。
- 修复了未配置服务地址时 CORS 中间件原样反射 Origin 的问题,严格限制允许跨域访问的源为精确配置的 Origin 列表,增强跨域请求安全性。
- 修复了已认证用户可以通过上传记录 ID 直接越权读取其他用户私有文件的问题,引入了基于文件所有权及权限模式的严格访问控制。
- 修复了 OIDC 策略强制执行不严以及未登录用户能够触发自动绑定导致账户接管的问题,增强了 OAuth 绑定过程中的策略校验。
- 修复了 OAuth 流程中 State 校验不严的问题,在 Session 中强制绑定 State 并在回调时进行一致性验证,防止 CSRF 和账号接管攻击。
- 修复了登录或认证重定向时未对 URL 目标域进行限制的问题,实现了重定向目标 URL 的安全净化与源校验,防范开放重定向与反射型 XSS 漏洞。
2026-06-13 10:31:54 +08:00
ryan 26e12594a2 fix(user): revoke all sessions and access tokens on password change
- Store user password hash in session during login

- Validate password hash compatibility on requests to prevent session reuse

- Revoke all user access tokens and clear session on ChangePassword
2026-06-13 10:27:28 +08:00
ryan eb999eba09 fix(logs): restrict websocket origin to prevent cswsh (LOG-2)
- Restrict WebSocket upgrade to same-origin or configured server_address allowed origins.
- Add comprehensive test suite in utils_test.go to verify origin matching rules.
2026-06-13 10:25:25 +08:00
ryan f6f8c25930 fix(router): restrict CORS origin reflection to allowed hosts (AUTH-ROUTE-5)
- Extract isOriginAllowed helper to match Origin against server_address configurations
- Ensure arbitrary origins are not reflected and credentials are not allowed when server_address is unconfigured or mismatched
- Trim trailing slashes from allowed origins configuration for robust matching
- Add TestCORSMiddleware to cover all CORS matching and rejection scenarios
2026-06-13 10:23:20 +08:00
ryan 7b379863b4 fix(upload): restrict cross-user private file access (UPLOAD-1)
- Add access_mode column to w_uploads table (0 = private, 1 = public) and initialize data in a single migration script
- Enforce strict ownership check for private files during download
- Allow public files to follow whitelisted public-access rules
- Default access_mode to public for avatars and private for generic uploads
- Update frontend service to support optional accessMode parameter
2026-06-13 10:13:41 +08:00
ryan 5412c385dc fix(oauth): remove pending oauth auto-binding and enforce oidc policies
- Complete removal of completePendingOAuthBinding logic to prevent unintended account takeovers (AUTH-ROUTE-1).
- Add strict OIDC policy checks (global switch and source active states) across authorization and callback paths (AUTH-POLICY-1).
- Fix OIDC test cases to properly clear the Redis-backed system config cache using composite keys.
2026-06-13 10:06:49 +08:00
ryan 895788974c fix(oauth): secure OAuth state session binding to prevent account takeover
Bind OAuth state payloads to the initiating session token and user ID.
Verifies session token hash continuity during callback, and validates that
the user ID completing the binding flow matches the user ID that initiated it.
2026-06-13 09:55:07 +08:00
ryan f48426dbf8 fix(frontend): sanitize redirect targets to prevent XSS/open redirect
Sanitize and validate redirect targets from callbackUrl parameter and sessionStorage in login, registration, and OAuth callback flows.
Introduced safeRedirectTarget helper which rejects protocol-relative URLs, non-relative schemes, control characters, backslashes, and encoding bypasses.
2026-06-13 09:51:30 +08:00
ryan 50d21b431b feat(valkey): migrate redis to valkey and fix updater custom prefix selection
Replace redis:7-alpine with valkey:8.0-alpine and configure MaintNotificationsConfig ModeDisabled to suppress handshake warnings on Valkey. Resolve updater bug by dynamically matching custom repository asset name prefixes like PixezSync.
2026-06-12 15:44:49 +08:00
ryan 3228574a8c 兼容包名 2026-06-12 15:32:47 +08:00
ryan 752101612e 打印日志 2026-06-12 15:17:45 +08:00
ryan c9642fb5e2 修复退出异常问题 2026-06-12 14:32:27 +08:00
ryan 179a23f1a0 MAKE 调整 2026-06-12 14:29:20 +08:00
ryan 55831efd44 界面优化 2026-06-12 14:17:53 +08:00
ryan c916f566d9 系统控制台完成更新 2026-06-12 14:12:03 +08:00
ryan 407c1edf74 更新指导 2026-06-12 13:42:17 +08:00
ryan 654d7fd646 修复导出编译问题 2026-06-12 13:10:39 +08:00
ryan 16604e5f86 设置增加站名配置 2026-06-12 11:57:30 +08:00
ryan 6a1f89936e 登录界面优化 2026-06-12 11:35:48 +08:00
ryan d9df78d2c7 邮箱注册要求 2026-06-12 10:55:24 +08:00
ryan 63e3ade7f4 登录注册分开 2026-06-12 10:27:03 +08:00
ryan 1fe5118029 登录状态记录 2026-06-12 10:15:29 +08:00
ryan 62fcd245f2 fix: 映射后台任务查询接口的 task_type 参数为 Asynq 任务名以解决类型过滤无数据问题 2026-06-11 23:28:50 +08:00
ryan 01b80ac376 优化CI 2026-06-11 20:32:42 +08:00
ryan 50533e1837 build: 优化 Docker/Workflow 构建,使用 Next.js 环境变量注入版本和构建时间,并移除对 package.json 的硬编码替换 2026-06-11 20:18:49 +08:00
ryan 7c125ccef2 fix: 修复任务执行记录列表按任务类型和状态过滤失效的 Bug 并更新 Swagger 2026-06-11 20:14:02 +08:00
ryan 46760d4286 缓存处理修补 2026-06-11 19:07:22 +08:00
ryan 5915b31519 图片预热任务 2026-06-11 17:49:08 +08:00
ryan 7da4b72d24 任务日志优化 2026-06-11 16:56:53 +08:00
ryan f14875a8de 图片缓存不过期 2026-06-11 15:57:31 +08:00
ryan 1eef5336e3 修复文件管理页面分页问题 2026-06-11 15:52:35 +08:00
ryan 533f783268 质量优化 2026-06-11 15:36:46 +08:00
ryan 6b93320404 接口参数调整 2026-06-11 15:32:44 +08:00
ryan ad97ca7df1 图片压缩调用缓存 2026-06-11 15:23:15 +08:00
ryan 0221d4de14 缓存框架 2026-06-11 15:12:09 +08:00
ryan e8e0326879 图片压缩 2026-06-11 14:58:17 +08:00
ryan 2a3a17b6fe 优化 2026-06-11 14:28:07 +08:00
ryan eb9f6023f0 文件管理权限控制 2026-06-11 14:17:36 +08:00
ryan 5e0de01c4b 文件管理权限控制 2026-06-11 14:09:32 +08:00
ryan 312bc7d4d5 框架表改名 w_{name} 2026-06-11 09:27:15 +08:00
ryan 786fe71778 优化定时任务 2026-06-11 09:20:28 +08:00
ryan b5a1707898 优化 2026-06-11 09:10:06 +08:00
ryan 616242fad8 去除 access_token 访问写库逻辑 2026-06-11 09:09:17 +08:00
ryan 6cbc368dc1 修复任务日志显示问题 2026-06-11 09:03:40 +08:00
ryan c1a1904a67 修复重复注册问题 2026-06-11 09:03:24 +08:00
ryan 3023d47eec 解耦任务框架与业务任务 2026-06-11 08:52:44 +08:00
ryan 5dedff1324 修复任务参数类型转换 2026-06-11 08:40:08 +08:00
ryan 8964054fd8 修复任务参数类型转换 2026-06-11 08:26:30 +08:00
ryan 5241055030 根据名称获取认证源(名称比较不区分大小写) 2026-06-11 08:06:46 +08:00
ryan 983228227e AccessToken 默认非管理员权限 2026-06-10 22:50:44 +08:00
ryan bff9e8811d 界面优化 2026-06-10 20:25:21 +08:00
ryan 3ebead0d15 界面优化 2026-06-10 20:22:10 +08:00
ryan e6a6182a0c 优化任务管理 2026-06-10 20:15:12 +08:00
ryan db163034c0 优化任务管理 2026-06-10 19:36:35 +08:00
ryan 69edb1252b 升级 nextjs 2026-06-10 15:18:01 +08:00
ryan 15b3c83625 更新示例 2026-06-10 15:11:42 +08:00
ryan 57944398e6 质量优化 2026-06-10 13:50:58 +08:00
ryan 3ed4dec4a3 db manager 2026-06-10 13:42:42 +08:00
ryan d05acd804f 精简 2026-06-10 11:33:33 +08:00
ryan df1961c42d 压缩 2026-06-09 22:00:01 +08:00
ryan 1b5d37a8e8 修复CI 2026-06-09 21:54:34 +08:00
ryan ebf644adb7 修复单位问题 2026-06-09 21:54:03 +08:00
ryan 7aeaa7b0d2 调整CI 2026-06-09 21:50:13 +08:00
ryan 32e4a32462 make build 2026-06-09 21:45:57 +08:00
ryan 05ef5ed7bd 数据导出 2026-06-09 21:18:58 +08:00
ryan 0ead6348f6 make bin 2026-06-09 21:01:13 +08:00
ryan 949c021d45 质量优化 2026-06-09 20:39:22 +08:00
ryan 1c76c7158a 质量优化 2026-06-09 20:38:14 +08:00
ryan 673061265c 质量优化 2026-06-09 20:28:05 +08:00
ryan 50f39a6983 修复创建账号逻辑 2026-06-09 20:07:25 +08:00
ryan 055005688a 升级数据库后更新缓存 2026-06-09 16:47:49 +08:00
ryan b1c161b255 skill 2026-06-09 16:47:49 +08:00
ryan 6b3c0217f0 goose 迁移 2026-06-09 16:47:49 +08:00
ryan 40e8a7cfa3 重构获取公共参数 2026-06-09 16:47:49 +08:00
ryan bff09241d3 skill 2026-06-09 16:47:49 +08:00
ryan 73b220de3c 用户详情 2026-06-09 16:47:49 +08:00
ryan 92664273ed async task skill 2026-06-09 16:47:49 +08:00
ryan d705f2ff64 skill 2026-06-09 16:47:49 +08:00
ryan 8801b3976c go dev skill
go dev skill

go dev skill

shadcn skill
2026-06-09 16:47:49 +08:00
ryan c6eea8111d fix(revive): rename unused parameters to _ for lint compliance 2026-06-09 15:03:13 +08:00
ryan e06f76436e refactor: extract magic numbers to named constants for mnd lint compliance 2026-06-09 13:44:29 +08:00
ryan b05d26c9c6 docs: add package and exported symbol comments for revive lint compliance 2026-06-09 13:42:06 +08:00
ryan 4ac9857fe8 代码质量优化 2026-06-09 12:28:12 +08:00
ryan f428839602 前端优化 2026-06-09 11:35:31 +08:00
ryan 31f0fb4ceb 规约 2026-06-09 11:26:57 +08:00
ryan e100441e8a 规约 2026-06-09 11:16:02 +08:00
ryan d7521dc49a 界面优化 2026-06-09 10:40:43 +08:00
ryan d4d214d074 界面优化 2026-06-09 10:39:59 +08:00
ryan 61bc569bd8 clickhouse 日志采集 2026-06-09 10:39:59 +08:00
ryan b41457553d 优化 2026-06-09 09:19:56 +08:00
ryan 7a62a78fad smtp 发件前验证配置 2026-06-09 09:01:14 +08:00
ryan 2ae55da52e seo 检索开关 2026-06-09 08:56:25 +08:00
ryan b0023787c5 fix 2026-06-09 08:51:53 +08:00
ryan 7579d3865f eslint 2026-06-09 08:19:38 +08:00
ryan d14d222ede env load 2026-06-08 23:14:57 +08:00
ryan ddda7c44ef docker-compose.yml 2026-06-08 23:14:57 +08:00
ryan 957116d983 修改ci 2026-06-08 21:13:18 +08:00
ryan 356b3df81e 融合模式启动 2026-06-08 21:12:55 +08:00
ryan db0d503bb4 ci 2026-06-08 20:59:00 +08:00
ryan 2cce8a3175 ci 2026-06-08 20:55:18 +08:00
ryan a998f02f2b ci 2026-06-08 20:47:48 +08:00
ryan 31253eb23d remove idea 2026-06-08 20:41:35 +08:00
ryan cd3d0c9f82 重构 2026-06-08 20:38:17 +08:00
ryan 02f458856d 用户优化 2026-06-08 20:38:17 +08:00
ryan d99bd5231a 安全加固 2026-06-08 20:38:17 +08:00
ryan 0f65202659 个人信息页面增强 2026-06-08 20:38:17 +08:00
ryan b96624a251 模板系统 2026-06-08 20:38:17 +08:00
ryan 3aff95d256 优化 2026-06-08 20:38:17 +08:00
ryan 85f91b1ed7 更新 license .github 2026-06-08 20:38:17 +08:00
ryan e3ef6c9d27 修改路径 2026-06-08 20:38:17 +08:00
ryan c1fbf73f7e 更新项目 2026-06-08 20:38:01 +08:00
ryan c6cc8c3305 logs 2026-06-08 20:38:01 +08:00
ryan 5c7c995a95 框架化 2026-06-08 20:38:01 +08:00
ryan b03d2d7ea6 改名 2026-06-08 20:38:01 +08:00
ryan f136c9abdb 邮箱 2026-06-08 20:38:01 +08:00
ryan 72c74803be 优化 2026-06-08 20:38:01 +08:00
ryan db68a130ce 嵌入与邮箱 2026-06-08 20:37:57 +08:00
ryan 62bd5d09d4 移除 risk 2026-06-08 20:37:57 +08:00
ryan 47c9bc53fa skill 2026-06-08 20:37:52 +08:00
ryan 474e3da3b7 代码优化
界面优化
2026-06-08 20:37:52 +08:00
ryan 72d72810b2 cap 与系统信息 2026-06-08 20:37:47 +08:00
ryan 9d0f9f0576 user 2026-06-08 20:37:40 +08:00
ryan 4b72419a96 去除默认OIDC 2026-06-08 20:37:40 +08:00
ryan 53a4c92176 设置界面优化 2026-06-08 20:37:40 +08:00
ryan 589ae08318 async task framework 2026-06-08 20:37:40 +08:00
ryan 70a13dc107 upload+accessKey 2026-06-08 20:37:40 +08:00
ryan 360a26f109 oauth 2026-06-08 20:34:28 +08:00
ryan 48d414e197 裁剪
swagger

移除 merchant

swagger

改造首页内容为通用后台管理系统定位

- 修改首页标题从 'LINUX DO Credit' 改为 'Modern Platform'
- 更新副标题为 '为二次开发而生'
- 更新首页描述为通用平台的特点
- 更新首页特性标签为 '开箱即用、高度可扩展、工业级基建'
- 修改展示卡片为技术栈和二次开发相关
- 更新开发者示例代码为通用的注册和 API Key 获取示例
- 更新页脚品牌名为 'Modern Platform'
- 调整页脚导航链接为通用平台相关内容

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

去除遗留

裁剪
移除 /api/v1/user/pay-key 相关代码

- 删除后端 UpdatePayKey 处理器函数和 UpdatePayKeyRequest 结构体
- 删除 User 模型中的 PayKey 字段
- 删除 User.VerifyPayKey 方法
- 删除 EncryptPayKeyFailed 错误常量
- 删除 /api/v1/user/pay-key PUT 路由
- 删除 OAuth 返回中的 IsPayKey 字段
- 删除前端 UserService.updatePayKey 方法
- 删除前端所有支付密钥 UI 和逻辑
- 更新相关的导出和注释

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

去除遗留

api 修正

系统配置

前端裁剪

后端裁剪

init
2026-06-08 20:34:28 +08:00
ryan 8a782525de 压缩历史至 95081aff 2026-06-08 20:34:27 +08:00
1343 changed files with 165431 additions and 132527 deletions
+217
View File
@@ -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,157 @@
---
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` |
**不要**把不同日志域并入同一 channel。新日志表先按 `logstore` skill 判定,再为本域建独立 writer。
## 新增 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) |
| 可丢弃的高频日志 | 队列满 → `WithDropHandler` 记 warn;不阻塞请求 |
## 禁止写法
```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`
- 日志抽象:`internal/repository/logstore`
- 生命周期管理器:`internal/platform/lifecycle/lifecycle.go`
- Bootstrap:`internal/platform/bootstrap/bootstrap.go`
+25
View File
@@ -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
+348
View File
@@ -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 中提问。
+21
View File
@@ -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.
+658
View File
@@ -0,0 +1,658 @@
<div align="center">
<h1>&#128269; 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>
&middot;
<a href="#chinese">中文</a>
&middot;
<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.
---
### &#10024; 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.
---
### &#127760; 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>&#9883;&#65039; React 19 / Next.js / TanStack Query v5</td>
<td><code>reference/react.md</code></td>
<td>~870</td>
</tr>
<tr>
<td>&#128154; Vue 3.5 + Composition API</td>
<td><code>reference/vue.md</code></td>
<td>~920</td>
</tr>
<tr>
<td>&#128302; Angular 17+ / Signals / Zoneless</td>
<td><code>reference/angular.md</code></td>
<td>~420</td>
</tr>
<tr>
<td>&#128293; Svelte 5 / SvelteKit</td>
<td><code>reference/svelte.md</code></td>
<td>~1,060</td>
</tr>
<tr>
<td>&#127912; CSS / Less / Sass</td>
<td><code>reference/css-less-sass.md</code></td>
<td>~660</td>
</tr>
<tr>
<td>&#128311; TypeScript</td>
<td><code>reference/typescript.md</code></td>
<td>~540</td>
</tr>
<tr>
<td rowspan="9"><strong>Backend</strong></td>
<td>&#9749; Java 17/21 + Spring Boot 3</td>
<td><code>reference/java.md</code></td>
<td>~410</td>
</tr>
<tr>
<td>&#9889; 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>&#128230; NestJS</td>
<td><code>reference/nestjs.md</code></td>
<td>~590</td>
</tr>
<tr>
<td>&#128013; Django / DRF</td>
<td><code>reference/django.md</code></td>
<td>~1,030</td>
</tr>
<tr>
<td>&#128057; Go</td>
<td><code>reference/go.md</code></td>
<td>~990</td>
</tr>
<tr>
<td>&#129408; Rust</td>
<td><code>reference/rust.md</code></td>
<td>~840</td>
</tr>
<tr>
<td>&#128187; C# / .NET 8</td>
<td><code>reference/csharp.md</code></td>
<td>~520</td>
</tr>
<tr>
<td>&#128013; Python</td>
<td><code>reference/python.md</code></td>
<td>~1,070</td>
</tr>
<tr>
<td rowspan="5"><strong>Mobile / Systems</strong></td>
<td>&#128241; Kotlin / Android</td>
<td><code>reference/kotlin.md</code></td>
<td>~1,020</td>
</tr>
<tr>
<td>&#127822; Swift / SwiftUI</td>
<td><code>reference/swift.md</code></td>
<td>~930</td>
</tr>
<tr>
<td>&#9881;&#65039; C</td>
<td><code>reference/c.md</code></td>
<td>~290</td>
</tr>
<tr>
<td>&#128297; C++</td>
<td><code>reference/cpp.md</code></td>
<td>~390</td>
</tr>
<tr>
<td>&#128421;&#65039; Qt Framework</td>
<td><code>reference/qt.md</code></td>
<td>~190</td>
</tr>
<tr>
<td rowspan="3"><strong>Cross-Cutting</strong></td>
<td>&#127963;&#65039; Architecture Design Review</td>
<td><code>reference/architecture-review-guide.md</code></td>
<td>~470</td>
</tr>
<tr>
<td>&#9889; Performance Review</td>
<td><code>reference/performance-review-guide.md</code></td>
<td>~820</td>
</tr>
<tr>
<td>&#128269; Universal Quality Anti-Patterns</td>
<td><code>reference/code-quality-universal.md</code></td>
<td>~490</td>
</tr>
</tbody>
</table>
---
### &#128260; 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
```
---
### &#127991;&#65039; Severity Labels
| Label | Meaning |
|-------|---------|
| &#128308; `blocking` | Must be fixed before merge |
| &#128992; `important` | Should be fixed; may block depending on context |
| &#128993; `nit` | Minor style or preference issue |
| &#128309; `suggestion` | Optional improvement worth considering |
| &#128218; `learning` | Educational note for the author |
| &#127775; `praise` | Explicitly highlight great work |
---
### &#128193; 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
```
---
### &#128640; 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/
```
---
### &#128161; 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 |
---
### &#128300; Highlights by Language
<details>
<summary><strong>&#9883;&#65039; 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>&#9749; 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>&#129408; 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>&#128057; 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>&#9881;&#65039; 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>
---
### &#129309; 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
---
### &#128196; License
MIT &copy; [awesome-skills](https://github.com/awesome-skills)
---
<a name="chinese"></a>
## 中文
### 这是什么?
**Code Review Skill** 是专为 [Claude Code](https://claude.ai/code) 打造的生产级代码审查技能,将 AI 辅助的代码审查从模糊建议转变为**结构化、一致且专业级**的流程。
覆盖 **20+ 种语言和框架**,拥有超过 **16,000 行**精心整理的代码审查指南——按需加载,最大程度减少上下文占用。
---
### &#10024; 核心特性
- **渐进式加载** — 核心技能仅 ~190 行,各语言指南(每份 200–1,000 行)仅在需要时才加载。
- **四阶段审查流程** — 从理解 PR 范围到输出清晰反馈,每一步都有规可循。
- **严重性标记** — 每条发现均分级:`blocking` · `important` · `nit` · `suggestion` · `learning` · `praise`
- **安全优先** — 每个语言生态均配备专属安全检查清单。
- **协作式语气** — 以提问替代命令,以建议替代指令。
- **自动化感知** — 明确区分人工审查应关注的内容与 linter 自动处理的内容。
---
### &#127760; 支持的语言与框架
| 分类 | 技术栈 | 指南文件 | 行数 |
|------|--------|----------|------|
| **前端** | &#9883;&#65039; React 19 / Next.js / TanStack Query v5 | `reference/react.md` | ~870 |
| | &#128154; Vue 3.5 Composition API | `reference/vue.md` | ~920 |
| | &#128302; Angular 17+ / Signals / Zoneless | `reference/angular.md` | ~420 |
| | &#128293; Svelte 5 / SvelteKit | `reference/svelte.md` | ~1,060 |
| | &#127912; CSS / Less / Sass | `reference/css-less-sass.md` | ~660 |
| | &#128311; TypeScript | `reference/typescript.md` | ~540 |
| **后端** | &#9749; Java 17/21 + Spring Boot 3 | `reference/java.md` | ~410 |
| | &#9889; FastAPI | `reference/fastapi.md` | ~590 |
| | PHP 8.x | `reference/php.md` | ~700 |
| | &#128230; NestJS | `reference/nestjs.md` | ~590 |
| | &#128013; Django / DRF | `reference/django.md` | ~1,030 |
| | &#128013; Python | `reference/python.md` | ~1,070 |
| | &#128057; Go | `reference/go.md` | ~990 |
| | &#129408; Rust | `reference/rust.md` | ~840 |
| | &#128187; C# / .NET 8 | `reference/csharp.md` | ~520 |
| **移动 / 系统** | &#128241; Kotlin / Android | `reference/kotlin.md` | ~1,020 |
| | &#127822; Swift / SwiftUI | `reference/swift.md` | ~930 |
| | &#9881;&#65039; C | `reference/c.md` | ~290 |
| | &#128297; C++ | `reference/cpp.md` | ~390 |
| | &#128421;&#65039; Qt 框架 | `reference/qt.md` | ~190 |
| **架构** | &#127963;&#65039; 架构设计审查 | `reference/architecture-review-guide.md` | ~470 |
| | &#9889; 性能审查 | `reference/performance-review-guide.md` | ~820 |
| | &#128269; 通用质量反模式 | `reference/code-quality-universal.md` | ~490 |
---
### &#128260; 四阶段审查流程
```
阶段一 - 上下文收集
理解 PR 范围、关联 Issue 和实现意图
|
v
阶段二 - 高层级审查
架构设计 - 性能影响 - 测试策略
|
v
阶段三 - 逐行深度分析
逻辑正确性 - 安全漏洞 - 可维护性 - 边界情况
|
v
阶段四 - 总结与决策
结构化反馈 - 审批状态 - 后续行动项
```
---
### &#127991;&#65039; 严重性标记说明
| 标记 | 含义 |
|------|------|
| &#128308; `blocking` | 合并前必须修复 |
| &#128992; `important` | 应当修复,视情况可能阻塞合并 |
| &#128993; `nit` | 风格或偏好上的小问题 |
| &#128309; `suggestion` | 值得考虑的可选优化 |
| &#128218; `learning` | 给作者的教育性说明 |
| &#127775; `praise` | 明确表扬优秀代码 |
---
### &#128193; 仓库结构
```
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 复杂度分析工具
```
---
### &#128640; 安装方法
**克隆到 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/
```
---
### &#128161; 使用方式
安装后,在 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 等 |
---
### &#128300; 各语言核心内容
<details>
<summary><strong>&#9883;&#65039; 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>&#9749; 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>&#129408; Rust</strong></summary>
- 所有权模式与常见陷阱
- `unsafe` 代码审查要求(必须有 `SAFETY` 注释)
- Async/await — 避免在异步上下文中阻塞,取消安全性
- 错误处理:库用 `thiserror`,应用用 `anyhow`
</details>
<details>
<summary><strong>&#128057; Go</strong></summary>
- Goroutine 生命周期管理与泄漏预防
- Channel 模式、select 用法
- `context.Context` 传播规范
- 接口设计原则(接受接口,返回结构体)
- 错误包装:使用 `%w`
</details>
<details>
<summary><strong>&#9881;&#65039; C / C++</strong></summary>
- **C**:指针/缓冲区安全、未定义行为、资源清理、整数溢出
- **C++**:RAII 所有权、Rule of 0/3/5、移动语义、异常安全、`noexcept`
- **Qt**:父子内存模型、线程安全的信号/槽连接、GUI 性能优化
</details>
---
### &#129309; 参与贡献
欢迎贡献!请查阅 [CONTRIBUTING.md](./CONTRIBUTING.md) 了解规范。
**可贡献方向:**
- 新增语言指南(Ruby、Elixir、Scala...)
- 框架专属指南(Laravel、Spring WebFlux...)
- 补充检查清单和审查模板
- 核心文档的多语言翻译
---
### &#128196; 开源协议
MIT &copy; [awesome-skills](https://github.com/awesome-skills)
---
<div align="center">
Made with &#10084;&#65039; for developers who care about code quality
</div>
+220
View File
@@ -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
View File
@@ -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
View File
@@ -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 &middot; 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 /__\__ \ ' &lt; | || |__| |__
\___\___/|___/|___| |_|_\___| \_/ |___|___| \_/\_/ |___/_|\_\___|____|____|</pre>
<div class="one-liner">
<span>
<span class="dim">$&nbsp;</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">&nbsp;|&nbsp;</span><a href="index.en.html" class="on">en_US</a>
</span>
</div>
<div class="one-liner-sub">
v1.0 &middot; awesome-skills &middot; MIT &middot; 20 languages &middot; 16,000+ lines
</div>
</div>
<!-- ═══ NAME ═══ -->
<h2 class="sec">NAME</h2>
<section class="body">
<p>
<span class="em">code-review-skill</span> &mdash; 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 &lt;<span class="dim">component</span>&gt;
<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 &mdash; progressive loading, zero overhead.</p>
<p>Every finding carries an explicit severity label. Every review proceeds through four phases: PR context &middot; high-level assessment &middot; line-by-line analysis &middot; summary &amp; 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 &middot; security &middot; performance &middot; maintainability &middot; edge cases. One by one.</dd>
<dt>4. summary &amp; 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>&nbsp;[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>&nbsp;[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>&nbsp;[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>&nbsp;[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>&nbsp;[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>&nbsp;[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&gt;</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 &middot; checks Hooks &middot; 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 &middot; SOLID &middot; anti-patterns &middot; 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>
+702
View File
@@ -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 &middot; 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 /__\__ \ ' &lt; | || |__| |__
\___\___/|___/|___| |_|_\___| \_/ |___|___| \_/\_/ |___/_|\_\___|____|____|</pre>
<div class="one-liner">
<span>
<span class="dim">$&nbsp;</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">&nbsp;|&nbsp;</span><a href="index.en.html">en_US</a>
</span>
</div>
<div class="one-liner-sub">
v1.0 &middot; awesome-skills &middot; MIT &middot; 20 languages &middot; 16,000+ lines
</div>
</div>
<!-- ═══ NAME ═══ -->
<h2 class="sec">NAME</h2>
<section class="body">
<p>
<span class="em">code-review-skill</span> &mdash; 面向 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 &lt;<span class="dim">component</span>&gt;
<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 上下文 &middot; 高层级评估 &middot; 逐行分析 &middot; 总结决策。</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>逻辑正确性 &middot; 安全 &middot; 性能 &middot; 可维护性 &middot; 边界情况。一一过目。</dd>
<dt>4. summary &amp; 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>&nbsp;[blocking]</span>
<span class="desc">必须修复 <span class="aside">— 合并前解决;安全漏洞 / 数据正确性 / 严重逻辑</span></span>
</li>
<li>
<span class="label"><span style="color:#d68a3d;">●</span>&nbsp;[important]</span>
<span class="desc">应当修复 <span class="aside">— 强烈建议;有分歧应讨论</span></span>
</li>
<li>
<span class="label"><span style="color:#c7a648;">●</span>&nbsp;[nit]</span>
<span class="desc">细节建议 <span class="aside">— 风格或偏好,不阻塞合并</span></span>
</li>
<li>
<span class="label"><span class="blue">●</span>&nbsp;[suggestion]</span>
<span class="desc">可选优化 <span class="aside">— 替代方案,由作者决定</span></span>
</li>
<li>
<span class="label"><span style="color:#9078b8;">●</span>&nbsp;[learning]</span>
<span class="desc">知识分享 <span class="aside">— 教育性说明,无需采取行动</span></span>
</li>
<li>
<span class="label"><span class="green">●</span>&nbsp;[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&gt;</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 &middot; 检查 Hooks &middot; 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">加载架构指南 &middot; SOLID &middot; 反模式 &middot; 耦合度</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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
File diff suppressed because it is too large Load Diff
+584
View File
@@ -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
View File
@@ -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
View File
@@ -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)
- [ ] 魔法值提取为常量或枚举
File diff suppressed because it is too large Load Diff
+593
View File
@@ -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
View File
@@ -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/)
File diff suppressed because it is too large Load Diff
+186
View File
@@ -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
View File
@@ -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
View File
@@ -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 |
File diff suppressed because it is too large Load Diff
+932
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+138
View File
@@ -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` 时启动不报错、不执行迁移。
+265
View File
@@ -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`
+167
View File
@@ -0,0 +1,167 @@
---
name: go-documentation
description: 在编写或审查 Go 包、类型、函数或方法的文档时使用。在创建新的导出类型、函数或包时也应主动使用,即使用户没有明确询问文档问题。不涵盖未导出符号的代码注释(参见 go-style-core)。
license: Apache-2.0
metadata:
sources: "Google 风格指南"
allowed-tools: Bash(bash:*)
---
# Go 文档
## 可用脚本
- **`scripts/check-docs.sh`** — 报告缺少文档注释的导出函数、类型、方法、常量和包。运行 `bash scripts/check-docs.sh --help` 查看选项。
> 在为新包或导出类型编写文档注释并需要所有文档约定的完整参考时,请参阅 `assets/doc-template.go`。
---
## 文档注释
> **规范**:所有顶层导出名称必须有文档注释。
### 基本规则
1. 以被描述对象的名称开头
2. 冠词("a"、"an"、"the")可以放在名称前面
3. 使用完整句子(首字母大写,带标点符号)
```go
// A Request represents a request to run a command.
type Request struct { ...
// Encode writes the JSON encoding of req to w.
func Encode(w io.Writer, req *Request) { ...
```
行为不明显的未导出类型/函数也应有文档注释。
> **验证**:添加文档注释后,运行 `bash scripts/check-docs.sh` 验证是否有导出符号缺少文档。修复所有缺失后再继续。
---
## 注释语句
> **规范**:文档注释必须是完整的句子。
- 首字母大写,以标点符号结尾
- 例外:如果含义清晰,可以以小写标识符开头
- 结构体字段的行尾注释可以是短语
---
## 注释行长度
> **建议**:目标约 80 列,但不设硬性限制。
根据标点符号换行。不要拆分长 URL。
---
## 结构体文档
使用段落注释对字段分组。标记可选字段及默认值:
```go
type Options struct {
// 通用设置:
Name string
Group *FooGroup
// 自定义设置:
LargeGroupThreshold int // 可选;默认值:10
}
```
---
## 包注释
> **规范**:每个包必须有且仅有一个包注释。
```go
// Package math provides basic constants and mathematical functions.
package math
```
- 对于 `main` 包,使用二进制名称:`// The seed_generator command ...`
- 对于较长的包注释,使用 `doc.go` 文件
> 在编写包级文档、main 包注释、doc.go 文件或可运行示例时,请阅读 [references/EXAMPLES.md](references/EXAMPLES.md)。
---
## 文档编写要点
> **建议**:记录非显而易见的行为,显而易见的行为无需记录。
| 主题 | 何时记录... | 何时跳过... |
|------|------------|------------|
| 参数 | 非显而易见的行为、边界情况 | 只是重复类型签名 |
| 上下文 | 行为与标准取消不同 | 标准 `ctx.Err()` 返回 |
| 并发 | 线程安全性不明确(例如,看似读取但内部修改) | 只读安全、修改不安全 |
| 清理 | 始终记录资源释放要求 | — |
| 错误 | 哨兵值、错误类型(使用 `*PathError`) | — |
| 命名返回值 | 多个同类型参数、面向操作命名 | 类型本身已足够清晰 |
关键原则:
- 上下文取消返回 `ctx.Err()` 是隐含的 — 不要重复说明
- 只读操作默认线程安全;修改操作默认不安全 — 不要重复说明
- 始终记录清理要求(例如,`Call Stop to release resources`)
- 在错误类型文档中使用指针(`*PathError`),以确保 `errors.Is`/`errors.As` 正确使用
- 不要仅为启用裸返回而命名返回值 — 清晰性 > 简洁性
> 在记录参数行为、上下文取消、并发安全性、清理要求、错误返回或函数文档注释中的命名返回参数时,请阅读 [references/CONVENTIONS.md](references/CONVENTIONS.md)。
---
## 可运行示例
> **建议**:在测试文件(`*_test.go`)中提供可运行示例。
```go
func ExampleConfig_WriteTo() {
cfg := &Config{Name: "example"}
cfg.WriteTo(os.Stdout)
// Output:
// {"name": "example"}
}
```
示例会出现在 Godoc 中,附加到对应的文档元素上。
> 在编写可运行 Example 函数、选择示例命名约定(Example vs ExampleType_Method)或添加包级 doc.go 文件时,请阅读 [references/EXAMPLES.md](references/EXAMPLES.md)。
---
## Godoc 格式化
> 在格式化 godoc 标题、链接、列表或代码块,使用信号增强来标记弃用通知,或在本地预览文档输出时,请阅读 [references/FORMATTING.md](references/FORMATTING.md)。
---
## 快速参考
| 主题 | 关键规则 |
|------|---------|
| 文档注释 | 以名称开头,使用完整句子 |
| 行长度 | 约 80 字符,优先考虑可读性 |
| 包注释 | 每个包一个,放在 `package` 声明之前 |
| 参数 | 仅记录非显而易见的行为 |
| 上下文 | 记录与隐含行为不同的例外情况 |
| 并发 | 记录线程安全性不明确的情况 |
| 清理 | 始终记录资源释放要求 |
| 错误 | 记录哨兵值和类型(注意指针) |
| 示例 | 在测试文件中使用可运行示例 |
| 格式化 | 空行分隔段落,缩进表示代码 |
---
## 相关技能
- **命名约定**:在为文档注释描述的标识符选择名称时,参见 [go-naming](../go-naming/SKILL.md)
- **测试示例**:在编写出现在 godoc 中的可运行 `Example` 测试函数时,参见 [go-testing](../go-testing/SKILL.md)
- **Lint 强制执行**:在使用 revive 或其他 linter 强制执行文档注释存在性时,参见 [go-linting](../go-linting/SKILL.md)
- **风格原则**:在平衡文档详细程度与清晰简洁时,参见 [go-style-core](../go-style-core/SKILL.md)
@@ -0,0 +1,61 @@
// Package example demonstrates proper Go documentation conventions.
//
// This package shows how to write doc comments for packages, types,
// functions, methods, and constants following Google Go Style Guide
// conventions.
//
// # Getting Started
//
// Create a new Widget with [NewWidget]:
//
// w := example.NewWidget("name")
// defer w.Close()
package example
import "errors"
// ErrNotFound is returned when a requested item does not exist.
var ErrNotFound = errors.New("example: not found")
// MaxRetries is the default number of retry attempts.
const MaxRetries = 3
// Widget processes items with configurable options.
//
// A zero-value Widget is not valid; use [NewWidget] to create one.
// Widget is safe for concurrent use.
//
// # Cleanup
//
// Call [Widget.Close] when done to release resources.
type Widget struct {
name string
}
// NewWidget creates a Widget with the given name.
//
// Name must be non-empty; NewWidget panics otherwise.
func NewWidget(name string) *Widget {
if name == "" {
panic("example: name must be non-empty")
}
return &Widget{name: name}
}
// Process handles the given input and returns the result.
//
// Process returns [ErrNotFound] if the input references
// a missing item.
func (w *Widget) Process(input string) (string, error) {
return input, nil
}
// Close releases resources held by the Widget.
func (w *Widget) Close() error {
return nil
}
// Deprecated: Use [NewWidget] with functional options instead.
func NewWidgetLegacy(name string) *Widget {
return NewWidget(name)
}
@@ -0,0 +1,239 @@
# 文档约定参考
## 参数和配置
> **建议**:记录容易出错或非显而易见的参数,而非所有参数。
```go
// 不好:重复了显而易见的信息
// Sprintf formats according to a format specifier and returns the resulting string.
//
// format is the format, and data is the interpolation data.
func Sprintf(format string, data ...any) string
// 好:记录了非显而易见的行为
// Sprintf formats according to a format specifier and returns the resulting string.
//
// The provided data is used to interpolate the format string. If the data does
// not match the expected format verbs or the amount of data does not satisfy
// the format specification, the function will inline warnings about formatting
// errors into the output string.
func Sprintf(format string, data ...any) string
```
---
## 上下文
> **建议**:不要重复隐含的上下文行为;记录例外情况。
上下文取消被隐含地认为会中断函数并返回 `ctx.Err()`。不要记录这一点。
```go
// 不好:重复了隐含的行为
// Run executes the worker's run loop.
//
// The method will process work until the context is cancelled.
func (Worker) Run(ctx context.Context) error
// 好:只记录关键信息
// Run executes the worker's run loop.
func (Worker) Run(ctx context.Context) error
```
**当行为不同时记录:**
```go
// 好:非标准的取消行为
// Run executes the worker's run loop.
//
// If the context is cancelled, Run returns a nil error.
func (Worker) Run(ctx context.Context) error
// 好:特殊的上下文要求
// NewReceiver starts receiving messages sent to the specified queue.
// The context should not have a deadline.
func NewReceiver(ctx context.Context) *Receiver
```
---
## 并发
> **建议**:记录非显而易见的线程安全特性。
只读操作被认为是安全的;修改操作被认为是不安全的。不要重复说明这一点。
**何时记录:**
```go
// 不明确的操作(看似只读但内部有修改)
// Lookup returns the data associated with the key from the cache.
//
// This operation is not safe for concurrent use.
func (*Cache) Lookup(key string) (data []byte, ok bool)
// API 提供同步机制
// NewFortuneTellerClient returns an *rpc.Client for the FortuneTeller service.
// It is safe for simultaneous use by multiple goroutines.
func NewFortuneTellerClient(cc *rpc.ClientConn) *FortuneTellerClient
// 接口有并发要求
// A Watcher reports the health of some entity (usually a backend service).
//
// Watcher methods are safe for simultaneous use by multiple goroutines.
type Watcher interface {
Watch(changed chan<- bool) (unwatch func())
Health() error
}
```
---
## 清理
> **建议**:始终记录显式清理要求。
```go
// 好:
// NewTicker returns a new Ticker containing a channel that will send the
// current time on the channel after each tick.
//
// Call Stop to release the Ticker's associated resources when done.
func NewTicker(d Duration) *Ticker
// 好:展示如何清理
// Get issues a GET to the specified URL.
//
// When err is nil, resp always contains a non-nil resp.Body.
// Caller should close resp.Body when done reading from it.
//
// resp, err := http.Get("http://example.com/")
// if err != nil {
// // handle error
// }
// defer resp.Body.Close()
// body, err := io.ReadAll(resp.Body)
func (c *Client) Get(url string) (resp *Response, err error)
```
---
## 错误
> **建议**:记录重要的错误哨兵值和类型。
```go
// 好:记录哨兵值
// Read reads up to len(b) bytes from the File and stores them in b.
//
// At end of file, Read returns 0, io.EOF.
func (*File) Read(b []byte) (n int, err error)
// 好:记录错误类型(包含指针接收者)
// Chdir changes the current working directory to the named directory.
//
// If there is an error, it will be of type *PathError.
func Chdir(dir string) error
```
注意使用 `*PathError`(而非 `PathError`)可以确保 `errors.Is` 和 `errors.As` 的正确使用。
对于包级别的错误约定,在包注释中记录。
---
## 命名返回参数
> **建议**:在类型本身不够清晰时用于文档说明。
```go
// 好:多个同类型参数
func (n *Node) Children() (left, right *Node, err error)
// 好:面向操作的名称阐明了用法
// The caller must arrange for the returned cancel function to be called.
func WithTimeout(parent Context, d time.Duration) (ctx Context, cancel func())
// 不好:类型已经很清晰,命名没有增加信息
func (n *Node) Parent1() (node *Node)
func (n *Node) Parent2() (node *Node, err error)
// 好:类型已足够
func (n *Node) Parent1() *Node
func (n *Node) Parent2() (*Node, error)
```
不要仅为启用裸返回而命名返回值。清晰性 > 简洁性。
---
## 弃用通知
> **建议**:使用 `// Deprecated:` 注释标记符号为已弃用。
`Deprecated:` 段落必须出现在文档注释中紧接在符号之前。应说明使用什么替代。
**标准格式:**
```
// Deprecated: Use NewThing instead.
```
Godoc 会以特殊的视觉样式渲染 `Deprecated:` 注释,使其容易被发现。
**函数弃用:**
```go
// EstimateSize returns an approximate byte count.
//
// Deprecated: Use [Size] instead, which returns an exact count.
func EstimateSize(r io.Reader) (int64, error)
```
**类型弃用:**
```go
// LegacyClient talks to the v1 API.
//
// Deprecated: Use [Client] instead, which supports v2.
type LegacyClient struct{ /* ... */ }
```
**包弃用** — 在包文档注释中添加 `Deprecated:`:
```go
// Package old provides the original implementation.
//
// Deprecated: Use package example/new instead.
package old
```
始终建议具体的替代方案,让调用者知道迁移目标。
---
## 注释语句 — 详细说明
> **规范**:文档注释必须是完整的句子。
- 首字母大写,以标点符号结尾
- 例外:如果含义清晰,可以以小写标识符开头
- 结构体字段的行尾注释可以是短语:
```go
// 好:
// A Server handles serving quotes from Shakespeare.
type Server struct {
// BaseDir points to the base directory for Shakespeare's works.
//
// Expected structure:
// {BaseDir}/manifest.json
// {BaseDir}/{name}/{name}-part{number}.txt
BaseDir string
WelcomeMessage string // 用户登录时显示
ProtocolVersion string // 与传入请求进行校验
PageLength int // 每页行数(可选;默认值:20)
}
```
@@ -0,0 +1,107 @@
# 包注释和示例参考
## 包注释
> **规范**:每个包必须有且仅有一个包注释。
```go
// 好:
// Package math provides basic constants and mathematical functions.
//
// This package does not guarantee bit-identical results across architectures.
package math
```
### Main 包
使用二进制名称(与 BUILD 文件匹配):
```go
// 好:
// The seed_generator command is a utility that generates a Finch seed file
// from a set of JSON study configs.
package main
```
有效格式:`Binary seed_generator`、`Command seed_generator`、`The seed_generator command`、`Seed_generator ...`
### doc.go
- 对于较长的包注释,使用仅包含包注释和 `package` 声明的 `doc.go` 文件
- 放在 import 之后的维护者注释不会出现在 Godoc 中
- 保持 doc.go 文件专注于面向用户的文档
```go
// Package complex provides advanced mathematical operations for
// complex number arithmetic, including polar form conversion,
// matrix operations, and numerical integration.
//
// Basic usage
//
// Create a complex number and perform operations:
//
// z := complex.New(3, 4)
// magnitude := z.Abs() // 5.0
// conjugate := z.Conj() // (3, -4)
//
// Matrix operations
//
// The package supports complex-valued matrices:
//
// m := complex.NewMatrix(2, 2)
// m.Set(0, 0, complex.New(1, 0))
// det := m.Det()
package complex
```
---
## 可运行示例
> **建议**:提供可运行示例来展示包的用法。
将示例放在测试文件(`*_test.go`)中:
```go
// 好:
func ExampleConfig_WriteTo() {
cfg := &Config{
Name: "example",
}
if err := cfg.WriteTo(os.Stdout); err != nil {
log.Exitf("Failed to write config: %s", err)
}
// Output:
// {
// "name": "example"
// }
}
```
示例会出现在 Godoc 中,附加到对应的文档元素上。
### 命名约定
| 函数名称 | 文档对象 |
|----------|---------|
| `Example()` | 包级别示例 |
| `ExampleFoo()` | 函数 `Foo` |
| `ExampleBar_Baz()` | 方法 `Bar.Baz` |
| `ExampleFoo_suffix()` | `Foo` 示例的命名变体 |
### 技巧
- 使用 `// Output:` 注释使示例可通过 `go test` 进行测试和验证
- 保持示例专注于展示一个概念
- 使用真实但精简的数据
- 对于复杂的设置,使用 `testMain` 或辅助函数保持示例主体简洁
- 同一符号的多个示例使用小写 `_suffix`:
```go
func ExampleNewClient_withTimeout() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
client := NewClient(ctx)
// ...
}
```
@@ -0,0 +1,85 @@
# Godoc 格式化参考
## Godoc 格式化
> **建议**:使用 godoc 语法编写格式良好的文档。
**段落** - 用空行分隔:
```go
// 好:
// LoadConfig reads a configuration out of the named file.
//
// See some/shortlink for config file format details.
```
**逐字/代码块** - 额外缩进两个空格:
```go
// 好:
// Update runs the function in an atomic transaction.
//
// This is typically used with an anonymous TransactionFunc:
//
// if err := db.Update(func(state *State) { state.Foo = bar }); err != nil {
// //...
// }
```
**列表和表格** - 使用逐字格式:
```go
// 好:
// LoadConfig treats the following keys in special ways:
// "import" will make this configuration inherit from the named file.
// "env" if present will be populated with the system environment.
```
**标题** - 单行,首字母大写,无标点(括号/逗号除外),后跟段落:
```go
// 好:
// Using headings
//
// Headings come with autogenerated anchor tags for easy linking.
```
---
## 信号增强
> **建议**:添加注释以突出不寻常或容易被忽略的模式。
以下两种情况很难区分:
```go
if err := doSomething(); err != nil { // 常见
// ...
}
if err := doSomething(); err == nil { // 不寻常!
// ...
}
```
添加注释来增强信号:
```go
// 好:
if err := doSomething(); err == nil { // 如果没有错误
// ...
}
```
---
## 文档预览
> **建议**:在代码审查之前和期间预览文档。
```bash
go install golang.org/x/pkgsite/cmd/pkgsite@latest
pkgsite
```
这可以验证 godoc 格式化是否正确渲染。
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION="1.0.0"
SCRIPT_NAME="$(basename "$0")"
usage() {
cat <<EOF
$SCRIPT_NAME v$VERSION — Check for missing doc comments on exported Go symbols
USAGE
bash $SCRIPT_NAME [options] [path]
DESCRIPTION
Scans Go source files for exported functions, types, methods, constants,
and variables that lack doc comments. Go convention requires all exported
symbols to have a doc comment starting with the symbol name.
Exits 0 if all exports are documented, 1 if undocumented exports found,
2 on error.
OPTIONS
-h, --help Show this help message
-v, --version Show version
--json Output results as JSON
--strict Also check unexported types/functions with 5+ lines
--limit N Show at most N results (default: all)
ARGUMENTS
path Directory or file to check (default: ./...)
EXAMPLES
bash $SCRIPT_NAME
bash $SCRIPT_NAME ./pkg/api
bash $SCRIPT_NAME --json .
bash $SCRIPT_NAME --strict ./internal/server
EOF
}
JSON_OUTPUT=false
STRICT=false
LIMIT=0
TARGET=""
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage; exit 0 ;;
-v|--version) echo "$SCRIPT_NAME v$VERSION"; exit 0 ;;
--json) JSON_OUTPUT=true; shift ;;
--strict) STRICT=true; shift ;;
--limit) LIMIT="${2:?error: --limit requires a number}"; shift 2 ;;
-*) echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;;
*) TARGET="$1"; shift ;;
esac
done
TARGET="${TARGET:-./...}"
json_escape() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\t'/\\t}"
s="${s//$'\r'/}"
s="${s//$'\n'/\\n}"
printf '%s' "$s"
}
find_go_files() {
local t="$1"
if [[ -f "$t" ]]; then
echo "$t"
elif [[ -d "$t" ]]; then
find "$t" -name '*.go' ! -name '*_test.go' ! -path '*/vendor/*' ! -path '*/.git/*' 2>/dev/null
else
local dir="${t%%/...}"
dir="${dir:-.}"
if [[ -d "$dir" ]]; then
find "$dir" -name '*.go' ! -name '*_test.go' ! -path '*/vendor/*' ! -path '*/.git/*' 2>/dev/null
else
echo "error: path not found: $t" >&2
exit 2
fi
fi
}
MISSING=()
add_missing() {
local file="$1" line="$2" kind="$3" name="$4"
MISSING+=("${file}:${line}|${kind}|${name}")
}
check_file() {
local file="$1"
local prev_line=""
local prev_prev_line=""
local line_num=0
local in_grouped_block=false
local grouped_kind=""
local re_method='^func[[:space:]]+\([^)]+\)[[:space:]]+([A-Z][a-zA-Z0-9]*)\('
local re_func='^func[[:space:]]+([A-Z][a-zA-Z0-9]*)\('
local re_unexported_func='^func[[:space:]]+([a-z][a-zA-Z0-9]*)\('
local re_grouped_open='^(const|var|type)[[:space:]]*\($'
local re_exported_type='^type[[:space:]]+([A-Z][a-zA-Z0-9]*)[[:space:]]'
local re_unexported_type='^type[[:space:]]+([a-z][a-zA-Z0-9]*)[[:space:]]'
local re_exported_const='^const[[:space:]]+([A-Z][a-zA-Z0-9]*)[[:space:]]'
local re_exported_var='^var[[:space:]]+([A-Z][a-zA-Z0-9]*)[[:space:]]'
local re_grouped_exported='^[[:space:]]+([A-Z][a-zA-Z0-9]*)'
local re_grouped_unexported='^[[:space:]]+([a-z][a-zA-Z0-9]*)'
while IFS= read -r line; do
line_num=$((line_num + 1))
# Check exported function/method declarations
if [[ "$line" =~ ^func[[:space:]] ]]; then
local name=""
local kind=""
# Method: func (r *Type) Name(
if [[ "$line" =~ $re_method ]]; then
name="${BASH_REMATCH[1]}"
kind="method"
# Function: func Name(
elif [[ "$line" =~ $re_func ]]; then
name="${BASH_REMATCH[1]}"
kind="function"
fi
if [[ -n "$name" ]]; then
if ! is_documented "$prev_line" "$prev_prev_line"; then
add_missing "$file" "$line_num" "$kind" "$name"
fi
fi
# Strict mode: also check unexported functions
if $STRICT && [[ -z "$name" ]] && [[ "$line" =~ $re_unexported_func ]]; then
name="${BASH_REMATCH[1]}"
if ! is_documented "$prev_line" "$prev_prev_line"; then
add_missing "$file" "$line_num" "function" "$name"
fi
fi
fi
# Check exported type declarations
if [[ "$line" =~ $re_exported_type ]]; then
local name="${BASH_REMATCH[1]}"
if ! is_documented "$prev_line" "$prev_prev_line"; then
add_missing "$file" "$line_num" "type" "$name"
fi
fi
# Strict mode: also check unexported type declarations
if $STRICT && [[ "$line" =~ $re_unexported_type ]]; then
local name="${BASH_REMATCH[1]}"
if ! is_documented "$prev_line" "$prev_prev_line"; then
add_missing "$file" "$line_num" "type" "$name"
fi
fi
# Check exported const (single-line, not in block)
if [[ "$line" =~ $re_exported_const ]]; then
local name="${BASH_REMATCH[1]}"
if ! is_documented "$prev_line" "$prev_prev_line"; then
add_missing "$file" "$line_num" "const" "$name"
fi
fi
# Check exported var (single-line, not blank identifier)
if [[ "$line" =~ $re_exported_var ]]; then
local name="${BASH_REMATCH[1]}"
if ! is_documented "$prev_line" "$prev_prev_line"; then
add_missing "$file" "$line_num" "var" "$name"
fi
fi
# Check package comment
if [[ "$line" =~ ^package[[:space:]]+ ]]; then
if ! is_documented "$prev_line" "$prev_prev_line"; then
local pkg_name
pkg_name=$(echo "$line" | sed 's/^package[[:space:]]*//;s/[[:space:]]*$//')
add_missing "$file" "$line_num" "package" "$pkg_name"
fi
fi
# Track grouped declaration blocks: const ( ... ), var ( ... ), type ( ... )
if [[ "$line" =~ $re_grouped_open ]]; then
in_grouped_block=true
grouped_kind="${BASH_REMATCH[1]}"
fi
if $in_grouped_block && [[ "$line" =~ ^\)[[:space:]]*$ ]]; then
in_grouped_block=false
grouped_kind=""
fi
if $in_grouped_block && [[ -n "$grouped_kind" ]]; then
# Check for exported names inside grouped block
if [[ "$line" =~ $re_grouped_exported ]]; then
local gname="${BASH_REMATCH[1]}"
if ! is_documented "$prev_line" "$prev_prev_line"; then
add_missing "$file" "$line_num" "$grouped_kind" "$gname"
fi
fi
# Strict: also check unexported names in grouped blocks
if $STRICT && [[ "$line" =~ $re_grouped_unexported ]]; then
local gname="${BASH_REMATCH[1]}"
if ! is_documented "$prev_line" "$prev_prev_line"; then
add_missing "$file" "$line_num" "$grouped_kind" "$gname"
fi
fi
fi
prev_prev_line="$prev_line"
prev_line="$line"
done < "$file"
}
is_documented() {
local prev="$1"
local prev_prev="$2"
# Previous line is a comment (// or end of block comment */)
if [[ "$prev" =~ ^[[:space:]]*//.* ]] || [[ "$prev" =~ \*/[[:space:]]*$ ]]; then
return 0
fi
# Previous line might be empty but line before is comment (allow one blank line)
if [[ -z "${prev// /}" ]] && [[ "$prev_prev" =~ ^[[:space:]]*//.* ]]; then
return 0
fi
return 1
}
FILES=()
while IFS= read -r f; do
[[ -n "$f" ]] && FILES+=("$f")
done < <(find_go_files "$TARGET")
if [[ ${#FILES[@]} -eq 0 ]]; then
if $JSON_OUTPUT; then
echo '{"missing":[],"count":0,"status":"no_go_files"}'
else
echo "No Go files found in: $TARGET"
fi
exit 0
fi
for file in "${FILES[@]}"; do
check_file "$file"
done
# Truncation
TOTAL=${#MISSING[@]}
TRUNCATED=false
if [[ $LIMIT -gt 0 && $TOTAL -gt $LIMIT ]]; then
MISSING=("${MISSING[@]:0:$LIMIT}")
TRUNCATED=true
fi
if $JSON_OUTPUT; then
echo "{"
echo ' "missing": ['
first=true
for entry in "${MISSING[@]+"${MISSING[@]}"}"; do
IFS='|' read -r location kind name <<< "$entry"
file="${location%%:*}"
line="${location#*:}"
$first || echo ","
first=false
printf ' {"file":"%s","line":%s,"kind":"%s","name":"%s"}' \
"$(json_escape "$file")" "$line" "$(json_escape "$kind")" "$(json_escape "$name")"
done
echo ""
echo " ],"
printf ' "total": %d,\n' "$TOTAL"
printf ' "truncated": %s\n' "$TRUNCATED"
echo "}"
else
if [[ $TOTAL -eq 0 ]]; then
echo "All exported symbols are documented."
exit 0
fi
echo "Undocumented exported symbols:"
echo ""
for entry in "${MISSING[@]}"; do
IFS='|' read -r location kind name <<< "$entry"
printf " %s [%s] %s\n" "$location" "$kind" "$name"
done
if $TRUNCATED; then
echo " ... and $((TOTAL - LIMIT)) more (use --limit to adjust)"
fi
echo ""
echo "Total: $TOTAL undocumented symbol(s)"
fi
if [[ $TOTAL -gt 0 ]]; then
exit 1
fi
exit 0
+187
View File
@@ -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` 会自动处理 |
+168
View File
@@ -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
View File
@@ -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
+92
View File
@@ -0,0 +1,92 @@
---
name: "logstore"
description: "Wavelet 项目专用:当新增或修改日志/分析用途表(访问日志、审计流水、可观测时序)、接入 internal/repository/logstore、切换日志主库、实现 PG/SQLite 回落,或判断一张表该走业务主库还是日志库时必须使用。"
---
# 日志用途表开发
开始前阅读根目录 `AGENTS.md`。DDL 用 `database-migration`;高频写入队列用 `clickhouse-batchwriter`;切换任务用 `new-async-task`。本技能只回答:**这张表是不是日志表,以及如何接入可切换的日志主库。**
分层与切换协议见 [日志用途表](../../../docs/LOGSTORE.md)。
## 先判定
日志表同时满足:
- 追加写入、几乎不更新单行
- 按时间查询/聚合,允许按保留天数删除
- 关闭 ClickHouse 后仍要能写、能查
- 不参与用户/配置/任务等事务一致性
**不要**做成日志表:用户、配置、任务执行、上传元数据、需要事务或强一致的业务实体。这些走主库 `repository`,不要进 `logstore`。
当前框架已接入的日志表:`w_user_access_logs`(管理端 API 访问审计)。
## 分层
| 层级 | 路径 | 职责 |
| :--- | :--- | :--- |
| 抽象 | `internal/repository/logstore` | 接口 + `Active`/`BuildForMigration`;apps **只**面向这里 |
| CH 实现 | `logstore` 委托 `internal/repository/analytics` | 原生 `PrepareBatch` / `ChDB` 查询 |
| 主库实现 | `logstore` GORM | PG(按月分区)与 SQLite(普通表) |
| Model | `internal/model/analytics` | 实体、`TableName`、`InsertColumns`、`BatchInsertSQL`,无 IO |
| 入队 | `internal/apps/<domain>` + `batchwriter` | `FlushFunc` 调 `logstore.Active().….BatchInsert` |
| 切换 | `internal/apps/admin/logs` 的 `logs:db_switch` | 冻结写入 → 排空 → 复制 → 翻转 `log_database` |
| 清理 | `logstore.CleanupExpired`,由 `system:cleanup` 调用 | 按库读取保留天数后 `DeleteBefore` |
`log_database` ∈ {`postgres`,`sqlite`,`clickhouse`},且只能是「随主库」或 ClickHouse:主库为 PG 时日志不能是 SQLite,反之亦然。`log_database` / `log_db_migration` 受保护,禁止管理端手动改。
## 新增一张日志表
按顺序做,列名三库必须一致。
1. **Model**
在 `internal/model/analytics/` 定义 struct;实现 `TableName()`;批量写再提供 `InsertColumns()` / `BatchInsertSQL()`。
2. **三套 DDL**(`database-migration`)
- ClickHouse:`goose/clickhouse/`,`MergeTree`,`PARTITION BY toYYYYMM(时间列)`。
- PostgreSQL:`goose/postgres/`,高频表用 `PARTITION BY RANGE (时间列)`,复合主键必须包含分区键。
- SQLite:`goose/sqlite/`,普通表 + 时间/过滤列索引。
不要在 PG/SQLite 上复制 CH 物化视图;聚合在查询时实时算。
3. **logstore 接口**
在对应 Store(现有 `UserAccessLogStore`,或新域自建接口并挂到 `Store`)补齐至少:
- 写入:`BatchInsert`(flush 目标;内调 `ensureWritable`)
- 查询:业务需要的 List/Count/聚合
- 迁移:`ListForMigration(afterID, limit)`、`MigrationRange`、`DeleteAll`、`EnsurePartitions`(PG 按月预建,CH/SQLite no-op)
- 清理:`DeleteBefore(cutoff)`、`DropEmptyPartitions`、`DropExpiredPartitions`(仅 PG;CH/SQLite no-op)
4. **双实现**
- CH:委托 `analyticsrepo`,零额外查询路径。
- GORM:PG/SQLite 共用一套;方言 SQL 只放小函数(如按日 `to_char` / `strftime`)。零值 `id` 落库前用 `idgen.NextUint64ID()`。
5. **`buildStore`**
在 `provider.go` 的 CH / GORM 分支同时挂上新域。
6. **写入**
apps 用独立 `batchwriter` 实例;`FlushFunc` → `logstore.Active(ctx)` → `BatchInsert`。禁止 `analyticsrepo.BatchInsert`、禁止 `db.ChConn`。迁移任务调用域的 `Drain`(等队列空一个 flush 周期,不要 `Stop` writer)。
7. **切换任务**
在 `copy*` 流程增加该表:`DeleteAll` 目标 → `MigrationRange` + `EnsurePartitions` → 按 id 分页复制。不要改切换协议(仍冻结写入、源数据不删、成功才翻转)。
8. **清理**
`CleanupExpired`:PG 先 `DropExpiredPartitions`(整月过期分区),再 `DeleteBefore`(边界月),最后 `DropEmptyPartitions`。保留天数用已有 `log_retention_days_*`。apps 禁止 import `repository/analytics`(`imports_test.go`)。
## 禁止
- apps 直接 `import` `internal/repository/analytics` 或 `db.ChConn` / `db.ChDB` 做日志读写
- 只建 CH 表、不建 PG/SQLite 回落
- 在 Handler 里逐条 `PrepareBatch` + `Send`
- 把业务表「顺便」放进 logstore 以便关 CH
- 管理端 API 改 `log_database` / `log_db_migration`
## 验证
```bash
go test ./internal/repository/logstore ./internal/repository/analytics
go test ./internal/apps/admin/logs ./internal/apps/risk_control ./internal/platform/bootstrap
make swagger # 若改了状态/查询 API
make code-check
```
对照:`w_user_access_logs` 的 model、三库 goose、`logstore` GORM/CH、`risk_control.InitLogWriter`、`logs.LogDBSwitchHandler`、`system:cleanup`。
+219
View File
@@ -0,0 +1,219 @@
---
name: "new-api"
description: "Wavelet 项目专用:当新增或修改业务 API、Handler、服务层逻辑、路由注册时必须使用。本技能指导 apps 业务包划分、路由注册、Handler/logics 分层、Swagger 与质量门禁;纠正把一切塞进 custom.go / apps/custom 或产品伞包的错误写法。"
---
# 新增业务 API 开发与路由注册规范
本技能是 Wavelet 接口开发与路由注册的唯一指导规范。在开发任何新接口前,请按本指南做架构决策与路由注册。
---
## 先搞清:脚手架 vs 产品化
Wavelet 是**通用全栈脚手架**。仓库里的 `custom` 相关代码是**示例/占位**,不是产品业务的标准落点。
| 层级 | 含义 | 典型包 |
| :--- | :--- | :--- |
| **平台能力** | 脚手架自带、与具体产品无关 | `oauth`、`user`、`admin/*`、`upload`、`cap`、`config`、`health`、`risk_control` |
| **产品业务** | 基于脚手架做具体产品时新增的域 | 直接落在 `internal/apps/<domain>/`,与平台包**平级** |
**一旦用脚手架开发具体产品,整个仓库就是该产品**——例如要做「消息平台」,业务模块应是 `apps/channel`、`apps/conversation`、`apps/delivery` 等,而不是先建 `apps/message` 伞包再往里塞子模块。
---
## 反模式(AI 最常踩的坑)
### 1. 把所有业务路由塞进 `custom.go` / 路径前缀 `/custom`
仓库中的:
- `internal/router/v1/custom.go`
- `internal/router/root/custom.go`
- `internal/apps/custom/`
是**演示如何挂一条示例接口**(`GET /api/v1/custom/hello`),**不是**「所有自定义业务必须写在这里」的规定。
| 错误 | 正确 |
| :--- | :--- |
| 新功能一律改 `v1/custom.go`,路径全是 `/api/v1/custom/...` | 按域新建 `apps/<domain>/`,路由用语义化路径(如 `/api/v1/channels`),在 `router/v1/` 下用**独立注册文件**挂载 |
| 把 `custom` 包当成业务垃圾桶 | 保留或删除示例均可;真正业务用独立包名 |
### 2. 产品伞包 + 深层子包
| 错误 | 正确 |
| :--- | :--- |
| `apps/message/channel`、`apps/message/inbox`、`apps/message/delivery`(先套一层产品名) | `apps/channel`、`apps/inbox`、`apps/delivery`(域模块与 `oauth`/`user` 平级) |
| `apps/myapp/...` 再嵌套所有业务 | 仓库即产品,**不要**再包一层产品根 |
**判定**:模块名应对齐**业务能力/限界上下文**(channel、order、invoice),而不是对齐产品营销名(message-platform、myapp)。
### 3. 其它仍须遵守的防线
- 不要在 `internal/router/router.go` 里直接挂业务 Handler(只做高层委派)。
- 不要破坏平台模块既有语义去硬塞无关业务(例如把消息逻辑塞进 `apps/user`)。
- 错误响应使用 `response.Abort*`,禁止 `c.JSON(..., response.Err(...))`(见 `AGENTS.md`)。
---
## 路由注册模型
### 谁可以改
| 文件 | 角色 | 产品化时 |
| :--- | :--- | :--- |
| `internal/router/router.go` | 引擎、中间件、委派入口 | 一般不改;特殊全局中间件才动 |
| `internal/router/v1/v1.go` | V1 分发:调用各 `Register*Routes` | **允许**:增加对新业务注册函数的一行调用 |
| `internal/router/v1/user.go` / `admin.go` | 平台用户端 / 管理端路由 | **优先不改**;仅当扩展平台能力(OAuth、上传、用户资料)时修改 |
| `internal/router/v1/<domain>.go`(新建) | 产品业务路由注册 | **推荐落点** |
| `internal/router/v1/custom.go` | **示例** | 可删可留;**不要**把真实业务堆在这里 |
| `internal/router/root/default.go` / `frontend.go` | 文件服务、health、前端静态 | 平台级,勿塞产品 API |
| `internal/router/root/custom.go` | 根路径**示例**占位 | 仅当确需根路径回调/短链时,用**语义路径**注册,或新建 `root/<domain>.go` 并由 `root.go` 调用 |
### 路径归属(产品 API 用语义路径)
| 目标路径特征 | 注册位置 | 说明 |
| :--- | :--- | :--- |
| `/api/v1/<domain>/...`(如 `/api/v1/channels`) | `v1/<domain>.go` 的 `Register<Domain>Routes`,在 `v1.go` 调用 | **产品业务默认做法** |
| `/api/v1/admin/<domain>/...` | 管理端:可在 `admin.go` 增加小组,或 `v1/admin_<domain>.go` 再由 `RegisterAdminRoutes`/ `v1.go` 组装 | 需 `admin.LoginAdminRequired()` |
| `/api/v1/user/...`、`/oauth/...`、`/upload/...` 等 | `user.go` 等平台文件 | 平台能力,勿把无关产品塞进来 |
| 根路径特殊接口(Webhook、短链) | `root` 下独立注册函数 | **不要**默认塞进 `custom` 前缀 |
| `GET /f/:id`、`/api/health`、`robots.txt` | `root/default.go` | 平台,勿改用途 |
`custom.go` 里现有的 `/api/v1/custom/...` **仅作脚手架演示**,不代表业务必须挂在 `/custom` 下。
---
## 推荐目录结构(产品业务)
以「频道 / channel」域为例(消息平台中的一个限界上下文):
```text
internal/
├── router/
│ └── v1/
│ ├── v1.go # [修改] 调用 RegisterChannelRoutes
│ └── channel.go # [新建] 只负责挂载 channel 路由
└── apps/
└── channel/ # 与 oauth、user、upload 平级
├── routers.go # HTTP Handlers(绑定、鉴权上下文、响应)
├── logics.go # 纯业务:context.Context,无 gin
├── errs.go # 模块错误文案常量(可选)
└── ... # 需要时再加 service.go、tasks.go 等
```
**不要**建成:
```text
internal/apps/message/ # ❌ 产品伞包
channel/
inbox/
internal/apps/custom/ # ❌ 示例包当业务垃圾桶
channel_handler.go
```
模块内若复杂度高,可在**该域包内**分子目录(如 `apps/channel/handler`),但仍是一个域包,不是「产品名/子域」两层品牌结构。
---
## 路由注册示例
### `internal/router/v1/channel.go`(产品业务)
```go
package v1
import (
"github.com/Rain-kl/Wavelet/internal/apps/channel"
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
"github.com/gin-gonic/gin"
)
// RegisterChannelRoutes mounts channel domain APIs under /api/v1.
func RegisterChannelRoutes(apiV1Router *gin.RouterGroup) {
r := apiV1Router.Group("/channels")
r.Use(oauth.LoginRequired())
{
r.GET("", channel.ListChannels)
r.POST("", channel.CreateChannel)
r.GET("/:id", channel.GetChannel)
}
}
```
### `internal/router/v1/v1.go`(增加一行委派)
```go
func RegisterV1Routes(apiV1Router *gin.RouterGroup, apiGroup *gin.RouterGroup) {
RegisterUserRoutes(apiV1Router, apiGroup)
RegisterAdminRoutes(apiV1Router)
RegisterChannelRoutes(apiV1Router) // 产品域
RegisterCustomRoutes(apiV1Router) // 可选:仅保留脚手架示例
}
```
### 根路径 Webhook(确有需要时)
在 `root` 用语义路径,例如 `POST /webhooks/stripe`,注册函数可放在 `root/webhooks.go` 或扩展现有 root 注册;**不要**为了「只能写 custom」而使用无意义的 `/custom` 前缀。
---
## 核心开发步骤
### 步骤 1:划定域包名
- 用**业务能力**命名:`channel`、`order`、`invoice`。
- 与现有 `apps/` 下平台包平级;禁止产品伞包。
### 步骤 2:库表与 model
若涉及新表/字段:按 [database-migration](../database-migration/SKILL.md) 在 goose 迁移与 `internal/model/` 中定义。
### 步骤 3:`logics.go` / `service.go`
放在 `internal/apps/<domain>/`:
- **优先**纯函数 `logics.go`:`context.Context` 入参,无 `*gin.Context`。
- 有状态依赖时用 `service.go` 构造注入。
- 跨模块副作用(推送、任务)经 `internal/listener` + `bootstrap`,禁止业务直接 import push(见 `push-notification`)。
### 步骤 4:Handler(`routers.go`)
- `ShouldBindJSON` / `ShouldBindQuery`。
- 成功:`c.JSON(http.StatusOK, response.OK(data))` 或 `response.OKNil()`。
- 失败:`response.AbortBadRequest` / `AbortUnauthorized` / `AbortNotFound` / `AbortInternal` 等,**禁止** `response.Err` 直接 `c.JSON`。
- 完整 Swagger 注释;`@Router` 使用真实语义路径。
参考:`references/handler_example.go`、`logics_example.go`、`service_example.go`(示例域名,非强制包名 `custom`)。
### 步骤 5:注册路由
新建 `internal/router/v1/<domain>.go`,在 `v1.go` 调用;管理端按需挂到 admin 组。
---
## 与平台路由的边界
- **扩展平台能力**(用户资料字段、上传策略、OAuth 源):改对应平台 `apps/*` 与 `user.go`/`admin.go`。
- **新产品功能**:新建 `apps/<domain>` + `router/v1/<domain>.go`,**不要**塞进 `custom` 或某个无关平台包。
- 管理端产品配置页 API:路径宜为 `/api/v1/admin/<domain>/...`,中间件与现有 admin 组一致。
---
## 质量验证门禁
1. `make license`(新 Go 文件许可头)
2. `make swagger`(Handler/Swagger 有变时)
3. `make format` 与 `make code-check`
4. `go test` 覆盖相关包
---
## 自检清单
- [ ] 未把真实业务堆进 `apps/custom` 或 `v1/custom.go`
- [ ] 未创建 `apps/<产品名>/` 伞包再塞子域
- [ ] 业务包与 `oauth`/`user`/`upload` 平级,路径语义化(非强制 `/custom`)
- [ ] 路由在 `router/v1/<domain>.go`(或 admin 对应处)注册,并由 `v1.go` 委派
- [ ] Handler 用 `response.Abort*` / `response.OK`,logics 不依赖 gin
- [ ] 需要时已跑 swagger / code-check
@@ -0,0 +1,55 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package references
import (
"net/http"
"github.com/Rain-kl/Wavelet/internal/shared/response"
"github.com/gin-gonic/gin"
)
// createChannelRequest 客户端请求体 DTO
type createChannelRequest struct {
Name string `json:"name" binding:"required,min=1,max=100"`
}
// createChannelResponse API 响应体 DTO
type createChannelResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
// CreateChannel 示例:产品域 Handler(应放在 internal/apps/channel/routers.go)
// @Summary 创建频道
// @Description 示例:语义路径下的业务接口,而非 /api/v1/custom/...
// @Tags channel
// @Accept json
// @Produce json
// @Param request body createChannelRequest true "业务请求参数"
// @Success 200 {object} response.Any{data=createChannelResponse} "操作成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Router /api/v1/channels [post]
func CreateChannel(c *gin.Context) {
var req createChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, "参数校验失败")
return
}
// 通常结合 oauth.LoginRequired();此处仅演示从上下文取用户
userID := int64(9527)
result, err := CreateChannelLogic(c.Request.Context(), userID, req.Name)
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(createChannelResponse{
ID: result.ID,
Name: result.Name,
}))
}
@@ -0,0 +1,38 @@
// 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"
)
// channelCreated 示例 logics 返回值(真实代码可用 model 或专用 DTO)
type channelCreated struct {
ID int64
Name string
}
// CreateChannelLogic 示例:模块内闭环业务(放在 apps/channel/logics.go)
// 接收 context.Context,不依赖 gin.Context,便于单测与 Worker 复用。
func CreateChannelLogic(ctx context.Context, userID int64, name string) (*channelCreated, error) {
if name == "" {
return nil, errors.New("name cannot be empty")
}
logger.Info(ctx, "creating channel",
zap.Int64("user_id", userID),
zap.String("name", name),
)
// 轻量级本地逻辑;复杂持久化可进 model/repository
return &channelCreated{
ID: 1,
Name: fmt.Sprintf("%s (by %d)", name, userID),
}, nil
}
@@ -0,0 +1,40 @@
// 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"
)
// ChannelService 示例有状态 Service(放在 internal/apps/channel/service.go)
// 需要注入 DB/客户端时使用;简单逻辑优先 logics.go 纯函数。
type ChannelService struct {
// 例如:repo ChannelRepository
}
// NewChannelService 构造函数
func NewChannelService() *ChannelService {
return &ChannelService{}
}
// Create 核心业务:首位参数必须是 context.Context;禁止依赖 Gin。
func (s *ChannelService) Create(ctx context.Context, userID int64, name string) (int64, error) {
if name == "" {
return 0, errors.New("name cannot be empty")
}
logger.Info(ctx, "channel service create",
zap.Int64("user_id", userID),
zap.String("name", name),
)
// DB 事务、远程调用等
_ = fmt.Sprintf("user=%d name=%s", userID, name)
return 1, nil
}
+121
View File
@@ -0,0 +1,121 @@
---
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`:执行记录和日志持久化
需要模板时阅读 [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,由任务框架处理状态和重试。
- 不要吞掉关键错误。
- 复杂 SQL 放到 `internal/model/` 或模块内的业务服务层(如 `internal/apps/<module>/service.go` 或 `logics.go`)。
### 注册
- 在 `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/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/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/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/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。
+159
View File
@@ -0,0 +1,159 @@
---
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` 和 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 常量、`SystemConfig` 模型、`GetByKey`、`GetBoolByKey`、`GetIntByKey`、`GetDecimalByKey` 等读取方法。
- `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. 读取配置。
- 后端业务代码优先使用 `model.GetBoolByKey`、`model.GetIntByKey`、`model.GetDecimalByKey` 或 `SystemConfig.GetByKey`。
- 运行时可热更新的规则不要放进 `config.Config`;启动时设置才走 `internal/infra/config/model.go` 和 `config.example.yaml`。
- 不要在 handler 或业务代码里直接读 `os.Getenv()`。
4. 如果前端需要未登录或全局消费,暴露为公共可见配置。
- 把该配置的 `visibility` 设为 `1`,`GetPublicConfig` 会通过 `model.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"`
- goose SQL 默认值:`value='false'`,`type` 按语义选 `"system"` 或 `"business"`,`visibility=1`。
- 后端读取:`model.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`。
- 后端读取:`model.GetIntByKey` 或 `model.GetDecimalByKey`。
- 前端图形控件:`Input type="number"` 或合适的 shadcn 数值控件;保存前做最小必要校验,错误用 toast。
### JSON 设置
- 默认值使用合法 JSON,例如 `"{}"` 或 `"[]"`。
- 在 model 或 service 层提供解析函数,像 `GetMenuDisplayConfig` 一样把 JSON 解析错误包装成清晰错误。
- 前端不要直接拼接 JSON 字符串;用 `JSON.stringify` 写入,用类型化对象在组件中操作。
## 验证
根据改动范围运行最小有效验证,最后提交前必须运行项目门禁。
- 新增或修改系统配置默认值、visibility 或公共配置读取:至少运行相关 Go 包测试,例如:
```bash
go test ./internal/model ./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:配置读取在请求链路或后台链路中传递取消和超时时使用。
+171
View File
@@ -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` 入口显式装配。
+112
View File
@@ -0,0 +1,112 @@
---
name: "release-guide"
description: "项目专用:根据自上一个正式版本 Tag 以来的提交记录,整理生成规范的 Version Bump Commit Message,用于触发自动双语 Release。"
---
# Release Commit Message Guide
## 目标
当用户准备发布新版本时,本 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。
+267
View File
@@ -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
+5
View File
@@ -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

+290
View File
@@ -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.
+209
View File
@@ -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.
+47
View File
@@ -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-*"
]
}
]
}
+105
View File
@@ -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`
+277
View File
@@ -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>
```
+195
View File
@@ -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">` |
+192
View File
@@ -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`.
+101
View File
@@ -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} />
```
+162
View File
@@ -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]`.
Symlink
+1
View File
@@ -0,0 +1 @@
.agents
+28 -9
View File
@@ -1,11 +1,30 @@
.git
.idea
anubis-source
**/node_modules
**/.next
**/build
**/dist
**/.cache
**/coverage
**/*.db
**/*.log
.vscode
.DS_Store
Thumbs.db
config.yaml
.env
.env.*
bin/
build/
dist/
data/
logs/
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/
+85
View File
@@ -0,0 +1,85 @@
# ──────────────────────────────────────────────────────────────────────────────
# wavelet — 环境变量配置模板
# 复制此文件为 .env 并填入实际值: cp .env.example .env
# 环境变量优先级高于 config.yaml
# docker compose 会读取本文件(env_file: .env)并替换 compose 中的 ${VAR}
# ──────────────────────────────────────────────────────────────────────────────
# ─── 时区 ─────────────────────────────────────────────────────────────────────
TZ=Asia/Shanghai
# ─── 应用配置 ──────────────────────────────────────────────────────────────────
APP_NAME=wavelet
APP_ENV=production
APP_ADDR=:8000
APP_NODE_ID=1
APP_API_PREFIX=/api
# APP_GRACEFUL_SHUTDOWN_TIMEOUT=30
APP_SESSION_COOKIE_NAME=wavelet_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/wavelet.db
DB_HOST=postgres
DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_NAME=wavelet
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=wavelet:
# REDIS_POOL_SIZE=100
# 启动时开关;修改后需重启服务
REDIS_MAINT_NOTIFICATIONS=false
# compose 宿主机映射端口(仅 docker-compose 使用)
# REDIS_PORT=6379
# ─── ClickHouse(可选,默认关闭)──────────────────────────────────────────
# 设置 CLICKHOUSE_HOST 后自动启用,也可显式控制
# CLICKHOUSE_ENABLED=false
# CLICKHOUSE_HOST=clickhouse:9000
# CLICKHOUSE_USERNAME=default
# CLICKHOUSE_PASSWORD=
# CLICKHOUSE_NAME=wavelet
# ─── 日志 ──────────────────────────────────────────────────────────────────────
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/Wavelet
# OTEL_TRACER_NAME=github.com/Rain-kl/Wavelet
# 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
View File
@@ -1 +0,0 @@
* -text
-122
View File
@@ -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' }}
-23
View File
@@ -1,23 +0,0 @@
---
name: 报告问题
about: 使用简练详细的语言描述你遇到的问题
title: ''
labels: bug
assignees: ''
---
**例行检查**
+ [ ] 我已确认目前没有类似 issue
+ [ ] 我已确认我已升级到最新版本
+ [ ] 我理解并愿意跟进此 issue,协助测试和提供反馈
+ [ ] 我理解并认可上述内容,并理解项目维护者精力有限,不遵循规则的 issue 可能会被无视或直接关闭
**问题描述**
**复现步骤**
**预期结果**
**相关截图**
如果没有的话,请删除此节。
+90
View File
@@ -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 做出贡献!
-18
View File
@@ -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 做出贡献!
+31
View File
@@ -0,0 +1,31 @@
## 基础规范
- 在任何情况都使用简体中文
- 你是一个专业的代码助手,专门为 wavelet 项目提供代码编写和优化服务
- 严格遵循项目的代码规范和最佳实践,确保代码质量和一致性
- 保持代码简洁、可读、高效
- 优先考虑项目的可维护性、性能和安全性,避免引入不必要的复杂性
- 注释和上一行代码之间保留一行空格
- 编写代码前仔细分析需求,确保改动有实际价值和意义
- 避免仅修改格式、注释或无影响力的拼写错误
- 重构代码时必须带来可维护性或功能上的实质提升
- 新增功能时考虑向后兼容性和 API 稳定性
- 遵循项目的 Apache2.0 许可证要求
- 遵循语义化版本控制规范
- 新增异步任务时使用项目技能 `.agents/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 库
+3
View File
@@ -0,0 +1,3 @@
- 如果有其他代码文件,忽略 Swagger 变更和版本号变更,只需要关注其他代码文件的变更
- 需要符合 Github 的提交规范,使用 <type>(<scope>): <subject> 格式
- Commit Message 必须有 Scope 信息
+25
View File
@@ -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 的变更原因 -->
+270
View File
@@ -0,0 +1,270 @@
name: Build Image
on:
workflow_dispatch:
inputs:
version:
description: "Image version/tag to publish (e.g. v1.0.0-beta). Leave empty to publish as canary."
required: false
type: string
push:
tags: ["v*"]
branches: ["canary"]
# One active run per ref (e.g. canary); newer runs cancel older in-progress builds.
concurrency:
group: build-image-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
packages: write
attestations: write
id-token: write
env:
IMAGE_NAME: wavelet
DOCKERFILE: docker/Dockerfile
jobs:
# Resolve version / registries once. No checkout: triggers alone determine the tag.
prepare:
name: Prepare metadata
runs-on: ubuntu-latest
outputs:
version: ${{ steps.prep.outputs.version }}
build_date: ${{ steps.prep.outputs.build_date }}
image: ${{ steps.prep.outputs.image }}
image_names: ${{ steps.prep.outputs.image_names }}
images: ${{ steps.prep.outputs.images }}
push_dockerhub: ${{ steps.prep.outputs.push_dockerhub }}
is_stable: ${{ steps.prep.outputs.is_stable }}
is_prerelease: ${{ steps.prep.outputs.is_prerelease }}
steps:
- name: Resolve version and images
id: prep
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
DOCKERHUB_NAMESPACE: ${{ secrets.DOCKERHUB_NAMESPACE }}
run: |
set -euo pipefail
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
OWNER="${GITHUB_REPOSITORY_OWNER,,}"
BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
if [[ "${GITHUB_REF}" == refs/heads/canary ]]; then
VERSION="canary"
elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then
VERSION="${GITHUB_REF_NAME}"
elif [[ -n "$INPUT_VERSION" ]]; then
VERSION="$INPUT_VERSION"
elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
VERSION="canary"
else
echo "unable to determine image version/tag" >&2
exit 1
fi
if [[ "$VERSION" == "canary" ]]; then
IS_STABLE="false"
IS_PRERELEASE="false"
elif [[ "$VERSION" =~ (alpha|beta|rc) ]]; then
IS_STABLE="false"
IS_PRERELEASE="true"
else
IS_STABLE="true"
IS_PRERELEASE="false"
fi
IMAGE="ghcr.io/${OWNER}/${IMAGE_NAME}"
IMAGE_NAMES="${IMAGE}"
# Newline-separated list for docker/metadata-action
IMAGES="${IMAGE}"
DOCKERHUB_USERNAME="${DOCKERHUB_USERNAME//[[:space:]]/}"
DOCKERHUB_TOKEN="${DOCKERHUB_TOKEN//[[:space:]]/}"
DOCKERHUB_NAMESPACE="${DOCKERHUB_NAMESPACE//[[:space:]]/}"
PUSH_DOCKERHUB="false"
if [[ -n "$DOCKERHUB_USERNAME" && -n "$DOCKERHUB_TOKEN" ]]; then
HUB_NS="${DOCKERHUB_NAMESPACE:-$DOCKERHUB_USERNAME}"
HUB_NS="${HUB_NS,,}"
IMAGE_DOCKERHUB="${HUB_NS}/${IMAGE_NAME}"
IMAGE_NAMES="${IMAGE_NAMES},${IMAGE_DOCKERHUB}"
IMAGES="${IMAGES}"$'\n'"${IMAGE_DOCKERHUB}"
PUSH_DOCKERHUB="true"
echo "Docker Hub publish enabled: ${IMAGE_DOCKERHUB}"
else
echo "Docker Hub secrets not set; publishing to GHCR only."
fi
{
echo "version=${VERSION}"
echo "build_date=${BUILD_DATE}"
echo "image=${IMAGE}"
echo "image_names=${IMAGE_NAMES}"
echo "push_dockerhub=${PUSH_DOCKERHUB}"
echo "is_stable=${IS_STABLE}"
echo "is_prerelease=${IS_PRERELEASE}"
echo "images<<EOF"
printf '%s\n' "${IMAGES}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "Resolved version=${VERSION} build_date=${BUILD_DATE} stable=${IS_STABLE} prerelease=${IS_PRERELEASE}"
build:
name: Build (${{ matrix.arch }})
needs: prepare
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-latest
- arch: arm64
platform: linux/arm64
# No ubuntu-latest-arm alias from GitHub; 24.04-arm is the current stable arm64 image.
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 1
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log into GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log into Docker Hub
if: needs.prepare.outputs.push_dockerhub == 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
id: build
uses: docker/build-push-action@v7
with:
context: .
file: ${{ env.DOCKERFILE }}
platforms: ${{ matrix.platform }}
outputs: type=image,"name=${{ needs.prepare.outputs.image_names }}",push-by-digest=true,name-canonical=true,push=true
build-args: |
VERSION=${{ needs.prepare.outputs.version }}
BUILD_DATE=${{ needs.prepare.outputs.build_date }}
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/${{ 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: ${{ env.IMAGE_NAME }}-digests-${{ matrix.arch }}
path: /tmp/${{ env.IMAGE_NAME }}-digests/*
if-no-files-found: error
retention-days: 1
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v3
with:
subject-name: ${{ needs.prepare.outputs.image }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
merge:
name: Merge multi-arch manifest
runs-on: ubuntu-latest
needs: [prepare, build]
steps:
# No repo checkout: tags come from prepare + metadata-action.
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ needs.prepare.outputs.images }}
flavor: |
latest=false
tags: |
type=raw,value=${{ needs.prepare.outputs.version }}
type=raw,value=latest,enable=${{ needs.prepare.outputs.is_stable == 'true' }}
type=raw,value=beta,enable=${{ needs.prepare.outputs.is_prerelease == 'true' }}
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/${{ env.IMAGE_NAME }}-digests
pattern: ${{ env.IMAGE_NAME }}-digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log into GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log into Docker Hub
if: needs.prepare.outputs.push_dockerhub == 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push manifest list
working-directory: /tmp/${{ env.IMAGE_NAME }}-digests
shell: bash
env:
IMAGE: ${{ needs.prepare.outputs.image }}
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }}
run: |
set -euo pipefail
shopt -s nullglob
references=()
for digest in *; do
references+=("${IMAGE}@sha256:${digest}")
done
if [ ${#references[@]} -eq 0 ]; then
echo "No digests found in /tmp/${{ env.IMAGE_NAME }}-digests" >&2
exit 1
fi
# shellcheck disable=SC2046
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
"${references[@]}"
- name: Inspect image
run: docker buildx imagetools inspect "${{ needs.prepare.outputs.image }}:${{ needs.prepare.outputs.version }}"
- name: Trigger webhook
env:
WEBHOOK_URL: ${{ secrets.WEBHOOK_URL }}
run: |
if [ -n "$WEBHOOK_URL" ]; then
curl -fsSL "$WEBHOOK_URL"
else
echo "Webhook URL is not set, skipping."
fi
+270
View File
@@ -0,0 +1,270 @@
name: Build Release
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
version:
description: "Release version/tag to build, for example v1.0.0-beta"
required: true
type: string
env:
APP_NAME: wavelet
GO_MAIN: ./main.go
GO_BUILD_TAGS: embed_frontend
GO_LDFLAGS: -s -w
NODE_VERSION: "22"
PNPM_VERSION: "10.10.0"
FRONTEND_DIR: frontend
FRONTEND_BUILD_COMMAND: pnpm build:embed
FRONTEND_OUT_DIR: frontend/out
EMBED_DIST_DIR: internal/router/root/dist
EXTRA_FILES: |
LICENSE
README.md
README_zh.md
config.example.yaml
DEPLOYMENT_zh.md
permissions:
contents: write
jobs:
prepare-message:
runs-on: ubuntu-latest
outputs:
commit_msg: ${{ steps.trans.outputs.commit_msg }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
fetch-depth: 0
- name: Prepare Commit Message
id: trans
shell: bash
run: |
msg=$(git log -1 --pretty=%B)
pip install deep-translator > /dev/null 2>&1 || true
export COMMIT_MSG="$msg"
echo "commit_msg<<EOF" >> "$GITHUB_OUTPUT"
if [ -f "scripts/translate_commit.py" ]; then
python3 scripts/translate_commit.py >> "$GITHUB_OUTPUT"
else
echo "Translation script not found, using raw message"
echo "$msg" >> "$GITHUB_OUTPUT"
fi
echo "EOF" >> "$GITHUB_OUTPUT"
create-release:
name: Create Release
needs: prepare-message
runs-on: ubuntu-latest
outputs:
version: ${{ steps.metadata.outputs.version }}
version_without_v: ${{ steps.metadata.outputs.version_without_v }}
build_date: ${{ steps.metadata.outputs.build_date }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Set release metadata
id: metadata
shell: bash
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
run: |
set -euo pipefail
input_version="${INPUT_VERSION//[[:space:]]/}"
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
version="$GITHUB_REF_NAME"
elif [[ -n "$input_version" ]]; then
version="$input_version"
else
echo "workflow_dispatch requires a version input" >&2
exit 1
fi
{
echo "version=$version"
echo "version_without_v=${version#v}"
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
} >> "$GITHUB_OUTPUT"
- name: Create release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.metadata.outputs.version }}
name: ${{ steps.metadata.outputs.version }}
body: ${{ needs.prepare-message.outputs.commit_msg }}
prerelease: ${{ contains(steps.metadata.outputs.version, 'alpha') || contains(steps.metadata.outputs.version, 'beta') || contains(steps.metadata.outputs.version, 'rc') }}
build-frontend:
name: Build Embedded Frontend
runs-on: ubuntu-latest
needs: create-release
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
run_install: false
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: ${{ env.FRONTEND_DIR }}/pnpm-lock.yaml
- name: Install frontend dependencies
working-directory: ${{ env.FRONTEND_DIR }}
run: pnpm install --frozen-lockfile
- name: Build frontend
env:
NEXT_PUBLIC_APP_VERSION: ${{ needs.create-release.outputs.version }}
NEXT_PUBLIC_APP_BUILD_DATE: ${{ needs.create-release.outputs.build_date }}
run: ${{ env.FRONTEND_BUILD_COMMAND }}
working-directory: ${{ env.FRONTEND_DIR }}
- name: Prepare embed directory
shell: bash
run: |
set -euo pipefail
rm -rf "$EMBED_DIST_DIR"
mkdir -p "$(dirname "$EMBED_DIST_DIR")"
cp -R "$FRONTEND_OUT_DIR" "$EMBED_DIST_DIR"
- name: Upload embedded frontend
uses: actions/upload-artifact@v4
with:
name: embedded-frontend
path: ${{ env.EMBED_DIST_DIR }}
if-no-files-found: error
retention-days: 1
build-binaries:
name: Build ${{ matrix.goos }}/${{ matrix.goarch }}
runs-on: ubuntu-latest
needs:
- create-release
- build-frontend
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: amd64
archive: tar.gz
- goos: linux
goarch: arm64
archive: tar.gz
- goos: darwin
goarch: amd64
archive: tar.gz
- goos: darwin
goarch: arm64
archive: tar.gz
- goos: windows
goarch: amd64
archive: zip
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download embedded frontend
uses: actions/download-artifact@v4
with:
name: embedded-frontend
path: ${{ env.EMBED_DIST_DIR }}
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Build binary
shell: bash
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
VERSION: ${{ needs.create-release.outputs.version }}
BUILD_DATE: ${{ needs.create-release.outputs.build_date }}
run: |
set -euo pipefail
mkdir -p dist
binary_name="$APP_NAME"
if [[ "$GOOS" == "windows" ]]; then
binary_name="${binary_name}.exe"
fi
ldflags="$GO_LDFLAGS -X github.com/Rain-kl/Wavelet/internal/buildinfo.Version=$VERSION -X github.com/Rain-kl/Wavelet/internal/buildinfo.BuildTime=$BUILD_DATE"
build_args=(
-trimpath
-ldflags "$ldflags"
-o "dist/$binary_name"
)
if [[ -n "$GO_BUILD_TAGS" ]]; then
build_args=(-tags "$GO_BUILD_TAGS" "${build_args[@]}")
fi
go build "${build_args[@]}" "$GO_MAIN"
- name: Package artifact
id: package
shell: bash
env:
VERSION: ${{ needs.create-release.outputs.version }}
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
ARCHIVE_FORMAT: ${{ matrix.archive }}
run: |
set -euo pipefail
package_name="${APP_NAME}_${VERSION}_${GOOS}_${GOARCH}"
staging_dir="dist/$package_name"
mkdir -p "$staging_dir"
if [[ "$GOOS" == "windows" ]]; then
cp "dist/${APP_NAME}.exe" "$staging_dir/"
else
cp "dist/${APP_NAME}" "$staging_dir/"
fi
while IFS= read -r extra_file; do
[[ -z "$extra_file" ]] && continue
if [[ -e "$extra_file" ]]; then
cp -R "$extra_file" "$staging_dir/"
fi
done <<< "$EXTRA_FILES"
if [[ "$ARCHIVE_FORMAT" == "zip" ]]; then
(cd dist && zip -r "${package_name}.zip" "$package_name")
artifact="dist/${package_name}.zip"
else
tar -C dist -czf "dist/${package_name}.tar.gz" "$package_name"
artifact="dist/${package_name}.tar.gz"
fi
echo "artifact=$artifact" >> "$GITHUB_OUTPUT"
- name: Upload release artifact
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.create-release.outputs.version }}
files: ${{ steps.package.outputs.artifact }}
@@ -1,7 +1,9 @@
name: Cleanup prerelease tags
name: Cleanup Prerelease
on:
workflow_dispatch:
schedule:
- cron: '0 3 * * *'
permissions:
contents: write
+40
View File
@@ -0,0 +1,40 @@
name: "CodeQL"
on:
pull_request:
branches: [ "*" ]
push:
branches:
- "dev"
- "main"
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-24.04
permissions:
security-events: write
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: go
build-mode: autobuild
- language: javascript-typescript
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"
-187
View File
@@ -1,187 +0,0 @@
name: Docker image build (Agent)
on:
workflow_dispatch:
inputs:
version:
description: "Image version/tag to publish, for example v1.0.0-beta"
required: false
type: string
push:
tags: ["v*"]
permissions:
contents: read
packages: write
attestations: write
id-token: write
jobs:
build:
name: Build (${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: arm64
platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-tags: true
fetch-depth: 0
persist-credentials: false
- name: Set image metadata
shell: bash
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
run: |
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}-agent" >> "$GITHUB_ENV"
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
VERSION="${GITHUB_REF_NAME}"
elif [[ -n "$INPUT_VERSION" ]]; then
VERSION="$INPUT_VERSION"
elif [[ -n "$POINTED_TAG" ]]; then
VERSION="$POINTED_TAG"
else
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
exit 1
fi
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log into registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: build
uses: docker/build-push-action@v7
with:
context: .
file: ./openflare_agent/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-agent-${{ matrix.arch }}
cache-to: type=gha,mode=max,ignore-error=true,timeout=20m,scope=docker-agent-${{ matrix.arch }}
- name: Export digest
shell: bash
run: |
mkdir -p /tmp/agent-digests
touch "/tmp/agent-digests/${DIGEST#sha256:}"
env:
DIGEST: ${{ steps.build.outputs.digest }}
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: agent-digests-${{ matrix.arch }}
path: /tmp/agent-digests/*
if-no-files-found: error
retention-days: 1
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v3
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
merge:
name: Merge multi-arch manifest
runs-on: ubuntu-24.04
needs: build
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-tags: true
fetch-depth: 0
persist-credentials: false
- name: Set image metadata
shell: bash
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
run: |
POINTED_TAG="$(git tag --points-at HEAD --list 'v*' | sort -V | tail -n1)"
INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}"
echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}-agent" >> "$GITHUB_ENV"
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
VERSION="${GITHUB_REF_NAME}"
elif [[ -n "$INPUT_VERSION" ]]; then
VERSION="$INPUT_VERSION"
elif [[ -n "$POINTED_TAG" ]]; then
VERSION="$POINTED_TAG"
else
echo "workflow_dispatch requires an explicit version input when HEAD is not tagged" >&2
exit 1
fi
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/agent-digests
pattern: agent-digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log into registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push manifest list
working-directory: /tmp/agent-digests
shell: bash
run: |
shopt -s nullglob
references=()
for digest in *; do
references+=("${IMAGE}@sha256:${digest}")
done
if [ ${#references[@]} -eq 0 ]; then
echo "No digests found in /tmp/agent-digests" >&2
exit 1
fi
if [[ "${VERSION}" =~ (alpha|beta|rc) ]]; then
FLOATING_TAG="beta"
else
FLOATING_TAG="latest"
fi
docker buildx imagetools create \
-t "${IMAGE}:${VERSION}" \
-t "${IMAGE}:${FLOATING_TAG}" \
"${references[@]}"
- name: Inspect image
run: docker buildx imagetools inspect "${IMAGE}:${VERSION}"

Some files were not shown because too many files have changed in this diff Show More