更新 skill 后端开发

This commit is contained in:
zeaslity
2026-07-01 13:45:30 +08:00
parent d213a1146c
commit 9cd57b92b8
30 changed files with 1952 additions and 1643 deletions

View File

@@ -0,0 +1,91 @@
---
name: developing-go-gin-gorm
description: >
用于 create / modify / review production-ready 的 Go Gin/GORM backend code。该 skill 强制执行
POST + JSON RequestBody API、handler-service-dao layering、自包含 common runtime、AppError error mapping、
single-DB / multi-DB design、Unit of Work / TxDAO transaction pattern、Asia/Shanghai time handling、
structured logging、JWT / security / audit middleware 以及 cross-platform validation。
---
# Go Gin/GORM 工程化开发 Skill
## 核心工作流
1. 先阅读目标模块,再动手修改。识别现有的 handler、service、DAO、DTO、entity、middleware、config 和数据库连接方式。
2. 如果目标项目没有本地 common 基础能力,从 `reference/common-runtime.md` 脚手架化生成;不要依赖外部 common 仓库或某台机器上的特定目录。
3.`handler -> service -> dao` 分层新增或修改代码DTO 与 entity 必须分离。
4. 所有业务 API 统一设计为 `POST + JSON RequestBody`;不新增 GET、PUT、PATCH、DELETE、Path Param 或 Query Param 风格业务接口。
5. DAO 返回底层错误Service 将基础设施错误转换为领域错误或 `AppError`Handler 只负责把 `AppError` 映射成统一响应,不能感知 GORM 细节。
6. 根据模块实际情况选择单数据库或多数据库设计;涉及事务的写操作必须使用 Unit of Work / TxDAO 模式。
7. 时间和日志只使用 skill 内定义的 common 运行时规范;时间固定为 `Asia/Shanghai`API 时间戳使用 RFC3339 且带 `+08:00` 偏移。
8. 受保护 API 必须接入安全与审计规则,包括 JWT claims、管理员校验、权限中间件、限流、CORS、敏感日志脱敏和关键操作审计。
9. 完成后运行 `scripts/validate_go_gin_gorm.py` 跨平台验证器;当目标项目可本地编译时,再运行 `gofmt``go test ./...`
## 强制规则
- API 路由必须使用 `POST("/xxx/action", handler.Method)`,请求参数必须使用 `ShouldBindJSON`
- Handler 禁止调用 `c.JSON``AbortWithStatusJSON``c.Param``c.Query``ShouldBindQuery`,也禁止 import `gorm`
- Handler 禁止承载业务规则、权限判断细节、事务边界和数据库访问。
- Service 负责业务编排、领域校验、错误映射、事务边界和关键业务日志。
- DAO 负责所有 GORM 操作,并且每个 GORM 调用必须使用 `WithContext(ctx)`
- DAO 返回底层错误Service 负责转换为 `common.AppError` 或明确的领域错误。
- 需要参与事务的 DAO 方法必须接收 `tx *gorm.DB`;同一个事务内的所有 DAO 调用必须使用同一个 `tx`
- DAO 禁止主动开启事务;事务只能由 Service 通过 Unit of Work 开启。
- 时间必须使用 `common.Now()``common.ParseTime()``common.FormatTime()`;除 common 时间运行时外,禁止直接使用 `time.Now()``time.Parse()`
- 日志必须使用 `common.Debug/Info/Warn/Error`禁止打印密码、Token、私钥、Secret、完整 Authorization Header 等敏感信息。
- Go 导出标识符和非显然业务逻辑必须写有价值的中文注释。
- 代码优先清晰、小而稳;只有在能消除真实复杂度或保护真实边界时才引入设计模式。
## 实现原则
- 以第一性原理为基础,优先解决真实业务流程、数据一致性、安全风险、可观测性缺口和核心工程问题。
- 发挥资深架构师经验,优化系统的可维护性、可扩展性、可观测性、稳定性、安全性和工程落地成本。
- 避免炫技式设计、性能表演、过早抽象和没有业务收益的复杂化。
- 优先使用显式 DTO、显式错误映射、显式事务所有权和显式业务边界日志。
- 只有当当前代码路径、数据量或故障模式证明成本真实存在时,才做针对性优化。
## 设计模式使用准则
- 使用分层架构约束 handler、service、dao 的依赖方向。
- 使用 DTO + Mapper 防止数据库 entity 泄漏到 API 合约。
- 使用 `AppError` 作为 Service 暴露给 Handler 的稳定错误契约。
- 使用 Unit of Work 管理 Service 层事务边界。
- 使用 TxDAO 模式,将 `tx *gorm.DB` 传入事务内 DAO 方法。
- Repository/DAO 只处理持久化访问,不承载业务决策。
- Handler、Service、DAO、DB Manager、Middleware 使用构造函数/工厂函数创建。
- 只有存在多个真实可替换算法时才使用 Strategy。
- 外部系统对接,例如 CI、支付、身份、对象存储优先使用 Adapter 隔离边界。
- Auth、Admin、Permission、RequestID、Audit、RateLimit、CORS、Recovery 等横切能力使用 Middleware / Decorator 风格组合。
- 不为了设计模式而设计;只有一个实现、没有边界收益的模式不要引入。
## 参考资料加载规则
| 场景 | 读取文件 |
|------|----------|
| 本地 common 运行时统一响应、错误码、AppError、时间、日志、请求上下文 | `reference/common-runtime.md` |
| 编码规范、注释、命名、错误处理、DTO/entity 分离 | `reference/coding-standards.md` |
| API 路由、DTO、分页、响应规范 | `reference/api-design-spec.md`, `reference/api-response-spec.md` |
| 单数据库、多数据库、Unit of Work、TxDAO、GORM 规则 | `reference/database-patterns.md`, `reference/framework-usage.md` |
| 日志分级、debug 模式、敏感字段脱敏 | `reference/logging-standards.md` |
| 东八区时间处理 | `reference/time-handling.md` |
| JWT、权限、管理员校验、审计、CORS、限流、安全规则 | `reference/security-audit.md` |
| 目录结构和依赖边界 | `reference/project-structure.md` |
| 代码示例 | `examples/*.go` |
| 跨平台验证 | `scripts/validate_go_gin_gorm.py` |
> 错误码与 `AppError` 的唯一规范来源是 `reference/common-runtime.md`。不要再维护独立的 `error-codes.go` reference避免双源漂移。
## 完成检查清单
- [ ] 所有业务 API 都是 POST + RequestBody。
- [ ] Handler 只做 DTO 绑定、Service 调用和 common 统一响应。
- [ ] Handler 没有 GORM import也不检查 GORM 错误。
- [ ] Service 将 DAO/基础设施错误映射为 `common.AppError`
- [ ] DAO 方法使用 `WithContext(ctx)` 并返回底层错误。
- [ ] 事务使用 Unit of Work并向所有参与事务的 DAO 方法传递同一个 `tx`
- [ ] 单数据库或多数据库归属明确。
- [ ] 时间使用 `common.Now/ParseTime/FormatTime`
- [ ] 日志使用 common 结构化日志,并对敏感字段脱敏。
- [ ] 需要保护的接口已接入 Auth/Admin/Permission/Audit/RateLimit/CORS 等规则。
- [ ] `python scripts/validate_go_gin_gorm.py <project-root>` 通过。
- [ ] `gofmt``go test ./...` 通过;如果因环境限制不能运行,需要明确说明。

View File

@@ -0,0 +1,73 @@
package dao
import (
"context"
"my-project/internal/model/dto"
"my-project/internal/model/entity"
"gorm.io/gorm"
)
// UserDAO 用户数据访问对象。DAO 只封装 GORM 操作,不做业务决策。
type UserDAO struct {
db *gorm.DB
}
// NewUserDAO 创建用户 DAO 实例。
func NewUserDAO(db *gorm.DB) *UserDAO {
return &UserDAO{db: db}
}
func (d *UserDAO) session(ctx context.Context, tx *gorm.DB) *gorm.DB {
if tx != nil {
return tx.WithContext(ctx)
}
return d.db.WithContext(ctx)
}
// FindByID 根据用户 ID 查询用户。未找到时返回 GORM 原始错误。
func (d *UserDAO) FindByID(ctx context.Context, tx *gorm.DB, userID int64) (*entity.User, error) {
var user entity.User
if err := d.session(ctx, tx).First(&user, "id = ?", userID).Error; err != nil {
return nil, err
}
return &user, nil
}
// ExistsByUsername 判断用户名是否已存在。
func (d *UserDAO) ExistsByUsername(ctx context.Context, tx *gorm.DB, username string) (bool, error) {
var count int64
if err := d.session(ctx, tx).Model(&entity.User{}).Where("username = ?", username).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
// Create 创建用户。事务内调用必须传入 tx。
func (d *UserDAO) Create(ctx context.Context, tx *gorm.DB, user *entity.User) error {
return d.session(ctx, tx).Create(user).Error
}
// List 分页查询用户列表。
func (d *UserDAO) List(ctx context.Context, tx *gorm.DB, req *dto.ListUsersRequest) ([]*entity.User, int64, error) {
query := d.session(ctx, tx).Model(&entity.User{})
if req.Keyword != "" {
query = query.Where("username LIKE ?", "%"+req.Keyword+"%")
}
if req.Status != "" {
query = query.Where("status = ?", req.Status)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var users []*entity.User
offset := (req.Page - 1) * req.PageSize
if err := query.Order("created_at DESC").Limit(req.PageSize).Offset(offset).Find(&users).Error; err != nil {
return nil, 0, err
}
return users, total, nil
}

View File

@@ -0,0 +1,62 @@
package handler
import (
"my-project/internal/common"
"my-project/internal/model/dto"
"my-project/internal/service"
"github.com/gin-gonic/gin"
)
// UserHandler 用户相关 API 处理器。
type UserHandler struct {
userService *service.UserService
}
// NewUserHandler 创建用户 Handler 实例。
func NewUserHandler(userService *service.UserService) *UserHandler {
return &UserHandler{userService: userService}
}
// RegisterUserRoutes 注册用户路由。业务 API 强制使用 POST + RequestBody。
func RegisterUserRoutes(group *gin.RouterGroup, h *UserHandler) {
users := group.Group("/users")
{
users.POST("/detail", h.GetUserDetail)
users.POST("/create", h.CreateUser)
}
}
// GetUserDetail 根据请求体中的用户 ID 获取用户详情。
func (h *UserHandler) GetUserDetail(c *gin.Context) {
var req dto.GetUserDetailRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ResponseAppError(c, common.WrapAppError(common.CodeParamError, "请求参数错误", err))
return
}
resp, err := h.userService.GetUserDetail(c.Request.Context(), &req)
if err != nil {
common.ResponseAppError(c, err)
return
}
common.ResponseSuccess(c, resp)
}
// CreateUser 创建用户。
func (h *UserHandler) CreateUser(c *gin.Context) {
var req dto.CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ResponseAppError(c, common.WrapAppError(common.CodeParamError, "请求参数错误", err))
return
}
resp, err := h.userService.CreateUser(c.Request.Context(), &req)
if err != nil {
common.ResponseAppError(c, err)
return
}
common.ResponseSuccessWithMessage(c, resp, "用户创建成功")
}

View File

@@ -0,0 +1,79 @@
package service
import (
"context"
"errors"
"my-project/internal/common"
"my-project/internal/dao"
"my-project/internal/model/dto"
"my-project/internal/model/entity"
"my-project/internal/model/mapper"
"gorm.io/gorm"
)
// UnitOfWork 定义 service 使用的事务边界。
type UnitOfWork interface {
Transaction(ctx context.Context, fn func(tx *gorm.DB) error) error
}
// UserService 用户业务服务。
type UserService struct {
uow UnitOfWork
userDAO *dao.UserDAO
}
// NewUserService 创建用户服务实例。
func NewUserService(uow UnitOfWork, userDAO *dao.UserDAO) *UserService {
return &UserService{uow: uow, userDAO: userDAO}
}
// GetUserDetail 根据用户 ID 获取用户详情。
func (s *UserService) GetUserDetail(ctx context.Context, req *dto.GetUserDetailRequest) (*dto.UserDetailResponse, error) {
user, err := s.userDAO.FindByID(ctx, nil, req.UserID)
if err != nil {
return nil, mapUserLookupError(err)
}
return mapper.ToUserDetailResponse(user), nil
}
// CreateUser 创建用户并通过 UnitOfWork 保证事务一致性。
func (s *UserService) CreateUser(ctx context.Context, req *dto.CreateUserRequest) (*dto.UserDetailResponse, error) {
var created *entity.User
err := s.uow.Transaction(ctx, func(tx *gorm.DB) error {
exists, err := s.userDAO.ExistsByUsername(ctx, tx, req.Username)
if err != nil {
return common.WrapAppError(common.CodeServerError, "检查用户名失败", err)
}
if exists {
return common.NewAppError(common.CodeDuplicate, "用户名已存在")
}
user := &entity.User{
Username: req.Username,
Email: req.Email,
CreatedAt: common.Now(),
UpdatedAt: common.Now(),
}
if err := s.userDAO.Create(ctx, tx, user); err != nil {
return common.WrapAppError(common.CodeServerError, "创建用户失败", err)
}
created = user
return nil
})
if err != nil {
return nil, err
}
common.Info(ctx, "用户创建成功", "user_id", created.ID, "username", created.Username)
return mapper.ToUserDetailResponse(created), nil
}
func mapUserLookupError(err error) error {
if errors.Is(err, gorm.ErrRecordNotFound) {
return common.WrapAppError(common.CodeNotFound, "用户不存在", err)
}
return common.WrapAppError(common.CodeServerError, "查询用户失败", err)
}

View File

@@ -0,0 +1,125 @@
# API 设计规范
所有业务 API 必须使用 `POST + JSON RequestBody`。这是生成代码的硬性规则。
## 路由规则
- 列表、详情、创建、更新、删除、同步、触发、导出、内部检查等接口都使用 `POST`
- 所有业务参数都放在 JSON request body 中。
- 禁止使用 path variables。
- 禁止使用 query parameters。
- 禁止使用 `ShouldBindQuery`
- 路由名称保持 action-oriented并保证语义稳定。
| 操作 | 路由后缀 | 示例 |
|------|----------|------|
| 列表 | `/list` | `/api/users/list` |
| 详情 | `/detail` | `/api/users/detail` |
| 创建 | `/create` | `/api/users/create` |
| 更新 | `/update` | `/api/users/update` |
| 删除 | `/delete` | `/api/users/delete` |
| 同步 | `/sync` | `/api/ci/resources/sync` |
| 触发 | `/trigger` | `/api/builds/trigger` |
| 导出 | `/export` | `/api/audit/logs/export` |
| 权限检查 | `/check` | `/api/permissions/check` |
## 路由注册
```go
func RegisterUserRoutes(group *gin.RouterGroup, h *UserHandler) {
users := group.Group("/users")
{
users.POST("/list", h.ListUsers)
users.POST("/detail", h.GetUserDetail)
users.POST("/create", h.CreateUser)
users.POST("/update", h.UpdateUser)
users.POST("/delete", h.DeleteUser)
}
}
```
## DTO 规则
```go
type PageRequest struct {
Page int `json:"page" binding:"required,min=1"`
PageSize int `json:"page_size" binding:"required,min=1,max=100"`
}
type ListUsersRequest struct {
PageRequest
Keyword string `json:"keyword,omitempty"`
Status string `json:"status,omitempty"`
}
type GetUserDetailRequest struct {
UserID int64 `json:"user_id" binding:"required,min=1"`
}
type CreateUserRequest struct {
Username string `json:"username" binding:"required"`
Email string `json:"email" binding:"omitempty,email"`
}
type UpdateUserRequest struct {
UserID int64 `json:"user_id" binding:"required,min=1"`
Email string `json:"email" binding:"omitempty,email"`
Status string `json:"status,omitempty"`
}
type DeleteUserRequest struct {
UserID int64 `json:"user_id" binding:"required,min=1"`
}
```
DTO 命名规则:
| 类型 | 命名 |
|------|------|
| 列表请求 | `List{Resource}Request` |
| 详情请求 | `Get{Resource}DetailRequest` |
| 创建请求 | `Create{Resource}Request` |
| 更新请求 | `Update{Resource}Request` |
| 删除请求 | `Delete{Resource}Request` |
| 列表响应 | `List{Resource}Response` |
| 详情响应 | `{Resource}DetailResponse` |
## Handler 模板
```go
func (h *UserHandler) GetUserDetail(c *gin.Context) {
var req dto.GetUserDetailRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ResponseAppError(c, common.WrapAppError(common.CodeParamError, "请求参数错误", err))
return
}
resp, err := h.userService.GetUserDetail(c.Request.Context(), &req)
if err != nil {
common.ResponseAppError(c, err)
return
}
common.ResponseSuccess(c, resp)
}
```
## 分页响应
优先使用字段明确的类型化响应;通用工具或临时场景可以使用 `common.PageResponse`
```go
type ListUsersResponse struct {
List []*UserDTO `json:"list"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
```
## 安全注意事项
- 敏感值必须只放在 request body 中,并且仍然禁止写入日志。
- 参数绑定和校验错误映射为 `CodeParamError``CodeValidationFail`
- Auth、Admin、Permission 中间件禁止从路径参数或查询参数中提取业务资源标识。
- 删除接口在需要业务恢复或审计追溯时优先使用软删除。

View File

@@ -0,0 +1,64 @@
# API 响应规范
所有 Handler 必须使用 `reference/common-runtime.md` 中定义的本地 common 响应函数。
## 响应体
```go
type Response struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data"`
Timestamp string `json:"timestamp"`
RequestID string `json:"request_id"`
}
```
规则:
- 成功响应使用 `CodeSuccess`,并保持 `message=success`
- 失败响应使用非 0 业务码,且 `data=null`
- `timestamp` 使用 Asia/Shanghai RFC3339例如 `2026-06-29T10:30:00+08:00`
- `request_id` 必须来自请求上下文或 RequestID 中间件。
- Handler 通过 `ResponseAppError(c, err)` 返回错误。
## 响应函数
```go
common.ResponseSuccess(c, data)
common.ResponseSuccessWithMessage(c, data, "创建成功")
common.ResponseError(c, common.CodeParamError, "请求参数错误")
common.ResponseAppError(c, err)
```
业务 Handler 禁止直接调用 Gin 原生响应方法。
## 错误映射
| 错误来源 | 映射责任方 | 响应码 |
|----------|------------|--------|
| JSON 绑定错误 | Handler | `CodeParamError``CodeValidationFail` |
| GORM 未找到记录 | Service | `CodeNotFound` |
| 唯一键冲突 | Service | `CodeDuplicate` |
| 领域校验失败 | Service | `CodeValidationFail``CodeBusinessError` |
| 权限不足 | Middleware/Service | `CodeForbidden` |
| 外部 API 失败 | Service/Adapter | `CodeExternalAPIError` |
| 未知基础设施失败 | Service 或 `ToAppError` fallback | `CodeServerError` |
## 分页结构
```go
type PageRequest struct {
Page int `json:"page" binding:"required,min=1"`
PageSize int `json:"page_size" binding:"required,min=1,max=100"`
}
type PageResponse struct {
List any `json:"list"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
```
能明确响应结构时优先使用类型化列表响应;通用工具场景再使用 `PageResponse`

View File

@@ -0,0 +1,57 @@
# 编码规范
## 命名规范
| 对象 | 规则 | 示例 |
|------|------|------|
| package | 小写、短名、不使用下划线 | `service`, `dao`, `common` |
| 导出标识符 | PascalCase并提供有价值的中文注释 | `CreateUser` |
| 非导出标识符 | camelCase | `mapUserError` |
| 请求 DTO | `{Action}{Resource}Request` | `CreateUserRequest` |
| 响应 DTO | `{Resource}{Shape}Response` | `UserDetailResponse` |
| DAO | `{Resource}DAO` | `UserDAO` |
| Service | `{Resource}Service` | `UserService` |
## 注释规范
- 导出的 type、func、method 必须有中文注释。
- 注释应解释意图、边界或业务含义。
- 避免只复述代码行为的空注释。
- 非显然的校验、事务、审计、补偿逻辑前应添加简短说明。
## 错误处理
- 禁止用 `_ = err` 丢弃错误。
- Service 包装基础设施错误时必须补充业务上下文。
- Handler 的参数绑定错误转换为 `common.AppError`
- DAO 返回的错误由 Service 转换为 `common.AppError`
- Handler 禁止检查 GORM 错误。
## DTO 与 Entity 分离
- Request DTO 承载客户端输入。
- Response DTO 定义客户端输出。
- Entity 定义数据库持久化结构。
- Mapper 负责 entity 与 DTO 的转换。
- 除非 entity 被明确设计为 API 合约,否则 API 禁止直接返回 entity。
## 实现纪律
- 优先解决真实业务问题。
- 代码保持足够小,方便 review。
- 只有存在清晰边界或重复复杂度时才添加抽象。
- 依赖关系优先通过构造函数显式注入。
- 除 logger 和只读配置类基础设施外,避免全局可变状态。
- 在工作流边界打印日志,不要每行都打日志。
- 让故障可观测:日志中应包含请求 ID、用户 ID、操作、资源 ID、耗时等关键字段。
## 业务代码禁用项
- 在 common 时间运行时之外直接使用 `time.Now()`
- 直接使用 `fmt.Println``log.Println` 或临时日志方案。
- Handler 直接返回 Gin JSON 响应。
- 业务 API 使用 path/query 参数绑定。
- Service import Gin。
- Handler import GORM。
- DAO 禁止 import service 或 handler。
- 用字符串拼接 SQL 并混入用户输入。

View File

@@ -0,0 +1,373 @@
# 自包含 Common Runtime
当目标项目没有等价的本地基础能力时,使用这些模板。不要依赖外部 common 仓库或某台机器上的固定路径。应用项目优先使用 `internal/common`;只有当模块确实需要向其他模块暴露这些辅助函数时,才使用 `pkg/common`
## 包结构
```text
internal/common/
app_error.go
codes.go
response.go
time.go
logging.go
request_context.go
```
## 错误码与 AppError
```go
package common
import (
"errors"
"fmt"
)
const (
CodeSuccess = 0
CodeParamError = 1001
CodeValidationFail = 1002
CodeUnauthorized = 1003
CodeForbidden = 1004
CodeNotFound = 1005
CodeTimeout = 1006
CodeServerError = 1007
CodeDuplicate = 1008
CodeOperationFail = 1009
CodeBusinessError = 2001
CodeDataNotReady = 2002
CodeStatusInvalid = 2003
CodeDependencyError = 2004
CodeExternalAPIError = 2005
CodeResourceLocked = 2006
CodeQuotaExceeded = 2007
CodeConcurrentConflict = 2008
)
var CodeMessage = map[int]string{
CodeSuccess: "success",
CodeParamError: "参数错误",
CodeValidationFail: "数据验证失败",
CodeUnauthorized: "未授权,请先登录",
CodeForbidden: "权限不足,禁止访问",
CodeNotFound: "资源不存在",
CodeTimeout: "请求超时",
CodeServerError: "服务器内部错误",
CodeDuplicate: "数据重复",
CodeOperationFail: "操作失败",
CodeBusinessError: "业务处理失败",
CodeDataNotReady: "数据未就绪",
CodeStatusInvalid: "状态不合法",
CodeDependencyError: "依赖服务错误",
CodeExternalAPIError: "外部服务调用失败",
CodeResourceLocked: "资源被锁定",
CodeQuotaExceeded: "配额超限",
CodeConcurrentConflict: "并发冲突",
}
// AppError 是 service 层向 handler 层暴露的稳定错误契约。
// DAO 返回底层错误service 负责转换为 AppError。
type AppError struct {
Code int
Message string
Cause error
Fields map[string]any
}
func NewAppError(code int, message string) *AppError {
if message == "" {
message = CodeMessage[code]
}
return &AppError{Code: code, Message: message}
}
func WrapAppError(code int, message string, cause error) *AppError {
appErr := NewAppError(code, message)
appErr.Cause = cause
return appErr
}
func (e *AppError) Error() string {
if e == nil {
return ""
}
if e.Cause == nil {
return fmt.Sprintf("%d:%s", e.Code, e.Message)
}
return fmt.Sprintf("%d:%s: %v", e.Code, e.Message, e.Cause)
}
func (e *AppError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
func AsAppError(err error) (*AppError, bool) {
var appErr *AppError
if errors.As(err, &appErr) {
return appErr, true
}
return nil, false
}
func ToAppError(err error) *AppError {
if err == nil {
return nil
}
if appErr, ok := AsAppError(err); ok {
return appErr
}
return WrapAppError(CodeServerError, CodeMessage[CodeServerError], err)
}
```
## 统一响应
```go
package common
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Response 是所有 API 的统一响应体。失败时 Data 必须为 nil。
type Response struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data"`
Timestamp string `json:"timestamp"`
RequestID string `json:"request_id"`
}
type PageResponse struct {
List any `json:"list"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
func ResponseSuccess(c *gin.Context, data any) {
respond(c, http.StatusOK, CodeSuccess, CodeMessage[CodeSuccess], data)
}
func ResponseSuccessWithMessage(c *gin.Context, data any, message string) {
respond(c, http.StatusOK, CodeSuccess, message, data)
}
func ResponseError(c *gin.Context, code int, message string) {
respond(c, httpStatusByCode(code), code, message, nil)
}
func ResponseAppError(c *gin.Context, err error) {
appErr := ToAppError(err)
Error(c.Request.Context(), appErr.Message, "code", appErr.Code, "error", appErr.Cause)
respond(c, httpStatusByCode(appErr.Code), appErr.Code, appErr.Message, nil)
}
func respond(c *gin.Context, httpStatus int, code int, message string, data any) {
if message == "" {
message = CodeMessage[code]
}
c.JSON(httpStatus, Response{
Code: code,
Message: message,
Data: data,
Timestamp: FormatTime(Now()),
RequestID: RequestIDFromGin(c),
})
}
func httpStatusByCode(code int) int {
switch code {
case CodeUnauthorized:
return http.StatusUnauthorized
case CodeForbidden:
return http.StatusForbidden
case CodeParamError, CodeValidationFail:
return http.StatusBadRequest
case CodeNotFound:
return http.StatusNotFound
default:
return http.StatusOK
}
}
```
## 请求上下文
```go
package common
import (
"context"
"github.com/gin-gonic/gin"
)
type contextKey string
const (
ContextKeyRequestID contextKey = "request_id"
ContextKeyUserID contextKey = "user_id"
ContextKeyUsername contextKey = "username"
ContextKeyRole contextKey = "role"
)
func WithRequestID(ctx context.Context, requestID string) context.Context {
return context.WithValue(ctx, ContextKeyRequestID, requestID)
}
func RequestIDFromContext(ctx context.Context) string {
if value, ok := ctx.Value(ContextKeyRequestID).(string); ok {
return value
}
return ""
}
func RequestIDFromGin(c *gin.Context) string {
if value, exists := c.Get(string(ContextKeyRequestID)); exists {
if requestID, ok := value.(string); ok {
return requestID
}
}
return RequestIDFromContext(c.Request.Context())
}
func UserIDFromContext(ctx context.Context) int64 {
if value, ok := ctx.Value(ContextKeyUserID).(int64); ok {
return value
}
return 0
}
```
## Asia/Shanghai 时间
```go
package common
import "time"
const TimeFormat = time.RFC3339
var shanghaiLocation = mustLoadShanghaiLocation()
func mustLoadShanghaiLocation() *time.Location {
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return time.FixedZone("Asia/Shanghai", 8*60*60)
}
return location
}
// Now 返回东八区当前时间。只有 common/time.go 允许直接调用 time.Now。
func Now() time.Time {
return time.Now().In(shanghaiLocation)
}
func FormatTime(t time.Time) string {
return t.In(shanghaiLocation).Format(TimeFormat)
}
func ParseTime(value string) (time.Time, error) {
parsed, err := time.Parse(TimeFormat, value)
if err != nil {
return time.Time{}, err
}
return parsed.In(shanghaiLocation), nil
}
func StartOfDay(t time.Time) time.Time {
local := t.In(shanghaiLocation)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, shanghaiLocation)
}
func EndOfDay(t time.Time) time.Time {
return StartOfDay(t).Add(24*time.Hour - time.Nanosecond)
}
```
## 结构化日志
```go
package common
import (
"context"
"fmt"
"log/slog"
"os"
"strings"
)
var logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
func InitLogger(debug bool) {
level := slog.LevelInfo
if debug {
level = slog.LevelDebug
}
logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
}
func Debug(ctx context.Context, message string, fields ...any) {
logger.DebugContext(ctx, message, appendContextFields(ctx, sanitizeFields(fields)...)...)
}
func Info(ctx context.Context, message string, fields ...any) {
logger.InfoContext(ctx, message, appendContextFields(ctx, sanitizeFields(fields)...)...)
}
func Warn(ctx context.Context, message string, fields ...any) {
logger.WarnContext(ctx, message, appendContextFields(ctx, sanitizeFields(fields)...)...)
}
func Error(ctx context.Context, message string, fields ...any) {
logger.ErrorContext(ctx, message, appendContextFields(ctx, sanitizeFields(fields)...)...)
}
func appendContextFields(ctx context.Context, fields ...any) []any {
if requestID := RequestIDFromContext(ctx); requestID != "" {
fields = append(fields, "request_id", requestID)
}
if userID := UserIDFromContext(ctx); userID > 0 {
fields = append(fields, "user_id", userID)
}
return fields
}
func sanitizeFields(fields []any) []any {
sanitized := make([]any, 0, len(fields))
for i := 0; i < len(fields); i += 2 {
key := fields[i]
if i+1 >= len(fields) {
sanitized = append(sanitized, key)
break
}
value := fields[i+1]
if isSensitiveKey(key) {
value = "***REDACTED***"
}
sanitized = append(sanitized, key, value)
}
return sanitized
}
func isSensitiveKey(key any) bool {
name := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(fmt.Sprint(key), "_", "")))
sensitive := []string{"password", "passwd", "token", "authorization", "secret", "privatekey", "apikey", "cookie"}
for _, item := range sensitive {
if strings.Contains(name, item) {
return true
}
}
return false
}
```

View File

@@ -0,0 +1,226 @@
# 数据库设计模式
当任务涉及 GORM、DAO 设计、事务、迁移或多数据库时,读取本文件。
## 通用规则
- DAO 负责所有 GORM 调用。
- DAO 返回底层错误,不把错误转换成响应码。
- Service 将 DAO 错误映射为 `common.AppError` 或明确的领域错误。
- Handler 禁止 import `gorm`,也禁止检查 `gorm.ErrRecordNotFound`
- 每个 GORM 操作都必须使用 `WithContext(ctx)`
- 可能参与事务的 DAO 方法必须接收 `tx *gorm.DB`
- DAO 禁止调用 `Transaction`;事务由 Service 通过 Unit of Work 开启。
- 同一个事务工作流中禁止混用事务 DAO 调用和非事务 DAO 调用。
- 更新操作必须显式指定字段,避免直接保存完整 request DTO。
- 列表 API 必须使用分页限制和稳定排序。
## 单数据库设计
当模块所有数据都位于同一个物理数据库,并且一个一致性边界足够时,使用单数据库设计。
```text
internal/db/
database.go
internal/dao/
user_dao.go
internal/service/
user_service.go
```
```go
package db
import (
"context"
"gorm.io/gorm"
)
// UnitOfWork 负责单数据库事务边界。
type UnitOfWork struct {
db *gorm.DB
}
func NewUnitOfWork(db *gorm.DB) *UnitOfWork {
return &UnitOfWork{db: db}
}
func (u *UnitOfWork) DB() *gorm.DB {
return u.db
}
func (u *UnitOfWork) Transaction(ctx context.Context, fn func(tx *gorm.DB) error) error {
return u.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return fn(tx)
})
}
```
Service 构造函数接收 `*db.UnitOfWork` 和需要的 DAO。非事务读取传入 `nil` tx事务写入必须把 Unit of Work 提供的 `tx` 传给每个 DAO 方法。
## 多数据库设计
当模块需要读写多个物理数据库,例如 `core``user``audit``ci``billing`,使用多数据库设计。
```go
package db
import (
"context"
"fmt"
"gorm.io/gorm"
)
type DatabaseName string
const (
DBCore DatabaseName = "core"
DBUser DatabaseName = "user"
DBAudit DatabaseName = "audit"
DBCI DatabaseName = "ci"
)
type Manager struct {
dbs map[DatabaseName]*gorm.DB
}
func NewManager(dbs map[DatabaseName]*gorm.DB) *Manager {
return &Manager{dbs: dbs}
}
func (m *Manager) DB(name DatabaseName) (*gorm.DB, error) {
db, ok := m.dbs[name]
if !ok || db == nil {
return nil, fmt.Errorf("database %s is not configured", name)
}
return db, nil
}
func (m *Manager) Transaction(ctx context.Context, name DatabaseName, fn func(tx *gorm.DB) error) error {
db, err := m.DB(name)
if err != nil {
return err
}
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return fn(tx)
})
}
```
多数据库约束:
- 每个 DAO 必须在构造函数或文件注释中声明数据库归属。
- 一个事务只能覆盖一个物理数据库。
- 禁止用嵌套 GORM transaction 伪造跨库事务。
- 跨库工作流应使用幂等键、outbox 表、重试、对账或显式补偿动作。
- 除非业务明确要求审计先落库再返回成功,否则审计写入失败不应破坏主业务事务。
## 带 tx 参数的 DAO
```go
package dao
import (
"context"
"gorm.io/gorm"
)
type UserDAO struct {
db *gorm.DB
}
func NewUserDAO(db *gorm.DB) *UserDAO {
return &UserDAO{db: db}
}
func (d *UserDAO) session(ctx context.Context, tx *gorm.DB) *gorm.DB {
if tx != nil {
return tx.WithContext(ctx)
}
return d.db.WithContext(ctx)
}
// Create 创建用户。事务内调用必须传入 tx非事务调用传 nil。
func (d *UserDAO) Create(ctx context.Context, tx *gorm.DB, user *User) error {
return d.session(ctx, tx).Create(user).Error
}
// FindByID 根据用户ID查询用户。未找到时返回 GORM 原始错误。
func (d *UserDAO) FindByID(ctx context.Context, tx *gorm.DB, userID int64) (*User, error) {
var user User
if err := d.session(ctx, tx).First(&user, "id = ?", userID).Error; err != nil {
return nil, err
}
return &user, nil
}
```
上方 `User` 类型代表本地 entity 类型。实际项目中 entity 应放在 `internal/model/entity`;这里为了突出 TxDAO 规则而缩短示例。
## Service 层事务模式
```go
func (s *UserService) CreateUser(ctx context.Context, req *dto.CreateUserRequest) (*dto.UserDTO, error) {
var created *entity.User
err := s.uow.Transaction(ctx, func(tx *gorm.DB) error {
exists, err := s.userDAO.ExistsByUsername(ctx, tx, req.Username)
if err != nil {
return common.WrapAppError(common.CodeServerError, "检查用户名失败", err)
}
if exists {
return common.NewAppError(common.CodeDuplicate, "用户名已存在")
}
user := &entity.User{Username: req.Username, Email: req.Email}
if err := s.userDAO.Create(ctx, tx, user); err != nil {
return common.WrapAppError(common.CodeServerError, "创建用户失败", err)
}
created = user
return nil
})
if err != nil {
return nil, err
}
common.Info(ctx, "用户创建成功", "user_id", created.ID, "username", created.Username)
return mapper.ToUserDTO(created), nil
}
```
## GORM 错误映射
推荐在 Service 中完成错误映射:
```go
func mapUserLookupError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return common.WrapAppError(common.CodeNotFound, "用户不存在", err)
}
return common.WrapAppError(common.CodeServerError, "查询用户失败", err)
}
```
各层 GORM import 规则:
| 层级 | 是否允许 import GORM | 原因 |
|------|----------------------|------|
| Handler | 不允许 | Handler 只应知道 HTTP、DTO、Service、common 统一响应 |
| Service | 有限允许 | 用于错误映射和 Unit of Work 事务类型 |
| DAO | 允许 | DAO 拥有 GORM 持久化访问 |
| Entity | 允许 | 用于 GORM tags |
| Common runtime | 默认不允许 | 仅当 DB/UnitOfWork 辅助能力被明确放在此处时例外 |
## 数据迁移规则
- 禁止在请求路径中随意调用 `AutoMigrate`
- 数据迁移只能在启动阶段或专用迁移命令中执行。
- 生产 schema 变更应具备幂等性,经过 review并尽量可回滚。
- 索引设计必须结合查询模式、唯一性约束和分页方式。
- 大表回填必须分批执行,并保证过程可观测。

View File

@@ -0,0 +1,153 @@
# Gin 与 GORM 使用规范
## Gin
### 路由分组
使用 router group 表达模块边界,并且业务路由只能注册为 `POST`
```go
func RegisterRoutes(r *gin.Engine, userHandler *UserHandler, auditHandler *AuditHandler) {
api := r.Group("/api")
{
users := api.Group("/users")
{
users.POST("/list", userHandler.ListUsers)
users.POST("/detail", userHandler.GetUserDetail)
users.POST("/create", userHandler.CreateUser)
users.POST("/update", userHandler.UpdateUser)
users.POST("/delete", userHandler.DeleteUser)
}
audit := api.Group("/audit")
{
audit.POST("/logs/list", auditHandler.ListLogs)
audit.POST("/logs/export", auditHandler.ExportLogs)
}
}
}
```
### Handler 规则
允许使用:
- `ShouldBindJSON`
- `c.Request.Context()`
- `common.ResponseSuccess`
- `common.ResponseAppError`
禁止使用:
- Path parameters
- Query parameters
- 直接调用 Gin 响应方法
- GORM imports
- 业务决策
- 数据库调用
### 中间件Middleware
横切 middleware 只注册一次,让业务 handler 保持小而清晰。
```go
func SetupMiddleware(r *gin.Engine, cfg Config, auditWriter AuditWriter) {
r.Use(common.Recovery())
r.Use(common.RequestIDMiddleware())
r.Use(common.CORSMiddleware(cfg.CORS))
r.Use(common.RateLimitMiddleware(cfg.RateLimit))
r.Use(common.AccessLogMiddleware())
r.Use(common.AuditMiddleware(auditWriter))
}
```
受保护路由组示例:
```go
users := api.Group("/users")
users.Use(common.AuthMiddleware(jwtSecret))
{
users.POST("/list", userHandler.ListUsers)
users.POST("/detail", userHandler.GetUserDetail)
}
adminUsers := api.Group("/admin/users")
adminUsers.Use(common.AuthMiddleware(jwtSecret), common.RequireAdmin())
{
adminUsers.POST("/create", userHandler.CreateUser)
adminUsers.POST("/delete", userHandler.DeleteUser)
}
```
## GORM
### 仅 DAO 操作 GORM
所有 GORM 调用必须放在 DAO 文件中。Service 只有在事务类型和基础设施错误映射需要时,才有限接触 GORM。
```go
func (d *UserDAO) List(ctx context.Context, tx *gorm.DB, req *dto.ListUsersRequest) ([]*entity.User, int64, error) {
session := d.session(ctx, tx).Model(&entity.User{})
if req.Keyword != "" {
session = session.Where("username ILIKE ?", "%"+req.Keyword+"%")
}
if req.Status != "" {
session = session.Where("status = ?", req.Status)
}
var total int64
if err := session.Count(&total).Error; err != nil {
return nil, 0, err
}
var users []*entity.User
offset := (req.Page - 1) * req.PageSize
if err := session.Order("created_at DESC").Limit(req.PageSize).Offset(offset).Find(&users).Error; err != nil {
return nil, 0, err
}
return users, total, nil
}
```
### 原生 SQLRaw SQL
只在 DAO 中为复杂 SQL、性能敏感查询或数据库特性使用 Raw/Exec。参数必须绑定禁止把不可信输入拼接进 SQL。
```go
func (d *UserDAO) CountActiveUsersByRole(ctx context.Context, tx *gorm.DB) ([]RoleCount, error) {
var rows []RoleCount
err := d.session(ctx, tx).Raw(`
SELECT role, COUNT(*) AS count
FROM users
WHERE status = ?
GROUP BY role
`, "active").Scan(&rows).Error
return rows, err
}
```
### 事务
Unit of Work 和 TxDAO 规则详见 `reference/database-patterns.md`。简版规则如下:
- Service 开启事务。
- DAO 接收 `tx *gorm.DB`
- 事务内每个 DAO 调用都使用同一个 `tx`
- DAO 永远不主动开启事务。
### 上下文Context
每个 GORM 调用都必须使用 request context
```go
d.session(ctx, tx).Create(entity)
d.session(ctx, tx).First(&entity.User{}, "id = ?", userID)
```
### 更新规则
- 使用 `Updates(map[string]any{...})` 或显式更新 DTO 映射。
- 禁止直接持久化 request DTO。
- 局部更新禁止使用 `Save`
- 状态流转必须先在 Service 中校验,再交给 DAO 更新。

View File

@@ -0,0 +1,95 @@
# 日志规范
只能使用 `reference/common-runtime.md` 中定义的本地 common 日志函数。
## 日志 API
```go
common.InitLogger(debug)
common.Debug(ctx, "调试信息", "key", value)
common.Info(ctx, "业务节点", "key", value)
common.Warn(ctx, "可预期异常", "key", value)
common.Error(ctx, "不可恢复错误", "key", value, "error", err)
```
Debug 模式:
- 开发环境或排障场景可以通过配置或环境变量开启 debug 模式。
- 生产环境默认使用 info 级别。
- Debug 日志也必须进行敏感字段脱敏。
## 日志级别规则
| 级别 | 使用场景 | 示例 |
|------|----------|------|
| Debug | 本地诊断和详细流程 | 解析后的筛选条件、分支决策、缓存命中/未命中 |
| Info | 成功的重要业务事件 | 登录成功、用户创建、权限分配 |
| Warn | 可预期但异常的情况 | 参数校验失败、权限不足、限流触发 |
| Error | 当前工作流发生非预期失败 | 数据库错误、外部 API 失败、事务回滚 |
## 必要字段
可获得时应包含这些字段:
- `request_id`
- `user_id`
- `username`
- `action`
- `resource_type`
- `resource_id`
- `elapsed_ms`
- `error`
- `external_service`
common logger 应在可获得时自动从上下文附加请求 ID 和用户 ID。
## 各层日志职责
| 层级 | 日志职责 |
|------|----------|
| Handler | 通常不主动打日志,参数绑定失败通过 common response 统一处理 |
| Middleware | 请求开始/结束、认证失败、权限拒绝、限流、审计 |
| Service | 业务成功、业务拒绝、事务失败 |
| DAO | 默认不打常规日志,错误向上返回 |
| External adapter | 请求失败、超时、重试、降级兜底 |
## 必打日志
以下场景必须打印日志:
- 应用启动和 debug 模式状态,禁止包含 secret。
- 数据库连接成功/失败,禁止包含密码。
- 登录成功/失败,失败日志使用脱敏用户名。
- 注册、改密、用户状态变更。
- 权限分配、撤销、复制、角色变更。
- 管理员拒绝或权限拒绝。
- 外部调用失败,包含耗时和脱敏后的错误。
- 批处理结果,包含聚合计数。
- 事务回滚,包含 action 和资源标识。
## 敏感数据
禁止记录:
- 密码或密码 hash。
- Token、Authorization 头、Cookie、Refresh token。
- 私钥、Secret、API key。
- MFA secret 或验证码。
- 可能包含 secret 的原始请求体。
使用脱敏后的标识:
```go
common.Warn(ctx, "用户登录失败",
"username", maskUsername(req.Username),
"ip", clientIP,
"reason", "invalid_credential",
)
```
## 反模式
- 禁止使用 `fmt.Println``log.Println` 或临时全局 logger。
- 禁止在紧密循环中逐条打印日志,应使用聚合计数。
- 禁止每层重复记录并返回同一个错误;只在拥有有效上下文的边界打印一次。
- 普通校验失败或权限失败不打印 stack trace。

View File

@@ -0,0 +1,67 @@
# 项目结构规范
除非目标仓库已经有兼容的本地约定,否则优先使用以下结构。生成代码必须自包含在目标 module 内。
```text
cmd/
server/
internal/
common/ # response, AppError, time, logging, request context
config/
db/ # 单数据库 UnitOfWork 或多数据库 Manager
handler/ # Gin handler绑定 DTO、调用 service、返回响应
middleware/ # auth、admin、permission、request ID、audit、rate limit、CORS
service/ # 业务编排、AppError 映射、事务、日志
dao/ # 仅负责 GORM 持久化
model/
dto/ # request/response DTO
entity/ # GORM entity
mapper/ # entity <-> DTO 转换
configs/
scripts/
```
## 依赖方向
```text
handler -> service -> dao -> entity
| | |
+----------+--------+-> common
middleware -> common
middleware -> service 仅在权限/认证逻辑确实需要业务数据时允许
mapper -> dto + entity
```
禁止依赖:
- `dao -> service`
- `dao -> handler`
- `service -> handler`
- `entity -> dto`
- `handler -> gorm`
- `handler -> dao`
- `common -> project business packages`
## 分层职责
| 层级 | 负责 | 禁止负责 |
|------|------|----------|
| Handler | JSON 绑定、Service 调用、common 统一响应 | 业务规则、GORM、事务 |
| Service | 业务规则、AppError 映射、事务、业务日志 | Gin context、HTTP 响应 |
| DAO | GORM 查询和持久化 | 业务决策、响应码 |
| Entity | 数据库结构和 GORM tags | API 展示结构 |
| DTO | API 请求/响应结构 | 数据库 tags 和持久化行为 |
| Mapper | Entity/DTO 转换 | 数据库调用 |
| Common | 横切基础能力 | 领域业务行为 |
## 新模块检查清单
- 先创建 DTO再写 handler。
- 只有需要持久化时才创建或复用 entity。
- 对外返回 entity 数据时必须创建 mapper。
- 每个持久化操作都通过 DAO 方法封装。
- Service 方法负责将 DAO 错误映射为 `common.AppError`
- Handler 方法只绑定 JSON 并调用 Service。
- 只注册带 action 后缀的 `POST` 路由。
- 为变更文件补充验证脚本覆盖。

View File

@@ -0,0 +1,170 @@
# 安全与审计规则
认证、授权、JWT claims、管理员校验、审计日志、限流、CORS、敏感数据处理相关任务需要读取本文件。
## 中间件Middleware顺序
按以下顺序注册 middleware
```text
Recovery -> RequestID -> CORS -> RateLimit -> AccessLog -> Auth -> RequireAdmin/RequirePermission -> Audit
```
规则:
- 公开认证接口可以跳过 `Auth`,但登录/注册尝试仍应使用 RequestID、CORS、RateLimit、AccessLog 和 Audit。
- 受保护接口必须使用 `Auth`
- 管理员接口必须先使用 `Auth`,再使用 `RequireAdmin`
- 权限接口必须先使用 `Auth`,再使用 `RequirePermission`
- 变更数据的接口必须创建审计事件。
## JWT 声明Claims
使用本地 claims 类型。原始 token 字符串不得离开认证中间件。
```go
type Claims struct {
UserID int64 `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
}
```
Auth middleware 职责:
- 只读取 `Authorization: Bearer <token>` 请求头。
- 校验签名、过期时间,以及配置要求的 issuer/audience。
-`user_id``username``role` 写入 Gin 上下文和请求上下文。
- token 缺失、格式错误、过期、非法时返回 `CodeUnauthorized`
- 日志只记录请求 ID、脱敏用户名、IP、路径、失败原因禁止记录 token 内容。
## 管理员与权限校验
```go
func RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
role, _ := c.Get("role")
if role != "admin" && role != "superadmin" {
common.ResponseError(c, common.CodeForbidden, "需要管理员权限")
c.Abort()
return
}
c.Next()
}
}
```
权限 middleware 必须:
- 从 JSON body DTO 或上下文读取权限输入,禁止从路径参数或查询参数读取。
- 资源标识缺失时 fail closed。
- 访问被拒绝时返回 `CodeForbidden`
- 使用 warn 级别记录拒绝日志,字段包含 `user_id``resource_type``resource_id``action``request_id`
## 敏感数据脱敏
禁止记录以下值:
- 密码、密码 hash、旧密码、新密码
- Token、刷新 token、authorization 请求头、cookie
- 私钥、secret、API key、access key
- MFA secret、验证码
- 非必要场景下的完整手机号或邮箱
推荐日志字段:
```go
common.Info(ctx, "用户登录成功", "user_id", user.ID, "username", user.Username, "ip", clientIP)
common.Warn(ctx, "用户登录失败", "username", maskUsername(req.Username), "ip", clientIP, "reason", "invalid_password")
```
## 审计事件
以下操作必须审计:
| 分类 | 事件 |
|------|------|
| 认证 | 登录成功、登录失败、退出登录、token 刷新、密码修改 |
| 用户 | 注册、创建用户、更新用户、禁用用户、删除用户 |
| 权限 | 分配权限、复制权限、撤销权限、角色变更 |
| 安全 | 管理员访问拒绝、权限拒绝、限流触发 |
| 外部影响 | 触发构建、部署、同步、导出、删除远端资源 |
审计记录最小字段:
```go
type AuditEvent struct {
RequestID string
UserID int64
Username string
Action string
ResourceType string
ResourceID string
Result string
Reason string
IP string
UserAgent string
CreatedAt time.Time
}
```
审计规则:
- 审计记录不得包含密码、token、私钥或请求体 secret。
- 用户 lookup 前发生登录失败时,记录脱敏用户名和 IP。
- 权限变更必须记录操作者、目标用户、资源、可获得的旧值和新值。
- 审计写入失败时必须记录 error是否中断请求由具体业务域决定。
## 限流
至少为以下接口增加限流:
- 登录/注册/重置密码接口
- Token refresh 接口
- 导出接口
- 高成本列表/搜索接口
- 外部触发类接口
默认建议:
| API 类型 | 默认值 |
|----------|--------|
| 登录 | 每 IP + 每用户名每分钟 5 次 |
| 注册 | 每 IP 每分钟 10 次 |
| 导出 | 每用户每分钟 3 次 |
| 外部触发 | 每用户每分钟 10 次 |
| 列表/搜索 | 每用户每分钟 60 次 |
单实例可以使用内存限流;多实例必须使用 Redis 或其他共享存储。
## CORS
CORS 规则:
- 生产环境禁止使用 wildcard origin。
- 只允许配置中的前端来源。
- 只允许必要方法;业务 API 应只允许 `POST``OPTIONS`
- 允许 `Authorization``Content-Type` 和请求 ID 请求头。
- 除非明确使用 cookie否则保持凭证携带能力关闭。
## 密码与 Token 处理
- 使用 bcrypt、argon2 或项目批准的密码哈希函数存储密码,禁止明文存储。
- 使用库提供的恒定时间比较能力校验密码 hash。
- JWT signing secret 必须放在代码和示例配置之外。
- Secret rotate 必须有明确运维方案。
- 登录失败时不要暴露到底是用户名错误还是密码错误,返回统一认证失败信息。
## 必要日志
必须打印这些日志:
- 启动配置摘要,禁止包含 secret。
- 数据库连接成功/失败,禁止包含密码。
- 认证成功/失败,必须脱敏。
- 权限拒绝和管理员拒绝。
- 数据变更操作成功,包含资源标识。
- 外部服务调用失败,包含 endpoint 名称、耗时和脱敏错误。
- 事务失败,包含操作、可获得的 entity ID 和请求 ID。
循环中禁止逐行打印噪声日志,应使用聚合计数。

View File

@@ -0,0 +1,66 @@
# 时间处理规范
时间是硬一致性规则,不是展示层偏好。
## 强制标准
- 时区:`Asia/Shanghai` (`UTC+08:00`)
- API 格式:带 offset 的 RFC3339例如 `2026-06-29T10:30:00+08:00`
- 存储类型:`time.Time`
- 运行时函数:本地 `common.Now()``common.FormatTime()``common.ParseTime()`
## 禁止项
```go
time.Now()
time.Parse(layout, value)
t.Format(layout)
```
除本地 common 时间运行时外,禁止直接使用上述调用。应使用:
```go
now := common.Now()
timestamp := common.FormatTime(now)
parsed, err := common.ParseTime(req.StartTime)
```
## GORM 实体Entity
```go
type User struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
ExpiresAt time.Time `json:"expires_at"`
}
```
规则:
- 持久化字段允许使用 GORM auto timestamps。
- 业务时间必须使用 `common.Now()` 赋值。
- API 响应中的时间由 mapper 统一格式化。
- Handler 禁止手动格式化时间。
## Mapper 示例
```go
func ToUserDTO(user *entity.User) *dto.UserDTO {
if user == nil {
return nil
}
return &dto.UserDTO{
ID: user.ID,
Username: user.Username,
CreatedAt: common.FormatTime(user.CreatedAt),
UpdatedAt: common.FormatTime(user.UpdatedAt),
}
}
```
## 数据库配置
- PostgreSQL 部署应显式设置服务端/会话时区。
- 应用代码仍负责把 API 输出转换为 Asia/Shanghai。
- 禁止依赖宿主机本地时区。

View File

@@ -0,0 +1,21 @@
param(
[string]$ProjectRoot = "."
)
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$Validator = Join-Path $ScriptDir "validate_go_gin_gorm.py"
$Python = Get-Command python -ErrorAction SilentlyContinue
if ($Python) {
& $Python.Source $Validator $ProjectRoot
exit $LASTEXITCODE
}
$PyLauncher = Get-Command py -ErrorAction SilentlyContinue
if ($PyLauncher) {
& $PyLauncher.Source -3 $Validator $ProjectRoot
exit $LASTEXITCODE
}
Write-Error "Python 3 is required to run the validator."
exit 2

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
PROJECT_ROOT=${1:-.}
if command -v python3 >/dev/null 2>&1; then
exec python3 "$SCRIPT_DIR/validate_go_gin_gorm.py" "$PROJECT_ROOT"
fi
if command -v python >/dev/null 2>&1; then
exec python "$SCRIPT_DIR/validate_go_gin_gorm.py" "$PROJECT_ROOT"
fi
echo "Python 3 is required to run the validator." >&2
exit 2

View File

@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""Cross-platform validator for the developing-go-gin-gorm skill."""
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
SKIP_DIRS = {
".git",
".idea",
".vscode",
"vendor",
"node_modules",
"dist",
"build",
"tmp",
}
FORBIDDEN_ROUTE_METHOD = re.compile(r"\.\s*(GET|PUT|PATCH|DELETE|HEAD|OPTIONS)\s*\(")
FORBIDDEN_GIN_INPUT = re.compile(r"\.\s*(Param|Query|DefaultQuery|ShouldBindQuery)\s*\(")
FORBIDDEN_DIRECT_RESPONSE = re.compile(r"\.\s*(JSON|AbortWithStatusJSON|String|XML|YAML)\s*\(")
FORBIDDEN_TIME = re.compile(r"\btime\s*\.\s*(Now|Parse)\s*\(")
FORBIDDEN_PRINT = re.compile(r"\b(fmt\.Println|log\.Println|println)\s*\(")
@dataclass
class Finding:
severity: str
path: Path
line: int
message: str
def render(self, root: Path) -> str:
rel = self.path.relative_to(root) if self.path.is_relative_to(root) else self.path
return f"[{self.severity}] {rel}:{self.line}: {self.message}"
def iter_files(root: Path, suffixes: set[str]) -> list[Path]:
files: list[Path] = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [name for name in dirnames if name not in SKIP_DIRS]
base = Path(dirpath)
for filename in filenames:
path = base / filename
if path.suffix in suffixes:
files.append(path)
return sorted(files)
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8", errors="replace")
def is_under(path: Path, *names: str) -> bool:
parts = path.parts
if len(parts) < len(names):
return False
for i in range(0, len(parts) - len(names) + 1):
if parts[i : i + len(names)] == names:
return True
return False
def is_common_runtime(path: Path) -> bool:
return is_under(path, "internal", "common") or is_under(path, "pkg", "common")
def is_handler(path: Path) -> bool:
return "handler" in path.parts
def is_service(path: Path) -> bool:
return "service" in path.parts
def is_dao(path: Path) -> bool:
return "dao" in path.parts or "repository" in path.parts
def scan_text(root: Path, path: Path, text: str) -> list[Finding]:
findings: list[Finding] = []
lines = text.splitlines()
for index, line in enumerate(lines, start=1):
if FORBIDDEN_ROUTE_METHOD.search(line):
findings.append(Finding("ERROR", path, index, "business routes must use POST only"))
if FORBIDDEN_GIN_INPUT.search(line):
findings.append(Finding("ERROR", path, index, "business APIs must bind JSON body, not path/query input"))
if FORBIDDEN_DIRECT_RESPONSE.search(line) and not is_common_runtime(path):
findings.append(Finding("ERROR", path, index, "use common response helpers instead of direct Gin responses"))
if FORBIDDEN_TIME.search(line) and not is_common_runtime(path):
findings.append(Finding("ERROR", path, index, "use common.Now/common.ParseTime instead of direct time calls"))
if FORBIDDEN_PRINT.search(line):
findings.append(Finding("ERROR", path, index, "use common structured logging instead of print/log.Println"))
if is_dao(path) and ".Transaction(" in line:
findings.append(Finding("ERROR", path, index, "DAO must not start transactions; service owns Unit of Work"))
if is_handler(path) and "gorm.io/" in text:
findings.append(Finding("ERROR", path, 1, "handler must not import GORM"))
if is_handler(path) and "/dao" in text:
findings.append(Finding("ERROR", path, 1, "handler must not import DAO directly"))
if is_service(path) and "github.com/gin-gonic/gin" in text:
findings.append(Finding("ERROR", path, 1, "service must not import Gin"))
if is_dao(path) and ("/service" in text or "/handler" in text):
findings.append(Finding("ERROR", path, 1, "DAO must not import service or handler"))
return findings
def scan_crlf(root: Path) -> list[Finding]:
findings: list[Finding] = []
for path in iter_files(root, {".go", ".md", ".sh", ".py", ".ps1", ".bat"}):
data = path.read_bytes()
if b"\r\n" in data:
findings.append(Finding("ERROR", path, 1, "file uses CRLF; use LF to keep scripts portable"))
return findings
def run_gofmt(root: Path, go_files: list[Path]) -> list[Finding]:
if not go_files or shutil.which("gofmt") is None:
return []
findings: list[Finding] = []
chunk_size = 100
for start in range(0, len(go_files), chunk_size):
chunk = go_files[start : start + chunk_size]
result = subprocess.run(
["gofmt", "-l", *[str(path) for path in chunk]],
cwd=root,
text=True,
capture_output=True,
check=False,
)
if result.returncode != 0:
findings.append(Finding("ERROR", root, 1, f"gofmt failed: {result.stderr.strip()}"))
continue
for filename in result.stdout.splitlines():
findings.append(Finding("ERROR", Path(filename), 1, "file is not gofmt-formatted"))
return findings
def run_go_test(root: Path) -> list[Finding]:
if shutil.which("go") is None:
return [Finding("WARN", root, 1, "go binary not found; skipped go test")]
if not (root / "go.mod").exists():
return [Finding("WARN", root, 1, "go.mod not found; skipped go test")]
result = subprocess.run(
["go", "test", "./..."],
cwd=root,
text=True,
capture_output=True,
check=False,
)
if result.returncode == 0:
return []
output = (result.stdout + "\n" + result.stderr).strip()
first_line = output.splitlines()[0] if output else "go test failed"
return [Finding("ERROR", root, 1, first_line)]
def validate(root: Path, run_tests: bool, skip_gofmt: bool) -> list[Finding]:
findings: list[Finding] = []
go_files = iter_files(root, {".go"})
findings.extend(scan_crlf(root))
for path in go_files:
findings.extend(scan_text(root, path, read_text(path)))
if not skip_gofmt:
findings.extend(run_gofmt(root, go_files))
if run_tests:
findings.extend(run_go_test(root))
return findings
def main() -> int:
parser = argparse.ArgumentParser(description="Validate Go Gin/GORM engineering rules.")
parser.add_argument("root", nargs="?", default=".", help="project root to validate")
parser.add_argument("--run-go-test", action="store_true", help="also run go test ./...")
parser.add_argument("--skip-gofmt", action="store_true", help="skip gofmt -l check")
args = parser.parse_args()
root = Path(args.root).resolve()
if not root.exists():
print(f"target root does not exist: {root}", file=sys.stderr)
return 2
findings = validate(root, run_tests=args.run_go_test, skip_gofmt=args.skip_gofmt)
errors = [finding for finding in findings if finding.severity == "ERROR"]
warnings = [finding for finding in findings if finding.severity == "WARN"]
for finding in findings:
print(finding.render(root))
if errors:
print(f"\nValidation failed: {len(errors)} error(s), {len(warnings)} warning(s).")
return 1
print(f"Validation passed: 0 error(s), {len(warnings)} warning(s).")
return 0
if __name__ == "__main__":
raise SystemExit(main())