Compare commits
5 Commits
c7ecf35c7c
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 7848dc2853 | |||
| 00742993db | |||
| 861748b7d9 | |||
| 596ea635c2 | |||
| fcf107036b |
13
.vscode/launch.json
vendored
13
.vscode/launch.json
vendored
@@ -4,6 +4,19 @@
|
|||||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"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",
|
"name": "provider",
|
||||||
"type": "go",
|
"type": "go",
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import (
|
|||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/spf13/cobra"
|
"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"
|
"go.ipao.vip/atomctl/v2/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,10 +22,10 @@ func CommandGenService(root *cobra.Command) {
|
|||||||
Long: `扫描 --path 指定目录(默认 ./app/services)下的 Go 文件,汇总服务名并渲染生成 services.gen.go。
|
Long: `扫描 --path 指定目录(默认 ./app/services)下的 Go 文件,汇总服务名并渲染生成 services.gen.go。
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
- 跳过 *_test.go 与 *.gen.go 文件,仅处理普通 .go 文件
|
- 扫描目录中带有 @provider 注释的结构体
|
||||||
- 以文件名作为服务名来源:
|
- 以结构体名称作为服务名:
|
||||||
- PascalCase 作为 CamelName,用于导出类型名
|
- StructName 作为 ServiceName,用于变量/字段类型
|
||||||
- camelCase 作为 ServiceName,用于变量/字段名
|
- PascalCase(StructName) 作为 CamelName,用于导出变量名
|
||||||
- 使用内置模板 services/services.go.tpl 渲染
|
- 使用内置模板 services/services.go.tpl 渲染
|
||||||
- 生成完成后会自动运行 gen provider 以补全注入`,
|
- 生成完成后会自动运行 gen provider 以补全注入`,
|
||||||
RunE: commandGenServiceE,
|
RunE: commandGenServiceE,
|
||||||
@@ -36,34 +39,53 @@ func CommandGenService(root *cobra.Command) {
|
|||||||
|
|
||||||
func commandGenServiceE(cmd *cobra.Command, args []string) error {
|
func commandGenServiceE(cmd *cobra.Command, args []string) error {
|
||||||
path := cmd.Flag("path").Value.String()
|
path := cmd.Flag("path").Value.String()
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
files, err := os.ReadDir(path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
type srv struct {
|
||||||
CamelName string
|
CamelName string
|
||||||
ServiceName string
|
ServiceName string
|
||||||
}
|
}
|
||||||
|
|
||||||
// get services from files
|
// get services from providers
|
||||||
var services []srv
|
var services []srv
|
||||||
for _, file := range files {
|
for _, p := range providers {
|
||||||
if file.IsDir() {
|
name := filepath.Base(p.Location.File)
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := file.Name()
|
|
||||||
if strings.HasSuffix(name, "_test.go") || strings.HasSuffix(name, ".gen.go") ||
|
if strings.HasSuffix(name, "_test.go") || strings.HasSuffix(name, ".gen.go") ||
|
||||||
!strings.HasSuffix(name, ".go") {
|
!strings.HasSuffix(name, ".go") {
|
||||||
|
log.Warnf("ignore file %s provider, %+v", p.Location.File, p)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
name = strings.TrimSuffix(name, ".go")
|
log.Infof("found service %s", p.StructName)
|
||||||
|
|
||||||
services = append(services, srv{
|
services = append(services, srv{
|
||||||
CamelName: lo.PascalCase(name),
|
CamelName: lo.PascalCase(p.StructName),
|
||||||
ServiceName: lo.CamelCase(name),
|
ServiceName: p.StructName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,9 @@ func (pb *ProviderBuilder) BuildFromTypeSpec(typeSpec *ast.TypeSpec, decl *ast.G
|
|||||||
Imports: make(map[string]string),
|
Imports: make(map[string]string),
|
||||||
PkgName: context.PackageName,
|
PkgName: context.PackageName,
|
||||||
ProviderFile: context.FilePath,
|
ProviderFile: context.FilePath,
|
||||||
|
Location: SourceLocation{
|
||||||
|
File: context.FilePath,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set return type
|
// Set return type
|
||||||
|
|||||||
@@ -226,14 +226,26 @@ func (p *MainParser) ParseFile(source string) ([]Provider, error) {
|
|||||||
// 查找对应的 AST 节点
|
// 查找对应的 AST 节点
|
||||||
provider, err := p.buildProviderFromDiscovery(discoveredProvider, node, builderContext)
|
provider, err := p.buildProviderFromDiscovery(discoveredProvider, node, builderContext)
|
||||||
if err != nil {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果启用严格模式,验证 Provider 配置
|
// 如果启用严格模式,验证 Provider 配置
|
||||||
if p.config.StrictMode {
|
if p.config.StrictMode {
|
||||||
if err := p.validator.Validate(&provider); err != nil {
|
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
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -286,7 +298,11 @@ func (p *MainParser) shouldProcessFile(source string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildProviderFromDiscovery builds a complete Provider from a discovered provider annotation
|
// 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
|
// Find the corresponding type specification in the AST
|
||||||
var typeSpec *ast.TypeSpec
|
var typeSpec *ast.TypeSpec
|
||||||
var genDecl *ast.GenDecl
|
var genDecl *ast.GenDecl
|
||||||
|
|||||||
@@ -434,7 +434,12 @@ func (p *GoParser) parseFileContent(filePath string, node *ast.File) ([]Provider
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseProviderDecl parses a provider from an AST declaration
|
// 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)
|
genDecl, ok := decl.(*ast.GenDecl)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -478,6 +483,9 @@ func (p *GoParser) parseProviderDecl(filePath string, fileNode *ast.File, decl a
|
|||||||
Imports: make(map[string]string),
|
Imports: make(map[string]string),
|
||||||
PkgName: fileNode.Name.Name,
|
PkgName: fileNode.Name.Name,
|
||||||
ProviderFile: filepath.Join(filepath.Dir(filePath), "provider.gen.go"),
|
ProviderFile: filepath.Join(filepath.Dir(filePath), "provider.gen.go"),
|
||||||
|
Location: SourceLocation{
|
||||||
|
File: filePath,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set default return type if not specified
|
// 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
|
// 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 {
|
for _, field := range structType.Fields.List {
|
||||||
if field.Names == nil {
|
if field.Names == nil {
|
||||||
continue
|
continue
|
||||||
@@ -573,7 +586,10 @@ func (p *GoParser) parseStructFields(structType *ast.StructType, imports map[str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseFieldType parses a field type and returns its components
|
// 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) {
|
switch t := expr.(type) {
|
||||||
case *ast.Ident:
|
case *ast.Ident:
|
||||||
typ = t.Name
|
typ = t.Name
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -57,33 +57,27 @@ func Serve(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
goose.SetBaseFS(database.MigrationFS)
|
goose.SetBaseFS(database.MigrationFS)
|
||||||
goose.SetTableName("migrations")
|
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...)
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
104
templates/project/llm.gorm_gen.txt.raw
Normal file
104
templates/project/llm.gorm_gen.txt.raw
Normal 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).
|
||||||
@@ -1,19 +1,32 @@
|
|||||||
# Backend Dev Rules (HTTP API + Model)
|
# Backend Dev Rules (HTTP API + Model)
|
||||||
|
|
||||||
This file condenses `backend/docs/dev/http_api.md` + `backend/docs/dev/model.md` into a checklist/rule format for LLMs.
|
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)
|
## 0) Golden rules (DO / DO NOT)
|
||||||
|
|
||||||
- DO follow existing module layout under `backend/app/http/<module>/`.
|
- 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 keep controller methods thin: parse/bind → call `services.*` → return result/error.
|
||||||
- DO regenerate code after changes (routes/docs/models).
|
- 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:
|
- DO NOT manually edit generated files:
|
||||||
- `backend/app/http/**/routes.gen.go`
|
- `app/http/**/routes.gen.go`
|
||||||
- `backend/app/http/**/provider.gen.go`
|
- `app/http/**/provider.gen.go`
|
||||||
- `backend/docs/docs.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`).
|
- 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -21,11 +34,12 @@ This file condenses `backend/docs/dev/http_api.md` + `backend/docs/dev/model.md`
|
|||||||
|
|
||||||
### 1.1 Where code lives
|
### 1.1 Where code lives
|
||||||
|
|
||||||
- Controllers: `backend/app/http/<module>/*.go`
|
- Controllers: `app/http/<module>/*.go`
|
||||||
- Example module: `backend/app/http/super/tenant.go`, `backend/app/http/super/user.go`
|
- Example module: `app/http/super/tenant.go`, `app/http/super/user.go`
|
||||||
- DTOs: `backend/app/http/<module>/dto/*`
|
- DTOs: `app/http/<module>/dto/*`
|
||||||
- Routes (generated): `backend/app/http/<module>/routes.gen.go`
|
- HTTP middlewares: `app/middlewares/*`
|
||||||
- Swagger output (generated): `backend/docs/swagger.yaml`, `backend/docs/swagger.json`, `backend/docs/docs.go`
|
- 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
|
### 1.2 Controller method signatures
|
||||||
|
|
||||||
@@ -84,7 +98,7 @@ Behavior:
|
|||||||
|
|
||||||
### 1.5 Generate routes + providers + swagger docs
|
### 1.5 Generate routes + providers + swagger docs
|
||||||
|
|
||||||
Run from `backend/`:
|
Run from ``:
|
||||||
|
|
||||||
- Generate routes: `atomctl gen route`
|
- Generate routes: `atomctl gen route`
|
||||||
- Generate providers: `atomctl gen provider`
|
- Generate providers: `atomctl gen provider`
|
||||||
@@ -93,20 +107,29 @@ Run from `backend/`:
|
|||||||
### 1.6 Local verify
|
### 1.6 Local verify
|
||||||
|
|
||||||
- Build/run: `make run`
|
- Build/run: `make run`
|
||||||
- Use REST client examples: `backend/test/[module]/[controller].http` (extend it for new endpoints)
|
- Use REST client examples: `tests/[module]/[controller].http` (extend it for new endpoints)
|
||||||
|
|
||||||
### 1.7 Testing
|
### 1.7 Testing
|
||||||
|
|
||||||
- Prefer existing test style under `backend/tests/e2e`.
|
- Prefer existing test style under `tests/e2e`.
|
||||||
- Run: `make test`
|
- 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
|
## 2) Add / update a DB model
|
||||||
|
|
||||||
Models live in:
|
Models live in:
|
||||||
|
|
||||||
- `backend/database/models/*` (generated model code + optional manual extensions)
|
- `database/models/*` (generated model code + optional manual extensions)
|
||||||
|
|
||||||
### 2.1 Migration → model generation workflow
|
### 2.1 Migration → model generation workflow
|
||||||
|
|
||||||
@@ -118,6 +141,7 @@ Models live in:
|
|||||||
|
|
||||||
- No explicit `BEGIN/COMMIT` needed (framework handles).
|
- No explicit `BEGIN/COMMIT` needed (framework handles).
|
||||||
- Table name should be plural (e.g. `tenants`).
|
- 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 field’s usage scenario and rules/constraints (e.g., valid range/format, default behavior, special cases).
|
||||||
|
|
||||||
3) Apply migration:
|
3) Apply migration:
|
||||||
|
|
||||||
@@ -125,16 +149,106 @@ Models live in:
|
|||||||
|
|
||||||
4) Map complex field types (JSON/ARRAY/UUID/…) via transform file:
|
4) Map complex field types (JSON/ARRAY/UUID/…) via transform file:
|
||||||
|
|
||||||
- `backend/database/.transform.yaml` → `field_type.<table>`
|
- `database/.transform.yaml` → `field_type.<table>`
|
||||||
|
|
||||||
5) Generate models:
|
5) Generate models:
|
||||||
|
|
||||||
- `atomctl gen model`
|
- `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 Jobs(River)
|
||||||
|
|
||||||
|
本项目使用 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{...})` 入队。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Events(Watermill)
|
||||||
|
|
||||||
|
本项目使用 `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
|
### 2.2 Enum strategy
|
||||||
|
|
||||||
- DO NOT use native DB ENUM.
|
- DO NOT use native DB ENUM.
|
||||||
- Define enums in Go under `backend/pkg/consts/<table>.go`, example:
|
- Define enums in Go under `pkg/consts/<table>.go`, example:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// swagger:enum UserStatus
|
// swagger:enum UserStatus
|
||||||
@@ -142,15 +256,50 @@ Models live in:
|
|||||||
type UserStatus string
|
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`
|
- Generate enum code: `atomctl gen enum`
|
||||||
|
|
||||||
### 2.3 Supported field types (`gen/types/`)
|
### 2.3 Supported field types (`gen/types/`)
|
||||||
|
|
||||||
`backend/database/.transform.yaml` typically imports `go.ipao.vip/gen` so you can use `types.*` in `field_type`.
|
`database/.transform.yaml` typically imports `go.ipao.vip/gen` so you can use `types.*` in `field_type`.
|
||||||
|
|
||||||
Common types:
|
Common types:
|
||||||
|
|
||||||
- JSON: `types.JSON`, `types.JSONMap`, `types.JSONType[T]`, `types.JSONSlice[T]`
|
- 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 的 JSON(payload 可以是多个不同 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]`
|
- Array: `types.Array[T]`
|
||||||
- UUID: `types.UUID`, `types.BinUUID`
|
- UUID: `types.UUID`, `types.BinUUID`
|
||||||
- Date/Time: `types.Date`, `types.Time`
|
- Date/Time: `types.Date`, `types.Time`
|
||||||
@@ -181,27 +330,128 @@ Generator will convert snake_case columns to Go struct field names (e.g. `class_
|
|||||||
|
|
||||||
### 2.5 Extending generated models
|
### 2.5 Extending generated models
|
||||||
|
|
||||||
- Add manual methods/hooks by creating `backend/database/models/<table>.go`.
|
- Add manual methods/hooks by creating `database/models/<table>.go`.
|
||||||
- Keep generated files untouched ; put custom logic only in your own file(s).
|
- Keep generated files untouched ; put custom logic only in your own file(s).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3) Service layer injection (when adding services)
|
## 3) Service layer injection (when adding services)
|
||||||
|
|
||||||
- Services are in `backend/app/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:
|
- After creating/updating a service provider, regenerate wiring:
|
||||||
- `atomctl gen service`
|
- `atomctl gen service`
|
||||||
- `atomctl gen provider`
|
- `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 call conventions:
|
||||||
- **Service-to-service (inside `services` package)**: call directly as `CamelCaseServiceStructName.Method()` (no `services.` prefix).
|
- **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()`.
|
- **From outside (controllers/handlers/etc.)**: call via the package entrypoint `services.CamelCaseServiceStructName.Method()`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4) Quick command summary (run in `backend/`)
|
## 4) Quick command summary (run in ``)
|
||||||
|
|
||||||
- `make run` / `make build` / `make test`
|
- `make run` / `make build` / `make test`
|
||||||
- `atomctl gen route` / `atomctl gen provider` / `atomctl swag init`
|
- `atomctl gen route` / `atomctl gen provider` / `atomctl swag init`
|
||||||
- `atomctl migrate create ...` / `atomctl migrate up`
|
- `atomctl migrate create ...` / `atomctl migrate up`
|
||||||
- `atomctl gen model` / `atomctl gen enum` / `atomctl gen service`
|
- `atomctl gen model` / `atomctl gen enum` / `atomctl gen service`
|
||||||
- `make init` (full refresh)
|
- `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 method’s key behavior contracts and boundary conditions via subcases (`Convey` blocks or `t.Run`) so the method’s 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user