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

20 KiB
Raw Permalink Blame History

Go 代码规范

AI 执行协议

本文档是 AI 编程助手的可执行规范。处理每条用户请求时AI MUST 执行以下流程:

  1. 请求分析解析用户意图识别涉及的代码层次Controller / Service / Repository / Middleware / 模板 等)。

  2. 规范对照:逐条检查请求是否违反本文档中任何 MUSTMUST NOT 规则。

  3. 违规警告:若检测到违规,MUST 在修改代码前明确警告用户,格式如下:

    ⚠️ 规范警告:[规则编号/章节] — [违规简述]
       当前请求:[用户想做什么]
       违反规则:[引用具体 MUST/MUST NOT 条文]
       合规做法:[给出替代方案]
    
  4. 等待确认:发出警告后 MUST NOT 继续执行修改,直到用户明确指示:

    • "忽略" / "按我说的做" → 照常执行,但需在代码中加 // NOTE: 已知违反 code-style.md [规则编号] 注释
    • "按合规做法" → 切换为合规方案执行
    • 修改请求 → 按新请求重新评估
  5. 通过则直接执行若无违规AI 直接按请求执行修改,无需额外确认。

  6. 修改后自检每次代码修改完成后AI MUST 运行 make fmt-checkmake 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 遵循):

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推荐

推荐启用 gofumptgofmt 的超集,更严格),替代基础 gofmt

gofumpt -l -w .

gofumptgofmt 基础上额外强制执行:字段对齐规则、多余空行清除、var 声明块合并等。

自动化检查

以下检查 MUST 在 CI 流水线中执行,且 MUST 零告警通过:

检查项 工具 命令示例
代码格式 gofmtgofumpt 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 更乱(如评论增删查+@提及+上传一体)
路由文件按业务域自然分割 文件超线但职责单一、内聚良好 — 维持现状

判定口诀:多域混居 → 拆;单域内聚 → 留。如果"辅助函数太多"到了怀疑域本身是否单一的 程度,那问题不是该拆文件,而是该把域拆小(如 PostServiceCommentService + LikeService),按变更原因重新切分。

瘦控制器铁律

Controller 每个方法不超过三步:

  1. 绑定请求参数
  2. 调用 Service
  3. 返回响应
// ✅ 正确
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> 标签拿到不同 NonceCSP 校验失败。
  • MUST NOTinit() 或包级变量中缓存 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.ContextController 方法除外,*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。仅 maintestinit 可创建 Background context。

命名规范

文件

  • Go 文件:snake_case.go
  • 模板文件:snake_case.html
  • CSS 文件:kebab-case.css(遵循前端生态通用约定)
  • JS 文件:camelCase.js(与前端 JS 生态主流惯例对齐)

  • 包名全小写,不使用下划线或驼峰:postservice(不是 postServicepost_service
  • 单单词优先,避免多级包名过长
  • 不要用 utilcommonbase 等无意义泛名(本项目已有的 common 包为历史特例,新代码不允许往 common 追加新的业务逻辑)

结构体与方法

  • Controllertype PostController struct{},方法 func (c *PostController) Create(...)
  • Servicetype PostService struct{ repo *PostRepo },方法 func (s *PostService) Create(...)
  • Repositorytype 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 NOTI 前缀或 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.WaitGroupgolang.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.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

// ✅ 正确
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)

重构原则

不向后兼容

本项目为全新项目,尚未发布正式版。重构时直接废弃旧实现,不做兼容层,不保留任何向后兼容代码。遇到旧代码直接删除/覆盖,不新增 deprecatedlegacy 等标记或包装函数。

CMS 化

纳入 CMS 配置化的内容(站点级可配置文案,需管理后台提供对应输入项):

  • 品牌名、标语、版权声明、联系邮箱
  • 页面标题前缀/后缀、SEO 描述
  • 空状态占位文案、系统级通知横幅
  • 其他面向最终用户展示的站点文本

不受此规则约束的例外(无需配置化):

  • 表单校验错误消息(如"用户名不能为空")— 属于应用逻辑,不是站点配置
  • common.Error() 的通用错误提示(如"参数错误""服务器错误")— 程序内部语义
  • CSS 注释、JS debug 日志、代码中的技术常量