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 检查项与规范对照
This commit is contained in:
41
.golangci.yml
Normal file
41
.golangci.yml
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
version: "2"
|
||||||
|
|
||||||
|
linters:
|
||||||
|
default: none
|
||||||
|
|
||||||
|
enable:
|
||||||
|
- errcheck # 未处理的 error 返回值
|
||||||
|
- gosec # 安全漏洞模式
|
||||||
|
- govet # go vet 标准检查(含 copylocks)
|
||||||
|
- staticcheck # 大量静态分析规则
|
||||||
|
- ineffassign # 无效赋值
|
||||||
|
- unused # 未使用的变量/常量/函数/类型
|
||||||
|
- revive # 代码风格(doc 注释等)
|
||||||
|
- contextcheck # context.Background() 误用检测
|
||||||
|
- errorlint # errors.Is / errors.As 使用检测
|
||||||
|
- errname # Sentinel error 必须以 Err 为前缀
|
||||||
|
- misspell # 拼写错误
|
||||||
|
|
||||||
|
settings:
|
||||||
|
gosec:
|
||||||
|
excludes: []
|
||||||
|
|
||||||
|
staticcheck:
|
||||||
|
checks: ["all"]
|
||||||
|
|
||||||
|
revive:
|
||||||
|
severity: warning
|
||||||
|
|
||||||
|
exclusions:
|
||||||
|
paths:
|
||||||
|
- third_party
|
||||||
|
- ".*_test\\.go$"
|
||||||
|
|
||||||
|
formatters:
|
||||||
|
enable:
|
||||||
|
- gofumpt # 比 gofmt 更严格的格式化
|
||||||
|
- goimports # import 自动分组 + 增删
|
||||||
|
|
||||||
|
run:
|
||||||
|
timeout: 5m
|
||||||
|
tests: true
|
||||||
35
Makefile
Normal file
35
Makefile
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
.PHONY: lint fmt check test
|
||||||
|
|
||||||
|
# === 代码格式化 ===
|
||||||
|
fmt:
|
||||||
|
@echo "==> gofumpt..."
|
||||||
|
@gofumpt -l -w internal/ cmd/
|
||||||
|
@echo "==> goimports..."
|
||||||
|
@goimports -w internal/ cmd/
|
||||||
|
|
||||||
|
# === 格式检查(CI 用,只读不改) ===
|
||||||
|
fmt-check:
|
||||||
|
@echo "==> gofumpt check..."
|
||||||
|
@test -z "$$(gofumpt -l internal/ cmd/)" || (echo "gofumpt: 以下文件格式不正确:" && gofumpt -l internal/ cmd/ && exit 1)
|
||||||
|
@echo "==> goimports check..."
|
||||||
|
@test -z "$$(goimports -l internal/ cmd/)" || (echo "goimports: 以下文件 import 不规范:" && goimports -l internal/ cmd/ && exit 1)
|
||||||
|
|
||||||
|
# === 静态分析(报告模式,不阻断) ===
|
||||||
|
lint:
|
||||||
|
@echo "==> golangci-lint (report only)..."
|
||||||
|
@golangci-lint run ./... || true
|
||||||
|
|
||||||
|
# === 静态分析(阻断模式,CI 用) ===
|
||||||
|
lint-strict:
|
||||||
|
@echo "==> golangci-lint (strict)..."
|
||||||
|
@golangci-lint run ./...
|
||||||
|
|
||||||
|
# === 测试 ===
|
||||||
|
test:
|
||||||
|
@echo "==> go test..."
|
||||||
|
@go test -v -race ./...
|
||||||
|
|
||||||
|
# === CI 完整流程(报告模式) ===
|
||||||
|
ci:
|
||||||
|
@$(MAKE) fmt-check
|
||||||
|
@$(MAKE) lint
|
||||||
@ -1,5 +1,103 @@
|
|||||||
# Go 代码规范
|
# 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 传播规则一致。
|
||||||
|
|
||||||
## 文件大小
|
## 文件大小
|
||||||
|
|
||||||
| 层次 | 单文件行数上限 | 说明 |
|
| 层次 | 单文件行数上限 | 说明 |
|
||||||
@ -11,14 +109,15 @@
|
|||||||
| Middleware | ≤ 60 行 | 单一职责 |
|
| Middleware | ≤ 60 行 | 单一职责 |
|
||||||
| Router | ≤ 60 行 | 只做路由映射 |
|
| Router | ≤ 60 行 | 只做路由映射 |
|
||||||
|
|
||||||
> **注意**:以上为建议上限,不是硬性指标。**是否拆分取决于职责是否内聚**,而非单纯看行数。拆分决策矩阵:
|
> **注意**:行数上限为**警告线**而非硬截断。拆分决策遵循 SRP(一个文件只有一个变更原因):
|
||||||
|
|
||||||
| 该拆 | 不该拆 |
|
| 该拆 | 不该拆 |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| 多个独立功能塞一个文件(如 7 个 Tab 的 JS 全混居 + 5 个 IIFE) | 页面内区域共享大量样式,拆了反而碎片化(如文章详情/列表共用 `article-card`) |
|
| 一个文件混了两个及以上业务域(如 `commentService` + `likeService` 塞同一个文件) | 只有单一业务域,但因逻辑复杂产生了大量私有辅助函数 — 保持内聚 |
|
||||||
| 一个文件承担了压根不同域的工作(如所有 API 路由写一个文件) | 功能高度耦合,拆了互相 import 更乱(如评论增删查+@提及+上传一体) |
|
| 一个文件承担了压根不同域的工作(如所有 API 路由写一个文件) | 功能高度耦合,拆了互相 import 更乱(如评论增删查+@提及+上传一体) |
|
||||||
| CSS 随 HTML 模板拆分自然跟随 | 公共工具函数拆成碎片没有意义 |
|
| 路由文件按业务域自然分割 | 文件超线但职责单一、内聚良好 — 维持现状 |
|
||||||
| 路由文件按业务域自然分割(与 admin.go 对齐) | 文件超线但职责单一、内聚良好 — 维持现状 |
|
|
||||||
|
> **判定口诀**:多域混居 → 拆;单域内聚 → 留。如果"辅助函数太多"到了怀疑域本身是否单一的 程度,那问题不是该拆文件,而是该把域拆小(如 `PostService` → `CommentService` + `LikeService`),按变更原因重新切分。
|
||||||
|
|
||||||
## 瘦控制器铁律
|
## 瘦控制器铁律
|
||||||
|
|
||||||
@ -60,13 +159,120 @@ func (c *PostController) Create(ctx *gin.Context) {
|
|||||||
|
|
||||||
业务判断全部放在 Service 层。
|
业务判断全部放在 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>` 标签拿到不同 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 禁止事项
|
## Controller 禁止事项
|
||||||
|
|
||||||
- ❌ 直接调用 Repository
|
- ❌ 直接调用 Repository
|
||||||
- ❌ 调用另一个 Controller
|
- ❌ 调用另一个 Controller
|
||||||
- ❌ 写 if-else 业务分支
|
- ❌ 写 if-else 业务分支
|
||||||
- ❌ 在 handler 里直接操作数据库
|
- ❌ 在 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。
|
||||||
|
|
||||||
## 命名规范
|
## 命名规范
|
||||||
|
|
||||||
@ -74,7 +280,14 @@ func (c *PostController) Create(ctx *gin.Context) {
|
|||||||
|
|
||||||
- Go 文件:`snake_case.go`
|
- Go 文件:`snake_case.go`
|
||||||
- 模板文件:`snake_case.html`
|
- 模板文件:`snake_case.html`
|
||||||
- CSS/JS 文件:`kebab-case.css` / `camelCase.js`
|
- CSS 文件:`kebab-case.css`(遵循前端生态通用约定)
|
||||||
|
- JS 文件:`camelCase.js`(与前端 JS 生态主流惯例对齐)
|
||||||
|
|
||||||
|
### 包
|
||||||
|
|
||||||
|
- 包名全小写,不使用下划线或驼峰:`postservice`(不是 `postService` 或 `post_service`)
|
||||||
|
- 单单词优先,避免多级包名过长
|
||||||
|
- 不要用 `util`、`common`、`base` 等无意义泛名(本项目已有的 `common` 包为历史特例,新代码不允许往 `common` 追加新的业务逻辑)
|
||||||
|
|
||||||
### 结构体与方法
|
### 结构体与方法
|
||||||
|
|
||||||
@ -82,6 +295,40 @@ func (c *PostController) Create(ctx *gin.Context) {
|
|||||||
- Service:`type PostService struct{ repo *PostRepo }`,方法 `func (s *PostService) Create(...)`
|
- Service:`type PostService struct{ repo *PostRepo }`,方法 `func (s *PostService) Create(...)`
|
||||||
- Repository:`type PostRepo struct{ db *gorm.DB }`,方法 `func (r *PostRepo) FindByID(...)`
|
- 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 状态码直接传入:
|
统一使用 `common` 包的响应函数,HTTP 状态码直接传入:
|
||||||
@ -99,7 +346,7 @@ common.Error(c, http.StatusInternalServerError, "服务器错误") // 500
|
|||||||
|
|
||||||
### 模板变量
|
### 模板变量
|
||||||
|
|
||||||
使用驼峰命名,与模板文件保持一致:
|
使用驼峰命名,变量名与 Go 模板中 `{{.VariableName}}` 的引用名一致:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
gin.H{
|
gin.H{
|
||||||
@ -109,6 +356,29 @@ gin.H{
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 注释规范
|
||||||
|
|
||||||
|
### 导出符号
|
||||||
|
|
||||||
|
所有导出的函数、类型、常量、变量必须有 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 的依赖通过构造函数传入:
|
使用构造器注入模式,Controller/Service/Middleware 的依赖通过构造函数传入:
|
||||||
@ -134,6 +404,128 @@ type siteSettingUseCase interface {
|
|||||||
|
|
||||||
各 Controller 自身的 ISP 声明放在对应的 `interfaces.go` 文件中,严禁 controller 直接 import 完整的 Service 结构体。
|
各 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)
|
||||||
|
```
|
||||||
|
|
||||||
## 设计原则
|
## 设计原则
|
||||||
|
|
||||||
| 原则 | 含义 | 本项目实践 |
|
| 原则 | 含义 | 本项目实践 |
|
||||||
@ -147,7 +539,7 @@ type siteSettingUseCase interface {
|
|||||||
|
|
||||||
### 架构风格
|
### 架构风格
|
||||||
|
|
||||||
分层架构 + ISP 接口隔离,非纯六边形。Gin、GORM 等框架层不额外抽象,仅在业务边界通过接口倒置依赖方向:
|
分层架构 + ISP 接口隔离,非纯六边形(即不在所有方向都引入 Port/Adapter 抽象,仅在业务边界 Controller ↔ Service、Service ↔ Repository 做接口倒置)。Gin、GORM 等框架层不额外抽象:
|
||||||
|
|
||||||
```
|
```
|
||||||
Web 适配器 (Gin Controller) → ISP Ports (interfaces.go)
|
Web 适配器 (Gin Controller) → ISP Ports (interfaces.go)
|
||||||
@ -165,6 +557,13 @@ Store Interfaces (repository.go) ← DB 适配器 (GORM Repo)
|
|||||||
|
|
||||||
### CMS 化
|
### CMS 化
|
||||||
|
|
||||||
所有面向最终用户的硬编码文本(品牌名、标语、邮箱、空状态提示、系统消息等)应纳入 `site_settings` 表配置化,而非硬编码在模板或 Go 代码中。管理后台需提供对应输入项。
|
**纳入 CMS 配置化的内容**(站点级可配置文案,需管理后台提供对应输入项):
|
||||||
|
- 品牌名、标语、版权声明、联系邮箱
|
||||||
|
- 页面标题前缀/后缀、SEO 描述
|
||||||
|
- 空状态占位文案、系统级通知横幅
|
||||||
|
- 其他面向最终用户展示的站点文本
|
||||||
|
|
||||||
例外:CSS 注释、JS debug 日志、代码中的技术常量不受此规则约束。
|
**不受此规则约束的例外**(无需配置化):
|
||||||
|
- 表单校验错误消息(如"用户名不能为空")— 属于应用逻辑,不是站点配置
|
||||||
|
- `common.Error()` 的通用错误提示(如"参数错误""服务器错误")— 程序内部语义
|
||||||
|
- CSS 注释、JS debug 日志、代码中的技术常量
|
||||||
|
|||||||
46
scripts/ci.sh
Executable file
46
scripts/ci.sh
Executable file
@ -0,0 +1,46 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# CI 流水线脚本
|
||||||
|
# 调用方式:bash scripts/ci.sh [--strict]
|
||||||
|
# --strict 阻断模式(lint 失败则 exit 1)
|
||||||
|
# 默认 报告模式(lint 只输出不阻断)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
export PATH="$HOME/go/bin:$PATH"
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
STRICT=false
|
||||||
|
if [[ "${1:-}" == "--strict" ]]; then
|
||||||
|
STRICT=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo " CI Pipeline"
|
||||||
|
echo " Mode: $( $STRICT && echo 'STRICT' || echo 'REPORT' )"
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
|
# 1. 格式检查
|
||||||
|
echo ""
|
||||||
|
echo "==> 1/3 格式检查 (gofumpt + goimports)"
|
||||||
|
make fmt-check
|
||||||
|
|
||||||
|
# 2. 编译检查
|
||||||
|
echo ""
|
||||||
|
echo "==> 2/3 编译检查 (go build)"
|
||||||
|
go build ./...
|
||||||
|
|
||||||
|
# 3. 静态分析
|
||||||
|
echo ""
|
||||||
|
echo "==> 3/3 静态分析 (golangci-lint)"
|
||||||
|
if $STRICT; then
|
||||||
|
make lint-strict
|
||||||
|
else
|
||||||
|
make lint
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo " CI Pipeline 完成"
|
||||||
|
echo "========================================"
|
||||||
12
scripts/pre-push
Normal file
12
scripts/pre-push
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Git pre-push hook — push 前跑 CI 检查
|
||||||
|
# 安装:cp scripts/pre-push .git/hooks/pre-push && chmod +x .git/hooks/pre-push
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "==> Pre-push CI check..."
|
||||||
|
bash "$ROOT/scripts/ci.sh" --strict
|
||||||
|
echo "==> OK"
|
||||||
Reference in New Issue
Block a user