.golangci.yml:
- 启用 linter: errcheck, gosec, govet, staticcheck, ineffassign, unused,
revive, contextcheck, errorlint, errname, misspell
- 启用 formatter: gofumpt + goimports
Makefile:
- fmt/fmt-check: gofumpt + goimports 格式检查
- lint/lint-strict: golangci-lint 报告/阻断模式
- test: go test -race
- ci: 完整 CI 流程 (fmt-check + lint)
docs/code-style.md:
- 新增 AI 执行协议 + 代码格式化/自动化检查/MUST规则
- 集成 golangci-lint 检查项与规范对照
20 KiB
Go 代码规范
AI 执行协议
本文档是 AI 编程助手的可执行规范。处理每条用户请求时,AI MUST 执行以下流程:
-
请求分析:解析用户意图,识别涉及的代码层次(Controller / Service / Repository / Middleware / 模板 等)。
-
规范对照:逐条检查请求是否违反本文档中任何 MUST 或 MUST NOT 规则。
-
违规警告:若检测到违规,MUST 在修改代码前明确警告用户,格式如下:
⚠️ 规范警告:[规则编号/章节] — [违规简述] 当前请求:[用户想做什么] 违反规则:[引用具体 MUST/MUST NOT 条文] 合规做法:[给出替代方案] -
等待确认:发出警告后 MUST NOT 继续执行修改,直到用户明确指示:
- "忽略" / "按我说的做" → 照常执行,但需在代码中加
// NOTE: 已知违反 code-style.md [规则编号]注释 - "按合规做法" → 切换为合规方案执行
- 修改请求 → 按新请求重新评估
- "忽略" / "按我说的做" → 照常执行,但需在代码中加
-
通过则直接执行:若无违规,AI 直接按请求执行修改,无需额外确认。
-
修改后自检:每次代码修改完成后,AI MUST 运行
make fmt-check和make lint。若产生新告警,MUST 立即修复后再次检查,直到零新告警。
判定原则:每次判定只基于本文档的 MUST/MUST NOT 文本本身,不引入主观解读或"社区惯例"作为额外标准。MAY 规则不触发警告,仅作为可选建议。
代码格式化
gofmt(强制)
所有 .go 文件提交前 MUST 通过 gofmt -s -w . 格式化。CI 中 MUST 配置 gofmt -s -d . 检查,若输出非空则流水线失败。
禁止手动调整代码排版——一切以 gofmt 输出为准。
goimports(强制)
所有 .go 文件提交前 MUST 通过 goimports -w . 处理 import 语句。goimports 自动完成两件事:
- 增删 import 行(用到的加、没用到的删)
- 按标准分组排列 import
Import 分组规则(goimports 默认行为,MUST 遵循):
import (
// 第一组:标准库
"context"
"fmt"
"time"
// 第二组:第三方库
"github.com/gin-gonic/gin"
"gorm.io/gorm"
// 第三组:本项目内部包
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
)
三组之间以空行分隔。MUST NOT 手动调整分组顺序或插入不属于该组的 import。
gofumpt(推荐)
推荐启用 gofumpt(gofmt 的超集,更严格),替代基础 gofmt:
gofumpt -l -w .
gofumpt 在 gofmt 基础上额外强制执行:字段对齐规则、多余空行清除、var 声明块合并等。
自动化检查
以下检查 MUST 在 CI 流水线中执行,且 MUST 零告警通过:
| 检查项 | 工具 | 命令示例 |
|---|---|---|
| 代码格式 | gofmt 或 gofumpt |
gofmt -s -d . |
| import 管理 | goimports |
goimports -l . |
| 静态分析 | golangci-lint |
golangci-lint run ./... |
| 编译检查 | go build |
go build ./... |
| 竞态检测 | go test -race |
在测试阶段执行 |
golangci-lint 至少启用以下 linter(配置写入 .golangci.yml):
| Linter | 检测内容 |
|---|---|
errcheck |
未处理的 error 返回值 |
gosec |
安全漏洞模式 |
revive |
代码风格(含文件行数/函数行数警告、doc 注释缺失等) |
contextcheck |
context.Background() 在非 init/main/test 代码中的误用 |
govet |
go vet 标准检查 |
staticcheck |
大量静态分析规则 |
ineffassign |
无效赋值 |
unused |
未使用的变量/常量/函数/类型 |
注意:
contextcheck会直接检测到在 Service/Repository 方法内调用context.Background()的行为并报错,与本文档 Context 传播规则一致。
文件大小
| 层次 | 单文件行数上限 | 说明 |
|---|---|---|
| Controller | ≤ 120 行 | 只管参数绑定 + 调用服务 + 返回 |
| Service | ≤ 300 行 | 承载业务逻辑,超过则拆子服务 |
| Repository | ≤ 200 行 | 纯数据库操作 |
| Model | ≤ 80 行 | 纯结构体 |
| Middleware | ≤ 60 行 | 单一职责 |
| Router | ≤ 60 行 | 只做路由映射 |
注意:行数上限为警告线而非硬截断。拆分决策遵循 SRP(一个文件只有一个变更原因):
| 该拆 | 不该拆 |
|---|---|
一个文件混了两个及以上业务域(如 commentService + likeService 塞同一个文件) |
只有单一业务域,但因逻辑复杂产生了大量私有辅助函数 — 保持内聚 |
| 一个文件承担了压根不同域的工作(如所有 API 路由写一个文件) | 功能高度耦合,拆了互相 import 更乱(如评论增删查+@提及+上传一体) |
| 路由文件按业务域自然分割 | 文件超线但职责单一、内聚良好 — 维持现状 |
判定口诀:多域混居 → 拆;单域内聚 → 留。如果"辅助函数太多"到了怀疑域本身是否单一的 程度,那问题不是该拆文件,而是该把域拆小(如
PostService→CommentService+LikeService),按变更原因重新切分。
瘦控制器铁律
Controller 每个方法不超过三步:
- 绑定请求参数
- 调用 Service
- 返回响应
// ✅ 正确
func (c *PostController) Create(ctx *gin.Context) {
var req dto.CreatePostRequest
if err := ctx.ShouldBind(&req); err != nil {
common.Error(ctx, common.ErrInvalidParam, "参数错误")
return
}
post, err := c.postService.Create(ctx, req, getCurrentUser(ctx))
if err != nil {
common.Error(ctx, common.ErrInternal, "创建失败")
return
}
common.Ok(ctx, post)
}
// ❌ 错误:Controller 里写 if-else 业务分支
func (c *PostController) Create(ctx *gin.Context) {
// ... 参数绑定 ...
if req.Type == "draft" {
// 草稿逻辑写在这里 ↓ 错误!
post.Status = 0
} else {
// 发布逻辑写在这里 ↓ 错误!
post.Status = 1
if post.Score > 100 { ... }
}
}
业务判断全部放在 Service 层。
Middleware 规范
CSP Nonce 生成与传播
CSP Nonce MUST 在最外层 Middleware 中生成(每个请求一次),MUST 通过 *gin.Context 传递给后续 Handler 及模板渲染:
// ✅ 正确:在 SecurityHeaders middleware 中统一生成
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
nonce := generateNonce()
c.Set("csp_nonce", nonce) // MUST 通过 c.Set 传递
c.Header("Content-Security-Policy",
fmt.Sprintf("script-src 'self' 'nonce-%s';", nonce))
c.Next()
}
}
- MUST NOT 在 Controller、Service、或模板内部生成 Nonce——会导致同一页面不同
<script>标签拿到不同 Nonce,CSP 校验失败。 - MUST NOT 在
init()或包级变量中缓存 Nonce——Nonce 必须每次请求重新生成(Number used ONCE)。 - 模板通过
{{.csp_nonce}}获取:<script nonce="{{.csp_nonce}}">...</script>。
Middleware 职责边界
Middleware MUST 保持单一职责,MUST NOT 包含以下内容:
- ❌ 数据库查询或调用 Repository
- ❌ 复杂的业务分支逻辑
- ❌ 直接操作请求体(
ctx.Request.Body)——应使用ctx.ShouldBind系列方法
Controller 禁止事项
- ❌ 直接调用 Repository
- ❌ 调用另一个 Controller
- ❌ 写 if-else 业务分支
- ❌ 在 handler 里直接操作数据库
- ❌ 数据库事务回滚(
tx.Rollback())、重试等编排逻辑(应放在 Service 层)
错误处理
错误包装
使用 fmt.Errorf + %w 包装底层错误,保留错误链,不得吞掉原始错误:
// ❌ 错误:丢失错误链
return errors.New("创建失败")
// ✅ 正确:使用 %w 包装
return fmt.Errorf("创建文章失败: %w", err)
Sentinel Error 声明
预定义错误变量放在 internal/common/errors.go 或对应包中,命名以 Err 为前缀:
var (
ErrNotFound = errors.New("记录不存在")
ErrUnauthorized = errors.New("未授权")
)
错误消息风格
错误字符串 MUST NOT 以大写字母开头,MUST NOT 以标点符号结尾(Go 官方惯例,因为错误经常被串联打印):
// ✅ 正确
errors.New("user not found")
fmt.Errorf("创建文章失败: %w", err)
// ❌ 错误
errors.New("User not found.") // 大写开头、句号结尾
fmt.Errorf("创建文章失败。%w", err) // 句号结尾
例外:首字母为专有名词时保持大写(如
"OpenAI API call failed")。
错误判断
使用 errors.Is / errors.As 而非 == 直接比较,以兼容 %w 包装后的错误链:
// ✅ 正确
if errors.Is(err, ErrNotFound) { ... }
// ❌ 错误:%w 包装后此判断将失败
if err == ErrNotFound { ... }
错误日志层级
| 错误类型 | 处理方式 |
|---|---|
| 预期内错误(参数校验、业务规则不满足) | 仅返回 error,不单独打日志 |
| 非预期错误(DB 连接失败、第三方调用失败) | Service 层 log.Error + 返回包装后 error |
Context 传播
所有跨层调用的方法签名第一参数必须为 context.Context(Controller 方法除外,*gin.Context 已实现 context.Context):
// Service 层
func (s *PostService) Create(ctx context.Context, req dto.CreatePostRequest, userID uint) (*model.Post, error)
// Repository 层
func (r *PostRepo) FindByID(ctx context.Context, id uint) (*model.Post, error)
用途:超时控制、请求取消传播、trace ID 传递。
禁止:在业务方法内部使用
context.Background()替代传入的 ctx。仅main、test、init可创建 Background context。
命名规范
文件
- Go 文件:
snake_case.go - 模板文件:
snake_case.html - CSS 文件:
kebab-case.css(遵循前端生态通用约定) - JS 文件:
camelCase.js(与前端 JS 生态主流惯例对齐)
包
- 包名全小写,不使用下划线或驼峰:
postservice(不是postService或post_service) - 单单词优先,避免多级包名过长
- 不要用
util、common、base等无意义泛名(本项目已有的common包为历史特例,新代码不允许往common追加新的业务逻辑)
结构体与方法
- Controller:
type PostController struct{},方法func (c *PostController) Create(...) - Service:
type PostService struct{ repo *PostRepo },方法func (s *PostService) Create(...) - Repository:
type PostRepo struct{ db *gorm.DB },方法func (r *PostRepo) FindByID(...)
Receiver 命名
MUST 使用类型名首字母缩写(1-2 个字母),同一类型的全部方法间 MUST 保持 receiver 名一致:
// ✅ 正确:首字母/首字母缩写,同类型全部方法一致
func (s *PostService) Create(...) // PostService → s
func (s *PostService) Update(...) // 同一类型,receiver 名 MUST 相同
func (r *PostRepo) FindByID(...) // PostRepo → r
func (c *PostController) Index(...) // PostController → c
// ❌ 错误
func (ps *PostService) Create(...) // 禁用多字母 receiver
func (s *PostService) Create(...) // Create 用 s
func (svc *PostService) Delete(...) // Delete 用 svc — 不一致!
func (self *PostService) List(...) // 禁用 self/this
接口命名
- 单方法接口:MUST 以
-er后缀结尾(Go 社区惯例)。type Reader interface { Read(p []byte) (n int, err error) } type Writer interface { Write(p []byte) (n int, err error) } - 多方法接口:使用描述性名词,MUST NOT 加
I前缀或Interface后缀。// ✅ 正确 type PostStore interface { ... } // ❌ 错误 type IPostStore interface { ... } // 禁用 I 前缀(C#/Java 风格) type PostStoreInterface interface { ... } // 禁用 Interface 后缀
响应格式
统一使用 common 包的响应函数,HTTP 状态码直接传入:
common.Ok(c, data) // 200 成功
common.OkWithMessage(c, data, "操作成功") // 200 带消息
common.OkMessage(c, "操作成功") // 200 纯消息
common.Error(c, http.StatusBadRequest, "参数错误") // 400
common.Error(c, http.StatusUnauthorized, "请先登录") // 401
common.Error(c, http.StatusForbidden, "权限不足") // 403
common.Error(c, http.StatusNotFound, "未找到") // 404
common.Error(c, http.StatusInternalServerError, "服务器错误") // 500
模板变量
使用驼峰命名,变量名与 Go 模板中 {{.VariableName}} 的引用名一致:
gin.H{
"Title": "页面标题",
"ExtraCSS": "/static/css/page.css",
"Guidelines": template.HTML(content), // HTML 内容需显式标记类型
}
注释规范
导出符号
所有导出的函数、类型、常量、变量必须有 doc 注释,且以被注释符号的名称开头(godoc 惯例):
// PostService 处理文章相关的业务逻辑。
type PostService struct{ ... }
// Create 创建一篇新文章,返回创建后的文章对象。
func (s *PostService) Create(ctx context.Context, req dto.CreatePostRequest, userID uint) (*model.Post, error) {
包注释
每个包应有包级注释,放在包内任一文件的 package 声明上方(如存在 doc.go 则优先放在其中):
// Package service 包含核心业务逻辑实现。
package service
依赖注入
使用构造器注入模式,Controller/Service/Middleware 的依赖通过构造函数传入:
// 构造器注入依赖
func NewAuthController(authService authUseCase, sm *session.Manager, limiter rateLimiter, cfg *config.Config, siteSettings *config.SiteSettings) *AuthController {
return &AuthController{authService: authService, sessionManager: sm, rateLimiter: limiter, cfg: cfg, siteSettings: siteSettings}
}
Controller 层通过 ISP 接口隔离 声明最小依赖 —— 只声明自己需要的方法子集,不依赖完整 Service 接口:
// controller/admin/interfaces.go
type siteSettingUseCase interface {
GetSettings() map[string]string
UpdateSetting(key, value string) error
UpdateBoolSetting(key string, value bool) error
GetAuditSettings(defaults *service.AuditDefaults) *service.AuditSettings
}
各 Controller 自身的 ISP 声明放在对应的 interfaces.go 文件中,严禁 controller 直接 import 完整的 Service 结构体。
测试
测试文件
测试文件命名 xxx_test.go,与被测文件放在同一包内(使用 _test 后缀包名进行黑盒测试除外)。
Table-Driven Tests
使用表驱动测试(table-driven test)模式——Go 社区共识:
func TestCreatePost(t *testing.T) {
tests := []struct {
name string
req dto.CreatePostRequest
wantErr bool
}{
{"空标题应报错", dto.CreatePostRequest{Title: ""}, true},
{"正常创建", dto.CreatePostRequest{Title: "Hello"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := service.Create(context.Background(), tt.req, 1)
if (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr = %v", err, tt.wantErr)
}
})
}
}
测试工具
使用标准库 testing 包。MUST NOT 引入第三方断言库(如 testify),以保持依赖最小化、代码风格统一。
复杂结构体比较
当 reflect.DeepEqual 对结构体切片的比较过于啰嗦时,MAY 使用 github.com/google/go-cmp/cmp 包:
import "github.com/google/go-cmp/cmp"
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("CreatePost() mismatch (-want +got):\n%s", diff)
}
go-cmp是 Google 维护的标准库补充包,不算破坏"依赖最小化"原则。MUST NOT 为此引入 testify 或其他重量级断言框架。
并发规范
Goroutine 生命周期
每个 go func() 启动的 goroutine MUST 有明确的退出路径。MUST NOT 启动永不退出的孤儿 goroutine。
// ✅ 正确:通过 ctx.Done() 退出
go func() {
for {
select {
case <-ctx.Done():
return
case msg := <-ch:
process(msg)
}
}
}()
// ❌ 错误:无退出路径
go func() {
for {
process(<-ch) // 永远阻塞
}
}()
WaitGroup / errgroup
需要等待多个 goroutine 完成时,MUST 使用 sync.WaitGroup 或 golang.org/x/sync/errgroup:
// ✅ 使用 errgroup(推荐:支持错误传播,与 Context 集成)
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return fetchA(ctx) })
g.Go(func() error { return fetchB(ctx) })
if err := g.Wait(); err != nil {
return fmt.Errorf("并发获取失败: %w", err)
}
// ✅ 使用 WaitGroup(无错误传播需求的场景)
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); doA() }()
go func() { defer wg.Done(); doB() }()
wg.Wait()
Channel 惯例
- 所有者关闭:只有发送方 goroutine MUST 关闭 channel,接收方 MUST NOT 关闭。
- 不强制关闭只读 channel:如果无"range 退出"需求,不关闭 channel 让 GC 回收是合法的。
- 无缓冲 vs 有缓冲:同步通知用无缓冲
make(chan T);异步队列用有缓冲make(chan T, size)。
sync 原语
sync.MutexMUST 通过defer mu.Unlock()释放,MUST NOT 存在Unlock后还有return分支。- MUST NOT 复制含有
sync.Mutex/sync.RWMutex的结构体(go vet会检测)。
Context 超时
所有可能长时间阻塞的 I/O 操作(HTTP 请求、DB 查询、外部 RPC 调用)MUST 使用带超时的 Context:
// ✅ 正确
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
result, err := repo.FindByID(ctx, id)
// ❌ 错误:直接使用原始 ctx,无超时保护
result, err := repo.FindByID(ctx, id)
设计原则
| 原则 | 含义 | 本项目实践 |
|---|---|---|
| YAGNI | You Ain't Gonna Need It — 不为可能的需求提前实现 | 分 Phase 推进,不做预留接口/未用抽象层 |
| DIP(依赖倒置) | 高层模块不依赖低层,都依赖抽象 | Controller → ISP 接口 ← Service;Service → Store 接口 ← Repository |
| SRP(单一职责) | 一个模块只为一个角色变更 | 拆分决策:「多域混居则拆,内聚共享则留」 |
| OCP(开闭原则) | 对扩展开放,对修改关闭 | config.yaml 角色/审核配置可扩展,不改代码 |
| ISP(接口隔离) | 不依赖用不到的接口 | Controller 只声明自己需要的方法子集,见下方 ISP 章节 |
| KISS | Keep It Simple | 连续字符校验纯函数、密码强度 switch-case,不做策略模式/DI 框架 |
架构风格
分层架构 + ISP 接口隔离,非纯六边形(即不在所有方向都引入 Port/Adapter 抽象,仅在业务边界 Controller ↔ Service、Service ↔ Repository 做接口倒置)。Gin、GORM 等框架层不额外抽象:
Web 适配器 (Gin Controller) → ISP Ports (interfaces.go)
↓
Service 核心逻辑
↓
Store Interfaces (repository.go) ← DB 适配器 (GORM Repo)
重构原则
不向后兼容
本项目为全新项目,尚未发布正式版。重构时直接废弃旧实现,不做兼容层,不保留任何向后兼容代码。遇到旧代码直接删除/覆盖,不新增 deprecated、legacy 等标记或包装函数。
CMS 化
纳入 CMS 配置化的内容(站点级可配置文案,需管理后台提供对应输入项):
- 品牌名、标语、版权声明、联系邮箱
- 页面标题前缀/后缀、SEO 描述
- 空状态占位文案、系统级通知横幅
- 其他面向最终用户展示的站点文本
不受此规则约束的例外(无需配置化):
- 表单校验错误消息(如"用户名不能为空")— 属于应用逻辑,不是站点配置
common.Error()的通用错误提示(如"参数错误""服务器错误")— 程序内部语义- CSS 注释、JS debug 日志、代码中的技术常量