Compare commits
15 Commits
1a7bb737af
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 7848dc2853 | |||
| 00742993db | |||
| 861748b7d9 | |||
| 596ea635c2 | |||
| fcf107036b | |||
| c7ecf35c7c | |||
| 6b3981c3bc | |||
| 12faa04a7e | |||
| df8c0627b4 | |||
| ff4fba7a43 | |||
| 3c180091e1 | |||
|
|
64ae2d6d44 | ||
|
|
e25004df5b | ||
|
|
daf93e7055 | ||
|
|
b37b12884f |
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
|
||||
"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",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -56,10 +56,10 @@ func commandSwagInitE(cmd *cobra.Command, args []string) error {
|
||||
PropNamingStrategy: swag.CamelCase,
|
||||
OutputDir: filepath.Join(root, outDir),
|
||||
OutputTypes: []string{"go", "json", "yaml"},
|
||||
ParseVendor: false,
|
||||
ParseDependency: 0,
|
||||
ParseVendor: true,
|
||||
ParseDependency: 1,
|
||||
MarkdownFilesDir: "",
|
||||
ParseInternal: false,
|
||||
ParseInternal: true,
|
||||
Strict: false,
|
||||
GeneratedTime: false,
|
||||
RequiredByDefault: false,
|
||||
|
||||
14
go.mod
14
go.mod
@@ -17,12 +17,12 @@ require (
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
go.ipao.vip/gen v0.0.0-20250909113008-7e6ae4534ada
|
||||
golang.org/x/mod v0.28.0
|
||||
golang.org/x/text v0.29.0
|
||||
golang.org/x/tools v0.36.0
|
||||
go.ipao.vip/gen v0.0.0-20250924024520-70c4accdea44
|
||||
golang.org/x/mod v0.31.0
|
||||
golang.org/x/text v0.32.0
|
||||
golang.org/x/tools v0.40.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.30.5
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -86,8 +86,8 @@ require (
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.42.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/hints v1.1.2 // indirect
|
||||
|
||||
13
go.sum
13
go.sum
@@ -227,6 +227,8 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.ipao.vip/gen v0.0.0-20250909113008-7e6ae4534ada h1:suAdnZAD6BZpgQ6/pK6wnH49T9x/52WCzGk+lf+oy7g=
|
||||
go.ipao.vip/gen v0.0.0-20250909113008-7e6ae4534ada/go.mod h1:ip5X9ioxR9hvM/mrsA77KWXFsrMm5oki5rfY5MSkssM=
|
||||
go.ipao.vip/gen v0.0.0-20250924024520-70c4accdea44 h1:i7zFEsfUYRJQo0mXUWI/RoEkgEdTNmLt0Io2rwhqY9E=
|
||||
go.ipao.vip/gen v0.0.0-20250924024520-70c4accdea44/go.mod h1:ip5X9ioxR9hvM/mrsA77KWXFsrMm5oki5rfY5MSkssM=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
@@ -251,6 +253,8 @@ golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
||||
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
@@ -265,6 +269,8 @@ golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -279,6 +285,7 @@ golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -291,6 +298,8 @@ golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
@@ -300,6 +309,8 @@ golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
|
||||
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -327,6 +338,8 @@ gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
gorm.io/gorm v1.30.5 h1:dvEfYwxL+i+xgCNSGGBT1lDjCzfELK8fHZxL3Ee9X0s=
|
||||
gorm.io/gorm v1.30.5/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gorm.io/hints v1.1.0 h1:Lp4z3rxREufSdxn4qmkK3TLDltrM10FLTHiuqwDPvXw=
|
||||
gorm.io/hints v1.1.0/go.mod h1:lKQ0JjySsPBj3uslFzY3JhYDtqEwzm+G1hv8rWujB6Y=
|
||||
gorm.io/hints v1.1.2 h1:b5j0kwk5p4+3BtDtYqqfY+ATSxjj+6ptPgVveuynn9o=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
9
pkg/ast/route/manual.go.tpl
Normal file
9
pkg/ast/route/manual.go.tpl
Normal file
@@ -0,0 +1,9 @@
|
||||
package {{.PackageName}}
|
||||
|
||||
func (r *Routes) Path() string {
|
||||
return "/{{.PackageName}}"
|
||||
}
|
||||
|
||||
func (r *Routes) Middlewares() []any{
|
||||
return []any{}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
40
templates/project/.air.toml.raw
Normal file
40
templates/project/.air.toml.raw
Normal file
@@ -0,0 +1,40 @@
|
||||
# .air.toml - Air 热重载配置文件
|
||||
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./tmp/main"
|
||||
cmd = "go build -o ./tmp/main ."
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", "frontend"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html", "yaml", "yml", "toml"]
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
send_interrupt = false
|
||||
stop_on_root = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
||||
58
templates/project/.env.example.raw
Normal file
58
templates/project/.env.example.raw
Normal file
@@ -0,0 +1,58 @@
|
||||
# 应用配置
|
||||
APP_MODE=development
|
||||
APP_BASE_URI=http://localhost:8080
|
||||
|
||||
# HTTP 服务配置
|
||||
HTTP_PORT=8080
|
||||
HTTP_HOST=0.0.0.0
|
||||
|
||||
# 数据库配置
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME={{.ProjectName}}
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=password
|
||||
DB_SSL_MODE=disable
|
||||
DB_MAX_CONNECTIONS=25
|
||||
DB_MAX_IDLE_CONNECTIONS=5
|
||||
DB_CONNECTION_LIFETIME=5m
|
||||
|
||||
# Redis 配置
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET_KEY=your-secret-key-here
|
||||
JWT_EXPIRES_TIME=168h
|
||||
|
||||
# HashIDs 配置
|
||||
HASHIDS_SALT=your-salt-here
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL=info
|
||||
LOG_FORMAT=json
|
||||
|
||||
# 文件上传配置
|
||||
UPLOAD_MAX_SIZE=10MB
|
||||
UPLOAD_PATH=./uploads
|
||||
|
||||
# 邮件配置
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
|
||||
# 第三方服务配置
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
DATABASE_URL=postgres://postgres:password@localhost:5432/{{.ProjectName}}?sslmode=disable
|
||||
|
||||
# 开发配置
|
||||
ENABLE_SWAGGER=true
|
||||
ENABLE_CORS=true
|
||||
DEBUG_MODE=true
|
||||
|
||||
# 监控配置
|
||||
ENABLE_METRICS=false
|
||||
METRICS_PORT=9090
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Build TGExporter
|
||||
run-name: ${{ gitea.actor }} Build TGExporter
|
||||
name: Build Application
|
||||
run-name: ${{ gitea.actor }} Build Application
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
|
||||
294
templates/project/.golangci.yml.raw
Normal file
294
templates/project/.golangci.yml.raw
Normal file
@@ -0,0 +1,294 @@
|
||||
# golangci-lint 配置文件
|
||||
# https://golangci-lint.run/usage/configuration/
|
||||
|
||||
# 运行时配置
|
||||
run:
|
||||
# 默认并行处理器数量
|
||||
default-concurrency: 4
|
||||
|
||||
# 超时时间
|
||||
timeout: 5m
|
||||
|
||||
# 退出代码
|
||||
issues-exit-code: 1
|
||||
|
||||
# 测试包含的文件
|
||||
tests: true
|
||||
|
||||
# 是否跳过文件
|
||||
skip-files:
|
||||
- "_test\\.go$"
|
||||
- ".*\\.gen\\.go$"
|
||||
- ".*\\.pb\\.go$"
|
||||
|
||||
# 是否跳过目录
|
||||
skip-dirs:
|
||||
- "vendor"
|
||||
- "node_modules"
|
||||
- ".git"
|
||||
- "build"
|
||||
- "dist"
|
||||
|
||||
# 输出配置
|
||||
output:
|
||||
# 输出格式
|
||||
format: colored-line-number
|
||||
|
||||
# 打印已使用的 linter
|
||||
print-issued-lines: true
|
||||
|
||||
# 打印 linter 名称
|
||||
print-linter-name: true
|
||||
|
||||
# 唯一性检查
|
||||
uniq-by-line: true
|
||||
|
||||
# linter 启用配置
|
||||
linters-settings:
|
||||
# 错误检查
|
||||
errcheck:
|
||||
# 检查类型断言
|
||||
check-type-assertions: true
|
||||
# 检查赋值
|
||||
check-blank: true
|
||||
|
||||
# 代码复杂度
|
||||
gocyclo:
|
||||
# 最小复杂度
|
||||
min-complexity: 15
|
||||
|
||||
# 函数参数和返回值
|
||||
gocognit:
|
||||
# 最小认知复杂度
|
||||
min-complexity: 20
|
||||
|
||||
# 函数长度
|
||||
funlen:
|
||||
# 最大行数
|
||||
lines: 60
|
||||
# 最大语句数
|
||||
statements: 40
|
||||
|
||||
# 代码行长度
|
||||
lll:
|
||||
# 最大行长度
|
||||
line-length: 120
|
||||
|
||||
# 导入顺序
|
||||
importas:
|
||||
# 别名规则
|
||||
no-unaliased: true
|
||||
alias:
|
||||
- pkg: "github.com/sirupsen/logrus"
|
||||
alias: "logrus"
|
||||
- pkg: "github.com/stretchr/testify/assert"
|
||||
alias: "assert"
|
||||
- pkg: "github.com/stretchr/testify/suite"
|
||||
alias: "suite"
|
||||
|
||||
# 重复导入
|
||||
dupl:
|
||||
# 重复代码块的最小 token 数
|
||||
threshold: 100
|
||||
|
||||
# 空值检查
|
||||
nilerr:
|
||||
# 检查返回 nil 的函数
|
||||
check-type-assertions: true
|
||||
check-blank: true
|
||||
|
||||
# 代码格式化
|
||||
gofmt:
|
||||
# 格式化简化
|
||||
simplify: true
|
||||
|
||||
# 导入检查
|
||||
goimports:
|
||||
# 本地前缀
|
||||
local-prefixes: "{{.ModuleName}}"
|
||||
|
||||
# 静态检查
|
||||
staticcheck:
|
||||
# 检查版本
|
||||
go_version: "1.22"
|
||||
|
||||
# 结构体标签
|
||||
structtag:
|
||||
# 检查标签
|
||||
required: []
|
||||
# 是否允许空标签
|
||||
allow-omit-latest: true
|
||||
|
||||
# 未使用的变量
|
||||
unused:
|
||||
# 检查字段
|
||||
check-exported-fields: true
|
||||
|
||||
# 变量命名
|
||||
varnamelen:
|
||||
# 最小变量名长度
|
||||
min-name-length: 2
|
||||
# 检查参数
|
||||
check-parameters: true
|
||||
# 检查返回值
|
||||
check-return: true
|
||||
# 检查接收器
|
||||
check-receiver: true
|
||||
# 检查变量
|
||||
check-variable: true
|
||||
# 忽略名称
|
||||
ignore-names:
|
||||
- "ok"
|
||||
- "err"
|
||||
- "T"
|
||||
- "i"
|
||||
- "n"
|
||||
- "v"
|
||||
# 忽略类型
|
||||
ignore-type-assert-ok: true
|
||||
ignore-map-index-ok: true
|
||||
ignore-chan-recv-ok: true
|
||||
ignore-decls:
|
||||
- "T any"
|
||||
- "w http.ResponseWriter"
|
||||
- "r *http.Request"
|
||||
|
||||
# 启用的 linter
|
||||
linters:
|
||||
enable:
|
||||
# 错误检查
|
||||
- errcheck
|
||||
- errorlint
|
||||
- goerr113
|
||||
|
||||
# 代码复杂度
|
||||
- gocyclo
|
||||
- gocognit
|
||||
- funlen
|
||||
|
||||
# 代码风格
|
||||
- gofmt
|
||||
- goimports
|
||||
- lll
|
||||
- misspell
|
||||
- whitespace
|
||||
|
||||
# 导入检查
|
||||
- importas
|
||||
- dupl
|
||||
|
||||
# 静态检查
|
||||
- staticcheck
|
||||
- unused
|
||||
- typecheck
|
||||
- ineffassign
|
||||
- bodyclose
|
||||
- contextcheck
|
||||
- nilerr
|
||||
|
||||
# 测试检查
|
||||
- tparallel
|
||||
- testpackage
|
||||
- thelper
|
||||
|
||||
# 性能检查
|
||||
- prealloc
|
||||
- unconvert
|
||||
|
||||
# 安全检查
|
||||
- gosec
|
||||
- noctx
|
||||
- rowserrcheck
|
||||
|
||||
# 代码质量
|
||||
- revive
|
||||
- varnamelen
|
||||
- exportloopref
|
||||
- forcetypeassert
|
||||
- govet
|
||||
- paralleltest
|
||||
- nlreturn
|
||||
- wastedassign
|
||||
- wrapcheck
|
||||
|
||||
# 禁用的 linter
|
||||
linters-disable:
|
||||
- deadcode # 被 unused 替代
|
||||
- varcheck # 被 unused 替代
|
||||
- structcheck # 被 unused 替代
|
||||
- interfacer # 已弃用
|
||||
- maligned # 已弃用
|
||||
- scopelint # 已弃用
|
||||
|
||||
# 问题配置
|
||||
issues:
|
||||
# 排除规则
|
||||
exclude-rules:
|
||||
# 排除测试文件的某些规则
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- funlen
|
||||
- gocyclo
|
||||
- dupl
|
||||
- gochecknoglobals
|
||||
- gochecknoinits
|
||||
|
||||
# 排除生成的文件
|
||||
- path: \.gen\.go$
|
||||
linters:
|
||||
- lll
|
||||
- funlen
|
||||
- gocyclo
|
||||
|
||||
# 排除错误处理中的简单错误检查
|
||||
- path: .*
|
||||
text: "Error return value of `.*` is not checked"
|
||||
|
||||
# 排除特定的 golangci-lint 注释
|
||||
- path: .*
|
||||
text: "// nolint:.*"
|
||||
|
||||
# 排除 context.Context 的未使用检查
|
||||
- path: .*
|
||||
text: "context.Context should be the first parameter of a function"
|
||||
|
||||
# 排除某些性能优化建议
|
||||
- path: .*
|
||||
text: "predeclared"
|
||||
|
||||
# 排除某些重复代码检查
|
||||
- path: .*
|
||||
linters:
|
||||
- dupl
|
||||
text: "is duplicate of"
|
||||
|
||||
# 最大问题数
|
||||
max-issues-per-linter: 50
|
||||
|
||||
# 最大相同问题数
|
||||
max-same-issues: 3
|
||||
|
||||
# 严重性配置
|
||||
severity:
|
||||
# 默认严重性
|
||||
default-severity: error
|
||||
|
||||
# 规则严重性
|
||||
rules:
|
||||
- linters:
|
||||
- dupl
|
||||
- gosec
|
||||
severity: warning
|
||||
|
||||
- linters:
|
||||
- misspell
|
||||
- whitespace
|
||||
severity: info
|
||||
|
||||
# 性能配置
|
||||
performance:
|
||||
# 是否使用内存缓存
|
||||
use-memory-cache: true
|
||||
|
||||
# 缓存超时时间
|
||||
cache-timeout: 5m
|
||||
32
templates/project/Dockerfile.dev
Normal file
32
templates/project/Dockerfile.dev
Normal file
@@ -0,0 +1,32 @@
|
||||
# 开发环境 Dockerfile
|
||||
FROM golang:1.22-alpine AS builder
|
||||
|
||||
# 安装必要的工具
|
||||
RUN apk add --no-cache git ca-certificates tzdata
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制 go mod 文件
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# 设置 Go 代理
|
||||
RUN go env -w GOPROXY=https://goproxy.cn,direct
|
||||
|
||||
# 下载依赖
|
||||
RUN go mod download
|
||||
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
|
||||
# 设置时区
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
# 安装 air 用于热重载
|
||||
RUN go install github.com/air-verse/air@latest
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8080 9090
|
||||
|
||||
# 启动命令
|
||||
CMD ["air", "-c", ".air.toml"]
|
||||
@@ -1,17 +1,82 @@
|
||||
FROM docker.hub.ipao.vip/alpine:3.20
|
||||
# 多阶段构建 Dockerfile
|
||||
# 阶段 1: 构建应用
|
||||
FROM golang:1.22-alpine AS builder
|
||||
|
||||
# Set timezone
|
||||
RUN apk add --no-cache tzdata && \
|
||||
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
|
||||
# 安装构建依赖
|
||||
RUN apk add --no-cache git ca-certificates tzdata
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制 go mod 文件
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# 设置 Go 代理
|
||||
ENV GOPROXY=https://goproxy.cn,direct
|
||||
ENV CGO_ENABLED=0
|
||||
ENV GOOS=linux
|
||||
ENV GOARCH=amd64
|
||||
|
||||
# 下载依赖
|
||||
RUN go mod download
|
||||
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
|
||||
# 构建应用
|
||||
RUN go build -a -installsuffix cgo -ldflags="-w -s" -o main .
|
||||
|
||||
# 阶段 2: 构建前端(如果有)
|
||||
# 如果有前端构建,取消下面的注释
|
||||
# FROM node:18-alpine AS frontend-builder
|
||||
# WORKDIR /app
|
||||
# COPY frontend/package*.json ./
|
||||
# RUN npm ci --only=production
|
||||
# COPY frontend/ .
|
||||
# RUN npm run build
|
||||
|
||||
# 阶段 3: 运行时镜像
|
||||
FROM alpine:3.20 AS runtime
|
||||
|
||||
# 安装运行时依赖
|
||||
RUN apk add --no-cache ca-certificates tzdata curl
|
||||
|
||||
# 设置时区
|
||||
RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
|
||||
echo "Asia/Shanghai" > /etc/timezone && \
|
||||
apk del tzdata
|
||||
|
||||
COPY backend/build/app /app/app
|
||||
COPY backend/config.toml /app/config.toml
|
||||
COPY frontend/dist /app/dist
|
||||
# 创建非 root 用户
|
||||
RUN addgroup -g 1000 appgroup && \
|
||||
adduser -u 1000 -G appgroup -s /bin/sh -D appuser
|
||||
|
||||
# 创建必要的目录
|
||||
RUN mkdir -p /app/config /app/logs /app/uploads && \
|
||||
chown -R appuser:appgroup /app
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
ENTRYPOINT ["/app/app"]
|
||||
# 从构建阶段复制应用
|
||||
COPY --from=builder /app/main .
|
||||
COPY --chown=appuser:appgroup config.toml ./config/
|
||||
|
||||
CMD [ "serve" ]
|
||||
# 如果有前端构建,取消下面的注释
|
||||
# COPY --from=frontend-builder /app/dist ./dist
|
||||
|
||||
# 创建空目录供应用使用
|
||||
RUN mkdir -p /app/logs /app/uploads && \
|
||||
chown -R appuser:appgroup /app
|
||||
|
||||
# 切换到非 root 用户
|
||||
USER appuser
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/health || exit 1
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8080
|
||||
|
||||
# 启动应用
|
||||
CMD ["./main", "serve"]
|
||||
|
||||
27
templates/project/Makefile.raw → templates/project/Makefile.tpl
Executable file → Normal file
27
templates/project/Makefile.raw → templates/project/Makefile.tpl
Executable file → Normal file
@@ -1,7 +1,8 @@
|
||||
buildAt=`date +%Y/%m/%d-%H:%M:%S`
|
||||
gitHash=`git rev-parse HEAD`
|
||||
version=`git rev-parse --abbrev-ref HEAD | grep -v HEAD || git describe --exact-match HEAD || git rev-parse HEAD` ## todo: use current release git tag
|
||||
flags="-X 'atom/utils.Version=${version}' -X 'atom/utils.BuildAt=${buildAt}' -X 'atom/utils.GitHash=${gitHash}'"
|
||||
gitHash=`(git log -1 --pretty=format:%H 2>/dev/null || echo "no-commit")`
|
||||
version=`(git describe --tags --exact-match HEAD 2>/dev/null || git rev-parse --abbrev-ref HEAD 2>/dev/null | grep -v HEAD 2>/dev/null || echo "dev")`
|
||||
# 修改为项目特定的变量路径
|
||||
flags="-X '{{.ModuleName}}/pkg/utils.Version=${version}' -X '{{.ModuleName}}/pkg/utils.BuildAt=${buildAt}' -X '{{.ModuleName}}/pkg/utils.GitHash=${gitHash}'"
|
||||
release_flags="-w -s ${flags}"
|
||||
|
||||
GOPATH:=$(shell go env GOPATH)
|
||||
@@ -15,9 +16,25 @@ release:
|
||||
@CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags=${flags} -o bin/release/{{.ProjectName}} .
|
||||
@cp config.toml bin/release/
|
||||
|
||||
.PHONY: build
|
||||
build:
|
||||
@go build -ldflags=${flags} -o bin/{{.ProjectName}} .
|
||||
|
||||
.PHONY: run
|
||||
run: build
|
||||
@./bin/{{.ProjectName}}
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@go test -v ./... -cover
|
||||
@go test -v ./tests/... -cover
|
||||
|
||||
.PHONY: info
|
||||
info:
|
||||
@echo "Build Information:"
|
||||
@echo "=================="
|
||||
@echo "Build Time: $(buildAt)"
|
||||
@echo "Git Hash: $(gitHash)"
|
||||
@echo "Version: $(version)"
|
||||
|
||||
.PHONY: lint
|
||||
lint:
|
||||
@@ -44,4 +61,4 @@ init: tools
|
||||
@buf generate
|
||||
@go mod tidy
|
||||
@go get -u
|
||||
@go mod tidy
|
||||
@go mod tidy
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
[App]
|
||||
Mode = "development"
|
||||
BaseURI = "baseURI"
|
||||
|
||||
[Http]
|
||||
Port = 8080
|
||||
|
||||
[Database]
|
||||
Host = "10.1.1.1"
|
||||
Database = "postgres"
|
||||
Password = "hello"
|
||||
|
||||
|
||||
[JWT]
|
||||
ExpiresTime = "168h"
|
||||
SigningKey = "Key"
|
||||
|
||||
[HashIDs]
|
||||
Salt = "Salt"
|
||||
|
||||
[Redis]
|
||||
Host = ""
|
||||
Port = 6379
|
||||
Password = "hello"
|
||||
DB = 0
|
||||
99
templates/project/config.toml.tpl
Normal file
99
templates/project/config.toml.tpl
Normal file
@@ -0,0 +1,99 @@
|
||||
# =========================
|
||||
# 应用基础配置
|
||||
# =========================
|
||||
[App]
|
||||
# 应用运行模式:development | production | testing
|
||||
Mode = "development"
|
||||
# 应用基础URI,用于生成完整URL
|
||||
BaseURI = "http://localhost:8080"
|
||||
|
||||
# =========================
|
||||
# HTTP 服务器配置
|
||||
# =========================
|
||||
[Http]
|
||||
# HTTP服务监听端口
|
||||
Port = 8080
|
||||
# 监听地址(可选,默认 0.0.0.0)
|
||||
# Host = "0.0.0.0"
|
||||
# 全局路由前缀(可选)
|
||||
# BaseURI = "/api/v1"
|
||||
|
||||
# =========================
|
||||
# 数据库配置
|
||||
# =========================
|
||||
[Database]
|
||||
# 数据库主机地址
|
||||
Host = "localhost"
|
||||
# 数据库端口
|
||||
Port = 5432
|
||||
# 数据库名称
|
||||
Database = "{{.ProjectName}}"
|
||||
# 数据库用户名
|
||||
Username = "postgres"
|
||||
# 数据库密码
|
||||
Password = "password"
|
||||
# SSL模式:disable | require | verify-ca | verify-full
|
||||
SslMode = "disable"
|
||||
# 时区
|
||||
TimeZone = "Asia/Shanghai"
|
||||
# 连接池配置(可选)
|
||||
MaxIdleConns = 10
|
||||
MaxOpenConns = 100
|
||||
ConnMaxLifetime = "1800s"
|
||||
ConnMaxIdleTime = "300s"
|
||||
|
||||
# =========================
|
||||
# JWT 认证配置
|
||||
# =========================
|
||||
[JWT]
|
||||
# JWT签名密钥(生产环境请使用强密钥)
|
||||
SigningKey = "your-secret-key-change-in-production"
|
||||
# Token过期时间,如:72h, 168h, 720h
|
||||
ExpiresTime = "168h"
|
||||
# 签发者(可选)
|
||||
Issuer = "{{.ProjectName}}"
|
||||
|
||||
# =========================
|
||||
# HashIDs 配置
|
||||
# =========================
|
||||
[HashIDs]
|
||||
# 盐值(用于ID加密,请使用随机字符串)
|
||||
Salt = "your-random-salt-here"
|
||||
# 最小长度(可选)
|
||||
MinLength = 8
|
||||
# 自定义字符集(可选)
|
||||
# Alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"
|
||||
|
||||
# =========================
|
||||
# Redis 缓存配置
|
||||
# =========================
|
||||
[Redis]
|
||||
# Redis主机地址
|
||||
Host = "localhost"
|
||||
# Redis端口
|
||||
Port = 6379
|
||||
# Redis密码(可选)
|
||||
Password = ""
|
||||
# 数据库编号
|
||||
DB = 0
|
||||
# 连接池配置(可选)
|
||||
PoolSize = 50
|
||||
MinIdleConns = 10
|
||||
MaxRetries = 3
|
||||
# 超时配置(可选)
|
||||
DialTimeout = "5s"
|
||||
ReadTimeout = "3s"
|
||||
WriteTimeout = "3s"
|
||||
|
||||
# =========================
|
||||
# 日志配置
|
||||
# =========================
|
||||
[Log]
|
||||
# 日志级别:debug | info | warn | error
|
||||
Level = "info"
|
||||
# 日志格式:json | text
|
||||
Format = "json"
|
||||
# 输出文件(可选,未配置则输出到控制台)
|
||||
# Output = "./logs/app.log"
|
||||
# 是否启用调用者信息(文件名:行号)
|
||||
EnableCaller = true
|
||||
@@ -0,0 +1,50 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token VARCHAR(255) UNIQUE NOT NULL,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
action VARCHAR(100) NOT NULL,
|
||||
entity_type VARCHAR(100) NOT NULL,
|
||||
entity_id INTEGER,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
metadata JSONB,
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes for better performance
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_entity ON audit_logs(entity_type, entity_id);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
DROP TABLE IF EXISTS audit_logs;
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
DROP TABLE IF EXISTS users;
|
||||
|
||||
-- +goose StatementEnd
|
||||
119
templates/project/docker-compose.yml.tpl
Normal file
119
templates/project/docker-compose.yml.tpl
Normal file
@@ -0,0 +1,119 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PostgreSQL 数据库
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: {{.ProjectName}}-postgres
|
||||
environment:
|
||||
POSTGRES_DB: {{.ProjectName}}
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./database/init:/docker-entrypoint-initdb.d
|
||||
networks:
|
||||
- {{.ProjectName}}-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Redis 缓存
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: {{.ProjectName}}-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- {{.ProjectName}}-network
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
# 应用服务
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: {{.ProjectName}}-app
|
||||
environment:
|
||||
- APP_MODE=development
|
||||
- HTTP_PORT=8080
|
||||
- DB_HOST=postgres
|
||||
- DB_PORT=5432
|
||||
- DB_NAME={{.ProjectName}}
|
||||
- DB_USER=postgres
|
||||
- DB_PASSWORD=password
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- REDIS_DB=0
|
||||
- JWT_SECRET_KEY=your-secret-key-here
|
||||
- HASHIDS_SALT=your-salt-here
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "9090:9090" # 监控端口
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/vendor
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- {{.ProjectName}}-network
|
||||
restart: unless-stopped
|
||||
|
||||
# PgAdmin 数据库管理工具
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:latest
|
||||
container_name: {{.ProjectName}}-pgadmin
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: admin@example.com
|
||||
PGADMIN_DEFAULT_PASSWORD: admin
|
||||
ports:
|
||||
- "8081:80"
|
||||
volumes:
|
||||
- pgadmin_data:/var/lib/pgadmin
|
||||
networks:
|
||||
- {{.ProjectName}}-network
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- tools
|
||||
|
||||
# Redis 管理工具
|
||||
redis-commander:
|
||||
image: rediscommander/redis-commander:latest
|
||||
container_name: {{.ProjectName}}-redis-commander
|
||||
environment:
|
||||
REDIS_HOSTS: local:redis:6379
|
||||
ports:
|
||||
- "8082:8081"
|
||||
networks:
|
||||
- {{.ProjectName}}-network
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- tools
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
pgadmin_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
{{.ProjectName}}-network:
|
||||
driver: bridge
|
||||
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).
|
||||
457
templates/project/llm.txt.raw
Normal file
457
templates/project/llm.txt.raw
Normal 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 field’s 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 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
|
||||
|
||||
- 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 的 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]`
|
||||
- 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 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)
|
||||
})
|
||||
}
|
||||
```
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"{{.ModuleName}}/app/commands/http"
|
||||
"{{.ModuleName}}/app/commands/migrate"
|
||||
"{{.ModuleName}}/pkg/utils"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"go.ipao.vip/atom"
|
||||
@@ -22,7 +23,11 @@ import (
|
||||
// @securityDefinitions.basic BasicAuth
|
||||
// @externalDocs.description OpenAPI
|
||||
// @externalDocs.url https://swagger.io/resources/open-api/
|
||||
|
||||
func main() {
|
||||
// 打印构建信息
|
||||
utils.PrintBuildInfo("{{.ProjectName}}")
|
||||
|
||||
opts := []atom.Option{
|
||||
atom.Name("{{ .ProjectName }}"),
|
||||
http.Command(),
|
||||
|
||||
44
templates/project/pkg/utils/build_info.go.tpl
Normal file
44
templates/project/pkg/utils/build_info.go.tpl
Normal file
@@ -0,0 +1,44 @@
|
||||
package utils
|
||||
|
||||
import "fmt"
|
||||
|
||||
// 构建信息变量,通过 ldflags 在构建时注入
|
||||
var (
|
||||
// Version 应用版本信息
|
||||
Version string
|
||||
|
||||
// BuildAt 构建时间
|
||||
BuildAt string
|
||||
|
||||
// GitHash Git 提交哈希
|
||||
GitHash string
|
||||
)
|
||||
|
||||
// GetBuildInfo 获取构建信息
|
||||
func GetBuildInfo() map[string]string {
|
||||
return map[string]string{
|
||||
"version": Version,
|
||||
"buildAt": BuildAt,
|
||||
"gitHash": GitHash,
|
||||
}
|
||||
}
|
||||
|
||||
// PrintBuildInfo 打印构建信息
|
||||
func PrintBuildInfo(appName string) {
|
||||
buildInfo := GetBuildInfo()
|
||||
|
||||
println("========================================")
|
||||
printf("🚀 %s\n", appName)
|
||||
println("========================================")
|
||||
printf("📋 Version: %s\n", buildInfo["version"])
|
||||
printf("🕐 Build Time: %s\n", buildInfo["buildAt"])
|
||||
printf("🔗 Git Hash: %s\n", buildInfo["gitHash"])
|
||||
println("========================================")
|
||||
println("🌟 Application is starting...")
|
||||
println()
|
||||
}
|
||||
|
||||
// 为了避免导入 fmt 包,我们使用内置的 print 和 printf 函数
|
||||
func printf(format string, args ...interface{}) {
|
||||
print(fmt.Sprintf(format, args...))
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"test/providers/postgres"
|
||||
"{{.ModuleName}}/providers/postgres"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
288
templates/project/tests/README.md
Normal file
288
templates/project/tests/README.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# 测试指南
|
||||
|
||||
本项目的测试使用 **Convey 框架**,分为三个层次:单元测试、集成测试和端到端测试。
|
||||
|
||||
## 测试结构
|
||||
|
||||
```
|
||||
tests/
|
||||
├── setup_test.go # 测试设置和通用工具
|
||||
├── unit/ # 单元测试
|
||||
│ ├── config_test.go # 配置测试
|
||||
│ └── ... # 其他单元测试
|
||||
├── integration/ # 集成测试
|
||||
│ ├── database_test.go # 数据库集成测试
|
||||
│ └── ... # 其他集成测试
|
||||
└── e2e/ # 端到端测试
|
||||
├── api_test.go # API 测试
|
||||
└── ... # 其他 E2E 测试
|
||||
```
|
||||
|
||||
## Convey 框架概述
|
||||
|
||||
Convey 是一个 BDD 风格的 Go 测试框架,提供直观的语法和丰富的断言。
|
||||
|
||||
### 核心概念
|
||||
|
||||
- **Convey**: 定义测试上下文,类似于 `Describe` 或 `Context`
|
||||
- **So**: 断言函数,验证预期结果
|
||||
- **Reset**: 清理函数,在每个测试后执行
|
||||
|
||||
### 基本语法
|
||||
|
||||
```go
|
||||
Convey("测试场景描述", t, func() {
|
||||
Convey("当某个条件发生时", func() {
|
||||
// 准备测试数据
|
||||
result := SomeFunction()
|
||||
|
||||
Convey("那么应该得到预期结果", func() {
|
||||
So(result, ShouldEqual, "expected")
|
||||
})
|
||||
})
|
||||
|
||||
Reset(func() {
|
||||
// 清理测试数据
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## 运行测试
|
||||
|
||||
### 运行所有测试
|
||||
```bash
|
||||
go test ./tests/... -v
|
||||
```
|
||||
|
||||
### 运行特定类型的测试
|
||||
```bash
|
||||
# 单元测试
|
||||
go test ./tests/unit/... -v
|
||||
|
||||
# 集成测试
|
||||
go test ./tests/integration/... -v
|
||||
|
||||
# 端到端测试
|
||||
go test ./tests/e2e/... -v
|
||||
```
|
||||
|
||||
### 运行带覆盖率报告的测试
|
||||
```bash
|
||||
go test ./tests/... -v -coverprofile=coverage.out
|
||||
go tool cover -html=coverage.out -o coverage.html
|
||||
```
|
||||
|
||||
### 运行基准测试
|
||||
```bash
|
||||
go test ./tests/... -bench=. -v
|
||||
```
|
||||
|
||||
## 测试环境配置
|
||||
|
||||
### 单元测试
|
||||
- 不需要外部依赖
|
||||
- 使用内存数据库或模拟对象
|
||||
- 快速执行
|
||||
|
||||
### 集成测试
|
||||
- 需要数据库连接
|
||||
- 使用测试数据库 `{{.ProjectName}}_test`
|
||||
- 需要启动 Redis 等服务
|
||||
|
||||
### 端到端测试
|
||||
- 需要完整的应用环境
|
||||
- 测试真实的 HTTP 请求
|
||||
- 可能需要 Docker 环境
|
||||
|
||||
## Convey 测试最佳实践
|
||||
|
||||
### 1. 测试结构设计
|
||||
- 使用描述性的中文场景描述
|
||||
- 遵循 `当...那么...` 的语义结构
|
||||
- 嵌套 Convey 块来组织复杂测试逻辑
|
||||
|
||||
```go
|
||||
Convey("用户认证测试", t, func() {
|
||||
var user *User
|
||||
var token string
|
||||
|
||||
Convey("当用户注册时", func() {
|
||||
user = &User{Name: "测试用户", Email: "test@example.com"}
|
||||
err := user.Register()
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
Convey("那么用户应该被创建", func() {
|
||||
So(user.ID, ShouldBeGreaterThan, 0)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("当用户登录时", func() {
|
||||
token, err := user.Login("password")
|
||||
So(err, ShouldBeNil)
|
||||
So(token, ShouldNotBeEmpty)
|
||||
|
||||
Convey("那么应该获得有效的访问令牌", func() {
|
||||
So(len(token), ShouldBeGreaterThan, 0)
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 2. 断言使用
|
||||
- 使用丰富的 So 断言函数
|
||||
- 提供有意义的错误消息
|
||||
- 验证所有重要的方面
|
||||
|
||||
### 3. 数据管理
|
||||
- 使用 `Reset` 函数进行清理
|
||||
- 每个测试独立准备数据
|
||||
- 确保测试间不相互影响
|
||||
|
||||
### 4. 异步测试
|
||||
- 使用适当的超时设置
|
||||
- 处理并发测试
|
||||
- 使用 channel 进行同步
|
||||
|
||||
### 5. 错误处理
|
||||
- 测试错误情况
|
||||
- 验证错误消息
|
||||
- 确保错误处理逻辑正确
|
||||
|
||||
## 常用 Convey 断言
|
||||
|
||||
### 相等性断言
|
||||
```go
|
||||
So(value, ShouldEqual, expected)
|
||||
So(value, ShouldNotEqual, expected)
|
||||
So(value, ShouldResemble, expected) // 深度比较
|
||||
So(value, ShouldNotResemble, expected)
|
||||
```
|
||||
|
||||
### 类型断言
|
||||
```go
|
||||
So(value, ShouldBeNil)
|
||||
So(value, ShouldNotBeNil)
|
||||
So(value, ShouldBeTrue)
|
||||
So(value, ShouldBeFalse)
|
||||
So(value, ShouldBeZeroValue)
|
||||
```
|
||||
|
||||
### 数值断言
|
||||
```go
|
||||
So(value, ShouldBeGreaterThan, expected)
|
||||
So(value, ShouldBeLessThan, expected)
|
||||
So(value, ShouldBeBetween, lower, upper)
|
||||
```
|
||||
|
||||
### 集合断言
|
||||
```go
|
||||
So(slice, ShouldHaveLength, expected)
|
||||
So(slice, ShouldContain, expected)
|
||||
So(slice, ShouldNotContain, expected)
|
||||
So(map, ShouldContainKey, key)
|
||||
```
|
||||
|
||||
### 字符串断言
|
||||
```go
|
||||
So(str, ShouldContainSubstring, substr)
|
||||
So(str, ShouldStartWith, prefix)
|
||||
So(str, ShouldEndWith, suffix)
|
||||
So(str, ShouldMatch, regexp)
|
||||
```
|
||||
|
||||
### 错误断言
|
||||
```go
|
||||
So(err, ShouldBeNil)
|
||||
So(err, ShouldNotBeNil)
|
||||
So(err, ShouldError, expectedError)
|
||||
```
|
||||
|
||||
## 测试工具
|
||||
|
||||
- `goconvey/convey` - BDD 测试框架
|
||||
- `gomock` - Mock 生成器
|
||||
- `httptest` - HTTP 测试
|
||||
- `sqlmock` - 数据库 mock
|
||||
- `testify` - 辅助测试工具(可选)
|
||||
|
||||
## 测试示例
|
||||
|
||||
### 配置测试示例
|
||||
```go
|
||||
Convey("配置加载测试", t, func() {
|
||||
var config *Config
|
||||
|
||||
Convey("当从文件加载配置时", func() {
|
||||
config, err := LoadConfig("config.toml")
|
||||
So(err, ShouldBeNil)
|
||||
So(config, ShouldNotBeNil)
|
||||
|
||||
Convey("那么配置应该正确加载", func() {
|
||||
So(config.App.Mode, ShouldEqual, "development")
|
||||
So(config.Http.Port, ShouldEqual, 8080)
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 数据库测试示例
|
||||
```go
|
||||
Convey("数据库操作测试", t, func() {
|
||||
var db *gorm.DB
|
||||
|
||||
Convey("当连接数据库时", func() {
|
||||
db = SetupTestDB()
|
||||
So(db, ShouldNotBeNil)
|
||||
|
||||
Convey("那么应该能够创建记录", func() {
|
||||
user := User{Name: "测试用户", Email: "test@example.com"}
|
||||
result := db.Create(&user)
|
||||
So(result.Error, ShouldBeNil)
|
||||
So(user.ID, ShouldBeGreaterThan, 0)
|
||||
})
|
||||
})
|
||||
|
||||
Reset(func() {
|
||||
if db != nil {
|
||||
CleanupTestDB(db)
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### API 测试示例
|
||||
```go
|
||||
Convey("API 端点测试", t, func() {
|
||||
var server *httptest.Server
|
||||
|
||||
Convey("当启动测试服务器时", func() {
|
||||
server = httptest.NewServer(NewApp())
|
||||
So(server, ShouldNotBeNil)
|
||||
|
||||
Convey("那么健康检查端点应该正常工作", func() {
|
||||
resp, err := http.Get(server.URL + "/health")
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode, ShouldEqual, http.StatusOK)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
So(result["status"], ShouldEqual, "ok")
|
||||
})
|
||||
})
|
||||
|
||||
Reset(func() {
|
||||
if server != nil {
|
||||
server.Close()
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## CI/CD 集成
|
||||
|
||||
测试会在以下情况下自动运行:
|
||||
- 代码提交时
|
||||
- 创建 Pull Request 时
|
||||
- 合并到主分支时
|
||||
|
||||
测试结果会影响代码合并决策。Convey 的详细输出有助于快速定位问题。
|
||||
419
templates/project/tests/e2e/api_test.go
Normal file
419
templates/project/tests/e2e/api_test.go
Normal file
@@ -0,0 +1,419 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"{{.ModuleName}}/app"
|
||||
"{{.ModuleName}}/app/config"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
// TestAPIHealth 测试 API 健康检查
|
||||
func TestAPIHealth(t *testing.T) {
|
||||
Convey("API 健康检查测试", t, func() {
|
||||
var server *httptest.Server
|
||||
var testConfig *config.Config
|
||||
|
||||
Convey("当启动测试服务器时", func() {
|
||||
testConfig = &config.Config{
|
||||
App: config.AppConfig{
|
||||
Mode: "test",
|
||||
BaseURI: "http://localhost:8080",
|
||||
},
|
||||
Http: config.HttpConfig{
|
||||
Port: 8080,
|
||||
},
|
||||
Log: config.LogConfig{
|
||||
Level: "debug",
|
||||
Format: "text",
|
||||
EnableCaller: true,
|
||||
},
|
||||
}
|
||||
|
||||
app := app.New(testConfig)
|
||||
server = httptest.NewServer(app)
|
||||
|
||||
Convey("服务器应该成功启动", func() {
|
||||
So(server, ShouldNotBeNil)
|
||||
So(server.URL, ShouldNotBeEmpty)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("当访问健康检查端点时", func() {
|
||||
resp, err := http.Get(server.URL + "/health")
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode, ShouldEqual, http.StatusOK)
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
Convey("响应应该包含正确的状态", func() {
|
||||
So(result["status"], ShouldEqual, "ok")
|
||||
})
|
||||
|
||||
Convey("响应应该包含时间戳", func() {
|
||||
So(result, ShouldContainKey, "timestamp")
|
||||
})
|
||||
|
||||
Convey("响应应该是 JSON 格式", func() {
|
||||
So(resp.Header.Get("Content-Type"), ShouldEqual, "application/json; charset=utf-8")
|
||||
})
|
||||
})
|
||||
|
||||
Convey("当访问不存在的端点时", func() {
|
||||
resp, err := http.Get(server.URL + "/api/nonexistent")
|
||||
So(err, ShouldBeNil)
|
||||
So(resp.StatusCode, ShouldEqual, http.StatusNotFound)
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
Convey("响应应该包含错误信息", func() {
|
||||
So(result, ShouldContainKey, "error")
|
||||
})
|
||||
})
|
||||
|
||||
Convey("当测试 CORS 支持", func() {
|
||||
req, err := http.NewRequest("OPTIONS", server.URL+"/api/test", nil)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
req.Header.Set("Origin", "http://localhost:3000")
|
||||
req.Header.Set("Access-Control-Request-Method", "POST")
|
||||
req.Header.Set("Access-Control-Request-Headers", "Content-Type,Authorization")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
So(err, ShouldBeNil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
Convey("应该返回正确的 CORS 头", func() {
|
||||
So(resp.StatusCode, ShouldEqual, http.StatusOK)
|
||||
So(resp.Header.Get("Access-Control-Allow-Origin"), ShouldContainSubstring, "localhost")
|
||||
So(resp.Header.Get("Access-Control-Allow-Methods"), ShouldContainSubstring, "POST")
|
||||
})
|
||||
})
|
||||
|
||||
Reset(func() {
|
||||
if server != nil {
|
||||
server.Close()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestAPIPerformance 测试 API 性能
|
||||
func TestAPIPerformance(t *testing.T) {
|
||||
Convey("API 性能测试", t, func() {
|
||||
var server *httptest.Server
|
||||
var testConfig *config.Config
|
||||
|
||||
Convey("当准备性能测试时", func() {
|
||||
testConfig = &config.Config{
|
||||
App: config.AppConfig{
|
||||
Mode: "test",
|
||||
BaseURI: "http://localhost:8080",
|
||||
},
|
||||
Http: config.HttpConfig{
|
||||
Port: 8080,
|
||||
},
|
||||
Log: config.LogConfig{
|
||||
Level: "error", // 减少日志输出以提升性能
|
||||
Format: "text",
|
||||
},
|
||||
}
|
||||
|
||||
app := app.New(testConfig)
|
||||
server = httptest.NewServer(app)
|
||||
})
|
||||
|
||||
Convey("当测试响应时间时", func() {
|
||||
start := time.Now()
|
||||
resp, err := http.Get(server.URL + "/health")
|
||||
So(err, ShouldBeNil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
duration := time.Since(start)
|
||||
So(resp.StatusCode, ShouldEqual, http.StatusOK)
|
||||
|
||||
Convey("响应时间应该在合理范围内", func() {
|
||||
So(duration, ShouldBeLessThan, 100*time.Millisecond)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("当测试并发请求时", func() {
|
||||
const numRequests = 50
|
||||
const maxConcurrency = 10
|
||||
const timeout = 5 * time.Second
|
||||
|
||||
var wg sync.WaitGroup
|
||||
successCount := 0
|
||||
errorCount := 0
|
||||
var mu sync.Mutex
|
||||
|
||||
// 使用信号量控制并发数
|
||||
sem := make(chan struct{}, maxConcurrency)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
for i := 0; i < numRequests; i++ {
|
||||
wg.Add(1)
|
||||
go func(requestID int) {
|
||||
defer wg.Done()
|
||||
|
||||
// 获取信号量
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", server.URL+"/health", nil)
|
||||
if err != nil {
|
||||
mu.Lock()
|
||||
errorCount++
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: timeout,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
mu.Lock()
|
||||
errorCount++
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
mu.Lock()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
successCount++
|
||||
} else {
|
||||
errorCount++
|
||||
}
|
||||
mu.Unlock()
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
duration := time.Since(start)
|
||||
|
||||
Convey("所有请求都应该完成", func() {
|
||||
So(successCount+errorCount, ShouldEqual, numRequests)
|
||||
})
|
||||
|
||||
Convey("所有请求都应该成功", func() {
|
||||
So(errorCount, ShouldEqual, 0)
|
||||
})
|
||||
|
||||
Convey("总耗时应该在合理范围内", func() {
|
||||
So(duration, ShouldBeLessThan, 10*time.Second)
|
||||
})
|
||||
|
||||
Convey("并发性能应该良好", func() {
|
||||
avgTime := duration / numRequests
|
||||
So(avgTime, ShouldBeLessThan, 200*time.Millisecond)
|
||||
})
|
||||
})
|
||||
|
||||
Reset(func() {
|
||||
if server != nil {
|
||||
server.Close()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestAPIBehavior 测试 API 行为
|
||||
func TestAPIBehavior(t *testing.T) {
|
||||
Convey("API 行为测试", t, func() {
|
||||
var server *httptest.Server
|
||||
var testConfig *config.Config
|
||||
|
||||
Convey("当准备行为测试时", func() {
|
||||
testConfig = &config.Config{
|
||||
App: config.AppConfig{
|
||||
Mode: "test",
|
||||
BaseURI: "http://localhost:8080",
|
||||
},
|
||||
Http: config.HttpConfig{
|
||||
Port: 8080,
|
||||
},
|
||||
Log: config.LogConfig{
|
||||
Level: "debug",
|
||||
Format: "text",
|
||||
EnableCaller: true,
|
||||
},
|
||||
}
|
||||
|
||||
app := app.New(testConfig)
|
||||
server = httptest.NewServer(app)
|
||||
})
|
||||
|
||||
Convey("当测试不同 HTTP 方法时", func() {
|
||||
testURL := server.URL + "/health"
|
||||
|
||||
Convey("GET 请求应该成功", func() {
|
||||
resp, err := http.Get(testURL)
|
||||
So(err, ShouldBeNil)
|
||||
defer resp.Body.Close()
|
||||
So(resp.StatusCode, ShouldEqual, http.StatusOK)
|
||||
})
|
||||
|
||||
Convey("POST 请求应该被处理", func() {
|
||||
resp, err := http.Post(testURL, "application/json", bytes.NewBuffer([]byte{}))
|
||||
So(err, ShouldBeNil)
|
||||
defer resp.Body.Close()
|
||||
// 健康检查端点通常支持所有方法
|
||||
So(resp.StatusCode, ShouldBeIn, []int{http.StatusOK, http.StatusMethodNotAllowed})
|
||||
})
|
||||
|
||||
Convey("PUT 请求应该被处理", func() {
|
||||
req, err := http.NewRequest("PUT", testURL, bytes.NewBuffer([]byte{}))
|
||||
So(err, ShouldBeNil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
So(err, ShouldBeNil)
|
||||
defer resp.Body.Close()
|
||||
So(resp.StatusCode, ShouldBeIn, []int{http.StatusOK, http.StatusMethodNotAllowed})
|
||||
})
|
||||
|
||||
Convey("DELETE 请求应该被处理", func() {
|
||||
req, err := http.NewRequest("DELETE", testURL, nil)
|
||||
So(err, ShouldBeNil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
So(err, ShouldBeNil)
|
||||
defer resp.Body.Close()
|
||||
So(resp.StatusCode, ShouldBeIn, []int{http.StatusOK, http.StatusMethodNotAllowed})
|
||||
})
|
||||
})
|
||||
|
||||
Convey("当测试自定义请求头时", func() {
|
||||
req, err := http.NewRequest("GET", server.URL+"/health", nil)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
// 设置各种请求头
|
||||
req.Header.Set("User-Agent", "E2E-Test-Agent/1.0")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
|
||||
req.Header.Set("X-Custom-Header", "test-value")
|
||||
req.Header.Set("X-Request-ID", "test-request-123")
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
So(err, ShouldBeNil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
Convey("请求应该成功", func() {
|
||||
So(resp.StatusCode, ShouldEqual, http.StatusOK)
|
||||
})
|
||||
|
||||
Convey("响应应该是 JSON 格式", func() {
|
||||
So(resp.Header.Get("Content-Type"), ShouldEqual, "application/json; charset=utf-8")
|
||||
})
|
||||
})
|
||||
|
||||
Convey("当测试错误处理时", func() {
|
||||
Convey("访问不存在的路径应该返回 404", func() {
|
||||
resp, err := http.Get(server.URL + "/api/v1/nonexistent")
|
||||
So(err, ShouldBeNil)
|
||||
defer resp.Body.Close()
|
||||
So(resp.StatusCode, ShouldEqual, http.StatusNotFound)
|
||||
})
|
||||
|
||||
Convey("访问非法路径应该返回 404", func() {
|
||||
resp, err := http.Get(server.URL + "/../etc/passwd")
|
||||
So(err, ShouldBeNil)
|
||||
defer resp.Body.Close()
|
||||
So(resp.StatusCode, ShouldEqual, http.StatusNotFound)
|
||||
})
|
||||
})
|
||||
|
||||
Reset(func() {
|
||||
if server != nil {
|
||||
server.Close()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestAPIDocumentation 测试 API 文档
|
||||
func TestAPIDocumentation(t *testing.T) {
|
||||
Convey("API 文档测试", t, func() {
|
||||
var server *httptest.Server
|
||||
var testConfig *config.Config
|
||||
|
||||
Convey("当准备文档测试时", func() {
|
||||
testConfig = &config.Config{
|
||||
App: config.AppConfig{
|
||||
Mode: "test",
|
||||
BaseURI: "http://localhost:8080",
|
||||
},
|
||||
Http: config.HttpConfig{
|
||||
Port: 8080,
|
||||
},
|
||||
Log: config.LogConfig{
|
||||
Level: "debug",
|
||||
Format: "text",
|
||||
EnableCaller: true,
|
||||
},
|
||||
}
|
||||
|
||||
app := app.New(testConfig)
|
||||
server = httptest.NewServer(app)
|
||||
})
|
||||
|
||||
Convey("当访问 Swagger UI 时", func() {
|
||||
resp, err := http.Get(server.URL + "/swagger/index.html")
|
||||
So(err, ShouldBeNil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
Convey("应该能够访问 Swagger UI", func() {
|
||||
So(resp.StatusCode, ShouldEqual, http.StatusOK)
|
||||
})
|
||||
|
||||
Convey("响应应该是 HTML 格式", func() {
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
So(contentType, ShouldContainSubstring, "text/html")
|
||||
})
|
||||
})
|
||||
|
||||
Convey("当访问 OpenAPI 规范时", func() {
|
||||
resp, err := http.Get(server.URL + "/swagger/doc.json")
|
||||
So(err, ShouldBeNil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
Convey("应该能够访问 OpenAPI 规范", func() {
|
||||
// 如果存在则返回 200,不存在则返回 404
|
||||
So(resp.StatusCode, ShouldBeIn, []int{http.StatusOK, http.StatusNotFound})
|
||||
})
|
||||
|
||||
Convey("如果存在,响应应该是 JSON 格式", func() {
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
So(contentType, ShouldContainSubstring, "application/json")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reset(func() {
|
||||
if server != nil {
|
||||
server.Close()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
364
templates/project/tests/integration/database_test.go
Normal file
364
templates/project/tests/integration/database_test.go
Normal file
@@ -0,0 +1,364 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"{{.ModuleName}}/app/config"
|
||||
"{{.ModuleName}}/app/database"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestUser 测试用户模型
|
||||
type TestUser struct {
|
||||
ID int `gorm:"primaryKey"`
|
||||
Name string `gorm:"size:100;not null"`
|
||||
Email string `gorm:"size:100;unique;not null"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
// TestDatabaseConnection 测试数据库连接
|
||||
func TestDatabaseConnection(t *testing.T) {
|
||||
Convey("数据库连接测试", t, func() {
|
||||
var db *gorm.DB
|
||||
var sqlDB *sql.DB
|
||||
var testConfig *config.Config
|
||||
var testDBName string
|
||||
|
||||
Convey("当准备测试数据库时", func() {
|
||||
testDBName = "{{.ProjectName}}_test_integration"
|
||||
testConfig = &config.Config{
|
||||
Database: config.DatabaseConfig{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
Database: testDBName,
|
||||
Username: "postgres",
|
||||
Password: "password",
|
||||
SslMode: "disable",
|
||||
MaxIdleConns: 5,
|
||||
MaxOpenConns: 20,
|
||||
ConnMaxLifetime: 30 * time.Minute,
|
||||
},
|
||||
}
|
||||
|
||||
Convey("应该能够连接到数据库", func() {
|
||||
dsn := testConfig.Database.GetDSN()
|
||||
var err error
|
||||
db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
So(err, ShouldBeNil)
|
||||
So(db, ShouldNotBeNil)
|
||||
|
||||
sqlDB, err = db.DB()
|
||||
So(err, ShouldBeNil)
|
||||
So(sqlDB, ShouldNotBeNil)
|
||||
|
||||
// 设置连接池
|
||||
sqlDB.SetMaxIdleConns(testConfig.Database.MaxIdleConns)
|
||||
sqlDB.SetMaxOpenConns(testConfig.Database.MaxOpenConns)
|
||||
sqlDB.SetConnMaxLifetime(testConfig.Database.ConnMaxLifetime)
|
||||
|
||||
// 测试连接
|
||||
err = sqlDB.Ping()
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
|
||||
Convey("应该能够创建测试表", func() {
|
||||
err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS integration_test_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(100) UNIQUE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`).Error
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("当测试数据库操作时", func() {
|
||||
Convey("应该能够创建记录", func() {
|
||||
user := TestUser{
|
||||
Name: "Integration Test User",
|
||||
Email: "integration@example.com",
|
||||
}
|
||||
|
||||
result := db.Create(&user)
|
||||
So(result.Error, ShouldBeNil)
|
||||
So(result.RowsAffected, ShouldEqual, 1)
|
||||
So(user.ID, ShouldBeGreaterThan, 0)
|
||||
})
|
||||
|
||||
Convey("应该能够查询记录", func() {
|
||||
// 先插入测试数据
|
||||
user := TestUser{
|
||||
Name: "Query Test User",
|
||||
Email: "query@example.com",
|
||||
}
|
||||
db.Create(&user)
|
||||
|
||||
// 查询记录
|
||||
var result TestUser
|
||||
err := db.First(&result, "email = ?", "query@example.com").Error
|
||||
So(err, ShouldBeNil)
|
||||
So(result.Name, ShouldEqual, "Query Test User")
|
||||
So(result.Email, ShouldEqual, "query@example.com")
|
||||
})
|
||||
|
||||
Convey("应该能够更新记录", func() {
|
||||
// 先插入测试数据
|
||||
user := TestUser{
|
||||
Name: "Update Test User",
|
||||
Email: "update@example.com",
|
||||
}
|
||||
db.Create(&user)
|
||||
|
||||
// 更新记录
|
||||
result := db.Model(&user).Update("name", "Updated Integration User")
|
||||
So(result.Error, ShouldBeNil)
|
||||
So(result.RowsAffected, ShouldEqual, 1)
|
||||
|
||||
// 验证更新
|
||||
var updatedUser TestUser
|
||||
err := db.First(&updatedUser, user.ID).Error
|
||||
So(err, ShouldBeNil)
|
||||
So(updatedUser.Name, ShouldEqual, "Updated Integration User")
|
||||
})
|
||||
|
||||
Convey("应该能够删除记录", func() {
|
||||
// 先插入测试数据
|
||||
user := TestUser{
|
||||
Name: "Delete Test User",
|
||||
Email: "delete@example.com",
|
||||
}
|
||||
db.Create(&user)
|
||||
|
||||
// 删除记录
|
||||
result := db.Delete(&user)
|
||||
So(result.Error, ShouldBeNil)
|
||||
So(result.RowsAffected, ShouldEqual, 1)
|
||||
|
||||
// 验证删除
|
||||
var deletedUser TestUser
|
||||
err := db.First(&deletedUser, user.ID).Error
|
||||
So(err, ShouldEqual, gorm.ErrRecordNotFound)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("当测试事务时", func() {
|
||||
Convey("应该能够执行事务操作", func() {
|
||||
// 开始事务
|
||||
tx := db.Begin()
|
||||
So(tx, ShouldNotBeNil)
|
||||
|
||||
// 在事务中插入数据
|
||||
user := TestUser{
|
||||
Name: "Transaction Test User",
|
||||
Email: "transaction@example.com",
|
||||
}
|
||||
result := tx.Create(&user)
|
||||
So(result.Error, ShouldBeNil)
|
||||
So(result.RowsAffected, ShouldEqual, 1)
|
||||
|
||||
// 查询事务中的数据
|
||||
var count int64
|
||||
tx.Model(&TestUser{}).Count(&count)
|
||||
So(count, ShouldEqual, 1)
|
||||
|
||||
// 提交事务
|
||||
err := tx.Commit().Error
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
// 验证数据已提交
|
||||
db.Model(&TestUser{}).Count(&count)
|
||||
So(count, ShouldBeGreaterThan, 0)
|
||||
})
|
||||
|
||||
Convey("应该能够回滚事务", func() {
|
||||
// 开始事务
|
||||
tx := db.Begin()
|
||||
|
||||
// 在事务中插入数据
|
||||
user := TestUser{
|
||||
Name: "Rollback Test User",
|
||||
Email: "rollback@example.com",
|
||||
}
|
||||
tx.Create(&user)
|
||||
|
||||
// 回滚事务
|
||||
err := tx.Rollback().Error
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
// 验证数据已回滚
|
||||
var count int64
|
||||
db.Model(&TestUser{}).Where("email = ?", "rollback@example.com").Count(&count)
|
||||
So(count, ShouldEqual, 0)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("当测试批量操作时", func() {
|
||||
Convey("应该能够批量插入记录", func() {
|
||||
users := []TestUser{
|
||||
{Name: "Batch User 1", Email: "batch1@example.com"},
|
||||
{Name: "Batch User 2", Email: "batch2@example.com"},
|
||||
{Name: "Batch User 3", Email: "batch3@example.com"},
|
||||
}
|
||||
|
||||
result := db.Create(&users)
|
||||
So(result.Error, ShouldBeNil)
|
||||
So(result.RowsAffected, ShouldEqual, 3)
|
||||
|
||||
// 验证批量插入
|
||||
var count int64
|
||||
db.Model(&TestUser{}).Where("email LIKE ?", "batch%@example.com").Count(&count)
|
||||
So(count, ShouldEqual, 3)
|
||||
})
|
||||
|
||||
Convey("应该能够批量更新记录", func() {
|
||||
// 先插入测试数据
|
||||
users := []TestUser{
|
||||
{Name: "Batch Update 1", Email: "batchupdate1@example.com"},
|
||||
{Name: "Batch Update 2", Email: "batchupdate2@example.com"},
|
||||
}
|
||||
db.Create(&users)
|
||||
|
||||
// 批量更新
|
||||
result := db.Model(&TestUser{}).
|
||||
Where("email LIKE ?", "batchupdate%@example.com").
|
||||
Update("name", "Batch Updated User")
|
||||
So(result.Error, ShouldBeNil)
|
||||
So(result.RowsAffected, ShouldEqual, 2)
|
||||
|
||||
// 验证更新
|
||||
var updatedCount int64
|
||||
db.Model(&TestUser{}).
|
||||
Where("name = ?", "Batch Updated User").
|
||||
Count(&updatedCount)
|
||||
So(updatedCount, ShouldEqual, 2)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("当测试查询条件时", func() {
|
||||
Convey("应该能够使用各种查询条件", func() {
|
||||
// 插入测试数据
|
||||
testUsers := []TestUser{
|
||||
{Name: "Alice", Email: "alice@example.com"},
|
||||
{Name: "Bob", Email: "bob@example.com"},
|
||||
{Name: "Charlie", Email: "charlie@example.com"},
|
||||
{Name: "Alice Smith", Email: "alice.smith@example.com"},
|
||||
}
|
||||
db.Create(&testUsers)
|
||||
|
||||
Convey("应该能够使用 LIKE 查询", func() {
|
||||
var users []TestUser
|
||||
err := db.Where("name LIKE ?", "Alice%").Find(&users).Error
|
||||
So(err, ShouldBeNil)
|
||||
So(len(users), ShouldEqual, 2)
|
||||
})
|
||||
|
||||
Convey("应该能够使用 IN 查询", func() {
|
||||
var users []TestUser
|
||||
err := db.Where("name IN ?", []string{"Alice", "Bob"}).Find(&users).Error
|
||||
So(err, ShouldBeNil)
|
||||
So(len(users), ShouldEqual, 2)
|
||||
})
|
||||
|
||||
Convey("应该能够使用 BETWEEN 查询", func() {
|
||||
var users []TestUser
|
||||
err := db.Where("id BETWEEN ? AND ?", 1, 3).Find(&users).Error
|
||||
So(err, ShouldBeNil)
|
||||
So(len(users), ShouldBeGreaterThan, 0)
|
||||
})
|
||||
|
||||
Convey("应该能够使用多条件查询", func() {
|
||||
var users []TestUser
|
||||
err := db.Where("name LIKE ? AND email LIKE ?", "%Alice%", "%example.com").Find(&users).Error
|
||||
So(err, ShouldBeNil)
|
||||
So(len(users), ShouldEqual, 2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reset(func() {
|
||||
// 清理测试表
|
||||
if db != nil {
|
||||
db.Exec("DROP TABLE IF EXISTS integration_test_users")
|
||||
}
|
||||
// 关闭数据库连接
|
||||
if sqlDB != nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestDatabaseConnectionPool 测试数据库连接池
|
||||
func TestDatabaseConnectionPool(t *testing.T) {
|
||||
Convey("数据库连接池测试", t, func() {
|
||||
var db *gorm.DB
|
||||
var sqlDB *sql.DB
|
||||
|
||||
Convey("当配置连接池时", func() {
|
||||
testConfig := &config.Config{
|
||||
Database: config.DatabaseConfig{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
Database: "{{.ProjectName}}_test_pool",
|
||||
Username: "postgres",
|
||||
Password: "password",
|
||||
SslMode: "disable",
|
||||
MaxIdleConns: 5,
|
||||
MaxOpenConns: 10,
|
||||
ConnMaxLifetime: 5 * time.Minute,
|
||||
},
|
||||
}
|
||||
|
||||
dsn := testConfig.Database.GetDSN()
|
||||
var err error
|
||||
db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
sqlDB, err = db.DB()
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
Convey("应该能够设置连接池参数", func() {
|
||||
sqlDB.SetMaxIdleConns(testConfig.Database.MaxIdleConns)
|
||||
sqlDB.SetMaxOpenConns(testConfig.Database.MaxOpenConns)
|
||||
sqlDB.SetConnMaxLifetime(testConfig.Database.ConnMaxLifetime)
|
||||
|
||||
// 验证设置
|
||||
stats := sqlDB.Stats()
|
||||
So(stats.MaxOpenConns, ShouldEqual, testConfig.Database.MaxOpenConns)
|
||||
So(stats.MaxIdleConns, ShouldEqual, testConfig.Database.MaxIdleConns)
|
||||
})
|
||||
|
||||
Convey("应该能够监控连接池状态", func() {
|
||||
// 获取初始状态
|
||||
initialStats := sqlDB.Stats()
|
||||
So(initialStats.OpenConnections, ShouldEqual, 0)
|
||||
|
||||
// 执行一些查询来创建连接
|
||||
for i := 0; i < 3; i++ {
|
||||
sqlDB.Ping()
|
||||
}
|
||||
|
||||
// 获取使用后的状态
|
||||
afterStats := sqlDB.Stats()
|
||||
So(afterStats.OpenConnections, ShouldBeGreaterThan, 0)
|
||||
So(afterStats.InUse, ShouldBeGreaterThan, 0)
|
||||
})
|
||||
})
|
||||
|
||||
Reset(func() {
|
||||
// 关闭数据库连接
|
||||
if sqlDB != nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
161
templates/project/tests/setup_test.go
Normal file
161
templates/project/tests/setup_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
// TestMain 测试入口点
|
||||
func TestMain(m *testing.M) {
|
||||
// 运行测试
|
||||
m.Run()
|
||||
}
|
||||
|
||||
// TestSetup 测试基础设置
|
||||
func TestSetup(t *testing.T) {
|
||||
Convey("测试基础设置", t, func() {
|
||||
Convey("当初始化测试环境时", func() {
|
||||
// 初始化测试环境
|
||||
testEnv := &TestEnvironment{
|
||||
Name: "test-env",
|
||||
Version: "1.0.0",
|
||||
}
|
||||
|
||||
Convey("那么测试环境应该被正确创建", func() {
|
||||
So(testEnv.Name, ShouldEqual, "test-env")
|
||||
So(testEnv.Version, ShouldEqual, "1.0.0")
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestEnvironment 测试环境结构
|
||||
type TestEnvironment struct {
|
||||
Name string
|
||||
Version string
|
||||
Config map[string]interface{}
|
||||
}
|
||||
|
||||
// NewTestEnvironment 创建新的测试环境
|
||||
func NewTestEnvironment(name string) *TestEnvironment {
|
||||
return &TestEnvironment{
|
||||
Name: name,
|
||||
Config: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
// WithConfig 设置配置
|
||||
func (e *TestEnvironment) WithConfig(key string, value interface{}) *TestEnvironment {
|
||||
e.Config[key] = value
|
||||
return e
|
||||
}
|
||||
|
||||
// GetConfig 获取配置
|
||||
func (e *TestEnvironment) GetConfig(key string) interface{} {
|
||||
return e.Config[key]
|
||||
}
|
||||
|
||||
// Setup 设置测试环境
|
||||
func (e *TestEnvironment) Setup() *TestEnvironment {
|
||||
// 初始化测试环境
|
||||
e.Config["initialized"] = true
|
||||
return e
|
||||
}
|
||||
|
||||
// Cleanup 清理测试环境
|
||||
func (e *TestEnvironment) Cleanup() {
|
||||
// 清理测试环境
|
||||
e.Config = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// TestEnvironmentManagement 测试环境管理
|
||||
func TestEnvironmentManagement(t *testing.T) {
|
||||
Convey("测试环境管理", t, func() {
|
||||
var env *TestEnvironment
|
||||
|
||||
Convey("当创建新测试环境时", func() {
|
||||
env = NewTestEnvironment("test-app")
|
||||
|
||||
Convey("那么环境应该有正确的名称", func() {
|
||||
So(env.Name, ShouldEqual, "test-app")
|
||||
So(env.Version, ShouldBeEmpty)
|
||||
So(env.Config, ShouldNotBeNil)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("当设置配置时", func() {
|
||||
env.WithConfig("debug", true)
|
||||
env.WithConfig("port", 8080)
|
||||
|
||||
Convey("那么配置应该被正确设置", func() {
|
||||
So(env.GetConfig("debug"), ShouldEqual, true)
|
||||
So(env.GetConfig("port"), ShouldEqual, 8080)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("当初始化环境时", func() {
|
||||
env.Setup()
|
||||
|
||||
Convey("那么环境应该被标记为已初始化", func() {
|
||||
So(env.GetConfig("initialized"), ShouldEqual, true)
|
||||
})
|
||||
})
|
||||
|
||||
Reset(func() {
|
||||
if env != nil {
|
||||
env.Cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestConveyBasicUsage 测试 Convey 基础用法
|
||||
func TestConveyBasicUsage(t *testing.T) {
|
||||
Convey("Convey 基础用法测试", t, func() {
|
||||
Convey("数字操作", func() {
|
||||
num := 42
|
||||
|
||||
Convey("应该能够进行基本比较", func() {
|
||||
So(num, ShouldEqual, 42)
|
||||
So(num, ShouldBeGreaterThan, 0)
|
||||
So(num, ShouldBeLessThan, 100)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("字符串操作", func() {
|
||||
str := "hello world"
|
||||
|
||||
Convey("应该能够进行字符串比较", func() {
|
||||
So(str, ShouldEqual, "hello world")
|
||||
So(str, ShouldContainSubstring, "hello")
|
||||
So(str, ShouldStartWith, "hello")
|
||||
So(str, ShouldEndWith, "world")
|
||||
})
|
||||
})
|
||||
|
||||
Convey("切片操作", func() {
|
||||
slice := []int{1, 2, 3, 4, 5}
|
||||
|
||||
Convey("应该能够进行切片操作", func() {
|
||||
So(slice, ShouldHaveLength, 5)
|
||||
So(slice, ShouldContain, 3)
|
||||
So(slice, ShouldNotContain, 6)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Map 操作", func() {
|
||||
m := map[string]interface{}{
|
||||
"name": "test",
|
||||
"value": 123,
|
||||
}
|
||||
|
||||
Convey("应该能够进行 Map 操作", func() {
|
||||
So(m, ShouldContainKey, "name")
|
||||
So(m, ShouldContainKey, "value")
|
||||
So(m["name"], ShouldEqual, "test")
|
||||
So(m["value"], ShouldEqual, 123)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
287
templates/project/tests/unit/config_test.go
Normal file
287
templates/project/tests/unit/config_test.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package unit
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"{{.ModuleName}}/app/config"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
// TestConfigLoading 测试配置加载功能
|
||||
func TestConfigLoading(t *testing.T) {
|
||||
Convey("配置加载测试", t, func() {
|
||||
var testConfig *config.Config
|
||||
var configPath string
|
||||
var testDir string
|
||||
|
||||
Convey("当准备测试配置文件时", func() {
|
||||
originalWd, _ := os.Getwd()
|
||||
testDir = filepath.Join(originalWd, "..", "..", "fixtures", "test_config")
|
||||
|
||||
Convey("应该创建测试配置目录", func() {
|
||||
err := os.MkdirAll(testDir, 0755)
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
|
||||
Convey("应该创建测试配置文件", func() {
|
||||
testConfigContent := `App:
|
||||
Mode: "test"
|
||||
BaseURI: "http://localhost:8080"
|
||||
Http:
|
||||
Port: 8080
|
||||
Database:
|
||||
Host: "localhost"
|
||||
Port: 5432
|
||||
Database: "test_db"
|
||||
Username: "test_user"
|
||||
Password: "test_password"
|
||||
SslMode: "disable"
|
||||
Log:
|
||||
Level: "debug"
|
||||
Format: "text"
|
||||
EnableCaller: true`
|
||||
|
||||
configPath = filepath.Join(testDir, "config.toml")
|
||||
err := os.WriteFile(configPath, []byte(testConfigContent), 0644)
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
|
||||
Convey("应该成功加载配置", func() {
|
||||
var err error
|
||||
testConfig, err = config.Load(configPath)
|
||||
So(err, ShouldBeNil)
|
||||
So(testConfig, ShouldNotBeNil)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("验证配置内容", func() {
|
||||
So(testConfig, ShouldNotBeNil)
|
||||
|
||||
Convey("应用配置应该正确", func() {
|
||||
So(testConfig.App.Mode, ShouldEqual, "test")
|
||||
So(testConfig.App.BaseURI, ShouldEqual, "http://localhost:8080")
|
||||
})
|
||||
|
||||
Convey("HTTP配置应该正确", func() {
|
||||
So(testConfig.Http.Port, ShouldEqual, 8080)
|
||||
})
|
||||
|
||||
Convey("数据库配置应该正确", func() {
|
||||
So(testConfig.Database.Host, ShouldEqual, "localhost")
|
||||
So(testConfig.Database.Port, ShouldEqual, 5432)
|
||||
So(testConfig.Database.Database, ShouldEqual, "test_db")
|
||||
So(testConfig.Database.Username, ShouldEqual, "test_user")
|
||||
So(testConfig.Database.Password, ShouldEqual, "test_password")
|
||||
So(testConfig.Database.SslMode, ShouldEqual, "disable")
|
||||
})
|
||||
|
||||
Convey("日志配置应该正确", func() {
|
||||
So(testConfig.Log.Level, ShouldEqual, "debug")
|
||||
So(testConfig.Log.Format, ShouldEqual, "text")
|
||||
So(testConfig.Log.EnableCaller, ShouldBeTrue)
|
||||
})
|
||||
})
|
||||
|
||||
Reset(func() {
|
||||
// 清理测试文件
|
||||
if testDir != "" {
|
||||
os.RemoveAll(testDir)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestConfigFromEnvironment 测试从环境变量加载配置
|
||||
func TestConfigFromEnvironment(t *testing.T) {
|
||||
Convey("环境变量配置测试", t, func() {
|
||||
var originalEnvVars map[string]string
|
||||
|
||||
Convey("当设置环境变量时", func() {
|
||||
// 保存原始环境变量
|
||||
originalEnvVars = map[string]string{
|
||||
"APP_MODE": os.Getenv("APP_MODE"),
|
||||
"HTTP_PORT": os.Getenv("HTTP_PORT"),
|
||||
"DB_HOST": os.Getenv("DB_HOST"),
|
||||
}
|
||||
|
||||
// 设置测试环境变量
|
||||
os.Setenv("APP_MODE", "test")
|
||||
os.Setenv("HTTP_PORT", "9090")
|
||||
os.Setenv("DB_HOST", "test-host")
|
||||
|
||||
Convey("环境变量应该被正确设置", func() {
|
||||
So(os.Getenv("APP_MODE"), ShouldEqual, "test")
|
||||
So(os.Getenv("HTTP_PORT"), ShouldEqual, "9090")
|
||||
So(os.Getenv("DB_HOST"), ShouldEqual, "test-host")
|
||||
})
|
||||
})
|
||||
|
||||
Convey("当从环境变量加载配置时", func() {
|
||||
originalWd, _ := os.Getwd()
|
||||
testDir := filepath.Join(originalWd, "..", "..", "fixtures", "test_config_env")
|
||||
|
||||
Convey("应该创建测试配置目录", func() {
|
||||
err := os.MkdirAll(testDir, 0755)
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
|
||||
Convey("应该创建基础配置文件", func() {
|
||||
testConfigContent := `App:
|
||||
Mode: "development"
|
||||
BaseURI: "http://localhost:3000"
|
||||
Http:
|
||||
Port: 3000
|
||||
Database:
|
||||
Host: "localhost"
|
||||
Port: 5432
|
||||
Database: "default_db"
|
||||
Username: "default_user"
|
||||
Password: "default_password"
|
||||
SslMode: "disable"`
|
||||
|
||||
configPath := filepath.Join(testDir, "config.toml")
|
||||
err := os.WriteFile(configPath, []byte(testConfigContent), 0644)
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
|
||||
Convey("应该成功加载并合并配置", func() {
|
||||
configPath := filepath.Join(testDir, "config.toml")
|
||||
loadedConfig, err := config.Load(configPath)
|
||||
|
||||
So(err, ShouldBeNil)
|
||||
So(loadedConfig, ShouldNotBeNil)
|
||||
|
||||
Convey("环境变量应该覆盖配置文件", func() {
|
||||
So(loadedConfig.App.Mode, ShouldEqual, "test")
|
||||
So(loadedConfig.Http.Port, ShouldEqual, 9090)
|
||||
So(loadedConfig.Database.Host, ShouldEqual, "test-host")
|
||||
})
|
||||
|
||||
Convey("配置文件的默认值应该保留", func() {
|
||||
So(loadedConfig.App.BaseURI, ShouldEqual, "http://localhost:3000")
|
||||
So(loadedConfig.Database.Database, ShouldEqual, "default_db")
|
||||
})
|
||||
})
|
||||
|
||||
Reset(func() {
|
||||
// 清理测试目录
|
||||
os.RemoveAll(testDir)
|
||||
})
|
||||
})
|
||||
|
||||
Reset(func() {
|
||||
// 恢复原始环境变量
|
||||
if originalEnvVars != nil {
|
||||
for key, value := range originalEnvVars {
|
||||
if value == "" {
|
||||
os.Unsetenv(key)
|
||||
} else {
|
||||
os.Setenv(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestConfigValidation 测试配置验证
|
||||
func TestConfigValidation(t *testing.T) {
|
||||
Convey("配置验证测试", t, func() {
|
||||
Convey("当配置为空时", func() {
|
||||
config := &config.Config{}
|
||||
|
||||
Convey("应该检测到缺失的必需配置", func() {
|
||||
So(config.App.Mode, ShouldBeEmpty)
|
||||
So(config.Http.Port, ShouldEqual, 0)
|
||||
So(config.Database.Host, ShouldBeEmpty)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("当配置端口无效时", func() {
|
||||
config := &config.Config{
|
||||
Http: config.HttpConfig{
|
||||
Port: -1,
|
||||
},
|
||||
}
|
||||
|
||||
Convey("应该检测到无效端口", func() {
|
||||
So(config.Http.Port, ShouldBeLessThan, 0)
|
||||
})
|
||||
})
|
||||
|
||||
Convey("当配置模式有效时", func() {
|
||||
validModes := []string{"development", "production", "testing"}
|
||||
|
||||
for _, mode := range validModes {
|
||||
config := &config.Config{
|
||||
App: config.AppConfig{
|
||||
Mode: mode,
|
||||
},
|
||||
}
|
||||
|
||||
Convey("模式 "+mode+" 应该是有效的", func() {
|
||||
So(config.App.Mode, ShouldBeIn, validModes)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestConfigDefaults 测试配置默认值
|
||||
func TestConfigDefaults(t *testing.T) {
|
||||
Convey("配置默认值测试", t, func() {
|
||||
Convey("当创建新配置时", func() {
|
||||
config := &config.Config{}
|
||||
|
||||
Convey("应该有合理的默认值", func() {
|
||||
// 测试应用的默认值
|
||||
So(config.App.Mode, ShouldEqual, "development")
|
||||
|
||||
// 测试HTTP的默认值
|
||||
So(config.Http.Port, ShouldEqual, 8080)
|
||||
|
||||
// 测试数据库的默认值
|
||||
So(config.Database.Port, ShouldEqual, 5432)
|
||||
So(config.Database.SslMode, ShouldEqual, "disable")
|
||||
|
||||
// 测试日志的默认值
|
||||
So(config.Log.Level, ShouldEqual, "info")
|
||||
So(config.Log.Format, ShouldEqual, "json")
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestConfigHelpers 测试配置辅助函数
|
||||
func TestConfigHelpers(t *testing.T) {
|
||||
Convey("配置辅助函数测试", t, func() {
|
||||
Convey("当使用配置辅助函数时", func() {
|
||||
config := &config.Config{
|
||||
App: config.AppConfig{
|
||||
Mode: "production",
|
||||
BaseURI: "https://api.example.com",
|
||||
},
|
||||
Http: config.HttpConfig{
|
||||
Port: 443,
|
||||
},
|
||||
}
|
||||
|
||||
Convey("应该能够获取应用环境", func() {
|
||||
env := config.App.Mode
|
||||
So(env, ShouldEqual, "production")
|
||||
})
|
||||
|
||||
Convey("应该能够构建完整URL", func() {
|
||||
fullURL := config.App.BaseURI + "/api/v1/users"
|
||||
So(fullURL, ShouldEqual, "https://api.example.com/api/v1/users")
|
||||
})
|
||||
|
||||
Convey("应该能够判断HTTPS", func() {
|
||||
isHTTPS := config.Http.Port == 443
|
||||
So(isHTTPS, ShouldBeTrue)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
44
templates/project/utils/build_info.go
Normal file
44
templates/project/utils/build_info.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package utils
|
||||
|
||||
import "fmt"
|
||||
|
||||
// 构建信息变量,通过 ldflags 在构建时注入
|
||||
var (
|
||||
// Version 应用版本信息
|
||||
Version string
|
||||
|
||||
// BuildAt 构建时间
|
||||
BuildAt string
|
||||
|
||||
// GitHash Git 提交哈希
|
||||
GitHash string
|
||||
)
|
||||
|
||||
// GetBuildInfo 获取构建信息
|
||||
func GetBuildInfo() map[string]string {
|
||||
return map[string]string{
|
||||
"version": Version,
|
||||
"buildAt": BuildAt,
|
||||
"gitHash": GitHash,
|
||||
}
|
||||
}
|
||||
|
||||
// PrintBuildInfo 打印构建信息
|
||||
func PrintBuildInfo(appName string) {
|
||||
buildInfo := GetBuildInfo()
|
||||
|
||||
println("========================================")
|
||||
printf("🚀 %s\n", appName)
|
||||
println("========================================")
|
||||
printf("📋 Version: %s\n", buildInfo["version"])
|
||||
printf("🕐 Build Time: %s\n", buildInfo["buildAt"])
|
||||
printf("🔗 Git Hash: %s\n", buildInfo["gitHash"])
|
||||
println("========================================")
|
||||
println("🌟 Application is starting...")
|
||||
println()
|
||||
}
|
||||
|
||||
// 为了避免导入 fmt 包,我们使用内置的 print 和 printf 函数
|
||||
func printf(format string, args ...interface{}) {
|
||||
print(fmt.Sprintf(format, args...))
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
package req
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"test/providers/req/cookiejar"
|
||||
"{{.ModuleName}}/providers/req/cookiejar"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
"go.ipao.vip/atom/container"
|
||||
"go.ipao.vip/atom/opt"
|
||||
"github.com/imroc/req/v3"
|
||||
"go.ipao.vip/atom/container"
|
||||
"go.ipao.vip/atom/opt"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
@@ -64,25 +64,25 @@ func Provide(opts ...opt.Option) error {
|
||||
client.EnableInsecureSkipVerify()
|
||||
}
|
||||
|
||||
if config.UserAgent != "" {
|
||||
client.SetUserAgent(config.UserAgent)
|
||||
}
|
||||
if config.BaseURL != "" {
|
||||
client.SetBaseURL(config.BaseURL)
|
||||
}
|
||||
if config.Timeout > 0 {
|
||||
client.SetTimeout(time.Duration(config.Timeout) * time.Second)
|
||||
}
|
||||
if config.UserAgent != "" {
|
||||
client.SetUserAgent(config.UserAgent)
|
||||
}
|
||||
if config.BaseURL != "" {
|
||||
client.SetBaseURL(config.BaseURL)
|
||||
}
|
||||
if config.Timeout > 0 {
|
||||
client.SetTimeout(time.Duration(config.Timeout) * time.Second)
|
||||
}
|
||||
|
||||
if config.CommonHeaders != nil {
|
||||
client.SetCommonHeaders(config.CommonHeaders)
|
||||
}
|
||||
if config.CommonQuery != nil {
|
||||
client.SetCommonQueryParams(config.CommonQuery)
|
||||
}
|
||||
if config.ContentType != "" {
|
||||
client.SetCommonContentType(config.ContentType)
|
||||
}
|
||||
if config.CommonHeaders != nil {
|
||||
client.SetCommonHeaders(config.CommonHeaders)
|
||||
}
|
||||
if config.CommonQuery != nil {
|
||||
client.SetCommonQueryParams(config.CommonQuery)
|
||||
}
|
||||
if config.ContentType != "" {
|
||||
client.SetCommonContentType(config.ContentType)
|
||||
}
|
||||
|
||||
if config.AuthBasic.Username != "" && config.AuthBasic.Password != "" {
|
||||
client.SetCommonBasicAuth(config.AuthBasic.Username, config.AuthBasic.Password)
|
||||
@@ -100,12 +100,12 @@ func Provide(opts ...opt.Option) error {
|
||||
client.SetRedirectPolicy(parsePolicies(config.RedirectPolicy)...)
|
||||
}
|
||||
|
||||
c.client = client
|
||||
if c.jar != nil {
|
||||
container.AddCloseAble(func() { _ = c.jar.Save() })
|
||||
}
|
||||
return c, nil
|
||||
}, o.DiOptions()...)
|
||||
c.client = client
|
||||
if c.jar != nil {
|
||||
container.AddCloseAble(func() { _ = c.jar.Save() })
|
||||
}
|
||||
return c, nil
|
||||
}, o.DiOptions()...)
|
||||
}
|
||||
|
||||
func parsePolicies(policies []string) []req.RedirectPolicy {
|
||||
@@ -140,11 +140,11 @@ func parsePolicies(policies []string) []req.RedirectPolicy {
|
||||
}
|
||||
|
||||
func (c *Client) R() *req.Request {
|
||||
return c.client.R()
|
||||
return c.client.R()
|
||||
}
|
||||
|
||||
func (c *Client) RWithCtx(ctx context.Context) *req.Request {
|
||||
return c.client.R().SetContext(ctx)
|
||||
return c.client.R().SetContext(ctx)
|
||||
}
|
||||
|
||||
func (c *Client) SaveCookJar() error {
|
||||
@@ -166,78 +166,78 @@ func (c *Client) AllCookiesKV() map[string]string {
|
||||
}
|
||||
|
||||
func (c *Client) SetCookie(u *url.URL, cookies []*http.Cookie) {
|
||||
c.jar.SetCookies(u, cookies)
|
||||
c.jar.SetCookies(u, cookies)
|
||||
}
|
||||
|
||||
func (c *Client) DoJSON(ctx context.Context, method, url string, in any, out any) error {
|
||||
r := c.RWithCtx(ctx)
|
||||
if in != nil {
|
||||
r.SetBody(in)
|
||||
}
|
||||
if out != nil {
|
||||
r.SetSuccessResult(out)
|
||||
}
|
||||
var resp *req.Response
|
||||
var err error
|
||||
switch strings.ToUpper(method) {
|
||||
case http.MethodGet:
|
||||
resp, err = r.Get(url)
|
||||
case http.MethodPost:
|
||||
resp, err = r.Post(url)
|
||||
case http.MethodPut:
|
||||
resp, err = r.Put(url)
|
||||
case http.MethodPatch:
|
||||
resp, err = r.Patch(url)
|
||||
case http.MethodDelete:
|
||||
resp, err = r.Delete(url)
|
||||
default:
|
||||
resp, err = r.Send(method, url)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return resp.Err
|
||||
r := c.RWithCtx(ctx)
|
||||
if in != nil {
|
||||
r.SetBody(in)
|
||||
}
|
||||
if out != nil {
|
||||
r.SetSuccessResult(out)
|
||||
}
|
||||
var resp *req.Response
|
||||
var err error
|
||||
switch strings.ToUpper(method) {
|
||||
case http.MethodGet:
|
||||
resp, err = r.Get(url)
|
||||
case http.MethodPost:
|
||||
resp, err = r.Post(url)
|
||||
case http.MethodPut:
|
||||
resp, err = r.Put(url)
|
||||
case http.MethodPatch:
|
||||
resp, err = r.Patch(url)
|
||||
case http.MethodDelete:
|
||||
resp, err = r.Delete(url)
|
||||
default:
|
||||
resp, err = r.Send(method, url)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return resp.Err
|
||||
}
|
||||
|
||||
func (c *Client) GetJSON(ctx context.Context, url string, out any, query map[string]string) error {
|
||||
r := c.RWithCtx(ctx)
|
||||
if query != nil {
|
||||
r.SetQueryParams(query)
|
||||
}
|
||||
r.SetSuccessResult(out)
|
||||
resp, err := r.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return resp.Err
|
||||
r := c.RWithCtx(ctx)
|
||||
if query != nil {
|
||||
r.SetQueryParams(query)
|
||||
}
|
||||
r.SetSuccessResult(out)
|
||||
resp, err := r.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return resp.Err
|
||||
}
|
||||
|
||||
func (c *Client) PostJSON(ctx context.Context, url string, in any, out any) error {
|
||||
r := c.RWithCtx(ctx)
|
||||
if in != nil {
|
||||
r.SetBody(in)
|
||||
}
|
||||
if out != nil {
|
||||
r.SetSuccessResult(out)
|
||||
}
|
||||
resp, err := r.Post(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return resp.Err
|
||||
r := c.RWithCtx(ctx)
|
||||
if in != nil {
|
||||
r.SetBody(in)
|
||||
}
|
||||
if out != nil {
|
||||
r.SetSuccessResult(out)
|
||||
}
|
||||
resp, err := r.Post(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return resp.Err
|
||||
}
|
||||
|
||||
func (c *Client) Download(ctx context.Context, url, filepath string) error {
|
||||
r := c.RWithCtx(ctx)
|
||||
resp, err := r.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Create(filepath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, resp.Body)
|
||||
return err
|
||||
r := c.RWithCtx(ctx)
|
||||
resp, err := r.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Create(filepath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user