Compare commits

...

8 Commits

16 changed files with 789 additions and 91 deletions

13
.vscode/launch.json vendored
View File

@@ -4,6 +4,19 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "service",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}",
"args": [
"gen",
"service",
"--path",
"/home/rogee/Projects/quyun_v2/backend/app/services"
]
},
{
"name": "provider",
"type": "go",

View File

@@ -8,7 +8,10 @@ import (
"text/template"
"github.com/samber/lo"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"go.ipao.vip/atomctl/v2/pkg/ast/provider"
"go.ipao.vip/atomctl/v2/pkg/utils/gomod"
"go.ipao.vip/atomctl/v2/templates"
)
@@ -19,10 +22,10 @@ func CommandGenService(root *cobra.Command) {
Long: `扫描 --path 指定目录(默认 ./app/services下的 Go 文件,汇总服务名并渲染生成 services.gen.go。
规则:
- 跳过 *_test.go 与 *.gen.go 文件,仅处理普通 .go 文件
- 以文件名作为服务名来源
- PascalCase 作为 CamelName用于导出类型
- camelCase 作为 ServiceName用于变量/字段
- 扫描目录中带有 @provider 注释的结构体
- 以结构体名称作为服务名:
- StructName 作为 ServiceName用于变量/字段类型
- PascalCase(StructName) 作为 CamelName用于导出变量名
- 使用内置模板 services/services.go.tpl 渲染
- 生成完成后会自动运行 gen provider 以补全注入`,
RunE: commandGenServiceE,
@@ -36,34 +39,53 @@ func CommandGenService(root *cobra.Command) {
func commandGenServiceE(cmd *cobra.Command, args []string) error {
path := cmd.Flag("path").Value.String()
files, err := os.ReadDir(path)
absPath, err := filepath.Abs(path)
if err != nil {
return err
}
// Try to parse go.mod from CWD or target path to ensure parser context
wd, _ := os.Getwd()
if err := gomod.Parse(filepath.Join(wd, "go.mod")); err != nil {
// fallback to check if go.mod is in the target path
if err := gomod.Parse(filepath.Join(absPath, "go.mod")); err != nil {
// If both fail, we might still proceed, but parser might lack module info.
// However, for just getting struct names, it might be fine.
// Logging warning could be good but we stick to error if critical.
// provider.ParseDir might depend on it.
}
}
log := log.WithField("path", absPath)
log.Info("finding service providers...")
parser := provider.NewGoParser()
providers, err := parser.ParseDir(absPath)
if err != nil {
return err
}
log.Infof("found %d providers", len(providers))
type srv struct {
CamelName string
ServiceName string
}
// get services from files
// get services from providers
var services []srv
for _, file := range files {
if file.IsDir() {
continue
}
name := file.Name()
for _, p := range providers {
name := filepath.Base(p.Location.File)
if strings.HasSuffix(name, "_test.go") || strings.HasSuffix(name, ".gen.go") ||
!strings.HasSuffix(name, ".go") {
log.Warnf("ignore file %s provider, %+v", p.Location.File, p)
continue
}
name = strings.TrimSuffix(name, ".go")
log.Infof("found service %s", p.StructName)
services = append(services, srv{
CamelName: lo.PascalCase(name),
ServiceName: lo.CamelCase(name),
CamelName: lo.PascalCase(p.StructName),
ServiceName: p.StructName,
})
}

View File

@@ -103,6 +103,9 @@ func (pb *ProviderBuilder) BuildFromTypeSpec(typeSpec *ast.TypeSpec, decl *ast.G
Imports: make(map[string]string),
PkgName: context.PackageName,
ProviderFile: context.FilePath,
Location: SourceLocation{
File: context.FilePath,
},
}
// Set return type

View File

@@ -226,14 +226,26 @@ func (p *MainParser) ParseFile(source string) ([]Provider, error) {
// 查找对应的 AST 节点
provider, err := p.buildProviderFromDiscovery(discoveredProvider, node, builderContext)
if err != nil {
context.AddError(source, 0, 0, fmt.Sprintf("failed to build provider %s: %v", discoveredProvider.StructName, err), "error")
context.AddError(
source,
0,
0,
fmt.Sprintf("failed to build provider %s: %v", discoveredProvider.StructName, err),
"error",
)
continue
}
// 如果启用严格模式,验证 Provider 配置
if p.config.StrictMode {
if err := p.validator.Validate(&provider); err != nil {
context.AddError(source, 0, 0, fmt.Sprintf("validation failed for provider %s: %v", provider.StructName, err), "error")
context.AddError(
source,
0,
0,
fmt.Sprintf("validation failed for provider %s: %v", provider.StructName, err),
"error",
)
continue
}
}
@@ -286,7 +298,11 @@ func (p *MainParser) shouldProcessFile(source string) bool {
}
// buildProviderFromDiscovery builds a complete Provider from a discovered provider annotation
func (p *MainParser) buildProviderFromDiscovery(discoveredProvider Provider, node *ast.File, context *BuilderContext) (Provider, error) {
func (p *MainParser) buildProviderFromDiscovery(
discoveredProvider Provider,
node *ast.File,
context *BuilderContext,
) (Provider, error) {
// Find the corresponding type specification in the AST
var typeSpec *ast.TypeSpec
var genDecl *ast.GenDecl

View File

@@ -434,7 +434,12 @@ func (p *GoParser) parseFileContent(filePath string, node *ast.File) ([]Provider
}
// parseProviderDecl parses a provider from an AST declaration
func (p *GoParser) parseProviderDecl(filePath string, fileNode *ast.File, decl ast.Decl, imports map[string]string) (*Provider, error) {
func (p *GoParser) parseProviderDecl(
filePath string,
fileNode *ast.File,
decl ast.Decl,
imports map[string]string,
) (*Provider, error) {
genDecl, ok := decl.(*ast.GenDecl)
if !ok {
return nil, nil
@@ -478,6 +483,9 @@ func (p *GoParser) parseProviderDecl(filePath string, fileNode *ast.File, decl a
Imports: make(map[string]string),
PkgName: fileNode.Name.Name,
ProviderFile: filepath.Join(filepath.Dir(filePath), "provider.gen.go"),
Location: SourceLocation{
File: filePath,
},
}
// Set default return type if not specified
@@ -518,7 +526,12 @@ func (p *GoParser) parseProviderDecl(filePath string, fileNode *ast.File, decl a
}
// parseStructFields parses struct fields for injection parameters
func (p *GoParser) parseStructFields(structType *ast.StructType, imports map[string]string, provider *Provider, onlyMode bool) error {
func (p *GoParser) parseStructFields(
structType *ast.StructType,
imports map[string]string,
provider *Provider,
onlyMode bool,
) error {
for _, field := range structType.Fields.List {
if field.Names == nil {
continue
@@ -573,7 +586,10 @@ func (p *GoParser) parseStructFields(structType *ast.StructType, imports map[str
}
// parseFieldType parses a field type and returns its components
func (p *GoParser) parseFieldType(expr ast.Expr, imports map[string]string) (star, pkg, pkgAlias, typ string, err error) {
func (p *GoParser) parseFieldType(
expr ast.Expr,
imports map[string]string,
) (star, pkg, pkgAlias, typ string, err error) {
switch t := expr.(type) {
case *ast.Ident:
typ = t.Name

View File

@@ -7,10 +7,12 @@ import (
"github.com/iancoleman/strcase"
"github.com/samber/lo"
"go.ipao.vip/atomctl/v2/pkg/utils/gomod"
)
type RenderBuildOpts struct {
PackageName string
ModuleName string
ProjectPackage string
Routes []RouteDefinition
}
@@ -19,6 +21,7 @@ func buildRenderData(opts RenderBuildOpts) (RenderData, error) {
rd := RenderData{
PackageName: opts.PackageName,
ProjectPackage: opts.ProjectPackage,
ModuleName: gomod.GetModuleName(),
Imports: []string{},
Controllers: []string{},
Routes: make(map[string][]Router),
@@ -147,7 +150,6 @@ func buildParamToken(item ParamDefinition) string {
return ""
}
func scalarSuffix(t string) string {
switch t {
case "string", "int", "int8", "int16", "int32", "int64",

View File

@@ -0,0 +1,9 @@
package {{.PackageName}}
func (r *Routes) Path() string {
return "/{{.PackageName}}"
}
func (r *Routes) Middlewares() []any{
return []any{}
}

View File

@@ -11,8 +11,12 @@ import (
//go:embed router.go.tpl
var routeTpl string
//go:embed manual.go.tpl
var routeManualTpl string
type RenderData struct {
PackageName string
ModuleName string
ProjectPackage string
Imports []string
Controllers []string
@@ -31,9 +35,11 @@ type Router struct {
func Render(path string, routes []RouteDefinition) error {
routePath := filepath.Join(path, "routes.gen.go")
routeManualPath := filepath.Join(path, "routes.manual.go")
data, err := buildRenderData(RenderBuildOpts{
PackageName: filepath.Base(path),
ModuleName: gomod.GetModuleName(),
ProjectPackage: gomod.GetModuleName(),
Routes: routes,
})
@@ -49,5 +55,15 @@ func Render(path string, routes []RouteDefinition) error {
if err := os.WriteFile(routePath, out, 0o644); err != nil {
return err
}
// if routes.manual.go not exists then create it
if _, err := os.Stat(routeManualPath); os.IsNotExist(err) {
manualOut, err := renderManualTemplate(data)
if err != nil {
return err
}
if err := os.WriteFile(routeManualPath, manualOut, 0o644); err != nil {
return err
}
}
return nil
}

View File

@@ -26,9 +26,10 @@ type TemplateInfo struct {
// RouteRenderer implements TemplateRenderer for route generation
type RouteRenderer struct {
template *template.Template
info TemplateInfo
logger *log.Entry
template *template.Template
manualTemplate *template.Template
info TemplateInfo
logger *log.Entry
}
// NewRouteRenderer creates a new RouteRenderer instance with proper initialization
@@ -54,6 +55,10 @@ func NewRouteRenderer() *RouteRenderer {
renderer.logger.WithError(err).Error("Failed to initialize template")
return nil
}
if err := renderer.initializeManualTemplate(); err != nil {
renderer.logger.WithError(err).Error("Failed to initialize manual template")
return nil
}
renderer.info.Size = len(routeTpl)
renderer.logger.WithFields(log.Fields{
@@ -64,6 +69,22 @@ func NewRouteRenderer() *RouteRenderer {
return renderer
}
func (r *RouteRenderer) initializeManualTemplate() error {
// Create template with sprig functions and custom options
tmpl := template.New(r.info.Name + "manual").
Funcs(sprig.FuncMap()).
Option("missingkey=error")
// Parse the template
parsedTmpl, err := tmpl.Parse(routeManualTpl)
if err != nil {
return WrapError(err, "failed to parse route template")
}
r.manualTemplate = parsedTmpl
return nil
}
// initializeTemplate sets up the template with proper functions and options
func (r *RouteRenderer) initializeTemplate() error {
// Create template with sprig functions and custom options
@@ -81,6 +102,41 @@ func (r *RouteRenderer) initializeTemplate() error {
return nil
}
// Render renders the template with the provided data
func (r *RouteRenderer) RenderManual(data RenderData) ([]byte, error) {
// Validate input data
if err := r.validateRenderData(data); err != nil {
return nil, err
}
// Create buffer for rendering
var buf bytes.Buffer
buf.Grow(estimatedBufferSize(data)) // Pre-allocate buffer for better performance
// Execute template with error handling
if err := r.manualTemplate.Execute(&buf, data); err != nil {
r.logger.WithError(err).WithFields(log.Fields{
"package_name": data.PackageName,
"routes_count": len(data.Routes),
}).Error("Template execution failed")
return nil, WrapError(err, "template execution failed for package: %s", data.PackageName)
}
// Validate rendered content
result := buf.Bytes()
if len(result) == 0 {
return nil, NewRouteError(ErrTemplateFailed, "rendered content is empty for package: %s", data.PackageName)
}
r.logger.WithFields(log.Fields{
"package_name": data.PackageName,
"routes_count": len(data.Routes),
"content_length": len(result),
}).Debug("Template rendered successfully")
return result, nil
}
// Render renders the template with the provided data
func (r *RouteRenderer) Render(data RenderData) ([]byte, error) {
// Validate input data
@@ -152,10 +208,20 @@ func (r *RouteRenderer) validateRenderData(data RenderData) error {
for i, route := range routes {
if route.Method == "" {
return NewRouteError(ErrInvalidInput, "route method cannot be empty for controller %s, route %d", controllerName, i)
return NewRouteError(
ErrInvalidInput,
"route method cannot be empty for controller %s, route %d",
controllerName,
i,
)
}
if route.Route == "" {
return NewRouteError(ErrInvalidInput, "route path cannot be empty for controller %s, route %d", controllerName, i)
return NewRouteError(
ErrInvalidInput,
"route path cannot be empty for controller %s, route %d",
controllerName,
i,
)
}
}
}
@@ -189,3 +255,11 @@ func renderTemplate(data RenderData) ([]byte, error) {
}
return renderer.Render(data)
}
func renderManualTemplate(data RenderData) ([]byte, error) {
renderer := NewRouteRenderer()
if renderer == nil {
return nil, NewRouteError(ErrTemplateFailed, "failed to create route renderer")
}
return renderer.RenderManual(data)
}

View File

@@ -10,6 +10,8 @@ import (
{{.}}
{{- end }}
{{- end }}
"{{.ModuleName}}/app/middlewares"
. "go.ipao.vip/atom/fen"
_ "go.ipao.vip/atom"
_ "go.ipao.vip/atom/contracts"
@@ -23,6 +25,8 @@ import (
// @provider contracts.HttpRoute atom.GroupRoutes
type Routes struct {
log *log.Entry `inject:"false"`
middlewares *middlewares.Middlewares
{{- if .Controllers }}
// Controller instances
{{- range .Controllers }}
@@ -54,7 +58,7 @@ func (r *Routes) Register(router fiber.Router) {
{{- range $value }}
{{- if .Route }}
r.log.Debugf("Registering route: {{.Method}} {{.Route}} -> {{.Controller}}.{{.Action}}")
router.{{.Method}}("{{.Route}}", {{.Func}}(
router.{{.Method}}("{{.Route}}"[len(r.Path()):], {{.Func}}(
r.{{.Controller}}.{{.Action}},
{{- if .Params }}
{{- range .Params }}

View File

@@ -4,7 +4,8 @@ import (
"context"
"time"
. "github.com/riverqueue/river"
// . "github.com/riverqueue/river"
"github.com/robfig/cron/v3"
_ "go.ipao.vip/atom"
"go.ipao.vip/atom/contracts"
)

View File

@@ -9,6 +9,7 @@ import (
"{{.ModuleName}}/app/commands"
"{{.ModuleName}}/app/errorx"
"{{.ModuleName}}/app/jobs"
"{{.ModuleName}}/app/middlewares"
_ "{{.ModuleName}}/docs"
"{{.ModuleName}}/providers/app"
"{{.ModuleName}}/providers/http"
@@ -40,6 +41,7 @@ func Command() atom.Option {
atom.Providers(
defaultProviders().
With(
middlewares.Provide,
jobs.Provide,
),
),
@@ -70,8 +72,8 @@ func Serve(cmd *cobra.Command, args []string) error {
Data: []byte{},
}))
group := svc.Http.Engine.Group("")
for _, route := range svc.Routes {
group := svc.Http.Engine.Group(route.Path(), route.Middlewares()...).Name(route.Name())
route.Register(group)
}

View File

@@ -1,35 +0,0 @@
package migrate
import (
"context"
"database/sql"
"github.com/pkg/errors"
"github.com/pressly/goose/v3"
"github.com/riverqueue/river/riverdriver/riverdatabasesql"
"github.com/riverqueue/river/rivermigrate"
)
func init() {
goose.AddMigrationNoTxContext(RiverQueueUp, RiverQueueDown)
}
func RiverQueueUp(ctx context.Context, db *sql.DB) error {
migrator, err := rivermigrate.New(riverdatabasesql.New(db), nil)
if err != nil {
return errors.Wrap(err, "river migrate up failed")
}
_, err = migrator.Migrate(ctx, rivermigrate.DirectionUp, &rivermigrate.MigrateOpts{TargetVersion: -1})
return err
}
func RiverQueueDown(ctx context.Context, db *sql.DB) error {
migrator, err := rivermigrate.New(riverdatabasesql.New(db), nil)
if err != nil {
return errors.Wrap(err, "river migrate down failed")
}
_, err = migrator.Migrate(ctx, rivermigrate.DirectionDown, &rivermigrate.MigrateOpts{TargetVersion: -1})
return err
}

View File

@@ -57,33 +57,27 @@ func Serve(cmd *cobra.Command, args []string) error {
goose.SetBaseFS(database.MigrationFS)
goose.SetTableName("migrations")
goose.AddNamedMigrationNoTxContext("0001_river_job.go", RiverUp, RiverDown)
goose.AddNamedMigrationNoTxContext(
"10000000000001_river_job.go",
func(ctx context.Context, db *sql.DB) error {
migrator, err := rivermigrate.New(riverdatabasesql.New(db), nil)
if err != nil {
return err
}
_, err = migrator.Migrate(ctx, rivermigrate.DirectionUp, &rivermigrate.MigrateOpts{TargetVersion: -1})
return err
},
func(ctx context.Context, db *sql.DB) error {
migrator, err := rivermigrate.New(riverdatabasesql.New(db), nil)
if err != nil {
return err
}
_, err = migrator.Migrate(ctx, rivermigrate.DirectionDown, &rivermigrate.MigrateOpts{TargetVersion: -1})
return err
})
return goose.RunContext(context.Background(), action, svc.DB, "migrations", args...)
})
}
func RiverUp(ctx context.Context, db *sql.DB) error {
migrator, err := rivermigrate.New(riverdatabasesql.New(db), nil)
if err != nil {
return err
}
// Migrate up. An empty MigrateOpts will migrate all the way up, but
// best practice is to specify a specific target version.
_, err = migrator.Migrate(ctx, rivermigrate.DirectionUp, &rivermigrate.MigrateOpts{})
return err
}
func RiverDown(ctx context.Context, db *sql.DB) error {
migrator, err := rivermigrate.New(riverdatabasesql.New(db), nil)
if err != nil {
return err
}
// TargetVersion -1 removes River's schema completely.
_, err = migrator.Migrate(ctx, rivermigrate.DirectionDown, &rivermigrate.MigrateOpts{
TargetVersion: -1,
})
return err
}

View File

@@ -0,0 +1,104 @@
# GORM Gen Library Summary (PostgreSQL Extended Version)
This document summarizes the capabilities of the GORM Gen code generation tool, specifically focusing on its extended version tailored for PostgreSQL. It covers standard Gen features and the substantial PostgreSQL-specific enhancements for types and field expressions.
## 1. DAO Interface Generation
- **Concept**: Generates type-safe Data Access Object (DAO) interfaces and query code.
- **Process**:
- **Configuration**: Use `gen.Config` to set output paths, package names, and modes.
- **PostgreSQL Enforcement**: The generator explicitly requires a PostgreSQL database connection via `g.UseDB(db)` (checks for "postgres" dialector).
- **Model Application**: Automatically maps database tables to Go structs using `g.GenerateAllTable()` or specific tables with `g.GenerateModel()`.
- **Output**: Generates DAO interfaces with CRUD methods, query structs, and model structs. Defaults to "Same Package" generation (models and queries in the same directory) for easier usage.
- **Usage**: Interact via a global `Q` variable or initialized query instances.
## 2. Creating Records
- **Standard**: `u.WithContext(ctx).Create(&user)`
- **Modifiers**: `Select()`, `Omit()` to control fields.
- **Batch**: `CreateInBatches()` for bulk inserts.
- **Upsert**: Supports `clause.OnConflict` strategies.
- **Extended Types**: Seamlessly handles extended types (Arrays, JSONB, Ranges, etc.) during creation.
## 3. Querying Data
- **Retrieval**: `First()`, `Take()`, `Last()`, `Find()`.
- **Conditions**: Type-safe methods (`Eq`, `Neq`, `Gt`, `Lt`, `Like`, `In`).
- **PostgreSQL Specific Conditions**:
- **JSON/JSONB**:
- `HasKey("key")` (operator `?`)
- `HasAllKeys("k1", "k2")` (operator `?&`)
- `KeyEq("path.to.key", value)` (extracts path and compares).
- **Arrays**:
- `Contains(val)` (operator `@>`)
- `ContainedBy(val)` (operator `<@`)
- `Overlaps(val)` (operator `&&`)
- **Ranges**: `Overlaps`, `Contains`, `Adjacent`, `StrictLeft`, `StrictRight`.
- **Network**: `Contains` (`>>`), `ContainedBy` (`<<`).
- **Full Text**: `Matches` (`@@`) for `TSVector` and `TSQuery`.
- **Geometry**: `DistanceTo` (`<->`), `ContainsPoint`, `WithinBox`.
- **Advanced**: Subqueries, Joins, Grouping, Having.
## 4. Updating Records
- **Standard**: `Update()`, `Updates()`.
- **JSON Updates**:
- Uses `JSONSet` expression for `JSONB_SET` operations.
- Example: `UpdateColumn("attr", types.JSONSet("attr").Set("{age}", 20))` updates a specific path inside a JSONB column without overwriting the whole document.
- **Modifiers**: `Select`, `Omit`.
## 5. Deleting Records
- **Safety**: Requires `Where` clause for bulk deletes.
- **Soft Delete**: Automatically handled if `gorm.DeletedAt` is present.
- **Associations**: Can delete specific associated records.
## 6. Transaction Management
- **Automatic**: `Transaction(func() error { ... })`.
- **Manual**: `Begin()`, `Commit()`, `Rollback()`.
- **SavePoints**: `SavePoint()`, `RollbackTo()` supported.
## 7. Association Handling
- **Relationships**: BelongsTo, HasOne, HasMany, Many2Many.
- **Eager Loading**: `Preload()` with conditions and nested paths.
- **Operations**: `Append`, `Replace`, `Delete`, `Clear` on associations.
## 8. PostgreSQL Specialized Extensions (Unique to this version)
This version of Gen is heavily customized for PostgreSQL, providing rich type support and SQL expressions that standard GORM Gen does not offer out-of-the-box.
### 8.1. Extended Type System (`go.ipao.vip/gen/types`)
Automatically maps PostgreSQL column types to specialized Go types:
- **JSON/JSONB**: `types.JSON`, `types.JSONB` (wraps `json.RawMessage`, supports GIN operators).
- **Arrays**: `types.Array[T]` (Generic implementation for `text[]`, `int[]`, etc.).
- **Ranges**:
- `types.Int4Range`, `types.Int8Range`, `types.NumRange`
- `types.TsRange` (Timestamp), `types.TstzRange` (TimestampTz), `types.DateRange`
- **Network**: `types.Inet`, `types.CIDR`, `types.MACAddr`.
- **Time**: `types.Date`, `types.Time` (Postgres specific time/date types).
- **Geometry**: `types.Point`, `types.Box`, `types.Circle`, `types.Polygon`, `types.Path`.
- **Full Text Search**: `types.TSVector`, `types.TSQuery`.
- **Others**: `types.UUID`, `types.BinUUID`, `types.Money`, `types.XML`, `types.BitString`.
- **Generics**: `types.JSONType[T]` for strong typing of JSON column content.
### 8.2. Extended Field Expressions (`go.ipao.vip/gen/field`)
Provides type-safe builders for PostgreSQL operators:
- **JSONB Querying**:
```go
// Query: attributes -> 'role' ? 'admin'
db.Where(u.Attributes.HasKey("role"))
// Query: attributes ->> 'age' > 18
db.Where(u.Attributes.KeyGt("age", 18))
```
- **Array Operations**:
```go
// Query: tags @> '{urgent}'
db.Where(u.Tags.Contains("urgent"))
```
- **Range Overlaps**:
```go
// Query: duration && '[2023-01-01, 2023-01-02)'
db.Where(u.Duration.Overlaps(searchRange))
```
### 8.3. Configuration & Generation
- **YAML Config**: Supports loading configuration from a `.transform.yaml` file (handling field type overrides, ignores, and relationships).
- **Auto Mapping**: `defaultDataTypeMap` in the generator automatically selects the correct extended type (e.g., `int4range` -> `types.Int4Range`) without manual config.
- **Field Wrappers**: Automatically wraps generated fields with their specific expression builders (e.g., a `jsonb` column generates a `field.JSONB` struct instead of a generic `field.Field`, enabling the `.HasKey()` method).

View File

@@ -0,0 +1,457 @@
# Backend Dev Rules (HTTP API + Model)
This file condenses `docs/dev/http_api.md` + `docs/dev/model.md` into a checklist/rule format for LLMs.
---
## 0) Golden rules (DO / DO NOT)
- DO follow existing module layout under `app/http/<module>/`.
- MUST: HTTP module folder name MUST be `snake_case` (e.g. `tenant_public`), not `camelCase`/`mixedCase`.
- DO keep controller methods thin: parse/bind → call `services.*` → return result/error.
- DO regenerate code after changes (routes/docs/models).
- MUST: in `app/services`, prefer the generated GORM-Gen DAO (`database/models/*`) for DB access ; treat raw `*gorm.DB` usage as a last resort.
- MUST: after adding/removing/renaming any files under `app/services/`, run `atomctl gen service --path ./app/services` to regenerate `app/services/services.gen.go` ; DO NOT edit `services.gen.go` manually.
- DO add `// @provider` above every controller/service `struct` declaration.
- DO keep HTTP middlewares in `app/middlewares/` only.
- DO keep all `const` declarations in `pkg/consts/` only (do not declare constants elsewhere).
- DO NOT manually edit generated files:
- `app/http/**/routes.gen.go`
- `app/http/**/provider.gen.go`
- `docs/docs.go`
- DO NOT manually write provider declarations (only `atomctl gen provider`).
- DO NOT manually write route declarations (only `atomctl gen route`).
- DO keep Swagger annotations consistent with actual Fiber route paths (including `:param`).
- MUST: route path parameter placeholders MUST be `camelCase` (e.g. `:tenantCode`), never `snake_case` (e.g. `:tenant_code`).
- MUST: when importing another HTTP module's `dto` package, the import alias MUST be `<module>_dto` (e.g. `tenant_dto`), not `<module>dto` (e.g. `tenantdto`).
- MUST: when creating/generating Go `struct` definitions (DTOs/requests/responses/etc.), add detailed per-field comments describing meaning, usage scenario, and validation/usage rules (do not rely on “self-explanatory” names).
- MUST: business code comments MUST be written in Chinese (中文注释), to keep review/maintenance consistent across the team.
- MUST: in `app/services`, add Chinese comments at key steps to explain business intent and invariants (e.g., 事务边界、幂等语义、余额冻结/扣减/回滚、权限/前置条件校验点), avoid “what the code does” boilerplate.
---
## 1) Add a new HTTP API endpoint
### 1.1 Where code lives
- Controllers: `app/http/<module>/*.go`
- Example module: `app/http/super/tenant.go`, `app/http/super/user.go`
- DTOs: `app/http/<module>/dto/*`
- HTTP middlewares: `app/middlewares/*`
- Routes (generated): `app/http/<module>/routes.gen.go`
- Swagger output (generated): `docs/swagger.yaml`, `docs/swagger.json`, `docs/docs.go`
### 1.2 Controller method signatures
- “Return data” endpoints: return `(<T>, error)`
- Example: `(*requests.Pager, error)` for paginated list
- “No data” endpoints: return `error`
### 1.3 Swagger annotations (minimum set)
Place above the handler function:
- `@Summary`
- `@Tags`
- `@Accept json`
- `@Produce json`
- `@Param` (query/path/body as needed)
- `@Success` for 200 responses
- `@Router <path> [get|post|patch|delete|put]`
- `@Bind` for parameters (see below)
Common `@Success` patterns:
- Paginated list: `requests.Pager{items=dto.Item}`
- Single object: `dto.Item`
- Array: `{array} dto.Item`
### 1.4 Parameter binding (@Bind)
Format:
`@Bind <paramName> <position> [key(<key>)] [model(<field>|<type>[:<field>])]`
Positions:
- `path`, `query`, `body`, `header`, `cookie`, `local`, `file`
Notes:
- `paramName` MUST match function parameter name (case-sensitive).
- Default key name is `paramName` ; override via `key(...)`.
- Scalar types: `string/int/int32/int64/float32/float64/bool`.
- Pointer types are supported (framework will handle deref for most positions).
#### Model binding (path-only)
Used to bind a model instance from a path value:
- `model(id)` (recommended)
- `model(id:int)` / `model(code:string)`
- `model(pkg.Type:field)` or `model(pkg.Type)` (default field is `id`)
Behavior:
- Generated binder queries by field and returns first row as the parameter value.
- Auto-imports field helper for query building.
### 1.5 Generate routes + providers + swagger docs
Run from ``:
- Generate routes: `atomctl gen route`
- Generate providers: `atomctl gen provider`
- Generate swagger docs: `atomctl swag init`
### 1.6 Local verify
- Build/run: `make run`
- Use REST client examples: `tests/[module]/[controller].http` (extend it for new endpoints)
### 1.7 Testing
- Prefer existing test style under `tests/e2e`.
- Run: `make test`
### 1.8 Module-level route group (Path + Middlewares)
If you need to define a module HTTP middleware (applies to the module route group):
1) Run `atomctl gen route` first.
2) Edit `app/http/<module>/routes.manual.go`:
- Update `Path()` to return the current module route group prefix (must match the prefix used in `routes.gen.go`, e.g. `/super/v1`, `/t/:tenantCode/v1`).
- Update `Middlewares()` return value: return a list like `[]any{r.middlewares.MiddlewareFunc1, r.middlewares.MiddlewareFunc2, ...}` (no `(...)`), where each item is `r.middlewares.<MiddlewareFunc>` referencing middleware definitions in `app/middlewares`.
---
## 2) Add / update a DB model
Models live in:
- `database/models/*` (generated model code + optional manual extensions)
### 2.1 Migration → model generation workflow
1) Create migration:
- `atomctl migrate create alter_table` or `atomctl migrate create create_table`
2) Edit migration:
- No explicit `BEGIN/COMMIT` needed (framework handles).
- Table name should be plural (e.g. `tenants`).
- MUST: when writing migration content, every field/column MUST include a brief Chinese remark, and also include commented details for that fields usage scenario and rules/constraints (e.g., valid range/format, default behavior, special cases).
3) Apply migration:
- `atomctl migrate up`
4) Map complex field types (JSON/ARRAY/UUID/…) via transform file:
- `database/.transform.yaml` → `field_type.<table>`
5) Generate models:
- `atomctl gen model`
---
## 3) Service-layer DB access (GORM Gen)
This project uses a PostgreSQL-focused GORM-Gen variant (`go.ipao.vip/gen` + generated `database/models/*`).
Reference: `llm.gorm_gen.txt`.
### 3.1 Query style (preferred)
- MUST: in services, build queries via:
- `tbl, q := models.<Table>Query.QueryContext(ctx)`
- Use type-safe conditions (`tbl.ID.Eq(...)`, `tbl.TenantID.Eq(...)`, `tbl.DeletedAt.IsNull()`, etc).
- DO NOT: use string SQL in `Where("...")` unless absolutely necessary.
### 3.2 Transactions
- MUST: use Gen transaction wrapper so all queries share the same tx connection:
- `models.Q.Transaction(func(tx *models.Query) error { ... })`
- Inside tx, use `tx.<Table>.QueryContext(ctx)` / `tx.<Table>.WithContext(ctx)`
- DO NOT: use `_db.WithContext(ctx).Transaction(...)` in services unless Gen cannot express a required operation.
### 3.3 Updates
- Prefer `UpdateSimple(...)` with typed assign expressions when possible.
- Otherwise use `Updates(map[string]any{...})`, but MUST:
- include tenant boundary conditions (`tenant_id`) in the WHERE,
- avoid updating columns by concatenating user input.
### 3.4 Columns not in generated models (temporary escape hatch)
If migrations add columns but `atomctl gen model` has not been re-run yet, the typed `models.<Struct>` will not contain those fields.
In this case:
- Use `q.UnderlyingDB()` (from Gen DO) to do a narrow query/update (single table, explicit columns).
- Add a short Chinese comment explaining why, and that `atomctl gen model` should be run when DB is reachable.
- Avoid spreading this pattern: keep it localized to one function.
---
## Async JobsRiver
本项目使用 River`github.com/riverqueue/river`)作为异步任务系统,并通过 `atomctl new job <name> [--cron]` 生成 `app/jobs/*.go`。
- MUST任务入队调用 `job.Add(...)` / `client.Insert(...)`)只能在 `service` / `controller` / `event` 层编写;其它位置(例如 `middlewares` / `database` / `models` / `providers` / `jobs` 的 worker 实现等)禁止写入任务,避免耦合与隐式副作用。
- MUST为避免 `services` 与 `jobs` 的循环依赖JobArgs 定义固定放在 `app/jobs/args/`Worker 放在 `app/jobs/`Worker 可以依赖 `services`,但 args 包禁止依赖 `services`)。
### Job一次性任务
- `Kind() string`任务类型标识job kind改名会导致“新旧任务类型不一致”。
- `InsertOpts() river.InsertOpts`:默认入队参数(队列、优先级、最大重试、唯一任务策略等)。
- `UniqueID() string`(项目约定):周期任务 handle 的稳定 key通常 `return Kind()`。
### Worker执行器
- `Work(ctx, job)`:执行入口;返回 `nil` 成功;返回 `error` 失败并按 River 策略重试。
- `river.JobSnooze(d)`:延后再跑一次,且 **不递增 attempt**;适合等待外部依赖就绪/限流等。
- `river.JobCancel(err)`:永久取消并记录原因;适合业务上永远不可能成功的情况(参数非法/语义过期等)。
- `NextRetry(job)`(可选):自定义该任务类型的重试节奏。
### CronJob周期任务
- `Prepare() error`:注册周期任务前做初始化/校验(避免重活/长阻塞)。
- `Args() []contracts.CronJobArg`:声明周期任务(间隔、是否启动即跑、入队的 JobArgs
### 业务侧如何入队
- 在业务结构体中注入 `*job.Job`(见 `providers/job`),然后调用 `obj.job.Add(jobs.XXXJob{...})` 入队。
---
## EventsWatermill
本项目使用 `ThreeDotsLabs/watermill` 做事件驱动,并通过框架封装在 `providers/event/` 中(支持 `Go`/`Kafka`/`Redis`/`Sql` 等 channel
- MUST事件发布调用 `PubSub.Publish(...)` 等)只能在 `service` / `controller` / `event` 层编写;其它位置(例如 `middlewares` / `database` / `models` / `providers` 等)禁止发布事件,避免耦合与隐式副作用。
- MUST事件订阅处理subscriber handler保持“薄”只做反序列化/幂等与边界校验 → 调用 `services.*` 完成业务。
### 生成与结构
- 新增事件:`atomctl new event <Name>`
- 会在 `app/events/topics.go` 中新增 topic 常量(形如 `event:<snake_case>`)。
- 会生成:
- `app/events/publishers/<snake_case>.go`publisher实现 `contracts.EventPublisher`,负责 `Marshal()` + `Topic()`
- `app/events/subscribers/<snake_case>.go`subscriber实现 `contracts.EventHandler`,负责 `Topic()` + `Handler(...)`
- 生成后:按项目约定运行一次 `atomctl gen provider`(用于刷新 DI/provider 生成文件)。
### Topic 约定
- 统一在 `app/events/topics.go` 维护 topic 常量,避免散落在各处形成“字符串协议”。
- topic 字符串建议使用稳定前缀(例如 `event:`),并使用 `snake_case` 命名。
### 2.2 Enum strategy
- DO NOT use native DB ENUM.
- Define enums in Go under `pkg/consts/<table>.go`, example:
```go
// swagger:enum UserStatus
// ENUM(pending_verify, verified, banned, )
type UserStatus string
```
- For every enum `type` defined under `pkg/consts/`, you MUST also define:
- `Description() string`: return the Chinese label for the specific enum value (used by API/FE display).
- `XxxItems() []requests.KV`: return the KV list for FE dropdowns (typically `Key=enum string`, `Value=Description()`). Example: `func TenantStatusItems() []requests.KV` and call it via `consts.TenantStatusItems()`.
- Prefer `string(t)` as `Key`, and use a stable default label for unknown values (e.g. `未知` / `未知状态`).
- MUST: `Description()` and `XxxItems()` MUST be placed immediately below the enum `type` definition (same file, directly under `type Xxx string`), to keep the enum self-contained and easy to review.
- Generate enum code: `atomctl gen enum`
### 2.3 Supported field types (`gen/types/`)
`database/.transform.yaml` typically imports `go.ipao.vip/gen` so you can use `types.*` in `field_type`.
Common types:
- JSON: `types.JSON`, `types.JSONMap`, `types.JSONType[T]`, `types.JSONSlice[T]`
### 2.4 JSONB 强类型规则(`types.JSONType[T]`
- 如果某个 `jsonb` 字段的数据结构是“确定且稳定”的,优先将 `types.JSON` 升级为 `types.JSONType[fields.TableNameFieldName]`,以获得类型约束与更清晰的读写代码。
- `fields.TableNameFieldName` 必须定义在 `database/fields/[table_name].go` 中,格式为 `type TableNameFieldName struct { ... }` 并为每个字段写好 `json` tag。
- 如果数据结构“不确定/随业务演进/允许任意键”,继续使用 `types.JSON`(不要强行 JSONType以免丢字段或引入频繁迁移
- 服务层读写 `types.JSONType[T]`
- 读取:`v := model.Field.Data()`
- 修改:`model.Field.Edit(func(v *T) { ... })` 或 `model.Field.Set(newValue)`
### 2.5 一个字段多种结构(判别联合)
- 当同一个 `jsonb` 字段存在多种不同结构(同一字段承载多个 payload不要让字段类型漂移为 `any/map`。
- 推荐统一包裹为“判别联合”结构:`type Xxx struct { Kind string ; Data json.RawMessage }`,并将该字段映射为 `types.JSONType[fields.Xxx]`。
- 写入时:
- `Kind` 建议与业务枚举/事件类型对齐,便于 SQL/报表按 `kind` 过滤。
- `Data` 写入对应 payload 的 JSONpayload 可以是多个不同 struct
- 读取时:
- 先 `snap := model.Snapshot.Data()`,再 `switch snap.Kind` 选择对应 payload 结构去 `json.Unmarshal(snap.Data, &payload)`。
- 兼容历史数据(旧 JSON 没有 kind/data`UnmarshalJSON` 可以将其标记为 `legacy` 并把原始 JSON 放入 `Data`,避免线上存量读取失败。
---
## 4) 审计与幂等(通用)
- 若你为任意表新增结构化审计字段(例如 `operator_user_id`、`biz_ref_type/biz_ref_id`),服务层写入必须同步补齐(避免只写 remark/JSON 导致追溯困难)。
- 注意PostgreSQL 的可空列在本项目的 gen model 中可能会生成非指针类型(例如 `string/int64`),这会导致“未赋值”落库为 `''/0`
- 若你要为 `(biz_ref_type,biz_ref_id,...)` 建唯一索引,**不要**只写 `IS NOT NULL` 条件;
- 应额外排除空/0例如 `biz_ref_type <> '' AND biz_ref_id <> 0`),否则会因默认值冲突导致大量写入失败。
- Array: `types.Array[T]`
- UUID: `types.UUID`, `types.BinUUID`
- Date/Time: `types.Date`, `types.Time`
- Money/XML/URL/Binary: `types.Money`, `types.XML`, `types.URL`, `types.HexBytes`
- Bit string: `types.BitString`
- Network: `types.Inet`, `types.CIDR`, `types.MACAddr`
- Ranges: `types.Int4Range`, `types.Int8Range`, `types.NumRange`, `types.TsRange`, `types.TstzRange`, `types.DateRange`
- Geometry: `types.Point`, `types.Polygon`, `types.Box`, `types.Circle`, `types.Path`
- Fulltext: `types.TSQuery`, `types.TSVector`
- Nullable: `types.Null[T]` and aliases (requires DB NULL)
Reference:
- Detailed examples: `gen/types/README.md`
### 2.4 Relationships (GORM-aligned) via `.transform.yaml`
Define in `field_relate.<table>.<FieldName>`:
- `relation`: `belongs_to` | `has_one` | `has_many` | `many_to_many`
- `table`: target table
- `pivot`: join table (many_to_many only)
- `foreign_key`, `references`
- `join_foreign_key`, `join_references` (many_to_many only)
- `json`: JSON field name in API outputs
Generator will convert snake_case columns to Go struct field names (e.g. `class_id` → `ClassID`).
### 2.5 Extending generated models
- Add manual methods/hooks by creating `database/models/<table>.go`.
- Keep generated files untouched ; put custom logic only in your own file(s).
---
## 3) Service layer injection (when adding services)
- Services are in `app/services`.
- Data access boundary:
- MUST: only the `services` layer may query the database via `models.*Query`, `models.Q.*`, `gorm.DB`, or raw SQL.
- DO NOT: perform any direct database query from HTTP modules (`app/http/**`) including controllers, DTO binders, or middlewares.
- HTTP modules must call `services.*` for all read/write operations.
- After creating/updating a service provider, regenerate wiring:
- `atomctl gen service`
- `atomctl gen provider`
- Injection rule: provider injected dependencies MUST be `success`. do not add business-level fallbacks for injection objects nil check.
- Service call conventions:
- **Service-to-service (inside `services` package)**: call directly as `CamelCaseServiceStructName.Method()` (no `services.` prefix).
- **From outside (controllers/handlers/etc.)**: call via the package entrypoint `services.CamelCaseServiceStructName.Method()`.
---
## 4) Quick command summary (run in ``)
- `make run` / `make build` / `make test`
- `atomctl gen route` / `atomctl gen provider` / `atomctl swag init`
- `atomctl migrate create ...` / `atomctl migrate up`
- `atomctl gen model` / `atomctl gen enum` / `atomctl gen service`
- `make init` (full refresh)
---
## 5) Service Layer Unit Testing Guidelines (Generic)
This section is framework-agnostic and applies to any Go service layer (regardless of DI container, ORM, or web framework).
### 5.1 Decide what you are testing
- **Pure unit tests**: no DB/network/filesystem ; dependencies are mocked/faked; tests are fast and deterministic.
- **DB-backed tests (recommended whenever the feature touches the database)**: exercise a real database to validate SQL, constraints, transactions, and ORM behavior.
- Always state which tier the test belongs to and keep the scope consistent.
### 5.2 Design the service for testability
- Inject dependencies via constructor or fields ; depend on **interfaces**, not concrete DB clients.
- Keep domain logic **pure** where possible: parse/validate/compute should be testable without IO.
- Make time/UUID/randomness deterministic by injecting `Clock`/`IDGenerator` when needed.
- If the feature requires database access, **do not mock the database** ; test with an **actual database** (ideally same engine/version as production) to ensure data accuracy. Use mocks/fakes only for non-DB external dependencies when appropriate (e.g., HTTP, SMS, third-party APIs).
### 5.3 Test structure and conventions
- Prefer `*_test.go` with table-driven tests and subtests: `t.Run("case", func(t *testing.T) { ... })`.
- Prefer testing the public API from an external package (`package xxx_test`) unless you must access unexported helpers.
- Avoid “focused” tests in committed code (e.g. `FocusConvey`, `FIt`, `fit`, `it.only`, or equivalent), because they silently skip other tests.
- MUST: in service layer tests, **one test method should focus on one service method** only (e.g. `Test_Freeze` covers `Ledger.Freeze`, `Test_Unfreeze` covers `Ledger.Unfreeze`) ; do not bundle multiple service methods into a single `Test_*` method.
- MUST: within that single `Test_<Method>` function, cover the methods key behavior contracts and boundary conditions via subcases (`Convey` blocks or `t.Run`) so the methods behavior can be reviewed in one place (do NOT claim to cover “all edge cases”, but cover the important ones).
- MUST (minimum set): for each service method test, cover at least: happy path ; invalid params / precondition failures; insufficient resources / permission denied (if applicable); idempotency/duplicate call behavior (if applicable); and at least one typical persistence/transaction failure branch (if it is hard to simulate reliably, move that branch coverage to a DB-backed integration/e2e test).
### 5.4 Isolation rules
- Each test must be independent and order-agnostic.
- For integration tests:
- Use transaction rollback per test when possible ; otherwise use truncate + deterministic fixtures.
- Never depend on developer-local state ; prefer ephemeral DB (container) or a dedicated test database/schema.
### 5.5 Assertions and error checks
- Always assert both **result** and **error** (and error types via `errors.Is` / `errors.As` when wrapping is used).
- Keep assertions minimal but complete: verify behavior, not implementation details.
- Use the standard library (`testing`) or a single assertion library consistently across the repo.
### 5.6 Minimal test file template (DI-bootstrapped, DB-backed)
This template matches a common pattern where tests boot a DI container and run against a real database. Replace the bootstrap (`testx.Default/Serve`, `Provide`) and cleanup (`database.Truncate`) with your project's equivalents.
```go
package services
import (
"database/sql"
"testing"
"quyun/v2/app/commands/testx"
"quyun/v2/database"
"quyun/v2/database/models"
. "github.com/smartystreets/goconvey/convey"
"github.com/stretchr/testify/suite"
"go.ipao.vip/atom/contracts"
"go.uber.org/dig"
)
type XxxTestSuiteInjectParams struct {
dig.In
DB *sql.DB
Initials []contracts.Initial `group:"initials"`
}
type XxxTestSuite struct {
suite.Suite
XxxTestSuiteInjectParams
}
func Test_Xxx(t *testing.T) {
providers := testx.Default().With(Provide)
testx.Serve(providers, t, func(p XxxTestSuiteInjectParams) {
suite.Run(t, &XxxTestSuite{XxxTestSuiteInjectParams: p})
})
}
func (s *XxxTestSuite) Test_Method() {
Convey("describe behavior here", s.T(), func() {
ctx := s.T().Context()
database.Truncate(ctx, s.DB, models.TableNameUser)
got, err := User.FindByUsername(ctx, "alice")
So(err, ShouldNotBeNil)
So(got, ShouldBeNil)
})
}
```