更新 skill 后端开发
This commit is contained in:
91
1-AgentSkills/coding-go-gin-gorm/SKILL.md
Normal file
91
1-AgentSkills/coding-go-gin-gorm/SKILL.md
Normal 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 ./...` 通过;如果因环境限制不能运行,需要明确说明。
|
||||
73
1-AgentSkills/coding-go-gin-gorm/examples/dao-example.go
Normal file
73
1-AgentSkills/coding-go-gin-gorm/examples/dao-example.go
Normal 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
|
||||
}
|
||||
62
1-AgentSkills/coding-go-gin-gorm/examples/handler-example.go
Normal file
62
1-AgentSkills/coding-go-gin-gorm/examples/handler-example.go
Normal 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, "用户创建成功")
|
||||
}
|
||||
79
1-AgentSkills/coding-go-gin-gorm/examples/service-example.go
Normal file
79
1-AgentSkills/coding-go-gin-gorm/examples/service-example.go
Normal 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)
|
||||
}
|
||||
125
1-AgentSkills/coding-go-gin-gorm/reference/api-design-spec.md
Normal file
125
1-AgentSkills/coding-go-gin-gorm/reference/api-design-spec.md
Normal 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 中间件禁止从路径参数或查询参数中提取业务资源标识。
|
||||
- 删除接口在需要业务恢复或审计追溯时优先使用软删除。
|
||||
@@ -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`。
|
||||
@@ -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 并混入用户输入。
|
||||
373
1-AgentSkills/coding-go-gin-gorm/reference/common-runtime.md
Normal file
373
1-AgentSkills/coding-go-gin-gorm/reference/common-runtime.md
Normal 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
|
||||
}
|
||||
```
|
||||
226
1-AgentSkills/coding-go-gin-gorm/reference/database-patterns.md
Normal file
226
1-AgentSkills/coding-go-gin-gorm/reference/database-patterns.md
Normal 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,并尽量可回滚。
|
||||
- 索引设计必须结合查询模式、唯一性约束和分页方式。
|
||||
- 大表回填必须分批执行,并保证过程可观测。
|
||||
153
1-AgentSkills/coding-go-gin-gorm/reference/framework-usage.md
Normal file
153
1-AgentSkills/coding-go-gin-gorm/reference/framework-usage.md
Normal 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
|
||||
}
|
||||
```
|
||||
|
||||
### 原生 SQL(Raw 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 更新。
|
||||
@@ -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。
|
||||
@@ -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` 路由。
|
||||
- 为变更文件补充验证脚本覆盖。
|
||||
170
1-AgentSkills/coding-go-gin-gorm/reference/security-audit.md
Normal file
170
1-AgentSkills/coding-go-gin-gorm/reference/security-audit.md
Normal 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。
|
||||
|
||||
循环中禁止逐行打印噪声日志,应使用聚合计数。
|
||||
66
1-AgentSkills/coding-go-gin-gorm/reference/time-handling.md
Normal file
66
1-AgentSkills/coding-go-gin-gorm/reference/time-handling.md
Normal 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。
|
||||
- 禁止依赖宿主机本地时区。
|
||||
@@ -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
|
||||
@@ -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
|
||||
214
1-AgentSkills/coding-go-gin-gorm/scripts/validate_go_gin_gorm.py
Normal file
214
1-AgentSkills/coding-go-gin-gorm/scripts/validate_go_gin_gorm.py
Normal 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())
|
||||
Reference in New Issue
Block a user