Compare commits

...

15 Commits

Author SHA1 Message Date
7848dc2853 feat: 更新 llm.txt.raw,移除关于服务方法文件分割的规则 2025-12-23 11:32:49 +08:00
00742993db feat: 添加服务生成配置,优化服务解析逻辑,增强错误处理 2025-12-23 11:29:21 +08:00
861748b7d9 feat: 更新 llm.txt.raw 和新增 llm.gorm_gen.txt.raw 文档,优化开发规则和 GORM Gen 使用说明 2025-12-23 10:54:56 +08:00
596ea635c2 feat: 删除队列迁移文件 2025-12-23 10:52:34 +08:00
fcf107036b feat: 更新迁移功能,使用匿名函数替代命名函数 2025-12-23 10:52:21 +08:00
c7ecf35c7c feat: modify cronjob tpl 2025-12-22 22:10:32 +08:00
6b3981c3bc feat: modify http 2025-12-17 23:43:55 +08:00
12faa04a7e fix: route issues 2025-12-17 23:42:40 +08:00
df8c0627b4 fix: swagger 2025-12-16 17:11:55 +08:00
ff4fba7a43 fix: 修复导入路径以支持模块化结构 2025-12-15 11:17:34 +08:00
3c180091e1 feat: add river_queue migration 2025-12-15 10:50:41 +08:00
Rogee
64ae2d6d44 feat: 删除旧的配置文件并添加新的数据库迁移脚本以支持用户、会话和审计日志功能 2025-09-23 17:36:24 +08:00
Rogee
e25004df5b feat: 更新 Makefile.tpl 以改进构建信息获取逻辑 2025-09-23 17:28:34 +08:00
Rogee
daf93e7055 feat: 更新构建配置,添加构建信息打印功能并重构 Makefile 2025-09-23 17:23:44 +08:00
Rogee
b37b12884f feat(tests): add comprehensive unit, integration, and e2e tests for API and database functionality
- Implemented end-to-end tests for API health checks, performance, behavior, and documentation.
- Created integration tests for database connection, CRUD operations, transactions, and connection pool management.
- Developed unit tests for configuration loading, environment variable handling, validation, default values, and helper functions.
- Established a test setup with environment management and basic usage examples for the testing framework.
2025-09-23 17:06:47 +08:00
41 changed files with 3316 additions and 209 deletions

13
.vscode/launch.json vendored
View File

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

View File

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

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View 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

View File

@@ -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:

View 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

View 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"]

View File

@@ -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"]

View 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

View File

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

View File

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

View File

@@ -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

View 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

View File

@@ -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

View 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

View File

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

View File

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

View File

@@ -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(),

View 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...))
}

View File

@@ -6,7 +6,7 @@ import (
"sync"
"time"
"test/providers/postgres"
"{{.ModuleName}}/providers/postgres"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"

View 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 的详细输出有助于快速定位问题。

View 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()
}
})
})
}

View 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()
}
})
})
}

View 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)
})
})
})
}

View 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)
})
})
})
}

View 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...))
}

View File

@@ -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
}