给 Claude 看的项目专属规则。全局规则见
~/.claude/CLAUDE.md,这里只列本项目特有的约定。与
AGENTS.md保持同步:本项目并行维护两份 AI 编码助手规则文件(CLAUDE.md 给 Claude Code、AGENTS.md 给 Codex 等其他工具)。改一份时另一份也要改,否则两个助手会给出漂移的代码。
Go 1.26+ + Gin + GORM + PostgreSQL + Redis + Asynq。模块名 go-skeleton。
| 目录 | 职责 | 写代码时的硬约束 |
|---|---|---|
cmd/api cmd/worker cmd/migrate |
三个独立进程入口 | main.go 只做"加载配置 → 初始化 → 启动 → 信号优雅关闭",禁止写业务逻辑 |
config/ |
环境变量加载、运行时配置 | 不持有业务逻辑;新增配置项要补 .env.example |
internal/bootstrap/ |
进程级资源装配,输出 Registry |
API/Worker/Migrate 各自调对应的 InitXxx |
internal/ (package app) |
server.go / worker.go 把 Registry 装配成完整调用链 |
所有 handler/service/repository 的 new 集中在这里 |
internal/router/ |
URL → handler 映射 | 不构造依赖、不做初始化 |
internal/handler/ service/ repository/ model/ |
分层业务代码 | 见下文"分层规则" |
internal/middleware/ |
Gin 中间件 | 错误响应走 response.ErrorResponse |
pkg/errcode/ |
业务错误码 | 只在这里定义新错误,不在 service/handler 内联构造 |
internal/task/ |
Asynq 任务类型定义 | API 和 Worker 共享 |
internal/worker/ |
Asynq 消费端的 handler 实现 | 复用 service/repository,不要重写业务逻辑 |
internal/taskqueue/ |
Asynq client 的薄封装 | service 通过 ExampleQueue 这种接口依赖它,不直接 import asynq |
pkg/ |
跟业务无关的通用工具 | 严禁 import internal/ 任何包 |
数据库迁移用 cmd/migrate(基于 goose 库 API)跑仓库根目录 migrations/ 下的版本化 SQL 文件,文件经 //go:embed 打进二进制。真相源是这些 SQL 文件、不是 Go struct——AutoMigrate 已移除,改表结构走"make migrate-create name=xxx 生成空迁移 → 填 SQL → 跑 make run-migrate"。文件名是时间戳前缀(goose 时间戳风格):<YYYYMMDDHHMMSS>_<描述>.sql,由 make migrate-create 自动生成、天然全局有序、多人并行不撞号;版本号必须是文件名首个 _ 前的纯数字段,时间戳要连写、不要在中间插下划线(goose 解析不了)。命令:make run-migrate(up)/ make migrate-down(回滚一版)/ make migrate-status(看状态)/ make migrate-create name=xxx(新建空迁移)。迁移文件放仓库根 migrations/,不要塞 internal/。cmd/migrate 用 goose 的 Provider API(绑死 DialectPostgres,本项目只支持 Postgres)并配 Postgres advisory lock,多实例/多机并发跑 migrate 时自动串行化、不竞态。生产迁移要对旧代码向后兼容(只增不破坏),破坏性变更走 expand-contract 两阶段发布——详见 docs/deploy.md 升级/回滚段。
迁移文件 lint 由 migrations/migrations_test.go 在 make verify 链里执行,强制三道门:(1) 文件名严格 <14位时间戳>_<snake_case>.sql;(2) 必须含 -- +goose Up 与 -- +goose Down 注解;(3) Up 段里 DROP TABLE/COLUMN/CONSTRAINT、ALTER COLUMN TYPE/SET NOT NULL、RENAME COLUMN/TO、TRUNCATE 这类破坏性 DDL 必须配 -- breaking: <reason>(或 -- +breaking <reason>)显式标注。新增危险 DDL 形态时同步更新 migrations_test.go::dangerousDDL。
调用方向是单向的,不允许反向依赖,不允许越级。
- 只做三件事:参数绑定、调 service、格式化响应。不写业务规则。
- 参数校验失败走
response.BuildValidationErrorResponse(c, err)→c.JSON(200, ...)。 - service 返回 error 时统一走
response.WriteError(c, err),由它根据errcode.Error还是兜底来转协议。 - 成功响应走
response.WriteSuccess(c, data)。
- 入参用
context.Context,禁止用*gin.Context。Worker 也消费 service,绑死 gin 会让 Worker 跑不通。 - 返回
errcode包里的错误值(如errcode.DatabaseError),不要返回拼接字符串、不要在 service 里调c.JSON。 - 业务流程编排可以跨多个 repository / queue / cache;不能直接写 GORM 链式调用。
- 依赖通过构造函数注入,不要在 service 内部
new其他 service / repository。 - 依赖接口(如
ExampleRepository、ExampleQueue)就近定义在 service 包里,方便测试 mock。
跨 repository 事务编排(标准范式):当一次业务操作需要在原子事务内调用两个及以上 repository 时,只能 在 service 层用 repository.InTx 包起来,repository 内部不要自己开事务(否则嵌套调用会撞 SAVEPOINT 语义)。模板:
// service 层:跨 OrderRepository + InventoryRepository 的下单流程
func (s *OrderService) Place(ctx context.Context, req *PlaceOrderReq) (*Order, error) {
var created *Order
err := repository.InTx(ctx, s.db, func(txCtx context.Context) error {
// 1) 扣减库存(库存不足 repo 返业务错,整事务回滚)
if err := s.inventory.Reserve(txCtx, req.SkuID, req.Qty); err != nil {
return err // 透传 errcode.Error,InTx 不会包装
}
// 2) 落订单
order, err := s.orders.Create(txCtx, req.ToModel())
if err != nil {
return err
}
created = order
return nil
})
if err != nil {
return nil, err // 已是 errcode.Error;handler 走 WriteError 自动映射 HTTP
}
return created, nil
}要点:
txCtx必须传给 repository,不要在 fn 里继续用外层ctx——否则 repository 内dbFromContext取不到事务句柄,会从 base db 起新连接绕过事务。- repository 接口直接收
context.Context,不给事务版另写一套方法签名(CreateInTx这种风格禁止)。事务上下文通过 ctx 传,业务接口形状只有一种。 - fn 返 error → GORM rollback;返 nil → commit。中途 panic 也会 rollback(GORM 默认行为,不要写自己的 recover 绕过)。
- 需要强 isolation(分页 total 强一致、批量入账 + count)走
repository.InTxWithOptions(ctx, db, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true}, fn);嵌套调用 opts 会被忽略,isolation 必须在最外层定。 - 不要 把队列投递(
taskqueue.Queue.Enqueue)放进 InTx fn——Redis / Asynq 不参与 PG 事务,事务回滚队列消息也不会回滚。先 commit DB 再投队列;若必须同步走 outbox pattern(PG 表存待发消息 + 独立 worker 扫描),不在本骨架范围内。
- 唯一允许写 GORM 或原生 SQL 的层。其他层禁止 import
gorm.io/gorm。 - 所有查询都用
db.WithContext(ctx),禁止context.Background()替换。 - 走事务时用
repository.InTx(ctx, db, fn)+dbFromContext(ctx, r.db),让上层组合事务。需要自定义隔离级别 / 只读事务用repository.InTxWithOptions(ctx, db, *sql.TxOptions, fn)(如分页强一致 total 走sql.LevelRepeatableRead+ReadOnly);嵌套调用 opts 会被忽略,isolation 必须在最外层定。
- 纯 GORM 数据结构,不挂带业务规则的方法。复杂行为放 service。
不要预先架空。只有当一次操作要协调 ≥3 个 service / 跨领域时再考虑加 usecase。当前骨架不需要。
- 不引入 Wire / Dig / Fx。装配集中写在
internal/server.go的newHTTPHandlers/newEngine里。 bootstrap.Registry持有跨进程共享资源:Cfg、DB、Cache、Auth、Queue。新增基础依赖时挂到Registry,并在Close()里补关闭逻辑。- handler / service / repository 声明依赖、不构造依赖。新增模块的标准动作:
- 在对应分层包里加
NewXxx(...)构造器; - 在
internal/server.go的newHTTPHandlers里组装; - 在
internal/router/router.go的Dependencies里加字段,并在registerXxxRoutes注册路由; - Worker 需要的话在
internal/worker.go的buildWorkerDeps里挂依赖、internal/worker/handler.go里注册 Asynq handler。
- 在对应分层包里加
所有业务 API 返回统一信封,结构定义在 pkg/response/response.go:
{ "code": 0, "message": "success", "data": { ... } }
{ "code": 1001, "message": "...", "reason": "INVALID_PARAMS", "metadata": { "trace_id": "..." } }字段名都用完整单词(message / reason / metadata),不引入简写。需要新增响应字段时同理。
HTTP 状态码按 errcode 映射(由 errcode.Error.HTTPStatus() 决定,pkg/response 的 WriteError / WriteValidationError 自动应用):
- 成功(
code=0)→ 200 - 1xxx 客户端错误段位 → 400 / 401 / 403 / 404 / 408 / 429 / 503(按 reason 精确映射)
- 9xxx 服务端错误段位 → 500 / 501 / 503
完整映射表见 docs/errcodes.md(由 make docs-errcodes 生成)。客户端仍以 body code 做精确业务分支;HTTP status 给监控 / LB / 透明代理用作粗粒度信号,两者互不替代。
例外:/livez 与 /health 不走信封,直接返 200 / 503 给 K8s 探针;/livez 是 liveness(永远 200),/health 是 readiness(依赖不可用时 503)。
新增错误码:(1) 去 pkg/errcode/common.go 加 newError(code, "REASON") 常量;(2) 在 pkg/response/response.go::MessageFor 补默认英文文案;(3) 如果新 reason 应映射到 HTTP 段位之外的特定 status,去 pkg/errcode/type.go::HTTPStatus 的 switch 加 case + 配套单测;(4) 跑 make docs-errcodes 重新生成 docs/errcodes.md。
参考实现:internal/service/example.go 的 ListExamplesReq / ListExamplesRes。新模块写列表查询照这套来,不要每个模块各发明一套字段。
请求侧(service DTO,handler 用 c.ShouldBindQuery(&req)):
- 小数据集(< 10w 条总量,UI 跳页)走
limit+offset:type ListXxxReq struct { Limit int `form:"limit" binding:"omitempty,min=1,max=100"` Offset int `form:"offset" binding:"omitempty,min=0"` }
limit必须 设上限(默认 100,与examples表对齐),防单次返回过大;limit=0在 service 层回退到默认(一般 20),不当成"返 0 条"——前端少传一个字段不应该返空。offset不设上限——offset是小数据约定,大数据应换 cursor。 - 大数据集 / 流式翻页(≥ 10w 条 / 时间线 / 排行榜)走
limit+cursor:type ListXxxReq struct { Limit int `form:"limit" binding:"omitempty,min=1,max=100"` Cursor string `form:"cursor" binding:"omitempty"` }
cursor通常是上一页末条的id或(created_at, id)复合编码(base64 url-safe),禁止裸传 SQL 表达式。
不要同时收 offset + cursor——一个 endpoint 选一种,选定后不要互转。需要混合的极少数场景写 issue 单独讨论。
响应侧(service Res,handler response.WriteSuccess(c, res)):
- offset 分页:
{items, total}。total走RepeatableRead + ReadOnly事务保证和items同快照——见repository.InTxWithOptions+sql.LevelRepeatableRead,否则并发写会让total与len(items)对不上、前端算总页数翻车。 - cursor 分页:
{items, next_cursor}。next_cursor=""表示已到末页;不返total(大数据集精确 count 代价太高,估算值会误导)。 - 字段名固定用
items/total/next_cursor,不用list/count/cursor这类近义词散开。OpenAPI 里同步声明这三个字段。(example 模块沿用历史命名examples+total,新模块走items;不要因为 example 长那样就在新模块复制资源名复数。)
SQL 侧(repository):
- offset 分页用
ORDER BY id DESC LIMIT ? OFFSET ?,配LevelRepeatableReadReadOnly tx 跟Count同事务。 - cursor 分页用
WHERE id < ? ORDER BY id DESC LIMIT ?(?= 上次 next_cursor 解出来的 id)。复合 cursor 用(created_at, id) < (?, ?),保证 created_at 撞值时仍有稳定顺序。
当前未实现。如果未来加,按下面这套约定来,不要散落到各层:
- 翻译文件统一放
config/i18n/locales/{lang}.json,key 用errcode的Reason(如INVALID_PARAMS)。 - 在
pkg/response的MessageFor内根据Accept-Language切换文案,handler 和 service 不自己拼语言相关字符串。 pkg/validator的 binding 错误翻译也走同一套机制,不要硬编码中文。
pkg/auth.JWTManager 当前实现 Layer 1(HS256 签名 + exp + iss 校验) + pkg/auth/jwt.go 的 Bearer 解析;middleware.BearerAuth 把 Subject 写到 gin.Context 的 "auth_subject" 键。
未实现(需要时再加,不要默认启用):
- Layer 2:Redis JTI 黑名单(解决主动登出)。
- Layer 3:
token_version比对(解决批量失效,比如改密码踢人)。
加 Layer 3 时引入 TokenVersionStore 接口注入到 JWTManager,让不需要的项目不付额外成本。
- 选 Asynq,理由:复用 Redis,自带 Scheduler 和 Asynqmon。不要引入 Kafka / RabbitMQ。
- 任务类型常量和 payload 定义放
internal/task/,API 和 Worker 共享。 - 所有 payload struct 必须头部匿名嵌入
task.Header(带Version+TraceID),通过task.NewHeader(traceID)构造。worker handler 反序列化后第一时间调task.CheckHeader(p.Header, task.CurrentSupported)——schema 不兼容时返 error 走 retry,不要静默吞(吞了等于丢消息)。改 payload 字段语义 / 删字段必须升task.PayloadSchemaVersion并同步更新CurrentSupported;新增字段 + omitempty 不用升版本。 - 新建 task 走
task.DefaultOptions()(含 MaxRetry=5、Timeout=30s)而不是各工厂自己写一份asynq.MaxRetry(...)。业务有长任务 / 特殊重试需求时显式append覆盖。 - 业务键稳定的 task 用
asynq.TaskID(task.BuildTaskID("ns", keys...))做永久全局去重(订单状态机推进、用户操作日志)。BuildTaskID对超长 ID 会保留可读前缀并追加 SHA-256 后缀,避免简单截断导致不同长业务键误去重;命中 1KB 上限通常说明 caller 把过大的业务对象当 key,应回头收敛 key。短窗口防抖用asynq.Unique(ttl)(用户点按钮、定时拉取)。两套语义不同,不要混用——TaskID 冲突返ErrTaskIDConflict、Unique 重复返ErrDuplicateTask,业务上对幂等的预期差很多。 - service 通过
ExampleQueue接口依赖taskqueue.Queue,不直接拿*asynq.Client。 - Worker 消费端 handler 在
internal/worker/handler.go注册,业务流程委托给 service。 - Worker 停服走两阶段:
srv.Stop()停止接新任务、srv.Shutdown()等当前任务完成(已在internal/worker.go实现,扩展时不要破坏顺序)。 - Worker 启动用
asynq.Server.Start(同步、返回启动期 error),不要退回server.Run。Run=Start+ 它内置的waitForSignals+Shutdown,那条内置 signal loop 会和cmd/worker/main.go的signal.NotifyContext抢 SIGTERM;而且Run异步起 goroutine 没法精确知道启动成败,会让 sd_notifyREADY=1早发。internal/worker.go的Run(ctx, onReady)已经是:Start成功后才回调onReady、停服由传入的ctx驱动。 - Production 漏注入业务 processor 必须 fail-fast。
internal/worker.go::buildWorkerDeps在APP_ENV=production下调deps.RequiredProcessors()(定义在internal/worker/handler.go)显式检查每个 task 的 processor 注入状态;任一 missing 就返 error,让NewWorker启动期失败。dev / staging 仍允许 noop 兜底,方便从模板态启动;但生产环境消息被 noop 消费 + ack 掉,比 panic 更危险(消息消失但只剩 warn 日志),所以宁可起不来也不能静默。加新 task 类型时:必须同步在Deps.RequiredProcessors追加一条记录——漏加 = production 下静默 noop,回到改造前的隐患。这是显式声明取代"reg.DB == nil"巧合代理的强制点。
请求级 context.Context 从 handler 一路传到 repository,业务层禁止用 context.Background() 替换。它带着 trace_id、超时、取消信号,断了会导致:HTTP 已超时但 DB 查询还在傻跑。
写测试可以用 context.Background(),业务代码不行。
- 入库模板只有根目录
.env.example一份,所有进程共用。新增配置项必须同步更新它。 - 运行时加载顺序(在
cmd/<proc>/main.go里):本地想给某个进程开"差异化覆盖"时,自行手写真实环境变量 > cmd/<proc>/.env(如果存在) > 根目录 .envcmd/<proc>/.env——它不入库,被.gitignore兜底。 - 所有
.env*(除.env.example)都在.gitignore,禁止把真实凭证落到仓库。 APP_ENV(development / production,默认 development)控制启动期安全 guard 的严格度,不等同于GIN_MODE。设production时config/validate.go会硬拦截不安全配置:JWT_SECRET为占位值 / 空 / 短于 32 字节、AUTH_DEV_TOKEN_ENABLED=true、GIN_MODE != release、LOG_FORMAT != json,任一不满足进程直接 fail-fast 退出。"非致命但大概率漏配"的项走config.ProductionWarnings(cfg)集中输出 warn(由cmd/api/main.go启动时打):RATE_LIMIT_PER_MINUTE=0、TRUSTED_PROXIES空、/metrics与业务同端口(建议设METRICS_ADDR拆独立 listener)、PPROF_ENABLED=true且PPROF_ADDR非 loopback(公网暴露 pprof = heap/goroutine 泄露 + DoS 向量,应绑127.0.0.1/::1+ SSH 隧道)。新增"生产必须硬拦"的项加到validateProductionSecrets,"非致命漏配 warn"加到ProductionWarnings,不要散到各层。
middleware.TraceLogger 默认开启,按 AUDIT_LOG_EXCLUDE_PATHS 排除路径(如 /health)。日志字段用 zap 结构化输出,敏感字段 (Authorization、Cookie、body 里的 password / token) 自动脱敏。不要绕过中间件自己打日志带请求体。
trace_id 从 applog.TraceIDFrom(ctx) 取;service / repository 打日志统一用 applog.FromContext(ctx).Xxx(...),不要 applog.L() 全局 logger 打业务日志。
中间件同时把 trace_id 写到响应头 X-Request-ID,客户端传入相同头会被复用。
pkg/{auth,cache,database,errcode,log,response,validator} 是通用工具。改这些包之前确认:
- 严禁 import
internal/下任何包。pkg内部互相 import 合法(pkg/response依赖pkg/errcode就是这样)。 - 接口稳定优先于功能堆叠,因为理论上可以被其他项目复用。
下面这些规则写得很明白,但 AI 助手仍然会犯。开始任何写代码任务前先内化这几条,可以省下大量返工:
- 不要修改
internal/oapi/oapi.gen.go。它顶部标了 DO NOT EDIT,唯一改它的方式是改api/openapi.yaml然后make oapi。哪怕只是改一行 import / 注释 / 字段名都会被 oapi-verify 抓出来。 oapi.Example、oapi.CreateExampleReq等业务实体类型不要 import。业务结构以internal/service包为准(如service.CreateExampleReq);只有协议层 schema(oapi.HealthResponse/oapi.LivenessResponse/oapi.ListExamplesParams等)可以直接用。- service 入参永远是
context.Context,不是*gin.Context。Worker 也消费 service,绑死 gin 会让 Worker 跑不通。需要 trace_id / auth subject 这种字段,由 handler 提前从*gin.Context取出来,作为 primitive 传给 service。 - 测试不要引入 testify / gomock / mockery / sqlmock / testcontainers。本项目坚持标准库
testing+ 手写 mock,参考internal/service/example_test.go。 - 响应字段是
message,不是msg(早期用过msg,已经统一改成message这种完整单词;不要又退回简写)。 - 错误返回值用
pkg/errcode里的常量,不要fmt.Errorf("...")字符串拼接。底层错误用applog.FromContext(ctx).Error(..., zap.Error(err))单独记日志。
- ❌ 在 handler 写业务规则 → ✅ 挪到 service。
- ❌ service 收
*gin.Context→ ✅ 收context.Context,需要的字段由 handler 传 primitive。 - ❌ repository 之外的层 import
gorm.io/gorm→ ✅ 通过 service 包里定义的接口隔离。 - ❌ 用
fmt.Errorf("xxx")直接返 → ✅ 返errcode.XxxError;底层错误applog.FromContext(ctx).Error(..., zap.Error(err))记进日志。 - ❌ 在 service 里
context.Background()起新 ctx 调 DB → ✅ 传原 ctx;只有 fire-and-forget 后台任务才允许,且必须独立带超时。 - ❌ 给 Worker 复制一份和 API 不同的业务逻辑 → ✅ 共享 service。
真相源是 api/openapi.yaml。新增 endpoint 走 yaml 驱动:
- 改
api/openapi.yaml:加 path + schema。operationId 用驼峰命名(如listOrders / createOrder / getOrder,与 oapi-codegen 生成的ServerInterface.<Method>对齐)。需要鉴权的 op 加security: [{ bearerAuth: [] }]。资源归属用x-resource: <Name>(推荐)—— path 级声明一次下面所有 verb 继承,operation 级可覆盖;不加x-resource时 fallback 到"operationId 大小写不敏感包含 NAME"老逻辑。动作名推不出来时加 yaml extensionx-handler-method: <Action>显式指定。 make oapi重新生成internal/oapi/oapi.gen.go。make new-endpoint NAME=<Name>—— 脚本按 yaml 资源归属生成 handler / service / repository / model / task 五层骨架 + 三个测试模板,并注入internal/server.go装配链、internal/router/router.go路由(按 yamlsecurity推deps.AuthRequired子组)、internal/handler/openapi.go::APIServer字段 + 转发方法。生成的 service / repository 方法返errcode.NotImplementedYet(9005)—— 仓库立即可以make verify通过,填业务时换 nil 或具体错误码。review 用--dry-run/DRY_RUN=1只打印计划不写盘。- 填业务:handler 补
c.ShouldBind...、service 填业务规则换掉 NotImplementedYet、repository 写 SQL、model 补字段。 make verify通过——oapi-verify会用git diff --quiet检查生成产物已 commit。
编译期保险线在 internal/handler/openapi.go 里的:
var _ oapi.ServerInterface = (*APIServer)(nil)yaml 和代码一旦漂移,build 直接失败,不依赖人去 review 注释。
直接手改 internal/server.go / router.go / handler/openapi.go 的注入区是禁忌——那些块由 // NEH ... 锚点界定,下次跑 make new-endpoint 会按锚点继续注入。改 yaml 重跑而不是手编辑那几个文件。
支持的形态:
- 资源归属:
x-resource: <Name>(推荐 path 级,operation 级可覆盖)。没声明时 fallback 到 operationId 包含 NAME——歧义场景(如NAME=Order命中listOrderPayments)请改用x-resource显式声明。 - handler 动作名:默认 operationId 去掉 NAME 后剩余 + 首字母大写;推不出(剩余为空 / 非法)时报错,加
x-handler-method: <Action>显式指定。 - path 参数:0 或 1 个
{var}。参数名按 yaml 实际取({order_id}→c.Param("order_id")+ service(ctx, order_id string))。 - 鉴权:yaml
security: [{ bearerAuth: [] }]自动放进deps.AuthRequired子组;非 bearerAuth 的 scheme(API key / OAuth2 / ...)当前忽略。 - dry-run:
--dry-run/DRY_RUN=1跑解析 / 校验但不写盘——review 计划用。 - drift 检查:
make new-endpoint-check只读 checker,重新解析 yaml 比对代码端,按[!] Missing/[~] Stale/[-] Mismatch三档报漂移。不写盘 / 不删代码。传NAME=Order只扫单资源;不并入make verify(避免 schema 调整让 PR 抖动),单跑作为调试入口。 - DTO 反推(可选):
--dto/DTO=1时从 yaml schema 反推 service 包内的请求 DTO struct(如CreateOrderReq)+ handler 自动ShouldBindJSON/ShouldBindQuery+ service 签名同步换成(ctx, *XxxReq)。默认关——避免一刀切改变骨架行为。仅支持简单 object schema(顶层 string / integer / boolean + required + min/max/minLength/maxLength);遇到 allOf / oneOf / anyOf / 嵌套 object / array / enum / $ref 时降级到空 struct +// TODO注释,让作者照 yaml 手写。query DTO 用form:tag(ginShouldBindQuery不看 json);body DTO 用json:tag。
不支持,需要手写的形态:
- ≥2 个 path 参数(如
/users/{uid}/orders/{oid}):脚本 fail-fast。加x-handler-method拿到方法名后手写 handler / router。 - 同一资源跨多个根路径:脚本按
x-resource拉到一起没问题;但r.Group路径取 ops 的最长公共前缀,多根路径时只能用最长公共部分,子路径走g.GET("/sub/...", ...)——少见,能跑但不优雅。
scripts/new-endpoint.go(~2k 行)/ new-endpoint-check.go(~1k 行)/ drop-example.go(~800 行)是已知的大文件——单文件 main 同时承载 yaml 解析 + AST 扫描 + render + 装配注入,这是 有意保留 的状态,不是欠的债。拆分时机:当且仅当出现"两个 main 之间要共享一份 OpenAPI 解析 / AST 抽取代码"时再做(提取到 scripts/internal/* 子包);只是单个文件大、不构成拆分理由。
测试侧已经按被测脚本拆开(scripts/{env_verify,architecture_verify,new_endpoint,new_endpoint_check,new_endpoint_dto}_test.go + helpers_test.go + shell_scripts_test.go),最大单测文件 ~1k 行;测试拆分独立于业务脚本拆分。
工具入口集中在 make target:
make new-endpoint NAME=<Name>—— 生成五层骨架(+DRY_RUN=1只打印计划;DTO=1反推 DTO struct)make new-endpoint-check [NAME=<Name>]—— 只读 drift detector,不并入make verifymake drop-example—— 一次性清掉 example 演示资源make scaffold-verify—— 单跑scripts/黑盒回归(调试 new-endpoint / env-verify / architecture-verify 时用)
不要直接 go run scripts/new-endpoint.go ... 绕开 make target——scaffold-verify 等回归测试通过 makefile 进入,绕开会让 fixture 路径解析、CI 集成漂移。
oapi-codegen 会生成两类东西,区分对待:
- 协议层 schema(
oapi.HealthResponse、oapi.LivenessResponse、oapi.HealthResponseChecks、oapi.ListExamplesParams、oapi.BearerAuthScopes等)—— handler 应该直接用,让响应/参数结构跟 yaml 强对齐。 - 业务实体(
oapi.Example、oapi.CreateExampleReq等业务请求/响应模型)—— handler / service / repository 不要 import;业务结构以internal/service包为准(如service.CreateExampleReq、service.ExampleService返回的*model.Example)。
判断方法:如果生成的类型只服务于 transport 层(响应外壳、query params、security scope),用它;如果它承载业务字段(id/name/created_at 这种领域属性),不用它。
internal/oapi 包对外的关键导出:
oapi.ServerInterface:用于编译期契约保险。oapi.GetSpecJSON():用于/openapi.json返回 embedded spec。- 协议层 schema 类型:handler 可以直接用。
internal/oapi/oapi.gen.go 顶部标了 DO NOT EDIT——不要手改它,改 yaml 然后 make oapi。它会入库(和 go.sum 一样),CI / 队友不需要重跑生成。
api/openapi.yaml 的 summary / description 字段统一用中文——会被 /docs(Stoplight Elements)渲染成 Markdown 给团队浏览,中文更易读。命名相关字段保持英文:
- ✅ 中文:
summary、description(path-level / operation-level / parameter / requestBody / response / schema / property / tag) - ✅ 英文:
operationId、字段名(key)、reason常量串(INVALID_PARAMS等需要稳定机读,不译) summary写短中文标题(左侧菜单和列表项标题),长说明放descriptiondescription支持 Markdown,可用列表 / 代码块 / 加粗;别用#/##一级二级标题(会与 Elements 自带层级冲突)- 错误码
reason字符串(如INVALID_PARAMS)保留英文常量,中文说明放周边 description 里
- 不生成 client SDK(内部联调用不到)。
- 不生成 strict-server wrapper(会绕开 Gin 上下文,破坏现有 middleware)。
- 在线文档用 Stoplight Elements(纯 CDN web component,零 Go 依赖、不接管路由)挂在
/docs,spec 复用/openapi.json;页面外观由启动期DOCS_*env 配置(title / theme / layout / hide_try_it / hide_schemas / logo,见.env.example),在handler.NewOpenAPIHandler里一次性预渲染、运行时不变。/docs与/openapi.json只在非生产环境注册(internal/server.go的newEngine里!reg.Cfg.Env.IsProduction()守卫),生产环境隐藏 API 契约与文档 UI、访问得 404。前端也可继续用/openapi.json导入 Postman / Bruno / Insomnia。仍不引入swaggo/swag、gin-swagger这类把 UI 框架编译进 Go、或用RegisterHandlers接管路由的重型方案。 - 不用
oapi.RegisterHandlers接管路由注册——业务路由仍走internal/router,享受细粒度中间件控制(如/auth/me要 BearerAuth、/auth/token不要)。
oapi-codegen 当前对 3.1 标注 "partial support",跑生成会打 WARNING。本项目实测可用;如果未来某个 3.1 特性导致生成失败,先看 yaml 能不能用 3.0 兼容写法表达,不要回退到 3.0。
声明任务完成前必须跑过:
make verify # fmt + vet + test + lint + architecture-verify + env-verify + tidy-verify + oapi-verify + docs-verify + docs-deploy-check + docs-errcodes-verify(每步打横幅,便于定位失败)需要单独跑某一项时见 make help。详见根目录 README.md 的 "Verify" 小节。
常用命令速查见 docs/runbook.md——它把"新增 endpoint / 新增任务 / 跑特定测试 / 排错"等高频动作整理成可执行清单,AI 助手优先读它。
二进制部署见 docs/deploy.md——主机初始化、systemd unit 安装、滚动升级、回滚、日志查询;与 Docker 路径并存。
项目目前的测试风格是标准库 testing + 手写 mock,下面这些约定是为了让新写的测试和老的一脉相承,不要凭直觉换风格。
- ✅ 用
testing、net/http/httptest、errors.As、gorm.io/gorm的DryRun。 - ❌ 不引入 testify / gomock / mockery / sqlmock / testcontainers。如果觉得不够用,先在 PR 描述里说服别人,再加依赖。
和被测代码同包同目录,文件名 xxx_test.go。不要单独建 test/ 顶层目录、不要把 mock 拆到 mocks/ 子目录。每个测试文件内部就近放 mock 类型,方便阅读。
| 层 | 怎么测 | 参考文件 |
|---|---|---|
service |
inline struct + func 字段实现依赖接口,注入 NewXxxService(...) |
internal/service/example_test.go |
handler |
gin.SetMode(gin.TestMode) + httptest.NewRecorder,反序列化 response.Response 断言字段 |
internal/handler/example_test.go |
repository |
gorm.Open(postgres.Open(...), &gorm.Config{DryRun: true, DisableAutomaticPing: true}) + GORM callback 捕获 SQL,不连真实 DB |
internal/repository/example_test.go |
middleware |
同 handler,构造 gin.Engine + 单条路由 |
internal/middleware/auth_test.go |
pkg/auth 等基础包 |
纯单元测试,覆盖正反两面 | pkg/auth/jwt_test.go |
-
测试里的日志静音:在
init()调applog.SetLogger(zap.NewNop())。否则跑go test ./...会刷一堆 audit log。handler 测试还要validator.InitValidator(),否则 binding 校验报错文案是空的。 -
错误断言走
errcode:用errors.As(err, &ec)拿errcode.Error,比对ec.Code() == errcode.XxxError.Code()。不要用err.Error() == "..."比较字符串。 -
mock 命名:包内未导出,
mockXxx驼峰;持 func 字段而不是写一堆条件分支:type mockExampleRepo struct { createFunc func(ctx context.Context, example *model.Example) error } func (m *mockExampleRepo) Create(ctx context.Context, e *model.Example) error { return m.createFunc(ctx, e) }
-
trace_id 注入:handler 测试如果要验证
metadata.trace_id,用一个gin.HandlerFunc提前c.Set("trace_id", "test-trace"),别去 mock 整套 TraceLogger。 -
context.Background()在测试里允许,业务代码里不行(见上文 context 传递)。 -
表驱动:测试用例多于 3 个时用
t.Run(name, ...)+ 切片表。同一个行为正反两面的 case 用独立TestXxx函数也可以,本项目两种都有,按可读性挑。
go test ./... # 全跑
go test ./internal/service/... -run TestCreate -v # 单包 + 单测
go test ./... -race # 带 race(CI 已开自动,写并发代码时本地也可手动跑)
go test ./... -cover # 看覆盖率**不要给跑不通的测试加 t.Skip() 蒙混过关。**测试坏了就修,或者删——不要假装绿。
仓库已初始化,主干 master,远程 origin = https://github.com/myxiaoao/go-skeleton。
格式:type(scope): description + 空一行 + 详细变更说明,每项一行。type 用英文(feat/fix/refactor/docs/test/chore)。本项目的 scope 约定:
| Scope | 对应改动 |
|---|---|
api |
cmd/api/、internal/server.go、HTTP 路由 / middleware、api/openapi.yaml 契约 |
worker |
cmd/worker/、internal/worker.go、internal/worker/、Asynq handler |
migrate |
cmd/migrate/、迁移相关 |
bootstrap |
internal/bootstrap/、config/ |
handler / service / repository / model / router / middleware / task / taskqueue |
对应 internal/* 子包 |
auth / cache / database / errcode / log / response / validator |
对应 pkg/* 子包 |
oapi |
OpenAPI codegen 配置 / 生成产物 (api/oapi-codegen.yaml、internal/oapi/) |
build |
Makefile、构建脚本 |
ci |
.github/workflows/*、.github/dependabot.yml |
docs |
README、AGENTS.md / CLAUDE.md、注释 |
deps |
go.mod / go.sum 调整 |
示例:
feat(service): example 新增分页参数校验
- 在 ListExamplesReq 上加 omitempty,min=1,max=100 binding
- service 默认 limit 改成 20
- 补 TestListDefaultLimit / TestListInvalidLimit
- 主干
master。功能分支feat/xxx,修复fix/xxx,重构refactor/xxx。 - 不要直接 push 到
master——走 PR。CI(.github/workflows/ci.yml)会跑make verify,全绿才合。 - 不要 force push 主干。force push 任何分支前先确认本地不会丢工作。
每次 commit 前一条命令搞定:
make verify # fmt + vet + test + lint + architecture-verify + env-verify + tidy-verify + oapi-verify + docs-verify + docs-deploy-check + docs-errcodes-verify任意一项挂了不要 --no-verify 跳过——按通用规则,hook 失败先修问题再重新 commit,不要 amend。
不要直接 git add .——.DS_Store、临时文件可能漏网。逐项 git add path/... 或用 git add -p。.env、bin/、dist/、coverage.out 虽在 .gitignore 兜底,但 stage 时仍以"知道自己加进去什么"为准。
- 任何真实凭证:
cmd/*/.env、.env、密钥文件。已在.gitignore,不要去改.gitignore放行。 - 构建产物:
bin/、dist/、coverage.out。已在.gitignore。 - IDE 个人配置:
.idea/、.vscode/(如确实要共享 VSCode 配置,单独讨论加白名单文件)。
go-example/
├── .env.example 配置模板(真实 .env 不入库)
├── .gitignore
├── .dockerignore
├── Makefile 开发与提交前一站式入口(make help 查全部 target)
├── README.md
├── AGENTS.md Codex 等 AI 编码助手的项目规则(与本文件并存维护)
├── CLAUDE.md 本文件(Claude Code 用)
├── CHANGELOG.md Keep a Changelog 格式
├── Dockerfile multi-stage 构建(默认 cmd/api)
├── docker-compose.yml 本地 Postgres + Redis
├── go.mod / go.sum 模块名 go-skeleton
│
├── api/ API 契约层
│ ├── openapi.yaml 真相源:OpenAPI 3.1 spec
│ └── oapi-codegen.yaml codegen 配置
│
├── migrations/ 版本化 SQL 迁移(真相源,go:embed 进二进制)
│ ├── embed.go //go:embed *.sql → fs.FS
│ └── <时间戳>_*.sql goose 时间戳风格(-- +goose Up / Down)
│
├── cmd/ 三个进程入口,main.go 只做启停
│ ├── api/main.go HTTP 服务
│ ├── worker/main.go Asynq 消费者
│ └── migrate/main.go goose 迁移(up/down/status)
│
├── config/ 配置加载 + 类型定义
│ ├── config.go
│ ├── runtime.go 进程级运行时初始化(logger 等)
│ └── types.go
│
├── internal/ business code(Go internal 约束)
│ ├── server.go package app: HTTP 装配入口 (NewServer)
│ ├── worker.go package app: Worker 装配入口 (NewWorker)
│ │
│ ├── bootstrap/ Registry 模式,进程资源装配
│ │ ├── registry.go Registry struct + Close
│ │ ├── api.go InitAPI
│ │ ├── worker.go InitWorker
│ │ └── runtime.go InitRuntime(logger 等)
│ │
│ ├── router/router.go URL → handler 注册(不构造依赖)
│ │
│ ├── handler/ HTTP 协议适配层
│ │ ├── auth.go /api/v1/auth/*
│ │ ├── example.go /api/v1/examples/*
│ │ ├── health.go /livez(liveness)+ /health(readiness)
│ │ └── openapi.go /openapi.json + APIServer (满足 oapi.ServerInterface)
│ │
│ ├── service/ 业务逻辑层(context.Context 入参)
│ │ └── example.go
│ │
│ ├── repository/ 数据访问层(唯一允许写 GORM)
│ │ ├── example.go
│ │ └── tx.go WithTx / InTx / InTxWithOptions / dbFromContext
│ │
│ ├── model/ GORM 数据结构
│ │ └── example.go
│ │
│ ├── middleware/ Gin 中间件
│ │ ├── auth.go BearerAuth + AuthSubject
│ │ ├── cors.go
│ │ ├── logger.go TraceLogger(审计日志 + 脱敏 + X-Request-ID)
│ │ ├── rate_limit.go IPRateLimiter
│ │ ├── recovery.go
│ │ └── timeout.go
│ │
│ ├── task/ Asynq 任务类型定义(API 和 Worker 共享)
│ ├── taskqueue/ Asynq client 薄封装
│ │ └── taskqueue.go Queue.Available / Enqueue
│ ├── worker/ Asynq 消费端
│ │ ├── server.go NewServer + NewRedisOpt
│ │ └── handler.go RegisterHandlers (Deps 注入)
│ │
│ └── oapi/ OpenAPI codegen 产物(DO NOT EDIT)
│ └── oapi.gen.go ServerInterface + GetSpecJSON
│
└── pkg/ 通用工具(严禁 import internal/)
├── auth/jwt.go JWTManager(Layer 1)
├── cache/ Redis client 封装
├── database/ GORM 初始化 + 健康检查
├── errcode/ 业务错误码集中地(type.go + common.go)
├── log/ zap logger + trace_id ctx helper
├── response/response.go 统一响应 (code/message/reason/data/metadata)
└── validator/ binding 错误翻译
加新模块时对照这棵树:handler / service / repository / model 各加一个文件,然后到 internal/server.go 装配,到 internal/router/router.go 注册路由。少一步都不算完成。