Files
mce/docs/code-style.md
Victor_Jay e65f903362 chore: 添加 CI 工具链 — golangci-lint + gofumpt + goimports + Makefile
.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 检查项与规范对照
2026-06-22 02:27:29 +08:00

570 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Go 代码规范
## AI 执行协议
本文档是 AI 编程助手的**可执行规范**。处理每条用户请求时AI **MUST** 执行以下流程:
1. **请求分析**解析用户意图识别涉及的代码层次Controller / Service / Repository / Middleware / 模板 等)。
2. **规范对照**:逐条检查请求是否违反本文档中任何 **MUST****MUST NOT** 规则。
3. **违规警告**:若检测到违规,**MUST** 在修改代码前明确警告用户,格式如下:
```
⚠️ 规范警告:[规则编号/章节] — [违规简述]
当前请求:[用户想做什么]
违反规则:[引用具体 MUST/MUST NOT 条文]
合规做法:[给出替代方案]
```
4. **等待确认**:发出警告后 **MUST NOT** 继续执行修改,直到用户明确指示:
- "忽略" / "按我说的做" → 照常执行,但需在代码中加 `// NOTE: 已知违反 code-style.md [规则编号]` 注释
- "按合规做法" → 切换为合规方案执行
- 修改请求 → 按新请求重新评估
5. **通过则直接执行**若无违规AI 直接按请求执行修改,无需额外确认。
6. **修改后自检**每次代码修改完成后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` 自动完成两件事:
1. 增删 import 行(用到的加、没用到的删)
2. 按标准分组排列 import
**Import 分组规则goimports 默认行为MUST 遵循):**
```go
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`
```bash
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 每个方法不超过三步:
1. 绑定请求参数
2. 调用 Service
3. 返回响应
```go
// ✅ 正确
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 及模板渲染:
```go
// ✅ 正确:在 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>` 标签拿到不同 NonceCSP 校验失败。
- **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` 包装底层错误,保留错误链,不得吞掉原始错误:
```go
// ❌ 错误:丢失错误链
return errors.New("创建失败")
// ✅ 正确:使用 %w 包装
return fmt.Errorf("创建文章失败: %w", err)
```
### Sentinel Error 声明
预定义错误变量放在 `internal/common/errors.go` 或对应包中,命名以 `Err` 为前缀:
```go
var (
ErrNotFound = errors.New("记录不存在")
ErrUnauthorized = errors.New("未授权")
)
```
### 错误消息风格
错误字符串 **MUST NOT** 以大写字母开头,**MUST NOT** 以标点符号结尾Go 官方惯例,因为错误经常被串联打印):
```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` 包装后的错误链:
```go
// ✅ 正确
if errors.Is(err, ErrNotFound) { ... }
// ❌ 错误:%w 包装后此判断将失败
if err == ErrNotFound { ... }
```
### 错误日志层级
| 错误类型 | 处理方式 |
|----------|----------|
| 预期内错误(参数校验、业务规则不满足) | 仅返回 error不单独打日志 |
| 非预期错误DB 连接失败、第三方调用失败) | Service 层 `log.Error` + 返回包装后 error |
## Context 传播
所有跨层调用的方法签名第一参数必须为 `context.Context`Controller 方法除外,`*gin.Context` 已实现 `context.Context`
```go
// 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 名一致:
```go
// ✅ 正确:首字母/首字母缩写,同类型全部方法一致
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 社区惯例)。
```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` 后缀。
```go
// ✅ 正确
type PostStore interface { ... }
// ❌ 错误
type IPostStore interface { ... } // 禁用 I 前缀C#/Java 风格)
type PostStoreInterface interface { ... } // 禁用 Interface 后缀
```
### 响应格式
统一使用 `common` 包的响应函数HTTP 状态码直接传入:
```go
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}}` 的引用名一致:
```go
gin.H{
"Title": "页面标题",
"ExtraCSS": "/static/css/page.css",
"Guidelines": template.HTML(content), // HTML 内容需显式标记类型
}
```
## 注释规范
### 导出符号
所有导出的函数、类型、常量、变量必须有 doc 注释且以被注释符号的名称开头godoc 惯例):
```go
// PostService 处理文章相关的业务逻辑。
type PostService struct{ ... }
// Create 创建一篇新文章,返回创建后的文章对象。
func (s *PostService) Create(ctx context.Context, req dto.CreatePostRequest, userID uint) (*model.Post, error) {
```
### 包注释
每个包应有包级注释,放在包内任一文件的 `package` 声明上方(如存在 `doc.go` 则优先放在其中):
```go
// Package service 包含核心业务逻辑实现。
package service
```
## 依赖注入
使用构造器注入模式Controller/Service/Middleware 的依赖通过构造函数传入:
```go
// 构造器注入依赖
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 接口:
```go
// 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 社区共识:
```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` 包:
```go
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。
```go
// ✅ 正确:通过 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`
```go
// ✅ 使用 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.Mutex` **MUST** 通过 `defer mu.Unlock()` 释放,**MUST NOT** 存在 `Unlock` 后还有 `return` 分支。
- **MUST NOT** 复制含有 `sync.Mutex` / `sync.RWMutex` 的结构体(`go vet` 会检测)。
### Context 超时
所有可能长时间阻塞的 I/O 操作HTTP 请求、DB 查询、外部 RPC 调用)**MUST** 使用带超时的 Context
```go
// ✅ 正确
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 接口 ← ServiceService → 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 日志、代码中的技术常量